diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e72c221..1a22e25 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,8 +37,10 @@ jobs: - run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - run: xvfb-run -a pnpm test:coverage env: - # Slow shutdown phases of a desktop app's launch, said (the Electron target's evidence). + # Slow shutdown phases of a desktop app's launch, and its failures' detail, said (the + # Electron target's evidence). KIFRAME_ELECTRON_TIMING: "1" + KIFRAME_ELECTRON_DEBUG: "1" # The desktop app, built and launched under a virtual display. - name: Desktop end-to-end run: xvfb-run -a pnpm --filter @kiframe/desktop e2e @@ -49,3 +51,21 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage/lcov.info fail_ci_if_error: false + + # Desktop apps run on macOS only (Seatbelt, codesign, plutil): their suite, where it holds. + electron-macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + - run: pnpm install --frozen-lockfile + # A desktop app's takes are encoded with ffmpeg (as on Linux). + - run: brew install ffmpeg + - run: pnpm exec vitest run --project electron + env: + KIFRAME_ELECTRON_TIMING: "1" + KIFRAME_ELECTRON_DEBUG: "1" diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index b1611b1..50ff9f0 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -246,7 +246,7 @@ Non-severe review findings deferred on purpose (see the review-round rule: only - **Copy and paste in a desktop app's demo:** the confinement denies the pasteboard (the user's clipboard may hold a password), so an app's own copy and paste does nothing in a run. A demo that needs it: a run-only pasteboard (a private named pasteboard can't be forced on an app) or typing the pasted text. - **What a confined app opens through LaunchServices:** an app can't start without `launchservicesd`, and through it it can open a file or a URL that then runs unconfined (a script it wrote, the user's browser). Stated in §0.9 (accident, not a hostile app); an Endpoint Security watcher or a per-run user account would close it. - **Desktop-app sweeps read processes more than once:** the startup sweep runs one `ps` per leftover sandbox (one `ps -axo pid=,lstart=` table would answer every owner), and a run's close reads the process table twice (`orphanedGroup` and `sweepProcesses`). Startup and close time only (#58 review). -- **Test hooks on the launch API (PR 4):** `appArgs`, `readable` and `allowUnconfined` are fields of `ElectronLaunch`, and `appArgs` skips the files/-only rule. Move them out of the public options when the desktop app first calls `launchElectron` (#58 review, agreed 2026-10-09). +- **Clicks in a desktop app under its own DevTools (macOS runner):** with DevTools opened mid-run over a hidden test window, a click waited for the button to be stable until its timeout on GitHub's macOS runner (animation frames throttled: the window covered); locally it clicks. The DevTools test reads instead of clicking; check clicks with DevTools open on a real app in PR 5. - **One launch helper:** browsers are launched by each entry point; nothing enforces an off-the-record context. A runtime-owned `launchBrowser()` / context factory would make it hard to bypass (the desktop app's main process). - **Secrets into contenteditable:** v0 writes secrets to inputs and textareas only (the value set on the approved element); a rich-text field is refused. - **Network exfiltration by the page itself:** a hostile page receives the typed password in its own field and can send it anywhere; that's the target app's own data. Out of scope for the vault (it only guarantees the value goes to the bound field on the allowed origins). diff --git a/docs/OBJECT-MODEL.md b/docs/OBJECT-MODEL.md index 453dd36..08b6b0e 100644 --- a/docs/OBJECT-MODEL.md +++ b/docs/OBJECT-MODEL.md @@ -229,7 +229,8 @@ One scene, three phases. The agent's own instructions say the same (`packages/st - **Each run launches it sandboxed** (its own HOME, `CFFIXED_USER_HOME`, XDG and temp folders, `--user-data-dir`; an allowlisted environment, never Kiframe's), attached with `--remote-debugging-port=0` + `connectOverCDP`; the viewport is emulated (a spike measured frames and clicks with the window covered, off-screen, minimized or hidden: a run never needs the user's screen; the window itself can't be moved over CDP). Isolation is enforced, never checked after the fact: every launch is confined (below). Its process group killed after each run, leftovers swept. - **Confinement: a guard, sealed at launch (PR 2, redesigned after three review rounds, design reviewed).** Once the app settles (its windows unchanged 1.5 s), its main window is the last opened still open; its own scheme or dev server is learned from that window alone, then sealed (nothing a run does adds to it). A main window that isn't the app's own (a wrapper's unlisted site) refuses the launch ("list it in its origins"). From then a **guard** checks every navigation of every frame of every window as it happens: a page that isn't the app's own is stopped at once (a popup closed, any other frame blanked: never left for a read or a frame) and recorded; the step fails `off-app`. The app's own: a local `file:` in its bundle or sandbox (decoded, normalized), its sealed scheme or dev server, a listed https site (`sameApp`, never plain http), a blob of those. Stated (as a web run): the guard acts as a page commits, so a page that isn't the app's own has loaded (its scripts ran, with the app's own rights) before it's stopped: following a link the app offers is what its user does too (blocking before the load needs request interception, which never sees `file:`); and the runner's own navigation events name it as they do for web runs (origin and path, known secret values scrubbed). What the main window embeds as it opens (a frame of another site) is the app as shipped: its origin is the app's own. A main window stopped goes back to the app's page (no goto there to bring it back); a failed load is said as one. **One app per run:** a desktop app's run never shows a site (no goto, URL condition or web preset; a web interrupt rule's goto skipped, said), is confined while grounding too, and keeps no sign-in between runs yet. - **Windows (PR 2, known limits, to settle on real apps in PR 3 and PR 5: decided by the user 2026-10-08):** the main window is the last opened still open once the app's windows were unchanged for 1.5 s (a splash that stays up longer is taken for the main window); windows the app's main process opens are followed as popups are (a hidden helper window opened during a step may be followed: CDP can't tell it's hidden); only the launch window is sent back when it goes off-app (another window is closed); a stop that lands after its step ended counts against the next step. A window that opens blank gets a moment to load (as a web page does); a frame of a window the run isn't driving is said, never the step's failure. -- **Every launch confined (PR 3a, 2026-10-09, design reviewed twice, measured):** on macOS each launch runs under Seatbelt (`sandbox-exec`, `electron-confine.ts`): writes only into its own sandbox; no reads of /Users, /Volumes, the temp folders, the user's home and the work area wherever they are (a home outside /Users) but its sandbox and its bundle; nothing run from its sandbox (the one place it writes: a program it drops there, a copied launcher), no `open`, `osascript`, `launchctl` nor Apple Events; no unix sockets (an agent's: ssh, Docker) but mDNSResponder; no preferences (cfprefsd), keychain (SecurityServer: a mock keychain instead, `--use-mock-keychain`) nor clipboard (the user's may hold a password just copied: an app's own copy and paste doesn't work in a run); network for runs (the app's backend), loopback only for a trial (no updater). Chromium's own sandbox can't nest in it (`--no-sandbox`): the guard and this profile replace it. A canary before every launch, before the files are copied (a read the profile refuses must fail, one of its sandbox succeed: a confinement that runs nothing never passes); no `sandbox-exec`, or a canary failed: no launch (never unconfined). Off macOS: desktop apps don't launch (CI's tests only, unconfined). Measured with a probe app: every escape above `EPERM` (a hard link or a symlink to an outside file too), the app driven as usual. This replaces the trial's after-the-fact isolation check, rejected by review (an app that leaks does its harm as it starts). **What it doesn't stop (stated):** an app can still ask macOS to open a file or a URL (LaunchServices: no app starts without reaching it, measured), and what opens runs outside the confinement; it reaches services on this machine (localhost: a dev build's own server needs it). The app's own pages are its bundle and what it writes in its sandbox's home, profile and temp folders (a page it builds there from remote content counts as its own, as its cache already does); never the files/ copy. The confinement keeps an app from the user's data by accident (the minmux incident), not an app built to escape (one the user runs unconfined every day anyway). +- **Every launch confined (PR 3a, 2026-10-09, design reviewed twice, measured):** on macOS each launch runs under Seatbelt (`sandbox-exec`, `electron-confine.ts`): writes only into its own sandbox; no reads of /Users, /Volumes, the temp folders, the user's home and the work area wherever they are (a home outside /Users) but its sandbox and its bundle; nothing run from its sandbox (the one place it writes: a program it drops there, a copied launcher), no `open`, `osascript`, `launchctl` nor Apple Events; no unix sockets (an agent's: ssh, Docker) but mDNSResponder; no preferences (cfprefsd), keychain (SecurityServer: a mock keychain instead, `--use-mock-keychain`) nor clipboard (the user's may hold a password just copied: an app's own copy and paste doesn't work in a run); the network on, for runs and trials alike (the app's backend, a wrapper's site: an updater can't touch the real app, nothing outside the sandbox being written; what an app could send is only what it can read here: its bundle, the system's files, its own sandbox). Chromium's own sandbox can't nest in it (`--no-sandbox`): the guard and this profile replace it. A canary before every launch, before the files are copied (a read the profile refuses must fail, one of its sandbox succeed: a confinement that runs nothing never passes); no `sandbox-exec`, or a canary failed: no launch (never unconfined). Off macOS: desktop apps don't launch (CI's tests only, unconfined). Measured with a probe app: every escape above `EPERM` (a hard link or a symlink to an outside file too), the app driven as usual. This replaces the trial's after-the-fact isolation check, rejected by review (an app that leaks does its harm as it starts). **What it doesn't stop (stated):** an app can still ask macOS to open a file or a URL (LaunchServices: no app starts without reaching it, measured), and what opens runs outside the confinement; it reaches services on this machine (localhost: a dev build's own server needs it). The app's own pages are its bundle and what it writes in its sandbox's home, profile and temp folders (a page it builds there from remote content counts as its own, as its cache already does); never the files/ copy. The confinement keeps an app from the user's data by accident (the minmux incident), not an app built to escape (one the user runs unconfined every day anyway). +- **Adding a desktop app (PR 3b, design reviewed 2026-10-09):** inspected first, statically (`electron-inspect.ts`: only `plutil` and `codesign` run): refused outside where an app can stay (a disk image, a temporary translocated copy, the Trash, inside another app), not Electron (its framework; no version gate: Figma reports 13.6.1), an executable that isn't its own file, a Mac App Store build (App Sandbox), a developer signature that doesn't hold (`codesign --verify --strict` against its team and signed identifier). Kept: its id, name, Electron's version, and its signer: a developer (team id) or, ad hoc and unsigned (the user's own builds), pinned to a digest of the whole bundle (a link leading outside it refused; at most 4 GB and 200,000 entries). Then **tried confined** (`electron-trial.ts`): launched with the network as in a run (an updater can't touch the real app: nothing outside the sandbox is written; a wrapper's offline page can't hide its site), its main window attached, closed; a wrapper's site is named (even unreached: the error page's unreachable URL, over CDP) and allowed by the user in a second trial. A main window that never loaded its own page (offline, a dev server not running) is refused at launch, said as such: never a window driven on Chromium's error page. An attach that hangs as the first page fails (measured 1 in 5; cause not found) is tried again within the launch's time (stated). A trial runs only the build that was picked (its developer's signature, or its pinned digest, checked first; a change between that check and the launch is the user's own build tool's: holding every launch to its approval is PR 4's, with runs). codesign that fails or times out refuses the app (never read as unsigned or unsandboxed). Apps that allow one copy (Slack, VS Code, Discord) need Chromium's temp folder in the sandbox (`MAC_CHROMIUM_TMPDIR`): its lock's socket otherwise goes to the user's /var/folders, denied, and the app quits. - **The work area `~/.kiframe/`** (decided by the user 2026-10-09: deletable any time): `sandboxes/` (one per launch, 0700, never a link), each named after the Kiframe that made it (`kiframe-app---…`: owned from the moment it exists, no shared state file to race on); a crash sweep at start removes only sandboxes no running Kiframe owns (the processes pointing at them killed first), never another build's launch, and nothing when `ps` can't be read. Kept out of Time Machine and Spotlight. Approvals stay in the app's settings, takes in app data. - **A project's `files/`** (decided by the user 2026-10-09): what a desktop app opens. Copied into the sandbox at each launch by Kiframe's own walk (folders and regular files only: a link, a FIFO, a device refused; 500 MB, 20,000 files, depth 32; cloned where the disk can, times kept); `args` name paths in it only (`files/vault`, given as the copy's path); the app's edits stay in the copy (thrown away: every run from the same files). The copy is the project's content, never the app's own pages for the guard. Filled by the user in v0: the agent's file tools never reach `files/` (it may hold what runs inside the app: an editor's tasks, a vault's plugins). - **Measured on a fixture app (PR 2, 2026-10-08):** HOME, XDG and `--user-data-dir` hold; macOS's own places don't move: Electron's `appData` (`~/Library/Application Support`) and the app's preferences (`NSUserDefaults`, written by `cfprefsd` into the user's real `~/Library/Preferences/.plist`) stay the user's whatever `HOME` or `CFFIXED_USER_HOME` say. Hence the confinement (PR 3a, below): the preferences daemon is denied and writes outside the sandbox refused, so neither can be reached (a check after launch, the earlier plan, came too late). diff --git a/packages/runtime/src/electron-confine.ts b/packages/runtime/src/electron-confine.ts index c0e0efd..7b529f8 100644 --- a/packages/runtime/src/electron-confine.ts +++ b/packages/runtime/src/electron-confine.ts @@ -28,28 +28,28 @@ export interface Confinement { * they are (a home outside /Users on a managed Mac). */ private: readonly string[] - /** "all": the app's backend reachable (a run); "loopback": nothing beyond this machine (a trial). */ - network: "all" | "loopback" } -/** A string in the profile's language (quoted, its backslashes and quotes escaped). */ -const q = (s: string) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` +/** + * A double-quoted string, its backslashes and quotes escaped: what Seatbelt's profile language and + * codesign's requirement language both read (a change here must hold for both). + */ +export const quoted = (s: string) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` /** The Seatbelt profile of a launch. Later rules win over earlier ones. */ export function seatbeltProfile(c: Confinement): string { - const readable = [c.sandbox, ...c.readable].map((p) => `(subpath ${q(p)})`).join(" ") + const readable = [c.sandbox, ...c.readable].map((p) => `(subpath ${quoted(p)})`).join(" ") return `(version 1) (allow default) (deny file-write*) -(allow file-write* (subpath ${q(c.sandbox)}) (literal "/dev/null") (literal "/dev/zero") (literal "/dev/dtracehelper") (regex #"^/dev/tty") (regex #"^/dev/fd/")) -(deny file-read-data (subpath "/Users") (subpath "/Volumes") (subpath "/private/var/folders") (subpath "/private/tmp") (subpath "/var/folders") (subpath "/tmp")${c.private.map((p) => ` (subpath ${q(p)})`).join("")}) +(allow file-write* (subpath ${quoted(c.sandbox)}) (literal "/dev/null") (literal "/dev/zero") (literal "/dev/dtracehelper") (regex #"^/dev/tty") (regex #"^/dev/fd/")) +(deny file-read-data (subpath "/Users") (subpath "/Volumes") (subpath "/private/var/folders") (subpath "/private/tmp") (subpath "/var/folders") (subpath "/tmp")${c.private.map((p) => ` (subpath ${quoted(p)})`).join("")}) (allow file-read-data ${readable}) (deny network-outbound (remote unix-socket)) (allow network-outbound (remote unix-socket (path-literal "/private/var/run/mDNSResponder"))) (deny appleevent-send) -(deny process-exec* (subpath ${q(c.sandbox)}) (literal "/usr/bin/open") (literal "/usr/bin/osascript") (literal "/bin/launchctl")) +(deny process-exec* (subpath ${quoted(c.sandbox)}) (literal "/usr/bin/open") (literal "/usr/bin/osascript") (literal "/bin/launchctl")) (deny mach-lookup (global-name "com.apple.cfprefsd.agent") (global-name "com.apple.SecurityServer") (global-name "com.apple.pasteboard.1")) -${c.network === "loopback" ? '(deny network-outbound)\n(allow network-outbound (remote ip "localhost:*"))\n(allow network-outbound (remote unix-socket (path-literal "/private/var/run/mDNSResponder")))' : ""} ` } diff --git a/packages/runtime/src/electron-inspect.ts b/packages/runtime/src/electron-inspect.ts new file mode 100644 index 0000000..082c369 --- /dev/null +++ b/packages/runtime/src/electron-inspect.ts @@ -0,0 +1,335 @@ +import { createHash } from "node:crypto" +import { constants, createReadStream } from "node:fs" +import { access, lstat, readdir, readlink, realpath } from "node:fs/promises" +import { basename, isAbsolute, join } from "node:path" +import { BUNDLE_ID } from "@kiframe/schema" +import { quoted } from "./electron-confine.ts" +import { command as run } from "./electron-workarea.ts" + +// A desktop app the user picks, inspected before Kiframe ever runs it (PR 3b, design reviewed +// 2026-10-09): only plutil and codesign run, nothing of the app. What's refused is said as is; +// what's kept is what an approval pins (its developer, or the exact build of an unsigned one). + +/** + * Who stands behind the app: a developer (Apple team id; `identifier` the signing identifier, kept + * for the signature check) or no one (ad hoc or unsigned: pinned to a digest of its code). + */ +export type Signer = + { kind: "team"; team: string; identifier: string } | { kind: "pinned"; digest: string } + +export interface DesktopApp { + /** The bundle's real path. */ + path: string + bundleId: string + /** Its name as shown (Finder's). */ + name: string + version: string | undefined + /** Electron's version in it (shown, never a condition: the confined trial decides). */ + electron: string + /** The executable's real path, inside the bundle. */ + executable: string + signer: Signer +} + +/** An app Kiframe won't add: said to the user as is. */ +export class InspectError extends Error {} + +/** codesign on a large bundle (a verify reads it all) can take a while: never forever. */ +const TIMEOUT = 60_000 + +/** Why an app can't be used from where it is (its real path), or undefined. */ +export function placeRefusal(real: string): string | undefined { + if (!real.endsWith(".app")) return "that isn't an app (a .app)" + if (real.slice(0, -4).includes(".app/")) + return "that app is inside another app: pick the app itself" + if (real.startsWith("/Volumes/")) { + return "that app is on a disk image or another disk: copy it to Applications first" + } + if (real.includes("/AppTranslocation/")) { + return "macOS runs that app from a temporary copy: move it to Applications, open it once, then add it" + } + if (real.includes("/.Trash/")) return "that app is in the Trash" + return undefined +} + +/** A value of an Info.plist (undefined when it has none), read by plutil. */ +async function plistValue( + plist: string, + key: string, + signal?: AbortSignal, +): Promise { + const out = await run( + "/usr/bin/plutil", + ["-extract", key, "raw", "-o", "-", plist], + TIMEOUT, + signal, + ) + signal?.throwIfAborted() + // An empty value is no value (a build with an empty name: its fallback instead). + const value = out.code === 0 ? out.stdout.trim() : "" + return value === "" ? undefined : value +} + +/** + * What `codesign -dv` says of a signature: unsigned (it says so), ad hoc, a developer's, or unread + * (it failed otherwise, timed out: never taken for "unsigned"). + */ +export function parseSigning( + code: number, + stderr: string, +): + | { kind: "unsigned" } + | { kind: "adhoc" } + | { kind: "team"; team: string; identifier: string } + | { kind: "unread" } { + if (code !== 0) + return /not signed at all/.test(stderr) ? { kind: "unsigned" } : { kind: "unread" } + const field = (name: string) => new RegExp(`^${name}=(.*)$`, "m").exec(stderr)?.[1]?.trim() + const team = field("TeamIdentifier") + const identifier = field("Identifier") + if (field("Signature") === "adhoc" || team === undefined || team === "not set") { + return { kind: "adhoc" } + } + return { kind: "team", team, identifier: identifier ?? "" } +} + +/** The requirement a developer's signature must meet: their team, the signed identifier. */ +export function teamRequirement(team: string, identifier: string): string { + return `anchor apple generic and identifier ${quoted(identifier)} and certificate leaf[subject.OU] = ${quoted(team)}` +} + +/** + * Whether the app at `path` is still the one approved: a developer's, its signature whole and + * theirs (`codesign --verify --strict`); a pinned build, the same digest. "unread" when that can't + * be told (codesign timed out or couldn't run; the bundle unreadable): never taken for a change. + */ +export async function buildHolds( + path: string, + signer: Signer, + signal?: AbortSignal, +): Promise<"holds" | "changed" | "unread"> { + if (signer.kind === "pinned") { + try { + return (await codeDigest(path, signal)) === signer.digest ? "holds" : "changed" + } catch (error) { + if (error instanceof InspectError) return "unread" + throw error + } + } + const out = await run( + "/usr/bin/codesign", + ["--verify", "--strict", `-R=${teamRequirement(signer.team, signer.identifier)}`, path], + TIMEOUT, + signal, + ) + signal?.throwIfAborted() + return out.code === 0 ? "holds" : out.code === -1 ? "unread" : "changed" +} + +/** A pinned build's limits (it's read whole when picked). */ +export const DIGEST_LIMITS = { bytes: 4 * 1024 * 1024 * 1024, entries: 200_000 } + +/** The digest's own version (a cache keyed by it, a future encoding: never confused). */ +const DIGEST_VERSION = "kiframe-code-v1" + +/** + * Where a link leads, resolved inside the bundle one step at a time (inner links followed, so a + * chain like `d -> .` then `e -> d/../x` is seen leaving): its parts under the root, `"outside"` + * (absolute, or above the root at any step), or `"dangling"` (a step that isn't there: it leads + * nowhere, so nowhere outside). + */ +async function resolveIn( + root: string, + base: readonly string[], + target: string, + depth: number, +): Promise { + if (depth > 40 || isAbsolute(target)) return "outside" + const parts = [...base] + for (const step of target.split("/")) { + if (step === "" || step === ".") continue + if (step === "..") { + if (parts.length === 0) return "outside" + parts.pop() + continue + } + parts.push(step) + let stat + try { + stat = await lstat(join(root, ...parts)) + } catch { + return "dangling" + } + if (stat.isSymbolicLink()) { + const inner = await resolveIn( + root, + parts.slice(0, -1), + await readlink(join(root, ...parts)), + depth + 1, + ) + if (typeof inner === "string") return inner + parts.splice(0, parts.length, ...inner) + } + } + return parts +} + +/** + * The code an unsigned app runs, hashed: the whole bundle, what pins the exact build (any change: a + * new digest). Each entry a JSON line (no name can pass for another entry); a link kept as a link + * when it leads inside the bundle (or nowhere), refused when it leads outside (code the pin + * couldn't cover); a socket or a device refused. Unreadable, changed while read, too large or + * stopped: refused, said. + */ +export async function codeDigest(picked: string, signal?: AbortSignal): Promise { + const hash = createHash("sha256") + const line = (entry: readonly unknown[]) => hash.update(`${JSON.stringify(entry)}\n`) + line([DIGEST_VERSION]) + let bytes = 0 + let entries = 0 + const tooLarge = () => new InspectError("that app is too large to pin") + const walk = async (root: string, rel: readonly string[]) => { + signal?.throwIfAborted() + const full = join(root, ...rel) + const name = rel.join("/") + const stat = await lstat(full) + entries += 1 + if (entries > DIGEST_LIMITS.entries) throw tooLarge() + if (stat.isSymbolicLink()) { + const target = await readlink(full) + if ((await resolveIn(root, rel.slice(0, -1), target, 0)) === "outside") { + throw new InspectError(`that app links outside itself (${name}): Kiframe can't pin it`) + } + line(["link", name, target]) + } else if (stat.isDirectory()) { + line(["dir", name]) + for (const entry of (await readdir(full)).sort()) await walk(root, [...rel, entry]) + } else if (stat.isFile()) { + bytes += stat.size + if (bytes > DIGEST_LIMITS.bytes) throw tooLarge() + line(["file", name, stat.size, (stat.mode & 0o111) !== 0]) + let read = 0 + for await (const chunk of createReadStream(full)) { + signal?.throwIfAborted() + read += (chunk as Buffer).length + hash.update(chunk as Buffer) + } + if (read !== stat.size) + throw new InspectError("that app changed while it was read: try again") + } else { + throw new InspectError(`that app holds what isn't a file (${name}): Kiframe can't pin it`) + } + } + try { + await walk(await realpath(picked), []) + } catch (error) { + if (signal?.aborted === true) throw signal.reason + if (error instanceof InspectError) throw error + throw new InspectError( + `that app can't be read whole (${(error as { code?: string }).code ?? "unreadable"})`, + ) + } + return hash.digest("hex") +} + +/** + * The signer of an app at `path`, and whether it's signed at all (an unsigned one has no + * entitlements to read). Unread: refused (never taken for unsigned). + */ +export async function signerOf( + path: string, + signal?: AbortSignal, +): Promise { + const signing = await run("/usr/bin/codesign", ["-dv", path], TIMEOUT, signal) + signal?.throwIfAborted() + const parsed = parseSigning(signing.code, signing.stderr) + if (parsed.kind === "unread") throw new InspectError("that app's signature couldn't be read") + if (parsed.kind === "team") return { ...parsed, signed: true } + return { + kind: "pinned", + digest: await codeDigest(path, signal), + signed: parsed.kind === "adhoc", + } +} + +/** + * The app at `picked`, inspected (static: nothing of it runs). Refused, said: not an app, where it + * can't stay, not Electron, an executable that isn't its own file, a Mac App Store build (its own + * sandbox), a developer signature that doesn't hold. + */ +export async function inspectDesktopApp(picked: string, signal?: AbortSignal): Promise { + let path: string + try { + path = await realpath(picked) + if (!(await lstat(path)).isDirectory()) throw new Error() + } catch { + throw new InspectError("that app can't be read (moved or removed?)") + } + const place = placeRefusal(path) + if (place !== undefined) throw new InspectError(place) + const plist = join(path, "Contents", "Info.plist") + const bundleId = await plistValue(plist, "CFBundleIdentifier", signal) + if (bundleId === undefined || !BUNDLE_ID.test(bundleId)) { + throw new InspectError("that app has no bundle id Kiframe can use") + } + const framework = join(path, "Contents/Frameworks/Electron Framework.framework") + const electron = await plistValue( + join(framework, "Resources/Info.plist"), + "CFBundleVersion", + signal, + ) + if (electron === undefined) { + throw new InspectError("that isn't an Electron app: Kiframe drives Electron apps only") + } + const exe = await plistValue(plist, "CFBundleExecutable", signal) + if (exe === undefined || exe === "." || exe === ".." || exe.includes("/")) { + throw new InspectError("that app names no executable Kiframe can run") + } + const executable = join(path, "Contents", "MacOS", exe) + try { + const stat = await lstat(executable) + if (!stat.isFile()) throw new Error() + if (!(await realpath(executable)).startsWith(`${path}/`)) throw new Error() + await access(executable, constants.X_OK) + } catch { + throw new InspectError("that app's executable isn't its own runnable file") + } + const { signed, ...signer } = await signerOf(path, signal) + // A signed app's entitlements, read (unread: refused, never taken for "not sandboxed"); an + // unsigned one has none. + if (signed) { + const entitlements = await run( + "/usr/bin/codesign", + ["-d", "--entitlements", "-", "--xml", path], + TIMEOUT, + signal, + ) + signal?.throwIfAborted() + if (entitlements.code !== 0) { + throw new InspectError("that app's entitlements couldn't be read") + } + if (/com\.apple\.security\.app-sandbox<\/key>\s*/.test(entitlements.stdout)) { + throw new InspectError( + "that's a Mac App Store build (it has its own sandbox, which can't run in Kiframe's)", + ) + } + } + if (signer.kind === "team" && (await buildHolds(path, signer, signal)) !== "holds") { + throw new InspectError("that app's signature is broken: reinstall it") + } + const [display, short, version] = await Promise.all( + ["CFBundleDisplayName", "CFBundleName", "CFBundleShortVersionString"].map((key) => + plistValue(plist, key, signal), + ), + ) + const name = display ?? short ?? basename(path, ".app") + return { + path, + bundleId, + name, + version, + electron, + executable, + signer, + } +} diff --git a/packages/runtime/src/electron-trial.ts b/packages/runtime/src/electron-trial.ts new file mode 100644 index 0000000..58926dd --- /dev/null +++ b/packages/runtime/src/electron-trial.ts @@ -0,0 +1,58 @@ +import type { Viewport } from "@kiframe/schema" +import { ElectronLaunchError, launchElectron } from "./electron.ts" +import { buildHolds, type DesktopApp } from "./electron-inspect.ts" + +/** + * A trial's outcome, read structurally: it runs confined and is driven (`ok`); its window is a site + * (`site`: a wrapper's, to allow as its own); it quit at once; or it failed (`failed`: said as is). + */ +export type TrialOutcome = { ok: true } | { site: string } | { quit: true } | { failed: string } + +export interface TrialOptions { + /** Sites it shows as its own (a second trial, after the first named one). */ + origins?: readonly string[] + workDir?: string + signal?: AbortSignal + viewport?: Pick + timeoutMs?: number +} + +/** + * An app tried before it's added (PR 3b): launched confined, its main window attached, then + * closed. A stop is thrown as the stop. + */ +export async function trialDesktopApp( + app: Pick, + opts: TrialOptions = {}, +): Promise { + const origins = opts.origins ?? [] + // The build that was picked, nothing else (a dev build rebuilt meanwhile: said). Checked before + // the launch only: a change in between is the user's own build tool's (stated). + const holds = await buildHolds(app.path, app.signer, opts.signal) + if (holds === "changed") return { failed: "the app changed since it was picked: pick it again" } + if (holds === "unread") + return { failed: "the app couldn't be checked (busy or unreadable): try again" } + let target + try { + target = await launchElectron({ + executable: app.executable, + bundle: app.path, + // The network as in a run (its site shown even behind its own offline page): an updater + // can't touch the real app all the same (nothing outside the sandbox is written). + origins, + viewport: opts.viewport ?? { width: 1440, height: 900, deviceScaleFactor: 1 }, + ...(opts.workDir !== undefined && { workDir: opts.workDir }), + ...(opts.signal !== undefined && { signal: opts.signal }), + ...(opts.timeoutMs !== undefined && { timeoutMs: opts.timeoutMs }), + }) + } catch (error) { + // The launch says every failure as an ElectronLaunchError (but a stop: thrown as is). + if (!(error instanceof ElectronLaunchError)) throw error + if (error.why === "site" && error.site !== undefined) return { site: error.site } + if (error.why === "quit") return { quit: true } + return { failed: error.message } + } + // Its close is bounded and never fails a trial that worked. + await target.close().catch(() => undefined) + return { ok: true } +} diff --git a/packages/runtime/src/electron-workarea.ts b/packages/runtime/src/electron-workarea.ts index b8e4354..806e858 100644 --- a/packages/runtime/src/electron-workarea.ts +++ b/packages/runtime/src/electron-workarea.ts @@ -108,15 +108,34 @@ function ownerOf(name: string): Owner | undefined { } /** A command's output (undefined when it fails; lsof's "nothing found" is an answer). */ -export function run(command: string, args: string[]): Promise { +export async function run(cmd: string, args: string[]): Promise { + const out = await command(cmd, args, 5000) + return out.code === 0 || out.code === 1 ? out.stdout : undefined +} + +/** + * A command's exit code (-1: it couldn't run, or timed out) and output, in the C locale: what's + * parsed (a start time, codesign's fields) reads the same in every language. + */ +export function command( + cmd: string, + args: string[], + timeoutMs: number, + signal?: AbortSignal, +): Promise<{ code: number; stdout: string; stderr: string }> { return new Promise((resolve) => { execFile( - command, + cmd, args, - // The C locale: what's parsed (a start time) reads the same in every language. - { timeout: 5000, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, LC_ALL: "C" } }, - (error, stdout) => { - resolve(error !== null && (error as { code?: unknown }).code !== 1 ? undefined : stdout) + { + timeout: timeoutMs, + maxBuffer: 16 * 1024 * 1024, + env: { ...process.env, LC_ALL: "C" }, + ...(signal !== undefined && { signal }), + }, + (error, stdout, stderr) => { + const code = error === null ? 0 : typeof error.code === "number" ? error.code : -1 + resolve({ code: error?.killed === true ? -1 : code, stdout, stderr }) }, ) }) diff --git a/packages/runtime/src/electron.ts b/packages/runtime/src/electron.ts index b28e6fa..abf46ae 100644 --- a/packages/runtime/src/electron.ts +++ b/packages/runtime/src/electron.ts @@ -26,6 +26,7 @@ import { import { untilStopped } from "./look.ts" import { chromium, + errors, type Browser, type BrowserContext, type CDPSession, @@ -87,6 +88,45 @@ async function exitedSoon(child: ChildProcess, ms: number): Promise { }) } +/** + * What a launch that failed says, the one rule (every failure an ElectronLaunchError with its + * `why`, but a stop): stopped → the stop; not started → said; what the app showed (a site, a + * page never loaded) → as is; the app quit → quit (whatever else was said); already said → as is; + * anything else (Playwright, CDP) → a fixed phrase (never + * its message: it can carry the debugging endpoint or a page's address), its detail on stderr for + * Kiframe's own debugging only (KIFRAME_ELECTRON_DEBUG). + */ +export function launchFailure( + error: unknown, + state: { stopped: unknown; spawnError: Error | undefined; quit: boolean }, +): unknown { + if (state.stopped !== undefined) return state.stopped + if (state.spawnError !== undefined) { + return new ElectronLaunchError(`the app couldn't be launched (${state.spawnError.message})`) + } + // What the app showed (a site, a page it never loaded): kept, whatever the app did next. + if (error instanceof ElectronLaunchError && (error.why === "site" || error.why === "unloaded")) { + return error + } + if (state.quit) return new ElectronLaunchError(QUIT_EARLY, { why: "quit" }) + if (error instanceof ElectronLaunchError) return error + debug("launch", error) + return new ElectronLaunchError( + error instanceof errors.TimeoutError + ? "the app didn't answer in time (it may refuse automation)" + : "the app couldn't be driven (its debugging connection failed)", + ) +} + +/** A failure's detail, for Kiframe's own debugging: stderr, only when asked (never a tool's). */ +function debug(phase: string, error: unknown) { + if (process.env.KIFRAME_ELECTRON_DEBUG !== undefined) { + process.stderr.write( + `[electron] ${phase}: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`, + ) + } +} + /** Shutdown phases that took long, said on stderr (KIFRAME_ELECTRON_TIMING): CI's evidence. */ function timing(phase: string, since: number) { const took = Date.now() - since @@ -98,8 +138,25 @@ function timing(phase: string, since: number) { const QUIT_EARLY = "the app quit before Kiframe could attach (it may allow only one instance: quit it and try again; be a launcher for another program; or refuse automation)" -/** An app that couldn't be launched or attached: said to the user as is. */ -export class ElectronLaunchError extends Error {} +/** + * An app that couldn't be launched or attached: said to the user as is. `why` read structurally (a + * trial's outcome): it quit at once, its main window showed a site (`site`: the https origin it + * showed or failed to load: a wrapper's own, to allow), or anything else. + */ +export class ElectronLaunchError extends Error { + readonly why: "quit" | "site" | "unloaded" | "other" + readonly site: string | undefined + constructor( + message: string, + detail: { why: "quit" | "unloaded" | "other" } | { why: "site"; site: string } = { + why: "other", + }, + ) { + super(message) + this.why = detail.why + this.site = detail.why === "site" ? detail.site : undefined + } +} export interface ElectronLaunch { /** The app's executable (`X.app/Contents/MacOS/X`), resolved and approved by the host. */ @@ -112,20 +169,6 @@ export interface ElectronLaunch { files?: string /** Kiframe's work area (default `~/.kiframe`): the launches' sandboxes. */ workDir?: string - /** The network: "all" (a run: the app's backend), "loopback" (a trial: nothing beyond). */ - network?: "all" | "loopback" - /** - * Tests: arguments given as they are, before `args` (the fixture app's folder, its modes): never - * a project's (those name files/ only). - */ - appArgs?: readonly string[] - /** Tests: more it may read (the repo's Electron). */ - readable?: readonly string[] - /** - * Off macOS only (Linux CI): launched without the Seatbelt confinement (there's none). Never in - * the app: desktop apps are macOS-only. - */ - allowUnconfined?: boolean /** The size its windows are shown at (emulated: the window itself is never moved). */ viewport: Pick /** The https sites a wrapper app shows as its own (the project's `origins`). */ @@ -140,6 +183,60 @@ export interface ElectronLaunch { timeoutMs?: number } +/** Tests only (never the package's: its launches take none of these). */ +export interface LaunchHooks { + /** + * Arguments given as they are, before `args` (the fixture app's folder, its modes): never a + * project's (those name files/ only). + */ + appArgs?: readonly string[] + /** More it may read (the repo's Electron). */ + readable?: readonly string[] + /** Off macOS only (Linux CI): launched without the Seatbelt confinement (there's none). */ + allowUnconfined?: boolean +} + +/** How long one attach may take before it's tried again. */ +const ATTACH_TRY_MS = 5000 + +/** + * Attached over CDP, tried again while time is left: an attach as the app's first page turns into + * Chromium's error page (a dev server not started) can hang (measured: 1 in 5), a second never did. + */ +export async function attach( + endpoint: string, + deadline: number, + signal?: AbortSignal, +): Promise { + for (;;) { + const left = deadline - Date.now() + try { + return await untilStopped( + chromium.connectOverCDP(endpoint, { timeout: Math.max(1, Math.min(left, ATTACH_TRY_MS)) }), + signal, + ) + } catch (error) { + // Out of time (or another failure): the launch's one rule words it. + if (!(error instanceof errors.TimeoutError) || left <= ATTACH_TRY_MS) throw error + } + } +} + +/** Where a page that failed to load was going (Chromium's error page keeps it, over CDP). */ +async function unreachableUrl(page: Page): Promise { + try { + const cdp = await page.context().newCDPSession(page) + try { + const { frameTree } = await cdp.send("Page.getFrameTree") + return frameTree.frame.unreachableUrl + } finally { + await cdp.detach().catch(() => undefined) + } + } catch { + return undefined + } +} + /** A page the guard stopped: in which window, whether it was the window itself, and where. */ export interface GuardStop { page: Page @@ -233,6 +330,9 @@ async function makeSandbox(work: string): Promise<{ XDG_CACHE_HOME: dirs.cache, XDG_STATE_HOME: dirs.state, TMPDIR: `${dirs.tmp}${sep}`, + // Chromium on macOS: its temp folder (the single-instance lock's socket) in the sandbox too, + // never the user's /var/folders (denied by the confinement: such an app would quit). + MAC_CHROMIUM_TMPDIR: dirs.tmp, }) return { root, @@ -247,8 +347,16 @@ async function makeSandbox(work: string): Promise<{ /** How long the app's windows must stay as they are before its main window is chosen. */ const SETTLE_MS = 1500 -/** Launches `opts.executable` in a fresh sandbox and attaches to its main window. */ -export async function launchElectron(opts: ElectronLaunch): Promise { +/** Launches `opts.executable` in a fresh sandbox, confined, and attaches to its main window. */ +export function launchElectron(opts: ElectronLaunch): Promise { + return launchElectronWith(opts, {}) +} + +/** `launchElectron` with the tests' hooks (not exported by the package). */ +export async function launchElectronWith( + opts: ElectronLaunch, + hooks: LaunchHooks, +): Promise { opts.signal?.throwIfAborted() // Before anything runs: an app moved since it was approved is said, never half-launched. let bundles: string[] @@ -267,7 +375,7 @@ export async function launchElectron(opts: ElectronLaunch): Promise undefined) + if (opts.signal?.aborted === true) throw opts.signal.reason as Error if (error instanceof FilesError || error instanceof ConfinementError) { throw new ElectronLaunchError(error.message) } - throw error + if (error instanceof ElectronLaunchError) throw error + debug("prepare", error) + throw new ElectronLaunchError("the app's sandbox couldn't be prepared") } // The app's own time to start (the copy and the canary never count against it). const deadline = Date.now() + (opts.timeoutMs ?? LAUNCH_MS) const launchArgs = [ - ...(opts.appArgs ?? []), + ...(hooks.appArgs ?? []), ...args, `--user-data-dir=${sandbox.profile}`, "--remote-debugging-port=0", @@ -352,10 +462,7 @@ export async function launchElectron(opts: ElectronLaunch): Promise spawnError, ) - browser = await untilStopped( - chromium.connectOverCDP(endpoint, { timeout: Math.max(1, deadline - Date.now()) }), - opts.signal, - ) + browser = await attach(endpoint, deadline, opts.signal) const context = browser.contexts()[0] if (context === undefined) throw new ElectronLaunchError("the app opened no window") const connected = browser @@ -366,21 +473,39 @@ export async function launchElectron(opts: ElectronLaunch): Promise child.exitCode !== null || child.signalCode !== null || !connected.isConnected(), opts.settleMs ?? SETTLE_MS, ) + // Where the main window is, or went: a load that failed (offline, a dev server not started) + // still names it (CDP's unreachable URL), judged and said by that. + const failed = page.url().startsWith("chrome-error:") + const shown = failed ? ((await unreachableUrl(page)) ?? page.url()) : page.url() // Sealed now, from the main window alone: its own scheme (app:) or dev server (loopback). const launched = new Set() const schemes = new Set() const embedded = new Set() - const origin = loopbackOrigin(page.url()) + const origin = loopbackOrigin(shown) if (origin !== undefined) launched.add(origin) - const scheme = ownScheme(page.url()) + const scheme = ownScheme(shown) if (scheme !== undefined) schemes.add(scheme) // What the main window embeds as it opens (a video, a widget) is part of the app as shipped. + // A frame still loading (its first navigation not committed: its url empty or about:blank) is + // read by its element's src, resolved against the page (else it'd be stopped once it loads). + const loading: { frame: Frame; url: string }[] = [] for (const frame of page.frames()) { - const url = URL.parse(frame.url()) - // https only (a listed site's rule: never plain http in the app's name). - if (frame !== page.mainFrame() && url !== null && url.protocol === "https:") { - embedded.add(url.origin) + if (frame === page.mainFrame()) continue + let shownIn = frame.url() + if (shownIn === "" || shownIn === "about:blank") { + const src = await frame + .frameElement() + .then((element) => element.getAttribute("src")) + .catch(() => null) + const resolved = src === null ? undefined : URL.parse(src, page.url())?.href + if (resolved !== undefined) { + shownIn = resolved + loading.push({ frame, url: resolved }) + } } + const url = URL.parse(shownIn) + // https only (a listed site's rule: never plain http in the app's name). + if (url !== null && url.protocol === "https:") embedded.add(url.origin) } const own = { bundles, @@ -391,9 +516,24 @@ export async function launchElectron(opts: ElectronLaunch): Promise allowedPage(url, own) - if (!allows(page.url())) { + // A frame still loading toward what isn't the app's: stopped now, at the launch (never as it + // lands in a run's first step). + for (const { frame, url } of loading) { + if (!allows(url)) await frame.goto("about:blank", { timeout: BACK_MS }).catch(() => undefined) + } + if (!shown.startsWith("chrome-error:") && !allows(shown)) { + const url = URL.parse(shown) + throw new ElectronLaunchError( + `the app shows ${placeOf(shown)}: if that's the app's own, list it in its origins`, + url?.protocol === "https:" ? { why: "site", site: url.origin } : { why: "other" }, + ) + } + // Its own page, never loaded: said (never a window driven on Chromium's error page). + if (failed) { + const where = shown.startsWith("chrome-error:") ? "its page" : placeOf(shown) throw new ElectronLaunchError( - `the app shows ${placeOf(page.url())}: if that's the app's own, list it in its origins`, + `the app couldn't load ${where} (offline? its server not running?)`, + { why: "unloaded" }, ) } // The guard: every frame of every window, every navigation, at once; one that isn't the app's @@ -410,6 +550,9 @@ export async function launchElectron(opts: ElectronLaunch): Promise { @@ -424,10 +567,16 @@ export async function launchElectron(opts: ElectronLaunch): Promise (back === null ? frame.goto("about:blank") : back)) + .catch((error: unknown) => { + debug("back", error) + return frame.goto("about:blank") + }) const done = action.then( () => undefined, () => undefined, @@ -483,12 +632,11 @@ export async function launchElectron(opts: ElectronLaunch): Promise finish(new ElectronLaunchError(QUIT_EARLY)) + const exited = () => finish(new ElectronLaunchError(QUIT_EARLY, { why: "quit" })) const aborted = () => finish(signal?.reason as Error) child.once("exit", exited) signal?.addEventListener("abort", aborted, { once: true }) @@ -585,7 +733,7 @@ async function mainWindow( for (;;) { signal?.throwIfAborted() // Quit (or dropped its port) before showing a window: said at once, never a wait to the end. - if (gone()) throw new ElectronLaunchError(QUIT_EARLY) + if (gone()) throw new ElectronLaunchError(QUIT_EARLY, { why: "quit" }) const now = windows() const key = now.map((p) => p.url()).join("\n") if (key !== seen) { diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index f762c97..c141306 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -16,6 +16,16 @@ export { launchElectron, } from "./electron.ts" export { defaultWorkDir, sweepWorkArea, WorkAreaError } from "./electron-workarea.ts" +export { + buildHolds, + codeDigest, + type DesktopApp, + inspectDesktopApp, + InspectError, + type Signer, + signerOf, +} from "./electron-inspect.ts" +export { trialDesktopApp, type TrialOptions, type TrialOutcome } from "./electron-trial.ts" export { ConfinementError, FILES_LIMITS, seatbeltProfile } from "./electron-confine.ts" export { LookRefusal, maskedScreenshot, type MaskedShot } from "./look.ts" export { diff --git a/packages/runtime/test/electron-inspect.test.ts b/packages/runtime/test/electron-inspect.test.ts new file mode 100644 index 0000000..5d20c9f --- /dev/null +++ b/packages/runtime/test/electron-inspect.test.ts @@ -0,0 +1,397 @@ +import { execFileSync } from "node:child_process" +import { + chmodSync, + cpSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + symlinkSync, + truncateSync, + writeFileSync, +} from "node:fs" +import { createRequire } from "node:module" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { + buildHolds, + codeDigest, + DIGEST_LIMITS, + inspectDesktopApp, + parseSigning, + placeRefusal, + teamRequirement, +} from "../src/electron-inspect.ts" +import { trialDesktopApp } from "../src/electron-trial.ts" + +// Inspecting a desktop app the user picks (PR 3b): real bundles built from the repo's Electron (a +// clone, the fixture as its app, re-signed ad hoc: no hook in them), refused shapes beside. + +const electron = createRequire(join(import.meta.dirname, "../../../apps/desktop/package.json"))( + "electron", +) as string +const electronApp = electron.slice(0, electron.indexOf(".app/") + 4) +const fixture = join(import.meta.dirname, "fixtures/electron-app") + +describe("where an app can't stay", () => { + it("refuses what isn't an app, an app inside another, disk images, translocation, the Trash", () => { + expect(placeRefusal("/Applications/Slack.app")).toBeUndefined() + expect(placeRefusal("/Users/me/Apps/Tool.app")).toBeUndefined() + expect(placeRefusal("/Applications/Slack")).toMatch(/isn't an app/) + expect(placeRefusal("/Applications/Outer.app/Contents/Inner.app")).toMatch(/inside another/) + expect(placeRefusal("/Volumes/Slack/Slack.app")).toMatch(/disk image/) + expect(placeRefusal("/private/var/folders/x/y/T/AppTranslocation/ABC/d/Slack.app")).toMatch( + /temporary copy/, + ) + expect(placeRefusal("/Users/me/.Trash/Slack.app")).toMatch(/Trash/) + }) +}) + +describe("a signature", () => { + it("reads unsigned, ad hoc and a developer's from codesign", () => { + expect(parseSigning(1, "code object is not signed at all")).toEqual({ kind: "unsigned" }) + // Any other failure (a timeout, a codesign that can't read it): unread, never "unsigned". + expect(parseSigning(-1, "")).toEqual({ kind: "unread" }) + expect(parseSigning(1, "invalid signature (code or signature have been modified)")).toEqual({ + kind: "unread", + }) + expect( + parseSigning(0, "Identifier=Electron\nSignature=adhoc\nTeamIdentifier=not set\n"), + ).toEqual({ + kind: "adhoc", + }) + expect( + parseSigning(0, "Identifier=com.tinyspeck.slackmacgap\nTeamIdentifier=BQR82RBBHL\n"), + ).toEqual({ kind: "team", team: "BQR82RBBHL", identifier: "com.tinyspeck.slackmacgap" }) + }) + + it("requires the developer's team and the signed identifier, quoted", () => { + expect(teamRequirement("BQR82RBBHL", 'a"b')).toBe( + 'anchor apple generic and identifier "a\\"b" and certificate leaf[subject.OU] = "BQR82RBBHL"', + ) + }) +}) + +// A pinned build's digest (any platform: plain folders). +describe("a pinned build's digest", () => { + const tree = (entries: Record, links: Record = {}) => { + const root = mkdtempSync(join(tmpdir(), "kiframe-el-digest-")) + for (const [rel, content] of Object.entries(entries)) { + mkdirSync(join(root, rel, ".."), { recursive: true }) + writeFileSync(join(root, rel), content) + } + for (const [rel, target] of Object.entries(links)) { + mkdirSync(join(root, rel, ".."), { recursive: true }) + symlinkSync(target, join(root, rel)) + } + return root + } + + it("keeps links inside the bundle, and one leading nowhere", async () => { + const root = tree( + { "Versions/A/code": "x" }, + { "Versions/Current": "A", code: "Versions/Current/code", "bin/gone": "../pruned/cli.js" }, + ) + expect(await codeDigest(root)).toMatch(/^[0-9a-f]{64}$/) + }) + + it("refuses a link leading outside, however it gets there", async () => { + for (const links of [ + { "lib.js": "/etc/hosts" }, + { "a/lib.js": "../../outside.js" }, + // Through an inner link: d is the root itself, so d/../x is above it. + { d: ".", e: "d/../x" }, + ]) { + await expect( + codeDigest(tree({ "a/code": "x" }, links)), + JSON.stringify(links), + ).rejects.toThrow(/links outside itself/) + } + }) + + it("never lets one tree pass for another (names can't forge entries)", async () => { + const two = tree({ A: "X", B: "Y" }) + // One file whose name spells A's entry, A's byte and B's entry (in a plain "file name size + // exec" encoding the two trees would read the same). + const one = tree({ ["A 1 false\nXfile B"]: "Y" }) + expect(await codeDigest(two)).not.toBe(await codeDigest(one)) + }) + + it("is the same through a link to the bundle, and changes with the code", async () => { + const root = tree({ "a/code": "x" }) + const linked = join(mkdtempSync(join(tmpdir(), "kiframe-el-digest-link-")), "App.app") + symlinkSync(root, linked) + const digest = await codeDigest(root) + expect(await codeDigest(linked)).toBe(digest) + writeFileSync(join(root, "a/code"), "y") + expect(await codeDigest(root)).not.toBe(digest) + }) + + it("refuses what isn't a file, what's too large, and stops when asked", async () => { + const fifo = tree({ "a/code": "x" }) + execFileSync("mkfifo", [join(fifo, "a/pipe")]) + await expect(codeDigest(fifo)).rejects.toThrow(/isn't a file/) + const limit = DIGEST_LIMITS.entries + DIGEST_LIMITS.entries = 2 + try { + await expect(codeDigest(tree({ "a/b": "1", "a/c": "2" }))).rejects.toThrow(/too large/) + } finally { + DIGEST_LIMITS.entries = limit + } + const stopping = new AbortController() + stopping.abort() + await expect(codeDigest(tree({ a: "1" }), stopping.signal)).rejects.toMatchObject({ + name: "AbortError", + }) + // In the middle of one large file (400 MB, sparse): ended at once, never at its end. + const large = tree({}) + const file = join(large, "framework") + writeFileSync(file, "") + truncateSync(file, 400 * 1024 * 1024) + try { + const t0 = Date.now() + await codeDigest(large) + const full = Date.now() - t0 + const midway = new AbortController() + setTimeout(() => midway.abort(), full / 4) + const t1 = Date.now() + await expect(codeDigest(large, midway.signal)).rejects.toMatchObject({ name: "AbortError" }) + expect(Date.now() - t1).toBeLessThan(full / 2 + 50) + } finally { + rmSync(large, { recursive: true, force: true }) + } + }, 60_000) +}) + +describe.runIf(process.platform === "darwin")("an app's inspection", { timeout: 60_000 }, () => { + let root: string + let base: string + // A real bundle: Electron cloned, the fixture as its app, its id and name its own, re-signed. + const bundle = (name: string, change?: (app: string) => void, sign: string[] = []) => { + const app = join(mkdtempSync(join(root, "app-")), `${name}.app`) + execFileSync("cp", ["-cR", base, app]) + change?.(app) + execFileSync("codesign", ["-s", "-", "-f", "--deep", ...sign, app], { stdio: "ignore" }) + return app + } + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), "kiframe-el-inspect-")) + base = join(root, "Base.app") + execFileSync("cp", ["-cR", electronApp, base]) + cpSync(fixture, join(base, "Contents/Resources/app"), { recursive: true }) + const plist = join(base, "Contents/Info.plist") + execFileSync("plutil", [ + "-replace", + "CFBundleIdentifier", + "-string", + "com.kiframe.fixture", + plist, + ]) + for (const key of ["CFBundleName", "CFBundleDisplayName"]) { + execFileSync("plutil", ["-replace", key, "-string", "Kiframe Fixture", plist]) + } + }, 120_000) + + afterAll(() => { + if (root !== undefined) rmSync(root, { recursive: true, force: true }) + }) + + it("keeps an Electron app's id, name, versions and executable, pinned to its build", async () => { + const app = bundle("Fixture") + const seen = await inspectDesktopApp(app) + expect(seen).toMatchObject({ + bundleId: "com.kiframe.fixture", + name: "Kiframe Fixture", + electron: "44.4.5", + executable: join(seen.path, "Contents/MacOS/Electron"), + signer: { kind: "pinned" }, + }) + // Any change to the code it runs: another build (its app, its framework). + const edited = bundle("Edited", (a) => + writeFileSync(join(a, "Contents/Resources/app/index.html"), "

changed

"), + ) + const digest = (await inspectDesktopApp(edited)).signer + expect(digest).not.toEqual(seen.signer) + // A helper app (where its pages and GPU run) changed: another build too. + const helper = bundle("Helper", (a) => { + const resources = join( + a, + "Contents/Frameworks/Electron Helper (Renderer).app/Contents/Resources", + ) + mkdirSync(resources, { recursive: true }) + writeFileSync(join(resources, "added.js"), "changed") + }) + expect((await inspectDesktopApp(helper)).signer).not.toEqual(seen.signer) + expect(await codeDigest(seen.path)).toBe( + seen.signer.kind === "pinned" ? seen.signer.digest : "", + ) + }) + + it("refuses an app that isn't Electron, a Mac App Store build, an executable that's a link", async () => { + const notElectron = bundle("Plain", (a) => + rmSync(join(a, "Contents/Frameworks/Electron Framework.framework"), { + recursive: true, + force: true, + }), + ) + await expect(inspectDesktopApp(notElectron)).rejects.toThrow(/Electron apps only/) + const entitlements = join(root, "sandbox.plist") + writeFileSync( + entitlements, + 'com.apple.security.app-sandbox', + ) + // Inspected only, never launched (its own sandbox would crash it). + const store = bundle("Store", undefined, ["--entitlements", entitlements]) + await expect(inspectDesktopApp(store)).rejects.toThrow(/Mac App Store build/) + // Swapped after signing (codesign won't sign it so). + const linked = bundle("Linked") + const exe = join(linked, "Contents/MacOS/Electron") + rmSync(exe) + symlinkSync("/bin/sh", exe) + await expect(inspectDesktopApp(linked)).rejects.toThrow(/executable isn't its own/) + }) + + it("names an app with an empty display name by its bundle name", async () => { + const app = bundle("Unnamed", (a) => + execFileSync("plutil", [ + "-replace", + "CFBundleDisplayName", + "-string", + "", + join(a, "Contents/Info.plist"), + ]), + ) + expect((await inspectDesktopApp(app)).name).toBe("Kiframe Fixture") + }) + + it("refuses a pinned app that links outside itself, or can't be read whole", async () => { + // After signing (code a link leads to outside the bundle: what the pin couldn't cover). + const outside = bundle("Outside") + symlinkSync("/etc/hosts", join(outside, "Contents/Resources/shared.js")) + await expect(inspectDesktopApp(outside)).rejects.toThrow(/links outside itself/) + const unreadable = bundle("Unreadable") + const locked = join(unreadable, "Contents/Resources/app/index.html") + chmodSync(locked, 0o000) + try { + await expect(inspectDesktopApp(unreadable)).rejects.toThrow(/can't be read whole \(EACCES\)/) + } finally { + chmodSync(locked, 0o644) + } + }) + + it("refuses an app inside another app", async () => { + const outer = join(mkdtempSync(join(root, "outer-")), "Outer.app") + mkdirSync(join(outer, "Contents"), { recursive: true }) + const inner = join(outer, "Contents", "Inner.app") + execFileSync("cp", ["-cR", bundle("Inner"), inner]) + await expect(inspectDesktopApp(inner)).rejects.toThrow(/inside another app/) + await expect(inspectDesktopApp(join(root, "Gone.app"))).rejects.toThrow(/can't be read/) + }) + + // A bundle whose app runs the fixture in a mode (a shim as its main: no argument from Kiframe). + const withMode = (name: string, mode: string) => + bundle(name, (a) => { + const dir = join(a, "Contents/Resources/app") + writeFileSync( + join(dir, "mode.cjs"), + `process.argv.push(${JSON.stringify(mode)}, "hidden")\nrequire("./main.cjs")\n`, + ) + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ name: "kiframe-fixture-electron", main: "mode.cjs" }), + ) + }) + + it("tries an app confined: driven, a wrapper's site named, one that quits said", async () => { + const work = mkdtempSync(join(tmpdir(), "kiframe-el-work-")) + const app = await inspectDesktopApp(withMode("Notes", "plain")) + expect(await trialDesktopApp(app, { workDir: work })).toEqual({ ok: true }) + const wrapper = await inspectDesktopApp(withMode("Wrapper", "wrapper")) + // Its site named, even unreached (where its window failed to go). + expect(await trialDesktopApp(wrapper, { workDir: work })).toEqual({ + site: "https://kiframe-wrapper.invalid", + }) + // Listed, unreachable: said as a failed load (a real one loads, in electron.test.ts). + expect( + await trialDesktopApp(wrapper, { + workDir: work, + origins: ["https://kiframe-wrapper.invalid"], + }), + ).toMatchObject({ failed: expect.stringMatching(/couldn't load/) as string }) + const quits = await inspectDesktopApp(withMode("Quits", "quit-at-once")) + expect(await trialDesktopApp(quits, { workDir: work })).toEqual({ quit: true }) + // Nothing left of any trial. + expect(readdirSync(join(work, "sandboxes"))).toEqual([]) + }) + + it("holds an app to the build that was picked (a pinned one rebuilt: changed)", async () => { + const path = withMode("Rebuilt", "plain") + const app = await inspectDesktopApp(path) + expect(await buildHolds(app.path, app.signer)).toBe("holds") + writeFileSync(join(path, "Contents/Resources/app/index.html"), "

rebuilt

") + expect(await buildHolds(app.path, app.signer)).toBe("changed") + // Never tried as the build that was picked. + expect( + await trialDesktopApp(app, { workDir: mkdtempSync(join(tmpdir(), "kiframe-el-work-")) }), + ).toEqual({ failed: "the app changed since it was picked: pick it again" }) + }) + + it("stops an inspection when asked, even in the middle of reading it", async () => { + const stopping = new AbortController() + stopping.abort() + await expect(inspectDesktopApp(bundle("Stop"), stopping.signal)).rejects.toMatchObject({ + name: "AbortError", + }) + // Mid-read (the whole build hashed: hundreds of MB): ended at once. + const path = bundle("Midway") + const t0 = Date.now() + const full = await codeDigest(path).then(() => Date.now() - t0) + const midway = new AbortController() + setTimeout(() => midway.abort(), full / 4) + const t1 = Date.now() + await expect(codeDigest(path, midway.signal)).rejects.toMatchObject({ name: "AbortError" }) + expect(Date.now() - t1).toBeLessThan(full / 2 + 100) + }) + + it("says a build it can't read as unread, never as changed", async () => { + const path = withMode("Locked", "plain") + const app = await inspectDesktopApp(path) + const locked = join(path, "Contents/Resources/app/index.html") + chmodSync(locked, 0o000) + try { + expect(await buildHolds(app.path, app.signer)).toBe("unread") + expect( + await trialDesktopApp(app, { workDir: mkdtempSync(join(tmpdir(), "kiframe-el-work-")) }), + ).toEqual({ failed: "the app couldn't be checked (busy or unreadable): try again" }) + } finally { + chmodSync(locked, 0o644) + } + }) + + it("takes a bundle id with underscores (electron-builder's own)", async () => { + const app = bundle("Underscore", (a) => + execFileSync("plutil", [ + "-replace", + "CFBundleIdentifier", + "-string", + "com.electron.my_app", + join(a, "Contents/Info.plist"), + ]), + ) + expect((await inspectDesktopApp(app)).bundleId).toBe("com.electron.my_app") + }) + + it("throws a stop as the stop", async () => { + const app = await inspectDesktopApp(withMode("Stopped", "plain")) + const stopping = new AbortController() + stopping.abort() + await expect( + trialDesktopApp(app, { + workDir: mkdtempSync(join(tmpdir(), "kiframe-el-work-")), + signal: stopping.signal, + }), + ).rejects.toMatchObject({ name: "AbortError" }) + }) +}) diff --git a/packages/runtime/test/electron.test.ts b/packages/runtime/test/electron.test.ts index fe631d2..638a083 100644 --- a/packages/runtime/test/electron.test.ts +++ b/packages/runtime/test/electron.test.ts @@ -17,6 +17,7 @@ import { createRequire } from "node:module" import { tmpdir, userInfo } from "node:os" import { dirname, join } from "node:path" import { parseProjectYaml, parseScenarioYaml } from "@kiframe/schema" +import { errors } from "playwright" import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest" import { execFileSync, spawn, spawnSync } from "node:child_process" import { createServer, type Server } from "node:http" @@ -29,7 +30,6 @@ import { ElectronLaunchError, type ElectronTarget, FILES_LIMITS, - launchElectron, recordScenario, runScenario, StepError, @@ -37,6 +37,13 @@ import { sweepWorkArea, } from "../src/index.ts" import { checkConfinement, copyFiles } from "../src/electron-confine.ts" +import { + attach, + type ElectronLaunch, + launchFailure, + launchElectronWith, + type LaunchHooks, +} from "../src/electron.ts" import { sandboxesOf } from "../src/electron-workarea.ts" // The Electron target (OBJECT-MODEL §0.9 "Electron apps"): a fixture desktop app launched in a @@ -55,6 +62,10 @@ const sandboxCount = () => /** This file's work area (never the user's ~/.kiframe). */ const work = mkdtempSync(join(tmpdir(), "kiframe-el-work-")) /** How every launch here runs: the fixture on the repo's Electron, confined on macOS. */ +// The tests' launches: the hooks (the fixture's folder and modes, the repo's Electron readable, +// unconfined on Linux CI) beside a launch's own options. +const launchElectron = (opts: ElectronLaunch & LaunchHooks) => launchElectronWith(opts, opts) + const base = { executable: electron, bundle: fixture, @@ -114,16 +125,26 @@ beforeAll(async () => { ) secureServer = createHttpsServer( { key: readFileSync(join(certs, "key.pem")), cert: readFileSync(join(certs, "cert.pem")) }, - (_req, res) => { - res.writeHead(200, { "content-type": "text/html" }) - res.end("

A widget

") + (req, res) => { + // "/slow": answered after a while (a widget still loading as the app opens). + const answer = () => { + res.writeHead(200, { "content-type": "text/html" }) + res.end("

A widget

") + } + if (req.url === "/slow") setTimeout(answer, 3000) + else answer() }, ) await new Promise((resolve) => secureServer.listen(0, "127.0.0.1", resolve)) secure = `https://127.0.0.1:${(secureServer.address() as AddressInfo).port}/` - server = createServer((_req, res) => { - res.writeHead(200, { "content-type": "text/html" }) - res.end("

Another site

") + server = createServer((req, res) => { + // "/slow": answered after a while (a frame still loading as the app opens). + const answer = () => { + res.writeHead(200, { "content-type": "text/html" }) + res.end("

Another site

") + } + if (req.url === "/slow") setTimeout(answer, 3000) + else answer() }) await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) other = `http://127.0.0.1:${(server.address() as AddressInfo).port}/` @@ -152,6 +173,7 @@ const launch = async ( readable?: string[] timeoutMs?: number signal?: AbortSignal + origins?: string[] } = {}, ) => { const target = await launchElectron({ @@ -478,10 +500,162 @@ steps: } }, 60_000) + // macOS's fix (Linux runs unconfined, its lock's socket path under its own length limit). + it.runIf(process.platform === "darwin")( + "launches an app that allows one copy only (its lock in the sandbox, never /var/folders)", + async () => { + const target = await launch(["single"]) + expect(await target.page.title()).toBe("Fixture notes") + }, + ) + + it("names the site a wrapper app shows, even when it can't load it", async () => { + const error = await launch(["wrapper"]).then( + () => undefined, + (e: unknown) => e as ElectronLaunchError, + ) + expect(error).toBeInstanceOf(ElectronLaunchError) + expect(error?.why).toBe("site") + expect(error?.site).toBe("https://kiframe-wrapper.invalid") + // Listed as its own but never loaded: said (never a window driven on an error page). + await expect( + launch(["wrapper"], { origins: ["https://kiframe-wrapper.invalid"] }), + ).rejects.toThrow(/couldn't load kiframe-wrapper\.invalid/) + // Listed and loaded: driven. + const site = new URL(secure).origin + const target = await launch([`wrapper=${secure}`, "trust-test-cert"], { origins: [site] }) + expect(target.page.url()).toBe(secure) + }) + + // A fake app: announces a debugging endpoint (a test's server) and, asked, quits after a while. + const fakeApp = () => { + const dir = mkdtempSync(join(tmpdir(), "kiframe-el-fake-")) + const script = join(dir, "fake-app") + writeFileSync( + script, + `#!${process.execPath} +const [port, quitAfter] = process.argv.slice(2) +process.stderr.write("DevTools listening on ws://127.0.0.1:" + port + "/devtools/browser/0f0e0d0c-0000-4000-8000-000000000000\\n") +if (quitAfter !== "never") setTimeout(() => process.exit(0), Number(quitAfter)) +else setInterval(() => undefined, 60_000) +`, + { mode: 0o755 }, + ) + return { script, readable: [realpathSync(dir), realpathSync(dirname(process.execPath))] } + } + const fakeLaunch = (port: number, quitAfter: string, timeoutMs: number) => { + const fake = fakeApp() + return launchElectron({ + executable: fake.script, + appArgs: [String(port), quitAfter], + readable: fake.readable, + workDir: work, + allowUnconfined: true, + viewport, + timeoutMs, + }).then( + () => undefined, + (e: unknown) => e, + ) + } + + it("says a broken debugging connection in its own words (never Playwright's, its endpoint)", async () => { + // An endpoint that refuses the WebSocket (a CDP it can't speak). + const refusing = createServer((_req, res) => res.writeHead(404).end()) + await new Promise((resolve) => refusing.listen(0, "127.0.0.1", resolve)) + try { + const error = await fakeLaunch((refusing.address() as AddressInfo).port, "never", 8000) + expect(error).toBeInstanceOf(ElectronLaunchError) + expect(String(error)).toMatch(/couldn't be driven \(its debugging connection failed\)/) + expect(String(error)).not.toMatch(/ws:\/\/|127\.0\.0\.1/) + } finally { + refusing.close() + } + }, 30_000) + + it("says an app that quits while its attach stalls as quit (never 'didn't answer')", async () => { + const silent = createNetServer(() => undefined) + await new Promise((resolve) => silent.listen(0, "127.0.0.1", resolve)) + try { + // It quits just after the launch's time: the attach times out, its exit lands after. + const error = await fakeLaunch((silent.address() as AddressInfo).port, "3300", 3000) + expect(error).toBeInstanceOf(ElectronLaunchError) + expect((error as ElectronLaunchError).why).toBe("quit") + } finally { + silent.close() + } + }, 30_000) + + it("words every failure by one rule: a stop, not started, quit, said, anything else", () => { + const stop = new DOMException("stopped", "AbortError") + const said = new ElectronLaunchError("said") + const raw = new Error("connect ECONNREFUSED ws://127.0.0.1:9/devtools/browser/x") + const none = { stopped: undefined, spawnError: undefined, quit: false } + expect(launchFailure(raw, { ...none, stopped: stop, quit: true })).toBe(stop) + expect( + String(launchFailure(raw, { ...none, spawnError: new Error("ENOENT"), quit: true })), + ).toMatch(/couldn't be launched \(ENOENT\)/) + // Already said (a site to allow, a page never loaded) stays said, even if the app then quit. + const site = new ElectronLaunchError("site", { why: "site", site: "https://a.example" }) + expect(launchFailure(site, { ...none, quit: true })).toBe(site) + expect(launchFailure(said, none)).toBe(said) + // Anything else said (no window in time) or unworded (an attach that stalled), the app gone: + // it quit (the one-copy advice kept). + expect(launchFailure(said, { ...none, quit: true })).toMatchObject({ why: "quit" }) + const unloaded = new ElectronLaunchError("unloaded", { why: "unloaded" }) + expect(launchFailure(unloaded, { ...none, quit: true })).toBe(unloaded) + expect(launchFailure(raw, { ...none, quit: true })).toMatchObject({ why: "quit" }) + const worded = launchFailure(raw, none) + expect(worded).toBeInstanceOf(ElectronLaunchError) + expect(String(worded)).not.toMatch(/ws:\/\//) + }) + + it("tries a stalled attach again, then says it as the launch's own (never Playwright's)", async () => { + // A debugging endpoint that accepts and never answers. + let connections = 0 + const silent = createNetServer(() => (connections += 1)) + await new Promise((resolve) => silent.listen(0, "127.0.0.1", resolve)) + const port = (silent.address() as AddressInfo).port + try { + const error = await attach( + `ws://127.0.0.1:${port}/devtools/browser/x`, + Date.now() + 7000, + ).then( + () => undefined, + (e: unknown) => e, + ) + // Tried twice, then the timeout handed to the launch's one rule, which words it. + expect(connections).toBe(2) + expect(error).toBeInstanceOf(errors.TimeoutError) + const worded = launchFailure(error, { + stopped: undefined, + spawnError: undefined, + quit: false, + }) + expect(worded).toBeInstanceOf(ElectronLaunchError) + expect(String(worded)).toMatch(/didn't answer in time/) + } finally { + silent.close() + } + }, 30_000) + + it("says a dev build whose server isn't running, as such (never an off-app site)", async () => { + const closed = createNetServer() + await new Promise((resolve) => closed.listen(0, "127.0.0.1", resolve)) + const port = (closed.address() as AddressInfo).port + await new Promise((resolve) => closed.close(resolve)) + await expect(launch([`dev-down=http://127.0.0.1:${port}/`])).rejects.toThrow( + new RegExp( + `couldn't load 127\\.0\\.0\\.1:${port} \\(offline\\? its server not running\\?\\)`, + ), + ) + }) + it("says an app that quits at once, and leaves nothing behind", async () => { const before = sandboxCount() await expect(launch(["quit-at-once"])).rejects.toThrow(ElectronLaunchError) await expect(launch(["quit-at-once"])).rejects.toThrow(/quit before Kiframe could attach/) + await expect(launch(["quit-at-once"])).rejects.toMatchObject({ why: "quit" }) expect(sandboxCount()).toBe(before) }) @@ -603,6 +777,43 @@ steps: [{ id: add, action: click, target: { by: role, role: button, name: Add no open = [] }) + it("keeps a frame the app embeds that's still loading as it opens", async () => { + // Its first navigation uncommitted when the launch seals what's its own: read by its src. + const slow = `${secure}slow` + const embeds = await launchElectron({ + ...base, + appArgs: [fixture, "hidden", `link=${slow}`, "embed-at-start", "trust-test-cert"], + settleMs: 300, + }) + open.push(embeds) + expect(await waitFor(() => embeds.page.frames().some((f) => f.url() === slow), 8000)).toBe(true) + await runScenario( + embeds.page, + parseScenarioYaml(`version: 1\nsteps: [{ id: a, action: pause, ms: 300 }]\n`), + project, + { electron: inTarget(embeds) }, + ) + expect(embeds.page.frames().some((f) => f.url() === slow)).toBe(true) + }) + + it("stops a frame loading toward another site as the app opens, never in the run", async () => { + // Its first navigation uncommitted when the launch seals: stopped then, at the launch. + const slow = `${other}slow` + const plain = await launchElectron({ + ...base, + appArgs: [fixture, "hidden", `link=${slow}`, "embed-at-start"], + settleMs: 300, + }) + open.push(plain) + await runScenario( + plain.page, + parseScenarioYaml(`version: 1\nsteps: [{ id: a, action: pause, ms: 4000 }]\n`), + project, + { electron: inTarget(plain) }, + ) + expect(plain.page.frames().some((f) => f.url() === slow)).toBe(false) + }) + it("keeps what the app embeds as it opens, and takes its window back from a site", async () => { // A frame of another (https) site shown at launch is the app as shipped: never a stop. const embeds = await launchElectron({ @@ -754,18 +965,20 @@ steps: ) }) - it("never counts the app's own DevTools as leaving it", async () => { - const target = await launch(["devtools"]) - await runScenario( - target.page, - parseScenarioYaml(`version: 1 + it("never counts the app's own DevTools as leaving it, opened at once or later", async () => { + for (const mode of ["devtools", "devtools-late"]) { + const target = await launch([mode]) + await runScenario( + target.page, + parseScenarioYaml(`version: 1 steps: - - { id: wait, action: pause, ms: 1500 } - - { id: add, action: click, target: { by: role, role: button, name: Add note } } + - { id: wait, action: pause, ms: 3500 } + - { id: still, action: expect, that: { visible: { by: role, role: button, name: Add note } } } `), - project, - { electron: inTarget(target), timeoutMs: 5000 }, - ) + project, + { electron: inTarget(target), timeoutMs: 5000 }, + ) + } }) it("follows a window that opens blank and loads later; a frame of another window is said only", async () => { @@ -1072,7 +1285,7 @@ steps: expect(linked).not.toBe(real) await expect( checkConfinement( - { sandbox: linked, readable: [], private: [], network: "all" }, + { sandbox: linked, readable: [], private: [] }, join(mkdtempSync(join(tmpdir(), "kiframe-el-work-")), "canary.txt"), ), ).rejects.toThrow(/doesn't run programs/) @@ -1082,7 +1295,7 @@ steps: // /var/tmp: outside every folder the profile denies by itself (as a /Network/Users home). const home = mkdtempSync("/private/var/tmp/kiframe-el-home-") const sandbox = mkdtempSync(join(realpathSync(tmpdir()), "kiframe-el-sb-")) - const confinement = { sandbox, readable: [], network: "all" } as const + const confinement = { sandbox, readable: [] } as const try { // Denied (the canary unread) only as one of the user's own folders. await expect( @@ -1096,18 +1309,10 @@ steps: } }) - it("keeps a trial off the network but this machine (no updater fetch)", () => { - const profile = seatbeltProfile({ - sandbox: "/s", - readable: [], - private: [], - network: "loopback", - }) - expect(profile).toContain("(deny network-outbound)") - expect(profile).toContain('(allow network-outbound (remote ip "localhost:*"))') - expect( - seatbeltProfile({ sandbox: "/s", readable: [], private: [], network: "all" }), - ).not.toContain("(deny network-outbound)\n") + it("leaves the network to the app (its backend), never other programs' sockets", () => { + const profile = seatbeltProfile({ sandbox: "/s", readable: [], private: [] }) + expect(profile).not.toContain("(deny network-outbound)\n") + expect(profile).toContain("(deny network-outbound (remote unix-socket))") }) }, ) diff --git a/packages/runtime/test/fixtures/crash-launch.ts b/packages/runtime/test/fixtures/crash-launch.ts index 46cd301..c0f2fd6 100644 --- a/packages/runtime/test/fixtures/crash-launch.ts +++ b/packages/runtime/test/fixtures/crash-launch.ts @@ -1,6 +1,6 @@ // A Kiframe that launches a desktop app and is then killed (the test's crash): it prints the // launch's sandbox and waits. Args: . -import { launchElectron } from "../../src/electron.ts" +import { launchElectronWith } from "../../src/electron.ts" const [workDir, executable, bundle, readable, app] = process.argv.slice(2) as [ string, @@ -9,14 +9,14 @@ const [workDir, executable, bundle, readable, app] = process.argv.slice(2) as [ string, string, ] -const target = await launchElectron({ - executable, - bundle, - readable: [readable], - workDir, - allowUnconfined: true, - appArgs: [app, "hidden"], - viewport: { width: 800, height: 600, deviceScaleFactor: 1 }, -}) +const target = await launchElectronWith( + { + executable, + bundle, + workDir, + viewport: { width: 800, height: 600, deviceScaleFactor: 1 }, + }, + { readable: [readable], allowUnconfined: true, appArgs: [app, "hidden"] }, +) process.stdout.write(`${target.sandbox}\n`) setInterval(() => undefined, 60_000) diff --git a/packages/runtime/test/fixtures/electron-app/main.cjs b/packages/runtime/test/fixtures/electron-app/main.cjs index ce4eea1..a663db1 100644 --- a/packages/runtime/test/fixtures/electron-app/main.cjs +++ b/packages/runtime/test/fixtures/electron-app/main.cjs @@ -19,7 +19,12 @@ if (splash) if (process.argv.includes("trust-test-cert")) app.commandLine.appendSwitch("ignore-certificate-errors") -if (process.argv.includes("quit-at-once")) { +// "single": it allows one copy only (as Slack, VS Code, Discord): Chromium's lock, a unix socket +// in its temp folder. +if ( + process.argv.includes("quit-at-once") || + (process.argv.includes("single") && !app.requestSingleInstanceLock()) +) { app.quit() } else { app.whenReady().then(() => { @@ -73,18 +78,31 @@ if (process.argv.includes("quit-at-once")) { prefs.loadFile(join(__dirname, "other.html")) }, 2500) } - // "devtools": it opens DevTools itself (a dev build). + // "devtools": it opens DevTools itself (a dev build); "devtools-late": a while after it opens + // (once Kiframe is attached: a window that appears, then shows DevTools). if (process.argv.includes("devtools")) { win.webContents.once("did-finish-load", () => win.webContents.openDevTools({ mode: "detach" }), ) } + if (process.argv.includes("devtools-late")) { + setTimeout(() => win.webContents.openDevTools({ mode: "detach" }), 2500) + } // "elsewhere": its window shows a page that's never an app's own (a data: page). // "link=": the other site its links and windows go to (a test's local server); // "embed-at-start": it shows that site in a frame as it opens. const link = process.argv.find((a) => a.startsWith("link="))?.slice(5) ?? "https://example.com/" const query = { link, ...(process.argv.includes("embed-at-start") && { embed: "1" }) } - if (process.argv.includes("elsewhere")) win.loadURL("data:text/html,

Elsewhere

") + // "wrapper[=]": its window is a site (as Slack's app.slack.com); by default one that never + // loads (.invalid). "dev-down=": its dev server, not running. + const wrapper = process.argv.find((a) => a === "wrapper" || a.startsWith("wrapper=")) + const devDown = process.argv.find((a) => a.startsWith("dev-down="))?.slice(9) + if (wrapper !== undefined) { + win.loadURL( + wrapper === "wrapper" ? "https://kiframe-wrapper.invalid/client" : wrapper.slice(8), + ) + } else if (devDown !== undefined) win.loadURL(devDown) + else if (process.argv.includes("elsewhere")) win.loadURL("data:text/html,

Elsewhere

") else win.loadFile(join(__dirname, "index.html"), { query }) }) app.on("window-all-closed", () => app.quit()) diff --git a/packages/schema/src/apps.test.ts b/packages/schema/src/apps.test.ts index bb9d056..13dc0fa 100644 --- a/packages/schema/src/apps.test.ts +++ b/packages/schema/src/apps.test.ts @@ -298,6 +298,8 @@ describe("a desktop Electron app (design 2026-10-08)", () => { expect(issue({ x: desk({ args: ["--inspect=9229"] }) })).toMatch(/positional arguments only/) expect(issue({ x: desk({ args: ["-e"] }) })).toMatch(/positional arguments only/) expect(issue({ x: desk({ bundleId: "/Applications/Notes.app" }) })).toMatch(/a bundle id/) + // An underscore is a bundle id's (electron-builder's com.electron.my_app). + expect(issue({ x: desk({ bundleId: "com.electron.my_app" }) })).toBe("parsed") expect(issue({ x: desk({ bundleId: "notes" }) })).toMatch(/a bundle id/) expect(issue({ x: desk({ url: "https://a.dev" }) })).not.toBe("parsed") // Chromium trims an argument before telling a switch: spaces and control characters too. diff --git a/packages/schema/src/project.ts b/packages/schema/src/project.ts index f57e0dd..ca4c997 100644 --- a/packages/schema/src/project.ts +++ b/packages/schema/src/project.ts @@ -41,6 +41,9 @@ const HttpsOrigin = z return url !== null && url.protocol === "https:" && url.origin === s }, "an https origin (https://host, no path)") +/** A macOS bundle id's form (underscores too: macOS runs electron-builder's com.electron.my_app). */ +export const BUNDLE_ID = /^[A-Za-z][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+)+$/ + /** * A desktop app's argument as a path in the project's files/ (`files/a/b` → ["a", "b"]); undefined * for anything else (files/ itself, an empty, "." or ".." part: never outside it). @@ -60,10 +63,7 @@ export function filesPath(arg: string): string[] | undefined { export const ElectronApp = z.strictObject({ kind: z.literal("electron"), /** The macOS bundle id (`com.example.app`). */ - bundleId: z - .string() - .max(255) - .regex(/^[A-Za-z][A-Za-z0-9-]*(\.[A-Za-z0-9-]+)+$/, "a bundle id (com.example.app)"), + bundleId: z.string().max(255).regex(BUNDLE_ID, "a bundle id (com.example.app)"), /** * What the app opens: paths in the project's files/ (`files/demo-vault`; a copy is what it gets, * decided 2026-10-09), positional only, never a switch. diff --git a/vitest.config.ts b/vitest.config.ts index b2c4c7f..e623c69 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,7 +2,7 @@ import { configDefaults, defineConfig } from "vitest/config" // The desktop-app target's tests launch a real Electron app each: they run alone, after every other // suite (a CPU busy with them made timing-sensitive tests elsewhere flaky in CI). -const ELECTRON = "packages/runtime/test/electron.test.ts" +const ELECTRON = "packages/runtime/test/electron*.test.ts" export default defineConfig({ test: {