diff --git a/apps/desktop/e2e/desktop-app.e2e.ts b/apps/desktop/e2e/desktop-app.e2e.ts new file mode 100644 index 0000000..9b6c919 --- /dev/null +++ b/apps/desktop/e2e/desktop-app.e2e.ts @@ -0,0 +1,104 @@ +// "Add desktop app…" end to end (macOS: Seatbelt, codesign): the built app adds a real bundle (the +// repo's Electron, the runtime's fixture as its app, re-signed ad hoc) picked through main's +// dialog (stubbed), inspected, tried confined, written to the project and approved. Its work area +// is a test's own (never ~/.kiframe). +import { execFileSync } from "node:child_process" +import { cpSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs" +import { createRequire } from "node:module" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { createProject } from "@kiframe/project" +import { _electron as electron, type ElectronApplication, type Page } from "playwright" +import { afterAll, beforeAll, describe, expect, it } from "vitest" + +const appDir = join(import.meta.dirname, "..") +const mac = process.platform === "darwin" +let app: ElectronApplication | undefined +let page: Page +let root: string +let bundle: string +let work: string + +beforeAll(async () => { + if (!mac) return + root = mkdtempSync(join(tmpdir(), "kiframe-e2e-desktop-")) + // A real bundle with no hook in it: Electron cloned, the fixture as its app, re-signed. + const electronExe = createRequire(join(appDir, "package.json"))("electron") as string + bundle = join(root, "Notes.app") + execFileSync("cp", ["-cR", electronExe.slice(0, electronExe.indexOf(".app/") + 4), bundle]) + cpSync( + join(appDir, "../../packages/runtime/test/fixtures/electron-app"), + join(bundle, "Contents/Resources/app"), + { recursive: true }, + ) + const plist = join(bundle, "Contents/Info.plist") + execFileSync("plutil", [ + "-replace", + "CFBundleIdentifier", + "-string", + "com.kiframe.e2e-notes", + plist, + ]) + for (const key of ["CFBundleName", "CFBundleDisplayName"]) { + execFileSync("plutil", ["-replace", key, "-string", "Notes", plist]) + } + execFileSync("codesign", ["-s", "-", "-f", "--deep", bundle], { stdio: "ignore" }) + work = join(root, "work") + const profile = join(root, "profile") + const env = Object.fromEntries( + Object.entries(process.env).filter( + (e): e is [string, string] => + e[1] !== undefined && e[0] !== "ELECTRON_RENDERER_URL" && e[0] !== "ELECTRON_RUN_AS_NODE", + ), + ) + env.KIFRAME_TEST_KEYCHAIN = "memory" + env.KIFRAME_WORK_DIR = work + app = await electron.launch({ args: [appDir, `--user-data-dir=${profile}`], cwd: appDir, env }) + page = await app.firstWindow() + await page.getByLabel("OpenRouter API key").fill("sk-or-test-not-a-real-key") + await page.getByRole("button", { name: "Save key" }).click() +}, 120_000) + +afterAll(async () => { + await app?.close() + if (root !== undefined) rmSync(root, { recursive: true, force: true }) +}) + +describe.runIf(mac)("adding a desktop app", () => { + it("picks it, tries it confined, adds it to the project and approves it on this Mac", async () => { + const dir = join(root, "demo.kiframe") + createProject(dir, { id: "p1", name: "Desktop demo", url: "https://app.example" }) + await app!.evaluate(({ dialog }, picked) => { + dialog.showOpenDialog = () => Promise.resolve({ canceled: false, filePaths: [picked] }) + }, dir) + await page.getByRole("button", { name: "Open a project…" }).click() + await page.getByRole("button", { name: /app\.example/ }).click() + const panel = page.getByRole("dialog", { name: "Apps" }) + // The app picker answers with the bundle (main's dialog, stubbed). + await app!.evaluate(({ dialog }, picked) => { + dialog.showOpenDialog = () => Promise.resolve({ canceled: false, filePaths: [picked] }) + }, bundle) + await panel.getByRole("button", { name: "Add desktop app…" }).click() + const adding = panel.getByRole("region", { name: "Adding Notes" }) + await expect.poll(() => adding.textContent()).toMatch(/not signed by a developer: pinned/) + await adding.getByRole("button", { name: "Check" }).click() + await expect + .poll(() => adding.getByRole("status").textContent(), { timeout: 40_000 }) + .toMatch(/runs confined/) + await adding.getByRole("button", { name: "Add Notes" }).click() + await expect + .poll(() => panel.getByRole("list", { name: "The project's apps" }).textContent(), { + timeout: 10_000, + }) + .toMatch(/notes.*com\.kiframe\.e2e-notes.*Ready on this Mac/s) + const written = JSON.parse(readFileSync(join(dir, "project.json"), "utf8")) as { + apps: Record + } + expect(written.apps["notes"]).toMatchObject({ + kind: "electron", + bundleId: "com.kiframe.e2e-notes", + }) + // The trial's sandbox gone; nothing of it in the user's own work area. + expect(readdirSync(join(work, "sandboxes")).filter((n) => !n.endsWith(".canary"))).toEqual([]) + }, 120_000) +}) diff --git a/apps/desktop/src/main/desktop-apps.ts b/apps/desktop/src/main/desktop-apps.ts new file mode 100644 index 0000000..d8a9765 --- /dev/null +++ b/apps/desktop/src/main/desktop-apps.ts @@ -0,0 +1,578 @@ +// Desktop apps a user added (PR 3b, design reviewed 2026-10-09): approved on this Mac and per +// project, kept in app data (never the project: a project from someone else names an app by its +// bundle id only). An approval covers the app (its developer, or the exact build of an unsigned one) +// AND what the project opens with it (its arguments and sites): a project that changed either asks +// again. Adding one: picked by the user, inspected (nothing runs), tried confined, then added. +import { createHash, randomBytes, randomUUID } from "node:crypto" +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { type OpenedProject, saveProject } from "@kiframe/project" +import type { DesktopApp, Signer, TrialOptions, TrialOutcome } from "@kiframe/runtime" +import { BUNDLE_ID, type ElectronApp } from "@kiframe/schema" +import { z } from "zod" + +const SignerSchema = z.union([ + z.strictObject({ + kind: z.literal("team"), + team: z.string().max(64), + identifier: z.string().max(1024), + }), + z.strictObject({ kind: z.literal("pinned"), digest: z.string().regex(/^[0-9a-f]{64}$/) }), +]) + +/** One copy of an app approved on this Mac (where it was picked, who stands behind it). */ +const Approval = z.strictObject({ + /** Where the app was picked (its real path). */ + path: z.string().max(4096), + name: z.string().max(200), + signer: SignerSchema, + approvedAt: z.string(), + /** + * Per project folder (the registry's scope): what it opens with the app, approved, and the build + * it tried (each project's own: another's update never changes its status). + */ + scopes: z.record( + z.string(), + z.strictObject({ + opens: z.string().regex(/^[0-9a-f]{64}$/), + version: z.string().max(100).optional(), + }), + ), +}) +type Approval = z.infer + +/** At most this many copies of one app kept (the oldest let go): the file always reads back. */ +const MAX_COPIES = 20 + +/** A version as kept (cut: compared the same way). */ +const versionOf = (app: Pick) => app.version?.slice(0, 100) + +/** + * Each bundle id's copies (a release in Applications, a dev build elsewhere: one id, several + * apps): a project uses one of them. + */ +const File = z.strictObject({ + version: z.literal(1), + apps: z.record(z.string().regex(BUNDLE_ID), z.array(Approval).max(MAX_COPIES)), +}) +type File = z.infer + +/** What a project opens with an app (its arguments and sites), as one digest. */ +export function opensOf(app: Pick): string { + return createHash("sha256") + .update(JSON.stringify({ args: app.args ?? [], origins: app.origins ?? [] })) + .digest("hex") +} + +/** The store's key: bundle ids compare without case (as the schema's project does). */ +const keyOf = (bundleId: string) => bundleId.toLowerCase() + +/** + * The approvals file (`desktop-apps.json` in app data, 0600, written whole: on disk first, then + * kept). One that doesn't read is set aside (renamed, said once), never overwritten. + */ +export class DesktopApprovals { + readonly #path: string + #file: File + #problem: string | null = null + + /** Why the file was set aside: given once (the panel says it), then null. */ + takeProblem(): string | null { + const problem = this.#problem + this.#problem = null + return problem + } + + constructor(dir: string) { + this.#path = join(dir, "desktop-apps.json") + this.#file = { version: 1, apps: {} } + if (!existsSync(this.#path)) return + try { + this.#file = File.parse(JSON.parse(readFileSync(this.#path, "utf8"))) + } catch { + const aside = `${this.#path}.bad-${Date.now()}` + renameSync(this.#path, aside) + this.#problem = `the desktop apps' approvals didn't read: set aside as ${aside}; add them again` + } + } + + /** The app's copies approved on this Mac. */ + copies(bundleId: string): readonly Approval[] { + const key = keyOf(bundleId) + return Object.hasOwn(this.#file.apps, key) ? (this.#file.apps[key] ?? []) : [] + } + + /** The copy this project folder uses (one at most). */ + copyFor(bundleId: string, scope: string): Approval | undefined { + return this.copies(bundleId).find((copy) => Object.hasOwn(copy.scopes, scope)) + } + + /** + * The app (the build that was tried) approved for this project folder and what it opens: its + * copy (by place) kept with its other projects when it's the same build or developer, started + * afresh when another is there now; the project's other copy of that id let go. + */ + approve(app: DesktopApp, scope: string, opens: string): void { + const atPlace = (copy: Approval) => copy.path === app.path + const here = this.copies(app.bundleId).find(atPlace) + const others = this.copies(app.bundleId) + .filter((copy) => !atPlace(copy)) + .map((copy) => ({ ...copy, scopes: without(copy.scopes, scope) })) + .filter((copy) => Object.keys(copy.scopes).length > 0) + const kept = here !== undefined && sameSigner(here.signer, app.signer) ? here.scopes : {} + const version = versionOf(app) + const copy: Approval = { + path: app.path, + name: app.name.slice(0, 200), + signer: app.signer, + approvedAt: new Date().toISOString(), + scopes: { ...kept, [scope]: { opens, ...(version !== undefined && { version }) } }, + } + try { + this.#write({ + ...this.#file.apps, + [keyOf(app.bundleId)]: [...others, copy].slice(-MAX_COPIES), + }) + } catch (error) { + if ((error as Error).name !== "ZodError") throw error + throw new Error("this app's details can't be kept (too long?)", { cause: error }) + } + } + + /** This project folder's approval of the app taken back (the app removed from the project). */ + drop(bundleId: string, scope: string): void { + if (this.copyFor(bundleId, scope) === undefined) return + const copies = this.copies(bundleId) + .map((copy) => ({ ...copy, scopes: without(copy.scopes, scope) })) + .filter((copy) => Object.keys(copy.scopes).length > 0) + this.#write({ ...this.#file.apps, [keyOf(bundleId)]: copies }) + } + + /** Written whole, then kept (a write that fails changes nothing here either). */ + #write(apps: File["apps"]): void { + // What reads back (never a file that would be set aside at the next start). + const next: File = File.parse({ version: 1, apps }) + mkdirSync(dirname(this.#path), { recursive: true }) + const tmp = `${this.#path}.${randomBytes(6).toString("hex")}.tmp` + writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 }) + renameSync(tmp, this.#path) + this.#file = next + } +} + +function without(record: Readonly>, key: string): Record { + return Object.fromEntries(Object.entries(record).filter(([k]) => k !== key)) +} + +/** The same app: its developer (a team app updated is still theirs), or the same pinned build. */ +function sameSigner(a: Signer, b: Signer): boolean { + return a.kind === "team" + ? b.kind === "team" && a.team === b.team && a.identifier === b.identifier + : b.kind === "pinned" && a.digest === b.digest +} + +/** + * A desktop app of the project, as this Mac has it: `ready`; `updated` (same developer, a new build: + * tried before its next run); `allow` (approved for another project, or never on this Mac: pick it + * to approve it here); `opens-changed` (the project changed what it opens with it); `changed` (another + * developer, or another build of an unsigned one); `not-found` (no longer where it was picked). + */ +export type DesktopStatus = + | { status: "ready" | "updated" } + | { status: "allow" | "opens-changed" | "changed" | "not-found"; why: string } + +/** The functions that look at an app (the runtime's; fakes in tests). */ +export interface Looks { + inspect: (path: string, signal?: AbortSignal) => Promise + trial: (app: DesktopApp, opts: TrialOptions) => Promise +} + +/** + * The project's desktop app's status, static only (opening the panel never launches anything): its + * approval here first (what needs no look at the app), then the copy it uses, inspected, compared. + */ +export async function desktopStatus( + entry: ElectronApp, + scope: string, + approvals: DesktopApprovals, + looks: Pick, +): Promise { + const copy = approvals.copyFor(entry.bundleId, scope) + if (copy === undefined) { + return approvals.copies(entry.bundleId).length === 0 + ? { status: "allow", why: "not added on this Mac: add it from Applications to use it here" } + : { status: "allow", why: "not allowed in this project yet: pick it to allow it here" } + } + if (copy.scopes[scope]?.opens !== opensOf(entry)) { + return { + status: "opens-changed", + why: "the project changed what it opens with it: allow it again", + } + } + let now: DesktopApp + try { + now = await looks.inspect(copy.path) + } catch (error) { + if (!existsSync(copy.path)) { + return { status: "not-found", why: `not at ${copy.path} any more: add it again` } + } + return { status: "changed", why: (error as Error).message } + } + if (keyOf(now.bundleId) !== keyOf(entry.bundleId) || !sameSigner(copy.signer, now.signer)) { + return { status: "changed", why: "another app is there now (or another build): add it again" } + } + return versionOf(now) !== copy.scopes[scope]?.version && now.signer.kind === "team" + ? { status: "updated" } + : { status: "ready" } +} + +/** An app's name in the project: its own, in the app-name form, made unique. */ +export function appNameFor(name: string, taken: readonly string[]): string { + const base = + name + .normalize("NFKD") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^[^a-z]+/, "") + .replace(/-+$/, "") + .slice(0, 32) + .replace(/-+$/, "") || "app" + if (!taken.includes(base)) return base + for (let i = 2; ; i++) if (!taken.includes(`${base}-${i}`)) return `${base}-${i}` +} + +/** The app written to the project as `name` (refused by the project's own rules: said in words). */ +function save(opened: OpenedProject, name: string, entry: ElectronApp): void { + try { + saveProject(opened, { ...opened.project, apps: { ...opened.project.apps, [name]: entry } }) + } catch (error) { + // Only the project's rules reworded (a file changed on disk, a write failed: said as is). + if ((error as Error).name !== "ZodError") throw error + const issue = (error as { issues?: { message: string }[] }).issues?.[0]?.message + throw new Error(`the project can't take it so${issue === undefined ? "" : ` (${issue})`}`, { + cause: error, + }) + } +} + +/** What the window shows of a picked app (never its path). */ +export interface DesktopCard { + token: string + name: string + bundleId: string + version: string | undefined + electron: string + /** Who stands behind it: a developer's team id, or none (pinned to this build). */ + signer: { kind: "team"; team: string } | { kind: "pinned" } + /** The project already names it (a project from someone else): adding approves it here. */ + existing: string | undefined + /** + * What that project opens with it, shown before it's allowed: its arguments (paths in files/, + * not passed to the check) and the sites it shows as its own (the check runs with them). + */ + opens: { args: string[]; origins: string[] } | undefined +} + +/** + * What the add flow needs of the app around it (main's; a fake in tests): read fresh at each step, + * never a snapshot. + */ +export interface AddHost { + /** The open project's session (none when no project is open). */ + session(): string | undefined + opened(): OpenedProject | null + /** Why nothing may change now (Kif working), or undefined. */ + busy(): string | undefined + /** The app the user picks (main's dialog): undefined when cancelled. */ + pickApp(): Promise + /** The app approved for this project with what it opens. */ + approve(app: DesktopApp, opened: OpenedProject, opens: string): void + /** The project's apps changed (its agent and the window told, if it's still the open one). */ + changed(opened: OpenedProject): void +} + +interface Pending { + owner: number + session: string + app: DesktopApp + existing: string | undefined + /** What the project opened the app with when picked (an add after a change: refused). */ + opensAtPick: string | undefined + /** The site the last trial named (kept here: the window never names one). */ + site: string | undefined + /** The sites it runs with (the project's, and any the user allowed after a trial named one). */ + origins: string[] + /** Sites the user allowed in its checks (written to the project with it). */ + allowed: string[] + /** A trial worked with the sites it runs with now (they changed since: not tried). */ + tried: boolean + trial: AbortController | undefined + adding: boolean + /** Ends what's under way for it (its add's look at the app) when it's dropped. */ + stopping: AbortController +} + +/** Refused: said to the user as is. */ +const refused = (why: string) => new Error(why) +const GONE = "that app isn't being added any more: pick it again" + +/** + * The add flow, owned whole (PR 3b-2, redesigned after three review rounds): the app picked (main's + * dialog; its path stays here: the window has a token), inspected, tried confined, added. One + * gate before every change and after every wait: the add still under way, its window's, its + * project the open one, Kif not working. A project switch ends every add at once (main's hook); + * the gate is the guarantee whatever path changed it. + */ +export class DesktopAdds { + readonly #pending = new Map() + /** Each window's pick in progress (a cancel ends it: its answer dropped). */ + readonly #picks = new Map() + #generation = 0 + readonly #looks: Looks + readonly #host: AddHost + readonly #workDir: string | undefined + + constructor(looks: Looks, host: AddHost, workDir?: string) { + this.#looks = looks + this.#host = host + this.#workDir = workDir + } + + /** + * The user picks an app (one pick per window at a time), inspected: its card, null when the + * dialog was cancelled or the pick given up meanwhile; refused: thrown, said. + */ + async pick(owner: number, session: string): Promise { + if (this.#picks.has(owner)) throw refused("an app is being picked already") + this.#current(session) + this.dropFor(owner) + const generation = ++this.#generation + const stopping = new AbortController() + this.#picks.set(owner, { generation, stopping }) + const still = () => + this.#picks.get(owner)?.generation === generation && this.#isCurrent(session) + try { + const path = await this.#host.pickApp() + if (path === undefined || !still()) return null + let app: DesktopApp + try { + app = await this.#looks.inspect(path, stopping.signal) + } catch (error) { + if (stopping.signal.aborted) return null + throw error + } + if (!still()) return null + this.#current(session) + const opened = this.#host.opened() + if (opened === null) return null + const named = Object.entries(opened.project.apps).find( + ([, a]) => a.kind === "electron" && keyOf(a.bundleId) === keyOf(app.bundleId), + ) + const existing = named?.[0] + // Named already: tried with the sites the project lists for it (what it'll run with), shown. + const entry = named?.[1].kind === "electron" ? named[1] : undefined + const listed = [...(entry?.origins ?? [])] + const token = randomUUID() + this.#pending.set(token, { + owner, + session, + app, + existing, + opensAtPick: entry === undefined ? undefined : opensOf(entry), + site: undefined, + origins: listed, + allowed: [], + tried: false, + trial: undefined, + adding: false, + stopping: new AbortController(), + }) + return { + token, + name: app.name, + bundleId: app.bundleId, + version: app.version, + electron: app.electron, + signer: + app.signer.kind === "team" ? { kind: "team", team: app.signer.team } : { kind: "pinned" }, + existing, + opens: entry === undefined ? undefined : { args: [...(entry.args ?? [])], origins: listed }, + } + } finally { + if (this.#picks.get(owner)?.generation === generation) this.#picks.delete(owner) + } + } + + /** + * The picked app tried, confined. `allowSite`: with the site the last trial named (kept here), + * as the app's own: tried again before it can be added. + */ + async check( + owner: number, + session: string, + token: string, + allowSite: boolean, + ): Promise { + const pending = this.#own(owner, session, token) + if (pending.trial !== undefined) throw refused("it's being checked already") + if (pending.adding) throw refused("it's being added already") + if (allowSite) { + if (pending.site === undefined) throw refused("no site to allow: check it first") + pending.origins = [...new Set([...pending.origins, pending.site])] + pending.allowed = [...new Set([...pending.allowed, pending.site])] + } + // What it runs with now is untried until this trial says so. + pending.tried = false + const stopping = new AbortController() + pending.trial = stopping + try { + const outcome = await this.#looks.trial(pending.app, { + origins: pending.origins, + signal: stopping.signal, + ...(this.#workDir !== undefined && { workDir: this.#workDir }), + }) + this.#own(owner, session, token) + pending.tried = "ok" in outcome + pending.site = "site" in outcome ? outcome.site : undefined + return outcome + } finally { + pending.trial = undefined + } + } + + /** + * The tried app added to the project (or, named already, allowed here): its build checked again + * (the one tried), the project written (its changed-on-disk refusal), then approved. Once the + * project is written the add is done (an approval that fails then: said, allowed again later). + */ + async add(owner: number, session: string, token: string): Promise { + const pending = this.#own(owner, session, token) + if (pending.trial !== undefined) throw refused("it's being checked: wait for it") + if (!pending.tried) throw refused("check it first: it's added once it ran here") + if (pending.adding) throw refused("it's being added already") + pending.adding = true + try { + const now = await this.#looks + .inspect(pending.app.path, pending.stopping.signal) + .catch((error: unknown) => { + // Given up meanwhile: said as such (the gate's words); else its own reason. + if (pending.stopping.signal.aborted) this.#own(owner, session, token) + throw error + }) + this.#own(owner, session, token) + if (!sameSigner(now.signer, pending.app.signer)) { + throw refused("the app changed since it was checked: pick it again") + } + const opened = this.#host.opened() + if (opened === null) throw refused(GONE) + let name = pending.existing + let entry: ElectronApp + if (name !== undefined) { + const named = opened.project.apps[name] + // What the user saw at the pick, still (a project changed meanwhile: never approved untried). + if (named?.kind !== "electron" || opensOf(named) !== pending.opensAtPick) { + throw refused("the project changed meanwhile: pick it again") + } + entry = named + // A site allowed in a check: the project's too (its runs need it). + if (pending.allowed.length > 0) { + entry = { + ...named, + origins: [...new Set([...(named.origins ?? []), ...pending.allowed])], + } + save(opened, name, entry) + } + } else { + name = appNameFor(now.name, Object.keys(opened.project.apps)) + entry = { + kind: "electron", + bundleId: now.bundleId, + ...(pending.origins.length > 0 && { origins: pending.origins }), + viewport: { width: 1440, height: 900, deviceScaleFactor: 1 }, + } + save(opened, name, entry) + } + // Written: done, whatever follows. + this.#pending.delete(token) + this.#host.changed(opened) + try { + // The build that was tried (a newer one since, same developer: "updated", tried at its run). + this.#host.approve(pending.app, opened, opensOf(entry)) + } catch (error) { + throw refused( + `added as ${name}, but not approved on this Mac (${(error as Error).message}): pick it again to allow it`, + ) + } + return name + } finally { + pending.adding = false + } + } + + /** A window's pick and add given up (cancelled, the window closed or reloaded). */ + dropFor(owner: number): void { + this.#picks.get(owner)?.stopping.abort() + this.#picks.delete(owner) + for (const [token, pending] of this.#pending) { + if (pending.owner !== owner) continue + pending.trial?.abort() + pending.stopping.abort() + this.#pending.delete(token) + } + } + + /** Every pick and add given up, their trials ended (a project switch, a quit). */ + dropAll(): void { + for (const pick of this.#picks.values()) pick.stopping.abort() + this.#picks.clear() + for (const pending of this.#pending.values()) { + pending.trial?.abort() + pending.stopping.abort() + } + this.#pending.clear() + } + + #isCurrent(session: string): boolean { + return this.#host.session() === session + } + + /** The session the window acts in is the open project's, and nothing may stop a change now. */ + #current(session: string): void { + if (!this.#isCurrent(session)) throw refused("the project changed: pick the app again") + const busy = this.#host.busy() + if (busy !== undefined) throw refused(busy) + } + + /** The gate: this window's add, still under way, its project the open one, Kif not working. */ + #own(owner: number, session: string, token: string): Pending { + const pending = this.#pending.get(token) + if (pending !== undefined && !this.#isCurrent(pending.session)) { + pending.trial?.abort() + pending.stopping.abort() + this.#pending.delete(token) + } + const still = this.#pending.get(token) + if (still === undefined || still.owner !== owner || still.session !== session) + throw refused(GONE) + const busy = this.#host.busy() + if (busy !== undefined) throw refused(busy) + return still + } +} + +/** The add flow's host from the workspace (main's, and tests' with a real one). */ +export function addHostOf( + workspace: { + readonly session: string | undefined + readonly opened: OpenedProject | null + readonly agent: { readonly running: boolean } | undefined + }, + deps: Pick, +): AddHost { + return { + session: () => workspace.session, + opened: () => workspace.opened, + busy: () => (workspace.agent?.running === true ? "Kif is working: stop it first" : undefined), + ...deps, + } +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index b8522e2..be4f9c7 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -3,12 +3,20 @@ import { join } from "node:path" import { fileURLToPath } from "node:url" import { type LlmClient, OpenAiCompatibleClient } from "@kiframe/agent" import { type OpenedProject, TakeStore } from "@kiframe/project" +import { defaultWorkDir, inspectDesktopApp, sweepWorkArea, trialDesktopApp } from "@kiframe/runtime" import { keychainBackend, memoryBackend } from "@kiframe/vault" import { app, type BrowserWindow, dialog, shell } from "electron" import { type Browser, chromium } from "playwright" import type { AppStatus } from "../shared/ipc.ts" import { errorMessage } from "../shared/util.ts" import { AgentHost } from "./agent.ts" +import { + addHostOf, + DesktopAdds, + DesktopApprovals, + desktopStatus, + type Looks, +} from "./desktop-apps.ts" import { FileVersions } from "./file-versions.ts" import { resolveAppAddress } from "./app-address.ts" import { emit, registerHandlers } from "./ipc.ts" @@ -261,6 +269,51 @@ function start(): void { return status() } + // Desktop apps (macOS): inspected and tried by the runtime; approvals in app data. + const looks: Looks = { inspect: inspectDesktopApp, trial: trialDesktopApp } + // Kiframe's work area (~/.kiframe); a test's own (never the user's) when it names one. + const workDir = process.env.KIFRAME_WORK_DIR ?? defaultWorkDir() + const adds: DesktopAdds = new DesktopAdds( + looks, + addHostOf(workspace, { + pickApp: async () => { + const picked = await dialog.showOpenDialog(parent(), { + title: "Add a desktop app", + buttonLabel: "Choose", + defaultPath: "/Applications", + properties: ["openFile"], + filters: [{ name: "Applications", extensions: ["app"] }], + }) + return picked.canceled ? undefined : picked.filePaths[0] + }, + approve: (picked, opened, opens) => + desktopApprovals().approve(picked, ids().scope(opened.dir), opens), + changed: (opened) => appsChanged(opened), + }), + workDir, + ) + // Every add ends, its trial too, as the project switches. + // Every add ends, its trial too, as the project switches. + workspace.onSwitch(() => adds.dropAll()) + let approvals: DesktopApprovals | undefined + const desktopApprovals = (): DesktopApprovals => + (approvals ??= new DesktopApprovals(app.getPath("userData"))) + /** The open project, if it's this session's (else why not). */ + const sessionProject = (session: string): OpenedProject | string => { + const opened = workspace.opened + if (opened === null || workspace.session !== session) return "the project changed" + return opened + } + /** The project's apps changed: its agent and the window told (never another project's). */ + const appsChanged = (opened: OpenedProject): void => { + if (workspace.opened !== opened) return + workspace.agent?.appsChanged(opened.project.apps) + void status().then((now) => { + if (workspace.opened === opened) emit(window, "status", now) + }) + } + const owner = () => window?.webContents.id ?? 0 + /** The window a dialog belongs to (modal to it: never opened behind it). */ const parent = (): BrowserWindow => { if (window === null) throw new Error("no window to show the dialog in") @@ -270,7 +323,14 @@ function start(): void { const showWindow = () => { if (window === null) { window = createWindow(join(here, "../preload"), icon, devServer) + const closing = window.webContents.id + // A reload keeps the id: its add given up all the same (the window starts afresh). + window.webContents.on("did-start-navigation", (event) => { + if (event.isMainFrame && !event.isSameDocument) adds.dropFor(closing) + }) window.on("closed", () => { + // Its add dropped, its trial ended (macOS keeps the app open without a window). + adds.dropFor(closing) window = null }) return @@ -295,6 +355,8 @@ function start(): void { event.preventDefault() if (cleanup === "running") return cleanup = "running" + // Every desktop app's trial ended (its confined app killed with it). + adds.dropAll() const work = (async () => { // The browser let go within the close, as for project:close (an open in flight never comes // between); a close refused still lets it go: the app is quitting. @@ -477,12 +539,61 @@ function start(): void { } catch (e) { return message(e) } - workspace.agent?.appsChanged(opened.project.apps) - void status().then((now) => { - if (workspace.opened === opened) emit(window, "status", now) - }) + // A desktop app's approval in this project goes with it (its other projects' stay). + if (removed?.kind === "electron") { + try { + desktopApprovals().drop(removed.bundleId, ids().scope(opened.dir)) + } catch (e) { + say(`its approval on this Mac couldn't be removed: ${message(e)}`) + } + } + appsChanged(opened) return null }, + "apps:desktop-pick": async ({ session }) => { + if (process.platform !== "darwin") { + return { refused: "desktop apps run on macOS only (they're confined there)" } + } + try { + const card = await adds.pick(owner(), session) + return card === null ? null : { card } + } catch (e) { + return { refused: message(e) } + } + }, + "apps:desktop-check": async ({ session, token, allowSite }) => { + try { + return await adds.check(owner(), session, token, allowSite) + } catch (e) { + return { failed: message(e) } + } + }, + "apps:desktop-add": async ({ session, token }) => { + try { + await adds.add(owner(), session, token) + return null + } catch (e) { + return message(e) + } + }, + "apps:desktop-cancel": () => adds.dropFor(owner()), + "apps:desktop-status": async ({ session }) => { + const opened = sessionProject(session) + const apps: Record>> = {} + if (typeof opened === "string") return { apps, problem: null } + const store = desktopApprovals() + const scope = ids().scope(opened.dir) + await Promise.all( + Object.entries(opened.project.apps).map(async ([name, entry]) => { + if (entry.kind !== "electron") return + apps[name] = + process.platform === "darwin" + ? await desktopStatus(entry, scope, store, looks) + : { status: "not-found", why: "desktop apps run on macOS only" } + }), + ) + return { apps, problem: store.takeProblem() } + }, "preview:open": (sceneId) => { const opened = workspace.opened if (opened === null) return { ok: false, why: "No project is open." } @@ -506,6 +617,12 @@ function start(): void { // After the window's first read: pushed to it (not an action's result). say(`couldn't clean up old recordings: ${message(e)}`) } + // What a crash left of desktop apps' launches (never another running Kiframe's). + if (process.platform === "darwin") { + sweepWorkArea(workDir).catch((e: unknown) => + say(`couldn't clean up desktop apps' leftovers: ${message(e)}`), + ) + } // Sealed, then scratch beyond the budget (said if it fails). void sealTakes().then(() => keeper?.evict().catch((e: unknown) => say(`couldn't tidy old recordings: ${message(e)}`)), diff --git a/apps/desktop/src/main/workspace.ts b/apps/desktop/src/main/workspace.ts index e0f1ce8..ba89ba2 100644 --- a/apps/desktop/src/main/workspace.ts +++ b/apps/desktop/src/main/workspace.ts @@ -18,6 +18,7 @@ export class Workspace { #session = "" #agent: A | undefined #switching: Promise = Promise.resolve() + readonly #onSwitch: (() => void)[] = [] readonly #ready: () => void @@ -39,6 +40,16 @@ export class Workspace { return this.#opened } + /** This opening's session; none when no project is open. */ + get session(): string | undefined { + return this.#opened === null ? undefined : this.#session + } + + /** Told as a project is opened, created or closed (what belonged to the last one ends). */ + onSwitch(listener: () => void): void { + this.#onSwitch.push(listener) + } + /** The open project's apps and this opening's session, without building its whole view. */ apps(): Pick | undefined { if (this.#opened === null) return undefined @@ -86,6 +97,14 @@ export class Workspace { this.#opened = opened this.#agent = agent this.#session = randomBytes(6).toString("hex") + // A listener that fails never keeps the old agent from closing. + for (const listener of this.#onSwitch) { + try { + listener() + } catch { + // said by the listener itself, if anywhere + } + } try { await old?.close() } finally { diff --git a/apps/desktop/src/renderer/src/components/apps-panel.tsx b/apps/desktop/src/renderer/src/components/apps-panel.tsx index 658e77f..dfabcbd 100644 --- a/apps/desktop/src/renderer/src/components/apps-panel.tsx +++ b/apps/desktop/src/renderer/src/components/apps-panel.tsx @@ -1,11 +1,29 @@ -// The project's apps: each with its exact origin (main's), and a way to remove one (never the -// first: where scenes start). Main asks before removing, naming the scenes that use it. -import { Globe, WarningCircle, X } from "@phosphor-icons/react" -import { useEffect, useRef, useState } from "react" -import { appViewIdentity, type ProjectView } from "../../../shared/ipc.ts" +// The project's apps: each with its exact origin (main's) or its bundle id, and a way to remove one +// (never the first: where scenes start). Main asks before removing, naming the scenes that use it. +// On macOS, a desktop app is added here: main shows the picker and inspects it, the card tries it +// confined, then adds it (the window holds a token, never the app's path or a site to allow). +import { AppWindow, Globe, WarningCircle, X } from "@phosphor-icons/react" +import { useCallback, useEffect, useRef, useState } from "react" +import { + appViewIdentity, + type DesktopCheck, + type DesktopPick, + type DesktopStatusView, + type ProjectView, +} from "../../../shared/ipc.ts" import { api } from "../api.ts" import { useChat } from "../chat-store.ts" +type Card = Extract["card"] + +/** A desktop app's status on this Mac, in words. */ +function statusText(entry: DesktopStatusView["apps"][string] | undefined): string { + if (entry === undefined) return "" + if (entry.status === "ready") return "Ready on this Mac" + if (entry.status === "updated") return "Updated: tried confined before its next run" + return entry.why ?? "" +} + export function AppsPanel({ project, onClose, @@ -14,8 +32,18 @@ export function AppsPanel({ onClose: () => void }) { const running = useChat((s) => s.running) + const mac = api().platform === "darwin" const [error, setError] = useState(null) const [busy, setBusy] = useState(false) + const [statuses, setStatuses] = useState({}) + const [card, setCard] = useState(null) + const [outcome, setOutcome] = useState(null) + const [checking, setChecking] = useState(false) + const [picking, setPicking] = useState(false) + const [adding, setAdding] = useState(false) + // The card a check's answer belongs to (an answer for a card given up: dropped). + const current = useRef(null) + current.current = card?.token ?? null const close = useRef(onClose) close.current = onClose useEffect(() => { @@ -26,6 +54,32 @@ export function AppsPanel({ return () => document.removeEventListener("keydown", onKey) }, []) + const hasDesktop = project.apps.some((a) => a.kind === "electron") + const refresh = useCallback(async () => { + if (!hasDesktop) return + const view = await api() + .invoke("apps:desktop-status", { session: project.session }) + .catch((e: unknown) => { + setError(`the desktop apps' status couldn't be read: ${String(e)}`) + return null + }) + if (view === null) return + setStatuses(view.apps) + if (view.problem !== null) setError(view.problem) + }, [hasDesktop, project.session]) + useEffect(() => { + void refresh() + }, [refresh]) + + // An add given up when the panel closes (its trial ended in main). + useEffect( + () => () => + void api() + .invoke("apps:desktop-cancel") + .catch(() => undefined), + [], + ) + const remove = async (name: string, identity: string) => { if (busy) return setBusy(true) @@ -36,6 +90,83 @@ export function AppsPanel({ setError(refused) } + // Busy flags read at once (a double click within one frame sees the first). + const pickingNow = useRef(false) + // The card an add is under way for (a card given up frees the panel at once). + const addingNow = useRef(null) + // The card a check is under way for (another card's Check is its own). + const checkingNow = useRef(null) + const pick = async () => { + if (pickingNow.current || (addingNow.current !== null && addingNow.current === current.current)) + return + pickingNow.current = true + setError(null) + setOutcome(null) + setPicking(true) + const picked = await api() + .invoke("apps:desktop-pick", { session: project.session }) + .catch((e: unknown) => ({ refused: String(e) })) + pickingNow.current = false + setPicking(false) + if (picked === null) return + if ("refused" in picked) { + setCard(null) + setError(picked.refused) + return + } + setCard(picked.card) + } + + const check = async (allowSite: boolean) => { + if (card === null || checkingNow.current === card.token) return + const token = card.token + checkingNow.current = token + setChecking(true) + setOutcome(null) + const result = await api() + .invoke("apps:desktop-check", { session: project.session, token, allowSite }) + .catch((e: unknown) => ({ failed: String(e) })) + if (checkingNow.current === token) checkingNow.current = null + // Given up meanwhile (cancelled, another app picked): never shown on another card. + if (current.current !== token) return + setChecking(false) + setOutcome(result) + } + + const add = async () => { + if (card === null || addingNow.current === card.token) return + const token = card.token + addingNow.current = token + setAdding(true) + const refused = await api() + .invoke("apps:desktop-add", { session: project.session, token }) + .catch((e: unknown) => String(e)) + if (addingNow.current === token) addingNow.current = null + setAdding(false) + // Given up meanwhile (cancelled, another app picked): never said on another card. + if (current.current !== token) { + if (refused === null) await refresh() + return + } + if (refused !== null) { + setError(refused) + return + } + setCard(null) + setOutcome(null) + await refresh() + } + + const cancel = () => { + setCard(null) + setOutcome(null) + setChecking(false) + setAdding(false) + void api() + .invoke("apps:desktop-cancel") + .catch(() => undefined) + } + return (
@@ -47,14 +178,18 @@ export function AppsPanel({

- The sites this project’s scenes may open. Kif asks before adding one. + The sites and desktop apps this project’s scenes may open. Kif asks before adding a site; + you add desktop apps.

    {project.apps.map((app, i) => (
  • - + {app.kind === "electron" ? : } {app.name} {appViewIdentity(app)} + {app.kind === "electron" && ( + {statusText(statuses[app.name])} + )}
    {i === 0 ? ( Where scenes start @@ -72,6 +207,73 @@ export function AppsPanel({
  • ))}
+ {mac && card === null && ( + + )} + {card !== null && ( +
+

+ {card.name} {card.version ?? ""} · Electron {card.electron} ·{" "} + {card.signer.kind === "team" + ? `signed by developer ${card.signer.team}` + : "not signed by a developer: pinned to this build"} +

+ {card.existing !== undefined && ( +

+ This project names it already (as {card.existing}): checking it allows it here, with + what the project opens with it:{" "} + {card.opens === undefined || + (card.opens.args.length === 0 && card.opens.origins.length === 0) + ? "nothing more." + : [ + ...card.opens.origins.map((o) => `the site ${o} as its own`), + ...card.opens.args.map((a) => `${a} (not opened by the check)`), + ].join(", ")} +

+ )} +

+ Kiframe opens {card.name} confined (it can’t read or change your files, your own{" "} + {card.name}’s data or its preferences) for a few seconds, to check it works there. +

+ {outcome !== null &&

{outcomeText(outcome, card.name)}

} +
+ {outcome !== null && "ok" in outcome ? ( + + ) : outcome !== null && "site" in outcome ? ( + + ) : ( + + )} + +
+
+ )} {error !== null && (
@@ -82,3 +284,13 @@ export function AppsPanel({
) } + +/** A trial's outcome, in words. */ +function outcomeText(outcome: DesktopCheck, name: string): string { + if ("ok" in outcome) return `${name} runs confined: Kiframe can drive it.` + if ("site" in outcome) { + return `${name}’s window is the site ${outcome.site}: if that’s ${name}’s own, allow it.` + } + if ("quit" in outcome) return `${name} quit at once: it may refuse automation.` + return outcome.failed +} diff --git a/apps/desktop/src/renderer/src/styles.css b/apps/desktop/src/renderer/src/styles.css index 9e945c4..af703e7 100644 --- a/apps/desktop/src/renderer/src/styles.css +++ b/apps/desktop/src/renderer/src/styles.css @@ -1708,6 +1708,18 @@ ul.strip-cards > li { color: var(--muted); } +/* A desktop app being added: what it is, its check's outcome, the next step. */ +.app-card { + margin-top: 8px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 8px; +} + +.app-card p { + margin: 0 0 8px; +} + /* The app chip opens the apps panel; "+2" when there are more. */ .chip-button { cursor: pointer; diff --git a/apps/desktop/src/renderer/test/apps.test.tsx b/apps/desktop/src/renderer/test/apps.test.tsx index ccca81e..ec3b981 100644 --- a/apps/desktop/src/renderer/test/apps.test.tsx +++ b/apps/desktop/src/renderer/test/apps.test.tsx @@ -95,3 +95,218 @@ describe("the project's apps", () => { ) }) }) + +// PR 3b: a desktop app added from the panel (main picks, inspects, tries; the window holds a token). +describe("adding a desktop app", () => { + const withDesktop: ProjectView = { + ...project, + apps: [...project.apps, { name: "notes", kind: "electron", bundleId: "com.example.Notes" }], + } + const card = { + token: "6a1c6c1e-0f0b-4d5e-9a3b-2c1d0e9f8a7b", + name: "Slack", + bundleId: "com.tinyspeck.slackmacgap", + version: "4.41", + electron: "44.4.5", + signer: { kind: "team" as const, team: "BQR82RBBHL" }, + existing: undefined, + opens: undefined, + } + + it("shows each desktop app's status on this Mac (nothing launched to know it)", async () => { + const { invoke } = stubApi({ + "app:status": () => status({ hasKey: true, project: withDesktop }), + "apps:desktop-status": () => ({ + apps: { + notes: { status: "allow", why: "approved for another project: allow it in this one" }, + }, + problem: null, + }), + "apps:desktop-cancel": () => undefined, + }) + render() + fireEvent.click(await screen.findByRole("button", { name: /app\.test/ })) + const panel = screen.getByRole("dialog", { name: "Apps" }) + expect(await within(panel).findByText(/approved for another project/)).toBeTruthy() + expect(invoke).toHaveBeenCalledWith("apps:desktop-status", { session: "s1" }) + }) + + it("picks, checks, allows a wrapper's site, then adds: never sending a path or a site", async () => { + let checks = 0 + const { invoke } = stubApi({ + "app:status": () => status({ hasKey: true, project }), + "apps:desktop-pick": () => ({ card }), + "apps:desktop-check": () => + checks++ === 0 ? { site: "https://app.slack.com" } : { ok: true }, + "apps:desktop-add": () => null, + "apps:desktop-cancel": () => undefined, + }) + render() + fireEvent.click(await screen.findByRole("button", { name: /app\.test/ })) + const panel = screen.getByRole("dialog", { name: "Apps" }) + fireEvent.click(within(panel).getByRole("button", { name: "Add desktop app…" })) + const adding = await within(panel).findByRole("region", { name: "Adding Slack" }) + expect(adding.textContent).toContain("signed by developer BQR82RBBHL") + expect(within(adding).queryByRole("button", { name: "Add Slack" })).toBeNull() + fireEvent.click(within(adding).getByRole("button", { name: "Check" })) + fireEvent.click( + await within(adding).findByRole("button", { + name: "Allow https://app.slack.com and check again", + }), + ) + fireEvent.click(await within(adding).findByRole("button", { name: "Add Slack" })) + await act(async () => { + await Promise.resolve() + }) + expect(invoke).toHaveBeenCalledWith("apps:desktop-check", { + session: "s1", + token: card.token, + allowSite: false, + }) + expect(invoke).toHaveBeenCalledWith("apps:desktop-check", { + session: "s1", + token: card.token, + allowSite: true, + }) + expect(invoke).toHaveBeenCalledWith("apps:desktop-add", { session: "s1", token: card.token }) + // The window named no path and no site: only its session, the token and a yes. + expect(JSON.stringify(invoke.mock.calls)).not.toMatch(/Applications|slack\.com"/) + }) + + it("says why an app can't be added, and offers nothing off macOS", async () => { + stubApi({ + "app:status": () => status({ hasKey: true, project }), + "apps:desktop-pick": () => ({ + refused: "that isn't an Electron app: Kiframe drives Electron apps only", + }), + "apps:desktop-cancel": () => undefined, + }) + render() + fireEvent.click(await screen.findByRole("button", { name: /app\.test/ })) + const panel = screen.getByRole("dialog", { name: "Apps" }) + fireEvent.click(within(panel).getByRole("button", { name: "Add desktop app…" })) + expect((await within(panel).findByRole("alert")).textContent).toMatch(/Electron apps only/) + cleanup() + window.kiframe = { ...window.kiframe, platform: "linux" } + render() + fireEvent.click(await screen.findByRole("button", { name: /app\.test/ })) + expect(screen.queryByRole("button", { name: "Add desktop app…" })).toBeNull() + }) + + it("shows what a project that names the app opens with it, before it's allowed", async () => { + stubApi({ + "app:status": () => status({ hasKey: true, project }), + "apps:desktop-pick": () => ({ + card: { + ...card, + existing: "slack", + opens: { args: ["files/vault"], origins: ["https://evil.example"] }, + }, + }), + "apps:desktop-cancel": () => undefined, + }) + render() + fireEvent.click(await screen.findByRole("button", { name: /app\.test/ })) + const panel = screen.getByRole("dialog", { name: "Apps" }) + fireEvent.click(within(panel).getByRole("button", { name: "Add desktop app…" })) + const adding = await within(panel).findByRole("region", { name: "Adding Slack" }) + expect(adding.textContent).toContain("the site https://evil.example as its own") + expect(adding.textContent).toContain("files/vault (not opened by the check)") + }) + + it("never shows a check's answer on a card picked after it was given up", async () => { + let answer: (v: unknown) => void = () => undefined + let picks = 0 + stubApi({ + "app:status": () => status({ hasKey: true, project }), + "apps:desktop-pick": () => ({ + card: + picks++ === 0 + ? card + : { ...card, token: "7b2d6d2f-1a1c-4e6f-8b4c-3d2e1f0a9b8c", name: "Notion" }, + }), + "apps:desktop-check": () => new Promise((resolve) => (answer = resolve)) as never, + "apps:desktop-cancel": () => undefined, + }) + render() + fireEvent.click(await screen.findByRole("button", { name: /app\.test/ })) + const panel = screen.getByRole("dialog", { name: "Apps" }) + fireEvent.click(within(panel).getByRole("button", { name: "Add desktop app…" })) + const slack = await within(panel).findByRole("region", { name: "Adding Slack" }) + fireEvent.click(within(slack).getByRole("button", { name: "Check" })) + fireEvent.click(within(slack).getByRole("button", { name: "Cancel" })) + fireEvent.click(within(panel).getByRole("button", { name: "Add desktop app…" })) + const notion = await within(panel).findByRole("region", { name: "Adding Notion" }) + await act(async () => { + answer({ ok: true }) + await Promise.resolve() + }) + expect(within(notion).queryByRole("status")).toBeNull() + expect(within(notion).queryByRole("button", { name: "Add Notion" })).toBeNull() + }) + + it("never says an add given up on the card picked after it", async () => { + let answer: (v: unknown) => void = () => undefined + let picks = 0 + stubApi({ + "app:status": () => status({ hasKey: true, project }), + "apps:desktop-pick": () => ({ + card: + picks++ === 0 + ? card + : { ...card, token: "7b2d6d2f-1a1c-4e6f-8b4c-3d2e1f0a9b8c", name: "Notion" }, + }), + "apps:desktop-check": () => ({ ok: true }), + "apps:desktop-add": () => new Promise((resolve) => (answer = resolve)) as never, + "apps:desktop-cancel": () => undefined, + }) + render() + fireEvent.click(await screen.findByRole("button", { name: /app\.test/ })) + const panel = screen.getByRole("dialog", { name: "Apps" }) + fireEvent.click(within(panel).getByRole("button", { name: "Add desktop app…" })) + const slack = await within(panel).findByRole("region", { name: "Adding Slack" }) + fireEvent.click(within(slack).getByRole("button", { name: "Check" })) + fireEvent.click(await within(slack).findByRole("button", { name: "Add Slack" })) + fireEvent.click(within(slack).getByRole("button", { name: "Cancel" })) + // Given up: the panel's free at once (main refuses that add's write). + expect( + within(panel).getByRole("button", { name: "Add desktop app…" }).hasAttribute("disabled"), + ).toBe(false) + await act(async () => { + answer("the project can't take it so") + await Promise.resolve() + }) + // The add given up: its answer said nowhere. + expect(within(panel).queryByRole("alert")).toBeNull() + fireEvent.click(within(panel).getByRole("button", { name: "Add desktop app…" })) + await within(panel).findByRole("region", { name: "Adding Notion" }) + }) + + it("checks the next card at once, whatever a given-up card's check is still doing", async () => { + let checks = 0 + let picks = 0 + stubApi({ + "app:status": () => status({ hasKey: true, project }), + "apps:desktop-pick": () => ({ + card: + picks++ === 0 + ? card + : { ...card, token: "7b2d6d2f-1a1c-4e6f-8b4c-3d2e1f0a9b8c", name: "Notion" }, + }), + "apps:desktop-check": () => + (checks++ === 0 ? new Promise(() => undefined) : { ok: true }) as never, + "apps:desktop-cancel": () => undefined, + }) + render() + fireEvent.click(await screen.findByRole("button", { name: /app\.test/ })) + const panel = screen.getByRole("dialog", { name: "Apps" }) + fireEvent.click(within(panel).getByRole("button", { name: "Add desktop app…" })) + const slack = await within(panel).findByRole("region", { name: "Adding Slack" }) + fireEvent.click(within(slack).getByRole("button", { name: "Check" })) + fireEvent.click(within(slack).getByRole("button", { name: "Cancel" })) + fireEvent.click(within(panel).getByRole("button", { name: "Add desktop app…" })) + const notion = await within(panel).findByRole("region", { name: "Adding Notion" }) + fireEvent.click(within(notion).getByRole("button", { name: "Check" })) + expect(await within(notion).findByRole("button", { name: "Add Notion" })).toBeTruthy() + }) +}) diff --git a/apps/desktop/src/shared/channels.ts b/apps/desktop/src/shared/channels.ts index 7235d04..5e0ac4a 100644 --- a/apps/desktop/src/shared/channels.ts +++ b/apps/desktop/src/shared/channels.ts @@ -18,6 +18,11 @@ export const INVOKE_CHANNELS = [ "secrets:add", "secrets:remove", "apps:remove", + "apps:desktop-pick", + "apps:desktop-check", + "apps:desktop-add", + "apps:desktop-cancel", + "apps:desktop-status", "preview:open", ] as const diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 83180a7..9f4d45a 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -300,6 +300,41 @@ export const LiveInput = z.discriminatedUnion("kind", [ ]) export type LiveInput = z.infer +/** A desktop app picked: its card (never its path), or why it can't be added (null: cancelled). */ +export type DesktopPick = + | { + card: { + token: string + name: string + bundleId: string + version: string | undefined + electron: string + signer: { kind: "team"; team: string } | { kind: "pinned" } + /** The project names it already (a project from someone else): adding approves it here. */ + existing: string | undefined + /** What that project opens with it (shown before it's allowed). */ + opens: { args: string[]; origins: string[] } | undefined + } + } + | { refused: string } + | null + +/** A trial's outcome, as the card shows it (or why it couldn't run). */ +export type DesktopCheck = { ok: true } | { site: string } | { quit: true } | { failed: string } + +/** Each desktop app of the open project (by its name): its status on this Mac, and why. */ +export interface DesktopStatusView { + apps: Record< + string, + { + status: "ready" | "updated" | "allow" | "opens-changed" | "changed" | "not-found" + why?: string + } + > + /** The approvals file didn't read and was set aside (said once). */ + problem: string | null +} + /** What the window needs to know to show the right screen. */ export interface AppStatus { /** An OpenRouter key is in the keychain (its value never leaves main). */ @@ -395,6 +430,24 @@ export const invokeArgs = { "apps:remove": z.tuple([ z.strictObject({ session: z.string().max(64), name: AppName, identity: z.string().max(2048) }), ]), + /** + * Adding a desktop app: main shows the picker (Applications) and inspects what was picked; the + * window gets a card and a token (never the app's path). + */ + "apps:desktop-pick": z.tuple([z.strictObject({ session: z.string().max(64) })]), + /** + * The picked app tried, confined. `allowSite`: with the site its last trial named as its own + * (main kept it: the window never names a site). + */ + "apps:desktop-check": z.tuple([ + z.strictObject({ session: z.string().max(64), token: z.uuid(), allowSite: z.boolean() }), + ]), + /** The tried app added to the open project (or, named already, approved here). */ + "apps:desktop-add": z.tuple([z.strictObject({ session: z.string().max(64), token: z.uuid() })]), + /** The add given up (its trial ended). */ + "apps:desktop-cancel": z.tuple([]), + /** The open project's desktop apps as this Mac has them (static: nothing launches). */ + "apps:desktop-status": z.tuple([z.strictObject({ session: z.string().max(64) })]), /** A scene of the open project, to play (its id, checked against the project in main). */ "preview:open": z.tuple([z.string().min(1).max(200)]), } satisfies Record<(typeof INVOKE_CHANNELS)[number], z.ZodTuple> @@ -423,6 +476,12 @@ export interface InvokeResults { "secrets:remove": string | null /** null when removed or cancelled; else why not, in words. */ "apps:remove": string | null + "apps:desktop-pick": DesktopPick + "apps:desktop-check": DesktopCheck + /** null when added; else why not, in words. */ + "apps:desktop-add": string | null + "apps:desktop-cancel": void + "apps:desktop-status": DesktopStatusView "preview:open": Preview } diff --git a/apps/desktop/test/desktop-apps.test.ts b/apps/desktop/test/desktop-apps.test.ts new file mode 100644 index 0000000..c209d1d --- /dev/null +++ b/apps/desktop/test/desktop-apps.test.ts @@ -0,0 +1,617 @@ +import { chmodSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { createProject, openProject, saveProject } from "@kiframe/project" +import type { DesktopApp, TrialOptions, TrialOutcome } from "@kiframe/runtime" +import type { ElectronApp } from "@kiframe/schema" +import { describe, expect, it } from "vitest" +import { Workspace } from "../src/main/workspace.ts" +import { + addHostOf, + type AddHost, + appNameFor, + DesktopAdds, + DesktopApprovals, + desktopStatus, + opensOf, +} from "../src/main/desktop-apps.ts" + +// Desktop apps added and approved (PR 3b): main's store, status and add flow, with the runtime's +// inspection and trial as fakes (nothing launches here; the runtime's own tests launch for real). + +const dir = () => mkdtempSync(join(tmpdir(), "kiframe-desktop-apps-")) +const project = () => + createProject(join(dir(), "demo.kiframe"), { id: "p1", name: "Demo", url: "https://app.test" }) + +const notes = (over: Partial = {}): DesktopApp => ({ + path: "/Applications/Notes.app", + bundleId: "com.example.Notes", + name: "Notes", + version: "1.0", + electron: "44.4.5", + executable: "/Applications/Notes.app/Contents/MacOS/Notes", + signer: { kind: "team", team: "TEAM123456", identifier: "com.example.Notes" }, + ...over, +}) + +/** Fakes of the runtime's looks: what's at each path, what a trial says (recorded). */ +const looks = ( + apps: Record, + outcome: (o: TrialOptions) => TrialOutcome = () => ({ ok: true }), +) => { + const trials: TrialOptions[] = [] + return { + trials, + looks: { + inspect: (path: string, _signal?: AbortSignal) => { + const app = apps[path] + return app === undefined + ? Promise.reject(new Error("that app can't be read (moved or removed?)")) + : Promise.resolve(app) + }, + trial: (_app: DesktopApp, o: TrialOptions) => { + trials.push(o) + return Promise.resolve(outcome(o)) + }, + }, + } +} + +const entry = (over: Partial = {}): ElectronApp => ({ + kind: "electron", + bundleId: "com.example.Notes", + viewport: { width: 1440, height: 900, deviceScaleFactor: 1 }, + ...over, +}) + +describe("a desktop app's approvals", () => { + it("are kept per Mac and per project, with what the project opens", () => { + const data = dir() + const approvals = new DesktopApprovals(data) + approvals.approve(notes(), "folder-a", opensOf(entry())) + const again = new DesktopApprovals(data) + // With the build this project tried. + expect(again.copyFor("COM.EXAMPLE.NOTES", "folder-a")?.scopes).toEqual({ + "folder-a": { opens: opensOf(entry()), version: "1.0" }, + }) + expect(readFileSync(join(data, "desktop-apps.json")).length).toBeGreaterThan(0) + again.drop("com.example.Notes", "folder-a") + expect(new DesktopApprovals(data).copies("com.example.Notes")).toEqual([]) + }) + + it("keep other projects' approvals for the same app, drop them for another build there", () => { + const approvals = new DesktopApprovals(dir()) + const scopes = () => approvals.copies("com.example.Notes").map((c) => Object.keys(c.scopes)) + approvals.approve(notes(), "folder-a", "a".repeat(64)) + // An update of the same developer's app: still theirs. + approvals.approve(notes({ version: "1.1" }), "folder-b", "b".repeat(64)) + expect(scopes()).toEqual([["folder-a", "folder-b"]]) + // Another developer at that place now: the others approved that one, not this. + approvals.approve( + notes({ signer: { kind: "team", team: "OTHER00000", identifier: "x" } }), + "folder-b", + "b".repeat(64), + ) + expect(scopes()).toEqual([["folder-b"]]) + }) + + it("keep each copy of an app apart (a release and a dev build of one id)", () => { + const approvals = new DesktopApprovals(dir()) + const release = notes() + const dev = notes({ + path: "/Users/me/dev/Notes.app", + signer: { kind: "pinned", digest: "c".repeat(64) }, + }) + approvals.approve(release, "folder-a", "a".repeat(64)) + approvals.approve(dev, "folder-b", "b".repeat(64)) + // Each project keeps its own copy (never taken from the other). + expect(approvals.copyFor("com.example.Notes", "folder-a")?.path).toBe(release.path) + expect(approvals.copyFor("com.example.Notes", "folder-b")?.path).toBe(dev.path) + // A project switching copies lets the other go. + approvals.approve(dev, "folder-a", "a".repeat(64)) + expect(approvals.copies("com.example.Notes").map((c) => c.path)).toEqual([dev.path]) + }) + + it("always read back (copies capped, long names cut): never set aside for what they wrote", () => { + const data = dir() + const approvals = new DesktopApprovals(data) + for (let i = 0; i < 25; i++) { + approvals.approve( + notes({ + path: `/Users/me/dev/w${i}/Notes.app`, + name: "N".repeat(300), + version: "9".repeat(150), + }), + `folder-${i}`, + opensOf(entry()), + ) + } + const again = new DesktopApprovals(data) + expect(again.takeProblem()).toBeNull() + expect(again.copies("com.example.Notes")).toHaveLength(20) + expect(again.copyFor("com.example.Notes", "folder-24")?.name).toHaveLength(200) + // A long version compares as it was kept (never "updated" for being long). + const team = notes({ path: "/Users/me/dev/w24/Notes.app", version: "9".repeat(150) }) + return desktopStatus(entry(), "folder-24", again, looks({ [team.path]: team }).looks).then( + (status) => expect(status).toEqual({ status: "ready" }), + ) + }) + + it("change nothing when a write fails (memory as on disk)", () => { + const data = dir() + const approvals = new DesktopApprovals(data) + approvals.approve(notes(), "folder-a", "a".repeat(64)) + chmodSync(data, 0o500) + try { + expect(() => approvals.approve(notes(), "folder-b", "b".repeat(64))).toThrow() + expect(approvals.copyFor("com.example.Notes", "folder-b")).toBeUndefined() + } finally { + chmodSync(data, 0o700) + } + }) + + it("set a file that doesn't read aside, never overwrite it", () => { + const data = dir() + writeFileSync(join(data, "desktop-apps.json"), "{ not json") + const approvals = new DesktopApprovals(data) + expect(approvals.takeProblem()).toMatch(/set aside as .*desktop-apps\.json\.bad-\d+/) + expect(readdirSync(data).some((n) => n.startsWith("desktop-apps.json.bad-"))).toBe(true) + approvals.approve(notes(), "folder-a", "a".repeat(64)) + const aside = readdirSync(data).find((n) => n.startsWith("desktop-apps.json.bad-")) + expect(readFileSync(join(data, aside ?? ""), "utf8")).toBe("{ not json") + }) +}) + +describe("a desktop app's status (static: nothing launches)", () => { + const status = async ( + at: Record, + approve: ((a: DesktopApprovals) => void) | undefined, + app = entry(), + ) => { + const approvals = new DesktopApprovals(dir()) + approve?.(approvals) + return desktopStatus(app, "folder-a", approvals, looks(at).looks) + } + const approved = (a: DesktopApprovals) => a.approve(notes(), "folder-a", opensOf(entry())) + + it("is ready when approved here for what it opens, updated when its developer shipped a new build", async () => { + expect(await status({ "/Applications/Notes.app": notes() }, approved)).toEqual({ + status: "ready", + }) + expect( + await status({ "/Applications/Notes.app": notes({ version: "2.0" }) }, approved), + ).toEqual({ + status: "updated", + }) + }) + + it("asks again: never added here, another project's, what it opens changed", async () => { + const at = { "/Applications/Notes.app": notes() } + expect((await status(at, undefined)).status).toBe("allow") + expect((await status(at, (a) => a.approve(notes(), "folder-b", opensOf(entry())))).status).toBe( + "allow", + ) + // A pull added a site the project shows as the app's own (or an argument): asked again. + const opens = entry({ origins: ["https://evil.example"] }) + expect((await status(at, approved, opens)).status).toBe("opens-changed") + }) + + it("keeps each project's tried build: another's approval of an update changes nothing here", async () => { + const approvals = new DesktopApprovals(dir()) + approvals.approve(notes(), "folder-b", opensOf(entry())) + const updated = { "/Applications/Notes.app": notes({ version: "1.1" }) } + expect(await desktopStatus(entry(), "folder-b", approvals, looks(updated).looks)).toEqual({ + status: "updated", + }) + // Project A tries and approves 1.1: B still hasn't tried it. + approvals.approve(notes({ version: "1.1" }), "folder-a", opensOf(entry())) + expect(await desktopStatus(entry(), "folder-b", approvals, looks(updated).looks)).toEqual({ + status: "updated", + }) + expect(await desktopStatus(entry(), "folder-a", approvals, looks(updated).looks)).toEqual({ + status: "ready", + }) + }) + + it("never looks at the app to say what needs no look (another project's, opens changed)", async () => { + const approvals = new DesktopApprovals(dir()) + approvals.approve(notes(), "folder-b", opensOf(entry())) + const never = { + inspect: () => Promise.reject(new Error("looked at the app")), + } + expect((await desktopStatus(entry(), "folder-a", approvals, never)).status).toBe("allow") + approvals.approve(notes(), "folder-a", opensOf(entry())) + const opens = entry({ origins: ["https://evil.example"] }) + expect((await desktopStatus(opens, "folder-a", approvals, never)).status).toBe("opens-changed") + }) + + it("is changed for another developer or another unsigned build, not found when gone", async () => { + const other = notes({ + signer: { kind: "team", team: "OTHER00000", identifier: "com.example.Notes" }, + }) + expect((await status({ "/Applications/Notes.app": other }, approved)).status).toBe("changed") + const pinned = notes({ signer: { kind: "pinned", digest: "a".repeat(64) } }) + const rebuilt = notes({ signer: { kind: "pinned", digest: "b".repeat(64) } }) + const approvePinned = (a: DesktopApprovals) => a.approve(pinned, "folder-a", opensOf(entry())) + expect((await status({ "/Applications/Notes.app": pinned }, approvePinned)).status).toBe( + "ready", + ) + expect((await status({ "/Applications/Notes.app": rebuilt }, approvePinned)).status).toBe( + "changed", + ) + expect((await status({}, approved)).status).toBe("not-found") + }) +}) + +/** A deferred answer (a wait the test lets go of when it wants). */ +function deferred() { + let resolve: (value: T) => void = () => undefined + const promise = new Promise((r) => (resolve = r)) + return { promise, resolve } +} + +/** + * The add flow with a fake host: the open project, its session, Kif's state and the picker driven + * by the test; what's approved and told recorded. + */ +function harness( + opened = project(), + at: Record = { "/Applications/Notes.app": notes() }, + outcome: (o: TrialOptions) => TrialOutcome = () => ({ ok: true }), +) { + const state = { + session: "s1" as string | undefined, + opened: opened as ReturnType | null, + busy: undefined as string | undefined, + picked: "/Applications/Notes.app" as string | undefined, + approved: [] as string[], + changed: 0, + } + const fake = looks(at, outcome) + const approvals = new DesktopApprovals(dir()) + const host: AddHost = { + session: () => state.session, + opened: () => state.opened, + busy: () => state.busy, + pickApp: () => Promise.resolve(state.picked), + approve: (app, o, opens) => { + approvals.approve(app, "folder-a", opens) + state.approved.push(app.bundleId) + void o + }, + changed: () => (state.changed += 1), + } + const adds = new DesktopAdds(fake.looks, host) + const pick = async () => { + const card = await adds.pick(1, "s1") + if (card === null) throw new Error("no card") + return card + } + return { adds, state, host, fake, approvals, opened, pick } +} + +describe("adding a desktop app", () => { + it("adds it once it ran confined: named, written to the project, approved here", async () => { + const h = harness() + const card = await h.pick() + expect(card).toMatchObject({ + name: "Notes", + bundleId: "com.example.Notes", + signer: { kind: "team", team: "TEAM123456" }, + }) + expect(JSON.stringify(card)).not.toContain("/Applications") + await expect(h.adds.add(1, "s1", card.token)).rejects.toThrow(/check it first/) + expect(await h.adds.check(1, "s1", card.token, false)).toEqual({ ok: true }) + expect(await h.adds.add(1, "s1", card.token)).toBe("notes") + expect(openProject(h.opened.dir).project.apps["notes"]).toMatchObject({ + kind: "electron", + bundleId: "com.example.Notes", + }) + expect(h.state.changed).toBe(1) + expect( + await desktopStatus( + h.opened.project.apps["notes"] as ElectronApp, + "folder-a", + h.approvals, + h.fake.looks, + ), + ).toEqual({ status: "ready" }) + }) + + it("allows a wrapper's site only as the trial named it, tried before it's added", async () => { + const h = harness(project(), undefined, (o) => + (o.origins ?? []).length > 0 ? { ok: true } : { site: "https://app.slack.com" }, + ) + const card = await h.pick() + await expect(h.adds.check(1, "s1", card.token, true)).rejects.toThrow(/no site to allow/) + expect(await h.adds.check(1, "s1", card.token, false)).toEqual({ + site: "https://app.slack.com", + }) + expect(await h.adds.check(1, "s1", card.token, true)).toEqual({ ok: true }) + expect(h.fake.trials.at(-1)?.origins).toEqual(["https://app.slack.com"]) + await h.adds.add(1, "s1", card.token) + expect(openProject(h.opened.dir).project.apps["notes"]).toMatchObject({ + origins: ["https://app.slack.com"], + }) + }) + + it("never adds while a check runs (a site allowed but not tried yet)", async () => { + const trial = deferred() + let first = true + const h = harness() + h.fake.looks.trial = (_app, o) => { + h.fake.trials.push(o) + if (first) { + first = false + return Promise.resolve({ site: "https://app.slack.com" }) + } + return trial.promise + } + const card = await h.pick() + await h.adds.check(1, "s1", card.token, false) + const checking = h.adds.check(1, "s1", card.token, true) + await expect(h.adds.add(1, "s1", card.token)).rejects.toThrow(/being checked/) + trial.resolve({ ok: true }) + await checking + expect(await h.adds.add(1, "s1", card.token)).toBe("notes") + }) + + it("refuses a token of another window or project, and a build changed since its trial", async () => { + const h = harness() + const card = await h.pick() + await expect(h.adds.check(2, "s1", card.token, false)).rejects.toThrow( + /isn't being added any more/, + ) + await expect(h.adds.check(1, "s2", card.token, false)).rejects.toThrow( + /isn't being added any more/, + ) + await h.adds.check(1, "s1", card.token, false) + h.fake.looks.inspect = () => + Promise.resolve(notes({ signer: { kind: "team", team: "OTHER00000", identifier: "x" } })) + await expect(h.adds.add(1, "s1", card.token)).rejects.toThrow(/changed since it was checked/) + }) + + it("says a file changed on disk as itself, and a site the project can't take in words", async () => { + const h = harness() + const card = await h.pick() + await h.adds.check(1, "s1", card.token, false) + writeFileSync( + join(h.opened.dir, "project.json"), + readFileSync(join(h.opened.dir, "project.json"), "utf8") + " ", + ) + await expect(h.adds.add(1, "s1", card.token)).rejects.toThrow(/changed on disk.*reopen it/) + // The site is a web app's of the project already (one app per site). + const opened = project() + saveProject(opened, { ...opened.project, apps: { ...opened.project.apps, desk: entry() } }) + const g = harness(opened, undefined, (o) => + (o.origins ?? []).length > 0 ? { ok: true } : { site: "https://app.test" }, + ) + const named = await g.pick() + await g.adds.check(1, "s1", named.token, false) + await g.adds.check(1, "s1", named.token, true) + const error = await g.adds.add(1, "s1", named.token).then( + () => "", + (e: unknown) => (e as Error).message, + ) + expect(error).toMatch(/^the project can't take it so/) + expect(error).not.toMatch(/"code"|\[\{/) + }) + + it("approves the build that was tried (one updated since: updated, never ready)", async () => { + const h = harness() + const card = await h.pick() + await h.adds.check(1, "s1", card.token, false) + // The developer's update lands between the check and the add. + h.fake.looks.inspect = () => Promise.resolve(notes({ version: "2.0" })) + await h.adds.add(1, "s1", card.token) + const desk = openProject(h.opened.dir).project.apps["notes"] as ElectronApp + expect(await desktopStatus(desk, "folder-a", h.approvals, h.fake.looks)).toEqual({ + status: "updated", + }) + }) + + it("stops looking at an app picked then given up", async () => { + const h = harness() + let signal: AbortSignal | undefined + h.fake.looks.inspect = (_path, s) => { + signal = s + return new Promise((_, reject) => + s?.addEventListener("abort", () => reject(s.reason as Error)), + ) + } + const picking = h.adds.pick(1, "s1") + await new Promise((resolve) => setTimeout(resolve, 0)) + h.adds.dropFor(1) + expect(await picking).toBeNull() + expect(signal?.aborted).toBe(true) + }) + + it("stops looking at the app again when its add is given up", async () => { + const h = harness() + const card = await h.pick() + await h.adds.check(1, "s1", card.token, false) + let signal: AbortSignal | undefined + h.fake.looks.inspect = (_path, s) => { + signal = s + return new Promise((_, reject) => + s?.addEventListener("abort", () => reject(s.reason as Error)), + ) + } + const adding = h.adds.add(1, "s1", card.token) + await new Promise((resolve) => setTimeout(resolve, 0)) + h.adds.dropFor(1) + await expect(adding).rejects.toThrow(/isn't being added any more/) + expect(signal?.aborted).toBe(true) + }) + + it("is done once written: an approval that fails then is said, never added twice", async () => { + const h = harness() + h.host.approve = () => { + throw new Error("EACCES") + } + const card = await h.pick() + await h.adds.check(1, "s1", card.token, false) + await expect(h.adds.add(1, "s1", card.token)).rejects.toThrow( + /added as notes, but not approved on this Mac/, + ) + await expect(h.adds.add(1, "s1", card.token)).rejects.toThrow(/isn't being added any more/) + expect(Object.keys(openProject(h.opened.dir).project.apps)).toEqual(["app", "notes"]) + }) +}) + +describe("an app's name in the project", () => { + it("is its own name in the app-name form, made unique; nothing left: app", () => { + expect(appNameFor("Visual Studio Code", [])).toBe("visual-studio-code") + expect(appNameFor("Slack", ["slack"])).toBe("slack-2") + expect(appNameFor("微信", [])).toBe("app") + expect(appNameFor("1Password", [])).toBe("password") + }) +}) + +describe("adding an app a project already names", () => { + const named = () => { + const opened = project() + saveProject(opened, { + ...opened.project, + apps: { + ...opened.project.apps, + desk: entry({ args: ["files/vault"], origins: ["https://a.example"] }), + }, + }) + return opened + } + + it("shows what it opens, tries it with its sites, and approves it without adding it twice", async () => { + const h = harness(named()) + const card = await h.pick() + expect(card.existing).toBe("desk") + expect(card.opens).toEqual({ args: ["files/vault"], origins: ["https://a.example"] }) + await h.adds.check(1, "s1", card.token, false) + expect(h.fake.trials[0]?.origins).toEqual(["https://a.example"]) + expect(await h.adds.add(1, "s1", card.token)).toBe("desk") + expect(Object.keys(openProject(h.opened.dir).project.apps)).toEqual(["app", "desk"]) + expect(h.approvals.copyFor("com.example.Notes", "folder-a")?.scopes["folder-a"]?.opens).toBe( + opensOf(entry({ args: ["files/vault"], origins: ["https://a.example"] })), + ) + }) + + it("never approves a project changed since the pick", async () => { + const h = harness(named()) + const card = await h.pick() + await h.adds.check(1, "s1", card.token, false) + const desk = h.opened.project.apps["desk"] as ElectronApp + saveProject(h.opened, { + ...h.opened.project, + apps: { + ...h.opened.project.apps, + desk: { ...desk, origins: ["https://a.example", "https://evil.example"] }, + }, + }) + await expect(h.adds.add(1, "s1", card.token)).rejects.toThrow(/the project changed meanwhile/) + }) + + it("writes a site allowed in its check to the project, and approves that", async () => { + const opened = project() + saveProject(opened, { ...opened.project, apps: { ...opened.project.apps, desk: entry() } }) + const h = harness(opened, undefined, (o) => + (o.origins ?? []).length > 0 ? { ok: true } : { site: "https://app.slack.com" }, + ) + const card = await h.pick() + await h.adds.check(1, "s1", card.token, false) + await h.adds.check(1, "s1", card.token, true) + expect(await h.adds.add(1, "s1", card.token)).toBe("desk") + const desk = openProject(opened.dir).project.apps["desk"] as ElectronApp + expect(desk.origins).toEqual(["https://app.slack.com"]) + expect(await desktopStatus(desk, "folder-a", h.approvals, h.fake.looks)).toEqual({ + status: "ready", + }) + }) +}) + +describe("an add's lifetime: its project's, checked after every wait", () => { + it("drops a pick whose project changed or that was cancelled while the user picked", async () => { + const h = harness() + const dialog = deferred() + h.host.pickApp = () => dialog.promise + const picking = h.adds.pick(1, "s1") + await expect(h.adds.pick(1, "s1")).rejects.toThrow(/being picked already/) + h.adds.dropFor(1) + dialog.resolve("/Applications/Notes.app") + expect(await picking).toBeNull() + // And the next pick works (nothing left stuck). + h.host.pickApp = () => Promise.resolve("/Applications/Notes.app") + expect(await h.adds.pick(1, "s1")).not.toBeNull() + // A project switch while the app is looked at: no card. + const inspecting = deferred() + h.fake.looks.inspect = () => inspecting.promise + const later = h.adds.pick(1, "s1") + h.state.session = "s2" + inspecting.resolve(notes()) + expect(await later).toBeNull() + }) + + it("never writes an add given up, or Kif started, while the app was looked at again", async () => { + for (const giveUp of ["switch", "cancel", "busy"] as const) { + const h = harness() + const card = await h.pick() + await h.adds.check(1, "s1", card.token, false) + const inspecting = deferred() + h.fake.looks.inspect = () => inspecting.promise + const adding = h.adds.add(1, "s1", card.token) + if (giveUp === "switch") h.state.session = "s2" + if (giveUp === "cancel") h.adds.dropFor(1) + if (giveUp === "busy") h.state.busy = "Kif is working: stop it first" + inspecting.resolve(notes()) + await expect(adding, giveUp).rejects.toThrow( + giveUp === "busy" ? /Kif is working/ : /isn't being added any more/, + ) + expect(openProject(h.opened.dir).project.apps["notes"], giveUp).toBeUndefined() + expect(h.state.approved, giveUp).toEqual([]) + } + }) + + it("reads the project after the pick (an app removed meanwhile is no longer 'named')", async () => { + const opened = project() + saveProject(opened, { ...opened.project, apps: { ...opened.project.apps, desk: entry() } }) + const h = harness(opened) + const dialog = deferred() + h.host.pickApp = () => dialog.promise + const picking = h.adds.pick(1, "s1") + const { desk: _desk, ...rest } = opened.project.apps + void _desk + saveProject(opened, { ...opened.project, apps: rest }) + dialog.resolve("/Applications/Notes.app") + expect((await picking)?.existing).toBeUndefined() + }) + + it("ends every add and its trial as the workspace switches project", async () => { + const workspace = new Workspace(() => ({ close: () => Promise.resolve(), running: false })) + await workspace.open(project().dir) + let signal: AbortSignal | undefined + const host = addHostOf(workspace, { + pickApp: () => Promise.resolve("/Applications/Notes.app"), + approve: () => undefined, + changed: () => undefined, + }) + const adds: DesktopAdds = new DesktopAdds( + { + inspect: () => Promise.resolve(notes()), + trial: (_app, o) => { + signal = o.signal + return new Promise((_, reject) => + o.signal?.addEventListener("abort", () => reject(o.signal?.reason as Error)), + ) + }, + }, + host, + ) + workspace.onSwitch(() => adds.dropAll()) + const session = workspace.session ?? "" + const card = await adds.pick(1, session) + const checking = adds.check(1, session, card?.token ?? "", false) + await workspace.close() + await expect(checking).rejects.toMatchObject({ name: "AbortError" }) + expect(signal?.aborted).toBe(true) + expect(workspace.session).toBeUndefined() + }) +}) diff --git a/apps/desktop/test/workspace.test.ts b/apps/desktop/test/workspace.test.ts index 97536d8..e370357 100644 --- a/apps/desktop/test/workspace.test.ts +++ b/apps/desktop/test/workspace.test.ts @@ -179,3 +179,23 @@ describe("the open project and its agent", () => { expect(ws.agent).toBe(made[2]) }) }) + +describe("a project switch", () => { + it("tells its listeners on open, create and close (one that throws stops nothing)", async () => { + const workspace = new Workspace(() => ({ close: () => Promise.resolve() })) + let told = 0 + workspace.onSwitch(() => { + throw new Error("a listener that fails") + }) + workspace.onSwitch(() => (told += 1)) + const dir = folder() + createProject(dir, { id: "p1", name: "Demo", url: "https://app.test" }) + await workspace.open(dir) + expect(workspace.session).toMatch(/^[0-9a-f]{12}$/) + await workspace.create(folder(), { name: "New", url: "https://new.test" }) + await workspace.close() + expect(told).toBe(3) + // No project: no session. + expect(workspace.session).toBeUndefined() + }) +}) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 50ff9f0..6766caa 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -247,6 +247,7 @@ Non-severe review findings deferred on purpose (see the review-round rule: only - **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). - **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. +- **A desktop app's check while Kif starts:** the Apps panel refuses a check while Kif works, but Kif starting during a check (10–40 s) doesn't end it: the confined app runs beside Kif's run, its result refused after. End the check when a run starts (the agent's start hook). - **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 08b6b0e..b6e91c9 100644 --- a/docs/OBJECT-MODEL.md +++ b/docs/OBJECT-MODEL.md @@ -231,6 +231,7 @@ One scene, three phases. The agent's own instructions say the same (`packages/st - **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); 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. +- **Approvals (PR 3b-2, user decisions 2026-10-09):** added from the Apps panel ("Add desktop app…", macOS): main shows the picker (Applications) and inspects what was picked; the window gets a card and a token, never the app's path, and never names a site to allow (the trial's, kept in main). Tried confined, then added (the build that was tried is approved: one updated since says "updated"; `project.json`, its changed-on-disk refusal) and approved in app data (`desktop-apps.json`, 0600, written whole; one that doesn't read is set aside and said, never overwritten). An approval is per copy of the app on this Mac (where it was picked: a release and a dev build of one bundle id kept apart, a project using one of them; at most 20 copies of one app kept, the oldest let go) and per project folder (the registry's scope) and covers the app (its developer's team, or the exact build of an unsigned one) **and what the project opens with it** (its `args` and `origins`, as a digest): a pull that changes either asks again. Statuses (static, opening the panel launches nothing): ready; updated (same developer, a build this project hasn't tried: tried confined before its next run, PR 4, which records it); allow (approved for another project, or never on this Mac: pick it to allow it here, without adding it twice); opens-changed; changed (another developer, another unsigned build); not found. Removing the app drops this project's approval. Off macOS: nothing offered. A trial in progress ends with its add (another pick, the project closed, a quit); what a crash left is swept at the start. - **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).