From fb925148169470abd66fd7d6a862677bb2962228 Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Fri, 9 Oct 2026 23:55:54 +0200 Subject: [PATCH 1/7] feat(desktop): add a desktop app to a project from the apps panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS, "Add desktop app…": main shows the picker and inspects what was picked (the window holds a token, never a path or a site to allow), the card tries it confined, then it's added to project.json and approved. Approvals (desktop-apps.json in app data, written whole, one that doesn't read set aside) are per Mac and per project, and cover the app (its developer, or the exact build of an unsigned one) and what the project opens with it. Each desktop app's status shows in the panel, checked statically (nothing launches). Picking an app the project already names allows it here. Removing it drops this project's approval; a trial ends with its add, the project or a quit; leftovers are swept at the start. --- apps/desktop/e2e/desktop-app.e2e.ts | 104 +++++ apps/desktop/src/main/desktop-apps.ts | 372 ++++++++++++++++++ apps/desktop/src/main/index.ts | 108 ++++- .../renderer/src/components/apps-panel.tsx | 181 ++++++++- apps/desktop/src/renderer/src/styles.css | 12 + apps/desktop/src/renderer/test/apps.test.tsx | 97 +++++ apps/desktop/src/shared/channels.ts | 5 + apps/desktop/src/shared/ipc.ts | 57 +++ apps/desktop/test/desktop-apps.test.ts | 282 +++++++++++++ docs/OBJECT-MODEL.md | 1 + 10 files changed, 1211 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/e2e/desktop-app.e2e.ts create mode 100644 apps/desktop/src/main/desktop-apps.ts create mode 100644 apps/desktop/test/desktop-apps.test.ts 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..24ac06d --- /dev/null +++ b/apps/desktop/src/main/desktop-apps.ts @@ -0,0 +1,372 @@ +// 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 { type App, 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(255), + }), + z.strictObject({ kind: z.literal("pinned"), digest: z.string().regex(/^[0-9a-f]{64}$/) }), +]) + +const Approval = z.strictObject({ + /** Where the app was picked (its real path). */ + path: z.string().max(4096), + name: z.string().max(200), + version: z.string().max(100).optional(), + signer: SignerSchema, + approvedAt: z.string(), + /** Per project folder (the registry's scope): what it opens with the app, approved. */ + scopes: z.record(z.string(), z.strictObject({ opens: z.string().regex(/^[0-9a-f]{64}$/) })), +}) +type Approval = z.infer + +const File = z.strictObject({ + version: z.literal(1), + apps: z.record(z.string().regex(BUNDLE_ID), Approval), +}) +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). One that doesn't read + * is set aside (renamed, said once), never overwritten: approvals start over, none lost silently. + */ +export class DesktopApprovals { + readonly #path: string + #file: File + /** Why the file was set aside, once (shown by the panel). */ + problem: string | null = null + + 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` + } + } + + get(bundleId: string): Approval | undefined { + const key = keyOf(bundleId) + return Object.hasOwn(this.#file.apps, key) ? this.#file.apps[key] : undefined + } + + /** The app as inspected now, approved for this project folder and what it opens. */ + approve(app: DesktopApp, scope: string, opens: string): void { + const known = this.get(app.bundleId) + const sameApp = + known !== undefined && known.path === app.path && sameSigner(known.signer, app.signer) + this.#file.apps[keyOf(app.bundleId)] = { + path: app.path, + name: app.name, + ...(app.version !== undefined && { version: app.version }), + signer: app.signer, + approvedAt: new Date().toISOString(), + // Another build or another place: the other projects' approvals go (they approved that one). + scopes: { ...(sameApp ? known.scopes : {}), [scope]: { opens } }, + } + this.#save() + } + + /** This project folder's approval of the app taken back (the app removed from the project). */ + drop(bundleId: string, scope: string): void { + const known = this.get(bundleId) + if (known === undefined || !Object.hasOwn(known.scopes, scope)) return + delete known.scopes[scope] + this.#save() + } + + #save(): void { + mkdirSync(dirname(this.#path), { recursive: true }) + const tmp = `${this.#path}.${randomBytes(6).toString("hex")}.tmp` + writeFileSync(tmp, `${JSON.stringify(this.#file, null, 2)}\n`, { mode: 0o600 }) + renameSync(tmp, this.#path) + } +} + +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, the app inspected where it was picked, compared. + */ +export async function desktopStatus( + entry: ElectronApp, + scope: string, + approvals: DesktopApprovals, + looks: Pick, +): Promise { + const known = approvals.get(entry.bundleId) + if (known === undefined) { + return { + status: "allow", + why: "not added on this Mac: add it from Applications to use it here", + } + } + let now: DesktopApp + try { + now = await looks.inspect(known.path) + } catch (error) { + if (!existsSync(known.path)) { + return { status: "not-found", why: `not at ${known.path} any more: add it again` } + } + return { status: "changed", why: (error as Error).message } + } + if (keyOf(now.bundleId) !== keyOf(entry.bundleId) || !sameDeveloper(known.signer, now.signer)) { + return { status: "changed", why: "another app is there now (or another build): add it again" } + } + const here = Object.hasOwn(known.scopes, scope) ? known.scopes[scope] : undefined + if (here === undefined) { + return { status: "allow", why: "approved for another project: allow it in this one" } + } + if (here.opens !== opensOf(entry)) { + return { + status: "opens-changed", + why: "the project changed what it opens with it: allow it again", + } + } + return now.version !== known.version && now.signer.kind === "team" + ? { status: "updated" } + : { status: "ready" } +} + +/** Same developer (a team app updated is still theirs), or the same pinned build. */ +function sameDeveloper(known: Signer, now: Signer): boolean { + return known.kind === "team" + ? now.kind === "team" && now.team === known.team && now.identifier === known.identifier + : now.kind === "pinned" && now.digest === known.digest +} + +/** 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}` +} + +/** 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 +} + +interface Pending { + owner: number + session: string + app: DesktopApp + existing: string | undefined + /** The site the last trial named (kept here: the window never names one). */ + site: string | undefined + /** Allowed by the user after a trial named it. */ + origins: string[] + tried: boolean + trial: AbortController | undefined +} + +/** + * Adds in progress, one per window: the app picked (its path stays in main: the window has a + * token), tried, then added. Dropped with the project or the window; every trial ends at a quit. + */ +export class DesktopAdds { + readonly #pending = new Map() + readonly #looks: Looks + readonly #workDir: string | undefined + + constructor(looks: Looks, workDir?: string) { + this.#looks = looks + this.#workDir = workDir + } + + /** The app the user picked, inspected (refused: said); a window's earlier add dropped. */ + async pick( + owner: number, + session: string, + path: string, + project: { apps: Readonly> }, + ): Promise { + this.dropFor(owner) + const app = await this.#looks.inspect(path) + const named = Object.entries(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). + const listed = named?.[1].kind === "electron" ? [...(named[1].origins ?? [])] : [] + const token = randomUUID() + this.#pending.set(token, { + owner, + session, + app, + existing, + site: undefined, + origins: listed, + tried: false, + trial: undefined, + }) + 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, + } + } + + /** + * The picked app tried, confined. `allowSite`: with the site the last trial named (kept here), + * as the app's own. A token of another window or project: refused. + */ + async check( + owner: number, + session: string, + token: string, + allowSite: boolean, + ): Promise { + const pending = this.#own(owner, session, token) + if (pending.trial !== undefined) throw new Error("it's being checked already") + if (allowSite) { + if (pending.site === undefined) throw new Error("no site to allow: check it first") + pending.origins = [...new Set([...pending.origins, pending.site])] + } + 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 }), + }) + 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, approved here): its build checked again + * (the one tried), the project written first (its changed-on-disk refusal), then the approval. + */ + async add( + owner: number, + session: string, + token: string, + opened: OpenedProject, + scope: string, + approvals: DesktopApprovals, + ): Promise { + const pending = this.#own(owner, session, token) + if (!pending.tried) throw new Error("check it first: it's added once it ran here") + const now = await this.#looks.inspect(pending.app.path) + if (!sameSigner(now.signer, pending.app.signer)) { + throw new Error("the app changed since it was checked: pick it again") + } + let name = pending.existing + let entry: ElectronApp + if (name !== undefined) { + const named = opened.project.apps[name] + if (named?.kind !== "electron") + throw new Error("the project changed meanwhile: pick it again") + entry = named + } 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 }, + } + saveProject(opened, { ...opened.project, apps: { ...opened.project.apps, [name]: entry } }) + } + approvals.approve(now, scope, opensOf(entry)) + this.#pending.delete(token) + return name + } + + /** A window's add dropped (another pick, the window closed, the project changed). */ + dropFor(owner: number): void { + for (const [token, pending] of this.#pending) { + if (pending.owner !== owner) continue + pending.trial?.abort() + this.#pending.delete(token) + } + } + + /** Every add dropped, its trial ended (a quit, the project closed). */ + dropAll(): void { + for (const pending of this.#pending.values()) pending.trial?.abort() + this.#pending.clear() + } + + #own(owner: number, session: string, token: string): Pending { + const pending = this.#pending.get(token) + if (pending === undefined || pending.owner !== owner || pending.session !== session) { + throw new Error("that app isn't being added any more: pick it again") + } + return pending + } +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index b8522e2..b310581 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -3,12 +3,14 @@ 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 { 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 +263,22 @@ 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 = new DesktopAdds(looks, workDir) + 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.apps()?.session !== session) return "the project changed" + return opened + } + 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") @@ -295,6 +313,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. @@ -361,6 +381,7 @@ function start(): void { }), "project:open": () => act(async () => { + adds.dropAll() const picked = await dialog.showOpenDialog(parent(), { title: "Open a project", buttonLabel: "Open", @@ -375,7 +396,10 @@ function start(): void { "project:close": () => // The browser's close isn't waited for (one that hangs never holds the window); a quit // waits for it. - act(() => workspace.close(dropBrowser)), + act(() => { + adds.dropAll() + return workspace.close(dropBrowser) + }), "external:open": async (url) => { if (isSafeExternal(url)) await shell.openExternal(url) }, @@ -477,12 +501,88 @@ function start(): void { } catch (e) { return message(e) } + // 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)}`) + } + } + workspace.agent?.appsChanged(opened.project.apps) + void status().then((now) => { + if (workspace.opened === opened) emit(window, "status", now) + }) + return null + }, + "apps:desktop-pick": async ({ session }) => { + if (process.platform !== "darwin") { + return { refused: "desktop apps run on macOS only (they're confined there)" } + } + const opened = sessionProject(session) + if (typeof opened === "string") return { refused: opened } + if (workspace.agent?.running === true) return { refused: "Kif is working: stop it first" } + const picked = await dialog.showOpenDialog(parent(), { + title: "Add a desktop app", + buttonLabel: "Choose", + defaultPath: "/Applications", + properties: ["openFile"], + filters: [{ name: "Applications", extensions: ["app"] }], + }) + const path = picked.filePaths[0] + if (picked.canceled || path === undefined) return null + try { + return { card: await adds.pick(owner(), session, path, opened.project) } + } catch (e) { + return { refused: message(e) } + } + }, + "apps:desktop-check": async ({ session, token, allowSite }) => { + if (workspace.agent?.running === true) return { failed: "Kif is working: stop it first" } + try { + return await adds.check(owner(), session, token, allowSite) + } catch (e) { + return { failed: message(e) } + } + }, + "apps:desktop-add": async ({ session, token }) => { + const opened = sessionProject(session) + if (typeof opened === "string") return opened + if (workspace.agent?.running === true) return "Kif is working: stop it first" + try { + await adds.add( + owner(), + session, + token, + opened, + ids().scope(opened.dir), + desktopApprovals(), + ) + } catch (e) { + return message(e) + } workspace.agent?.appsChanged(opened.project.apps) void status().then((now) => { if (workspace.opened === opened) emit(window, "status", now) }) return null }, + "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) + for (const [name, entry] of Object.entries(opened.project.apps)) { + if (entry.kind !== "electron") continue + 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.problem } + }, "preview:open": (sceneId) => { const opened = workspace.opened if (opened === null) return { ok: false, why: "No project is open." } @@ -506,6 +606,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/renderer/src/components/apps-panel.tsx b/apps/desktop/src/renderer/src/components/apps-panel.tsx index 658e77f..8fb5857 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,13 @@ 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 close = useRef(onClose) close.current = onClose useEffect(() => { @@ -26,6 +49,29 @@ 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(() => 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 +82,54 @@ export function AppsPanel({ setError(refused) } + const pick = async () => { + setError(null) + setOutcome(null) + const picked = await api() + .invoke("apps:desktop-pick", { session: project.session }) + .catch((e: unknown) => ({ refused: String(e) })) + 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) return + setChecking(true) + setOutcome(null) + const result = await api() + .invoke("apps:desktop-check", { session: project.session, token: card.token, allowSite }) + .catch((e: unknown) => ({ failed: String(e) })) + setChecking(false) + setOutcome(result) + } + + const add = async () => { + if (card === null) return + const refused = await api() + .invoke("apps:desktop-add", { session: project.session, token: card.token }) + .catch((e: unknown) => String(e)) + if (refused !== null) { + setError(refused) + return + } + setCard(null) + setOutcome(null) + await refresh() + } + + const cancel = () => { + setCard(null) + setOutcome(null) + void api() + .invoke("apps:desktop-cancel") + .catch(() => undefined) + } + return (
@@ -47,14 +141,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 +170,65 @@ 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. +

+ )} +

+ 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 +239,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..e15d157 100644 --- a/apps/desktop/src/renderer/test/apps.test.tsx +++ b/apps/desktop/src/renderer/test/apps.test.tsx @@ -95,3 +95,100 @@ 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, + } + + 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() + }) +}) 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..0a4cda6 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -300,6 +300,39 @@ 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 + } + } + | { 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 +428,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 +474,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..6e72afa --- /dev/null +++ b/apps/desktop/test/desktop-apps.test.ts @@ -0,0 +1,282 @@ +import { 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 { + 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) => { + 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) + expect(again.get("COM.EXAMPLE.NOTES")?.scopes).toEqual({ + "folder-a": { opens: opensOf(entry()) }, + }) + expect(readFileSync(join(data, "desktop-apps.json")).length).toBeGreaterThan(0) + again.drop("com.example.Notes", "folder-a") + expect(new DesktopApprovals(data).get("com.example.Notes")?.scopes).toEqual({}) + }) + + it("keep other projects' approvals for the same app, drop them for another build", () => { + const approvals = new DesktopApprovals(dir()) + 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(Object.keys(approvals.get("com.example.Notes")?.scopes ?? {})).toEqual([ + "folder-a", + "folder-b", + ]) + // Another developer (or another place): the others approved that one, not this. + approvals.approve( + notes({ signer: { kind: "team", team: "OTHER00000", identifier: "x" } }), + "folder-b", + "b".repeat(64), + ) + expect(Object.keys(approvals.get("com.example.Notes")?.scopes ?? {})).toEqual(["folder-b"]) + }) + + 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.problem).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("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") + }) +}) + +describe("adding a desktop app", () => { + const at = { "/Applications/Notes.app": notes() } + + it("adds it once it ran confined: named, written to the project, approved here", async () => { + const opened = project() + const approvals = new DesktopApprovals(dir()) + const adds = new DesktopAdds(looks(at).looks) + const card = await adds.pick(1, "s1", "/Applications/Notes.app", opened.project) + expect(card).toMatchObject({ + name: "Notes", + bundleId: "com.example.Notes", + signer: { kind: "team", team: "TEAM123456" }, + }) + expect(JSON.stringify(card)).not.toContain("/Applications") + // Never before a trial worked. + await expect(adds.add(1, "s1", card.token, opened, "folder-a", approvals)).rejects.toThrow( + /check it first/, + ) + expect(await adds.check(1, "s1", card.token, false)).toEqual({ ok: true }) + expect(await adds.add(1, "s1", card.token, opened, "folder-a", approvals)).toBe("notes") + expect(openProject(opened.dir).project.apps["notes"]).toMatchObject({ + kind: "electron", + bundleId: "com.example.Notes", + }) + expect( + await desktopStatus( + opened.project.apps["notes"] as ElectronApp, + "folder-a", + approvals, + looks(at).looks, + ), + ).toEqual({ status: "ready" }) + }) + + it("allows a wrapper's site only as the trial named it (never one the window names)", async () => { + const opened = project() + const fake = looks(at, (o) => + (o.origins ?? []).length > 0 ? { ok: true } : { site: "https://app.slack.com" }, + ) + const adds = new DesktopAdds(fake.looks) + const card = await adds.pick(1, "s1", "/Applications/Notes.app", opened.project) + await expect(adds.check(1, "s1", card.token, true)).rejects.toThrow(/no site to allow/) + expect(await adds.check(1, "s1", card.token, false)).toEqual({ site: "https://app.slack.com" }) + expect(await adds.check(1, "s1", card.token, true)).toEqual({ ok: true }) + expect(fake.trials.at(-1)?.origins).toEqual(["https://app.slack.com"]) + await adds.add(1, "s1", card.token, opened, "folder-a", new DesktopApprovals(dir())) + expect(openProject(opened.dir).project.apps["notes"]).toMatchObject({ + origins: ["https://app.slack.com"], + }) + }) + + it("approves an app the project already names, without adding it twice", async () => { + const opened = project() + saveProject(opened, { + ...opened.project, + apps: { ...opened.project.apps, desk: entry({ origins: ["https://a.example"] }) }, + }) + const approvals = new DesktopApprovals(dir()) + const fake = looks(at) + const adds = new DesktopAdds(fake.looks) + const card = await adds.pick(1, "s1", "/Applications/Notes.app", opened.project) + expect(card.existing).toBe("desk") + await adds.check(1, "s1", card.token, false) + // Tried with what the project runs it with. + expect(fake.trials[0]?.origins).toEqual(["https://a.example"]) + expect(await adds.add(1, "s1", card.token, opened, "folder-a", approvals)).toBe("desk") + expect(Object.keys(openProject(opened.dir).project.apps)).toEqual(["app", "desk"]) + expect(approvals.get("com.example.Notes")?.scopes["folder-a"]?.opens).toBe( + opensOf(entry({ origins: ["https://a.example"] })), + ) + }) + + it("refuses a token of another window or project, and a build changed since its trial", async () => { + const opened = project() + const apps = { ...at } + const adds = new DesktopAdds(looks(apps).looks) + const card = await adds.pick(1, "s1", "/Applications/Notes.app", opened.project) + await expect(adds.check(2, "s1", card.token, false)).rejects.toThrow( + /isn't being added any more/, + ) + await expect(adds.check(1, "s2", card.token, false)).rejects.toThrow( + /isn't being added any more/, + ) + await adds.check(1, "s1", card.token, false) + apps["/Applications/Notes.app"] = notes({ + signer: { kind: "team", team: "OTHER00000", identifier: "x" }, + }) + await expect( + adds.add(1, "s1", card.token, opened, "folder-a", new DesktopApprovals(dir())), + ).rejects.toThrow(/changed since it was checked/) + }) + + it("ends a trial when its add is dropped (another pick, the project closed, a quit)", async () => { + const opened = project() + let signal: AbortSignal | undefined + const adds = 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)), + ) + }, + }) + const card = await adds.pick(1, "s1", "/Applications/Notes.app", opened.project) + const checking = adds.check(1, "s1", card.token, false) + adds.dropAll() + await expect(checking).rejects.toMatchObject({ name: "AbortError" }) + expect(signal?.aborted).toBe(true) + await expect(adds.check(1, "s1", card.token, false)).rejects.toThrow( + /isn't being added any more/, + ) + }) +}) + +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") + }) +}) diff --git a/docs/OBJECT-MODEL.md b/docs/OBJECT-MODEL.md index 08b6b0e..a058e97 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 (`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 Mac 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 new build: tried confined before its next run, PR 4); 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). From b57a2c268a264d80ef7178ed70309f8c41501edc Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Sat, 10 Oct 2026 00:08:16 +0200 Subject: [PATCH 2/7] fix(desktop): show what a named desktop app opens, and steady its add - an app a project already names: its card shows what the project opens with it (sites, arguments: the check runs with the sites), and an add after the project changed what it opens is refused; a site allowed in its check is written to the project and approved - one pick and one add at a time; a check of a card given up never shows on another; the add dropped with the window or a new project, a check refused once its project closed - statuses read together; the set-aside note said once; a status that can't be read said --- apps/desktop/src/main/desktop-apps.ts | 88 +++++++++++++------ apps/desktop/src/main/index.ts | 29 ++++-- .../renderer/src/components/apps-panel.tsx | 39 ++++++-- apps/desktop/src/renderer/test/apps.test.tsx | 53 +++++++++++ apps/desktop/src/shared/ipc.ts | 2 + apps/desktop/test/desktop-apps.test.ts | 80 +++++++++++++++++ 6 files changed, 251 insertions(+), 40 deletions(-) diff --git a/apps/desktop/src/main/desktop-apps.ts b/apps/desktop/src/main/desktop-apps.ts index 24ac06d..a89f064 100644 --- a/apps/desktop/src/main/desktop-apps.ts +++ b/apps/desktop/src/main/desktop-apps.ts @@ -208,6 +208,11 @@ export interface DesktopCard { 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 } interface Pending { @@ -215,12 +220,16 @@ interface Pending { 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 /** Allowed by the user after a trial named it. */ origins: string[] tried: boolean trial: AbortController | undefined + /** An add under way (a second click waits for nothing: refused). */ + adding: boolean } /** @@ -229,6 +238,8 @@ interface Pending { */ export class DesktopAdds { readonly #pending = new Map() + /** Windows whose pick is being inspected (another pick meanwhile: refused). */ + readonly #picking = new Set() readonly #looks: Looks readonly #workDir: string | undefined @@ -244,24 +255,34 @@ export class DesktopAdds { path: string, project: { apps: Readonly> }, ): Promise { + if (this.#picking.has(owner)) throw new Error("an app is being looked at already") this.dropFor(owner) - const app = await this.#looks.inspect(path) + this.#picking.add(owner) + let app: DesktopApp + try { + app = await this.#looks.inspect(path) + } finally { + this.#picking.delete(owner) + } const named = Object.entries(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). - const listed = named?.[1].kind === "electron" ? [...(named[1].origins ?? [])] : [] + // 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, tried: false, trial: undefined, + adding: false, }) return { token, @@ -272,6 +293,7 @@ export class DesktopAdds { signer: app.signer.kind === "team" ? { kind: "team", team: app.signer.team } : { kind: "pinned" }, existing, + opens: entry === undefined ? undefined : { args: [...(entry.args ?? [])], origins: listed }, } } @@ -321,30 +343,46 @@ export class DesktopAdds { ): Promise { const pending = this.#own(owner, session, token) if (!pending.tried) throw new Error("check it first: it's added once it ran here") - const now = await this.#looks.inspect(pending.app.path) - if (!sameSigner(now.signer, pending.app.signer)) { - throw new Error("the app changed since it was checked: pick it again") - } - let name = pending.existing - let entry: ElectronApp - if (name !== undefined) { - const named = opened.project.apps[name] - if (named?.kind !== "electron") - throw new Error("the project changed meanwhile: pick it again") - entry = named - } 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 }, + if (pending.adding) throw new Error("it's being added already") + pending.adding = true + try { + const now = await this.#looks.inspect(pending.app.path) + if (!sameSigner(now.signer, pending.app.signer)) { + throw new Error("the app changed since it was checked: pick it again") + } + 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 new Error("the project changed meanwhile: pick it again") + } + entry = named + // A site allowed in the check: the project's too (its runs need it). + if ((named.origins ?? []).length !== pending.origins.length) { + entry = { ...named, origins: pending.origins } + saveProject(opened, { + ...opened.project, + apps: { ...opened.project.apps, [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 }, + } + saveProject(opened, { ...opened.project, apps: { ...opened.project.apps, [name]: entry } }) } - saveProject(opened, { ...opened.project, apps: { ...opened.project.apps, [name]: entry } }) + approvals.approve(now, scope, opensOf(entry)) + this.#pending.delete(token) + return name + } finally { + pending.adding = false } - approvals.approve(now, scope, opensOf(entry)) - this.#pending.delete(token) - return name } /** A window's add dropped (another pick, the window closed, the project changed). */ diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index b310581..4870f39 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -288,7 +288,10 @@ function start(): void { const showWindow = () => { if (window === null) { window = createWindow(join(here, "../preload"), icon, devServer) + const closing = window.webContents.id window.on("closed", () => { + // Its add dropped, its trial ended (macOS keeps the app open without a window). + adds.dropFor(closing) window = null }) return @@ -363,6 +366,7 @@ function start(): void { }), "project:create": (init) => act(async () => { + adds.dropAll() const url = targetUrl(init.url) // Where the address really lands (its www. or https form), asked while the user picks. const resolving = resolveAppAddress(url) @@ -538,6 +542,10 @@ function start(): void { } }, "apps:desktop-check": async ({ session, token, allowSite }) => { + if (typeof sessionProject(session) === "string") { + adds.dropAll() + return { failed: "the project changed: pick the app again" } + } if (workspace.agent?.running === true) return { failed: "Kif is working: stop it first" } try { return await adds.check(owner(), session, token, allowSite) @@ -574,14 +582,19 @@ function start(): void { if (typeof opened === "string") return { apps, problem: null } const store = desktopApprovals() const scope = ids().scope(opened.dir) - for (const [name, entry] of Object.entries(opened.project.apps)) { - if (entry.kind !== "electron") continue - 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.problem } + 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" } + }), + ) + // Said once (the file set aside at the first read). + const problem = store.problem + store.problem = null + return { apps, problem } }, "preview:open": (sceneId) => { const opened = workspace.opened diff --git a/apps/desktop/src/renderer/src/components/apps-panel.tsx b/apps/desktop/src/renderer/src/components/apps-panel.tsx index 8fb5857..f94fc43 100644 --- a/apps/desktop/src/renderer/src/components/apps-panel.tsx +++ b/apps/desktop/src/renderer/src/components/apps-panel.tsx @@ -39,6 +39,11 @@ export function AppsPanel({ 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(() => { @@ -54,7 +59,10 @@ export function AppsPanel({ if (!hasDesktop) return const view = await api() .invoke("apps:desktop-status", { session: project.session }) - .catch(() => null) + .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) @@ -83,11 +91,14 @@ export function AppsPanel({ } const pick = async () => { + if (picking) return setError(null) setOutcome(null) + setPicking(true) const picked = await api() .invoke("apps:desktop-pick", { session: project.session }) .catch((e: unknown) => ({ refused: String(e) })) + setPicking(false) if (picked === null) return if ("refused" in picked) { setCard(null) @@ -99,20 +110,25 @@ export function AppsPanel({ const check = async (allowSite: boolean) => { if (card === null) return + const token = card.token setChecking(true) setOutcome(null) const result = await api() - .invoke("apps:desktop-check", { session: project.session, token: card.token, allowSite }) + .invoke("apps:desktop-check", { session: project.session, token, allowSite }) .catch((e: unknown) => ({ failed: String(e) })) + // 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) return + if (card === null || adding) return + setAdding(true) const refused = await api() .invoke("apps:desktop-add", { session: project.session, token: card.token }) .catch((e: unknown) => String(e)) + setAdding(false) if (refused !== null) { setError(refused) return @@ -125,6 +141,7 @@ export function AppsPanel({ const cancel = () => { setCard(null) setOutcome(null) + setChecking(false) void api() .invoke("apps:desktop-cancel") .catch(() => undefined) @@ -174,11 +191,11 @@ export function AppsPanel({ )} {card !== null && ( @@ -191,7 +208,15 @@ export function AppsPanel({

{card.existing !== undefined && (

- This project names it already (as {card.existing}): checking it allows it here. + 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(", ")}

)}

@@ -201,7 +226,7 @@ export function AppsPanel({ {outcome !== null &&

{outcomeText(outcome, card.name)}

}
{outcome !== null && "ok" in outcome ? ( - ) : outcome !== null && "site" in outcome ? ( diff --git a/apps/desktop/src/renderer/test/apps.test.tsx b/apps/desktop/src/renderer/test/apps.test.tsx index e15d157..bc03a70 100644 --- a/apps/desktop/src/renderer/test/apps.test.tsx +++ b/apps/desktop/src/renderer/test/apps.test.tsx @@ -110,6 +110,7 @@ describe("adding a desktop app", () => { 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 () => { @@ -191,4 +192,56 @@ describe("adding a desktop app", () => { 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() + }) }) diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 0a4cda6..9f4d45a 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -312,6 +312,8 @@ export type DesktopPick = 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 } diff --git a/apps/desktop/test/desktop-apps.test.ts b/apps/desktop/test/desktop-apps.test.ts index 6e72afa..ced9694 100644 --- a/apps/desktop/test/desktop-apps.test.ts +++ b/apps/desktop/test/desktop-apps.test.ts @@ -280,3 +280,83 @@ describe("an app's name in the project", () => { expect(appNameFor("1Password", [])).toBe("password") }) }) + +describe("adding an app a project already names", () => { + const at = { "/Applications/Notes.app": notes() } + 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, and never approves a project changed since the pick", async () => { + const opened = named() + const adds = new DesktopAdds(looks(at).looks) + const card = await adds.pick(1, "s1", "/Applications/Notes.app", opened.project) + expect(card.opens).toEqual({ args: ["files/vault"], origins: ["https://a.example"] }) + await adds.check(1, "s1", card.token, false) + // A pull meanwhile adds a site: what the user saw isn't what would be approved. + const desk = opened.project.apps["desk"] as ElectronApp + saveProject(opened, { + ...opened.project, + apps: { + ...opened.project.apps, + desk: { ...desk, origins: ["https://a.example", "https://evil.example"] }, + }, + }) + await expect( + adds.add(1, "s1", card.token, opened, "folder-a", new DesktopApprovals(dir())), + ).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 fake = looks(at, (o) => + (o.origins ?? []).length > 0 ? { ok: true } : { site: "https://app.slack.com" }, + ) + const adds = new DesktopAdds(fake.looks) + const approvals = new DesktopApprovals(dir()) + const card = await adds.pick(1, "s1", "/Applications/Notes.app", opened.project) + await adds.check(1, "s1", card.token, false) + await adds.check(1, "s1", card.token, true) + expect(await adds.add(1, "s1", card.token, opened, "folder-a", approvals)).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", approvals, fake.looks)).toEqual({ + status: "ready", + }) + }) + + it("refuses a second pick while one is looked at, and a second add while one runs", async () => { + let release: () => void = () => undefined + const slow = new Promise((resolve) => (release = resolve)) + const adds = new DesktopAdds({ + inspect: async () => { + await slow + return notes() + }, + trial: () => Promise.resolve({ ok: true }), + }) + const opened = project() + const first = adds.pick(1, "s1", "/Applications/Notes.app", opened.project) + await expect(adds.pick(1, "s1", "/Applications/Notes.app", opened.project)).rejects.toThrow( + /being looked at/, + ) + release() + const card = await first + await adds.check(1, "s1", card.token, false) + const approvals = new DesktopApprovals(dir()) + const one = adds.add(1, "s1", card.token, opened, "folder-a", approvals) + await expect(adds.add(1, "s1", card.token, opened, "folder-a", approvals)).rejects.toThrow( + /being added already/, + ) + expect(await one).toBe("notes") + }) +}) From c601016d9af416a5ec056cc9e1b15478b262d36b Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Sat, 10 Oct 2026 00:20:50 +0200 Subject: [PATCH 3/7] fix(desktop): tie a desktop app's add to its project, checked after each wait - an add lives only while its project is the open one, checked at every step and after every wait (a project changed by any path ends it): an add given up while its app was looked at is never written - a finished add tells the open project's agent only; a site allowed in a check is kept as such (never guessed), one the project refuses is said - one app picker at a time (main); the panel never says an add given up, and picks nothing while one runs - the approvals' set-aside note given once by the store itself --- apps/desktop/src/main/desktop-apps.ts | 71 ++++++++-- apps/desktop/src/main/index.ts | 40 +++--- .../renderer/src/components/apps-panel.tsx | 21 ++- apps/desktop/src/renderer/test/apps.test.tsx | 37 +++++ apps/desktop/test/desktop-apps.test.ts | 126 +++++++++++++++--- 5 files changed, 234 insertions(+), 61 deletions(-) diff --git a/apps/desktop/src/main/desktop-apps.ts b/apps/desktop/src/main/desktop-apps.ts index a89f064..499f726 100644 --- a/apps/desktop/src/main/desktop-apps.ts +++ b/apps/desktop/src/main/desktop-apps.ts @@ -55,8 +55,14 @@ const keyOf = (bundleId: string) => bundleId.toLowerCase() export class DesktopApprovals { readonly #path: string #file: File - /** Why the file was set aside, once (shown by the panel). */ - problem: string | null = null + #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") @@ -67,7 +73,7 @@ export class DesktopApprovals { } 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` + this.#problem = `the desktop apps' approvals didn't read: set aside as ${aside}; add them again` } } @@ -197,6 +203,18 @@ export function appNameFor(name: string, taken: readonly string[]): string { 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 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) { + 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 @@ -224,8 +242,10 @@ interface Pending { opensAtPick: string | undefined /** The site the last trial named (kept here: the window never names one). */ site: string | undefined - /** Allowed by the user after a trial named it. */ + /** 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[] tried: boolean trial: AbortController | undefined /** An add under way (a second click waits for nothing: refused). */ @@ -242,9 +262,12 @@ export class DesktopAdds { readonly #picking = new Set() readonly #looks: Looks readonly #workDir: string | undefined + /** The open project's session (none: no project): an add lives only while its own is open. */ + readonly #session: () => string | undefined - constructor(looks: Looks, workDir?: string) { + constructor(looks: Looks, session: () => string | undefined, workDir?: string) { this.#looks = looks + this.#session = session this.#workDir = workDir } @@ -256,6 +279,7 @@ export class DesktopAdds { project: { apps: Readonly> }, ): Promise { if (this.#picking.has(owner)) throw new Error("an app is being looked at already") + if (this.#session() !== session) throw new Error("the project changed: pick it again") this.dropFor(owner) this.#picking.add(owner) let app: DesktopApp @@ -264,6 +288,7 @@ export class DesktopAdds { } finally { this.#picking.delete(owner) } + if (this.#session() !== session) throw new Error("the project changed: pick it again") const named = Object.entries(project.apps).find( ([, a]) => a.kind === "electron" && keyOf(a.bundleId) === keyOf(app.bundleId), ) @@ -280,6 +305,7 @@ export class DesktopAdds { opensAtPick: entry === undefined ? undefined : opensOf(entry), site: undefined, origins: listed, + allowed: [], tried: false, trial: undefined, adding: false, @@ -312,6 +338,7 @@ export class DesktopAdds { if (allowSite) { if (pending.site === undefined) throw new Error("no site to allow: check it first") pending.origins = [...new Set([...pending.origins, pending.site])] + pending.allowed = [...new Set([...pending.allowed, pending.site])] } const stopping = new AbortController() pending.trial = stopping @@ -321,6 +348,7 @@ export class DesktopAdds { 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 @@ -347,6 +375,8 @@ export class DesktopAdds { pending.adding = true try { const now = await this.#looks.inspect(pending.app.path) + // Still this add (never written once it was given up while the app was looked at). + this.#own(owner, session, token) if (!sameSigner(now.signer, pending.app.signer)) { throw new Error("the app changed since it was checked: pick it again") } @@ -359,13 +389,13 @@ export class DesktopAdds { throw new Error("the project changed meanwhile: pick it again") } entry = named - // A site allowed in the check: the project's too (its runs need it). - if ((named.origins ?? []).length !== pending.origins.length) { - entry = { ...named, origins: pending.origins } - saveProject(opened, { - ...opened.project, - apps: { ...opened.project.apps, [name]: entry }, - }) + // 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)) @@ -375,7 +405,7 @@ export class DesktopAdds { ...(pending.origins.length > 0 && { origins: pending.origins }), viewport: { width: 1440, height: 900, deviceScaleFactor: 1 }, } - saveProject(opened, { ...opened.project, apps: { ...opened.project.apps, [name]: entry } }) + save(opened, name, entry) } approvals.approve(now, scope, opensOf(entry)) this.#pending.delete(token) @@ -400,9 +430,22 @@ export class DesktopAdds { this.#pending.clear() } + /** + * The add of this window and session, still under way, its project still the open one (checked + * at every step and after every wait: a project changed meanwhile ends it, whatever changed it). + */ #own(owner: number, session: string, token: string): Pending { const pending = this.#pending.get(token) - if (pending === undefined || pending.owner !== owner || pending.session !== session) { + if (pending !== undefined && this.#session() !== pending.session) { + pending.trial?.abort() + this.#pending.delete(token) + } + if ( + this.#pending.get(token) !== pending || + pending === undefined || + pending.owner !== owner || + pending.session !== session + ) { throw new Error("that app isn't being added any more: pick it again") } return pending diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 4870f39..dc566e9 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -267,7 +267,9 @@ function start(): void { 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 = new DesktopAdds(looks, workDir) + const adds = new DesktopAdds(looks, () => workspace.apps()?.session, workDir) + // One app picker at a time (a second while it's open: nothing). + let picking = false let approvals: DesktopApprovals | undefined const desktopApprovals = (): DesktopApprovals => (approvals ??= new DesktopApprovals(app.getPath("userData"))) @@ -366,7 +368,6 @@ function start(): void { }), "project:create": (init) => act(async () => { - adds.dropAll() const url = targetUrl(init.url) // Where the address really lands (its www. or https form), asked while the user picks. const resolving = resolveAppAddress(url) @@ -385,7 +386,6 @@ function start(): void { }), "project:open": () => act(async () => { - adds.dropAll() const picked = await dialog.showOpenDialog(parent(), { title: "Open a project", buttonLabel: "Open", @@ -400,10 +400,7 @@ function start(): void { "project:close": () => // The browser's close isn't waited for (one that hangs never holds the window); a quit // waits for it. - act(() => { - adds.dropAll() - return workspace.close(dropBrowser) - }), + act(() => workspace.close(dropBrowser)), "external:open": async (url) => { if (isSafeExternal(url)) await shell.openExternal(url) }, @@ -526,13 +523,17 @@ function start(): void { const opened = sessionProject(session) if (typeof opened === "string") return { refused: opened } if (workspace.agent?.running === true) return { refused: "Kif is working: stop it first" } - const picked = await dialog.showOpenDialog(parent(), { - title: "Add a desktop app", - buttonLabel: "Choose", - defaultPath: "/Applications", - properties: ["openFile"], - filters: [{ name: "Applications", extensions: ["app"] }], - }) + if (picking) return null + picking = true + const picked = await dialog + .showOpenDialog(parent(), { + title: "Add a desktop app", + buttonLabel: "Choose", + defaultPath: "/Applications", + properties: ["openFile"], + filters: [{ name: "Applications", extensions: ["app"] }], + }) + .finally(() => (picking = false)) const path = picked.filePaths[0] if (picked.canceled || path === undefined) return null try { @@ -542,10 +543,6 @@ function start(): void { } }, "apps:desktop-check": async ({ session, token, allowSite }) => { - if (typeof sessionProject(session) === "string") { - adds.dropAll() - return { failed: "the project changed: pick the app again" } - } if (workspace.agent?.running === true) return { failed: "Kif is working: stop it first" } try { return await adds.check(owner(), session, token, allowSite) @@ -569,6 +566,8 @@ function start(): void { } catch (e) { return message(e) } + // Told to this project's agent only (another opened meanwhile: never its). + if (workspace.opened !== opened) return null workspace.agent?.appsChanged(opened.project.apps) void status().then((now) => { if (workspace.opened === opened) emit(window, "status", now) @@ -591,10 +590,7 @@ function start(): void { : { status: "not-found", why: "desktop apps run on macOS only" } }), ) - // Said once (the file set aside at the first read). - const problem = store.problem - store.problem = null - return { apps, problem } + return { apps, problem: store.takeProblem() } }, "preview:open": (sceneId) => { const opened = workspace.opened diff --git a/apps/desktop/src/renderer/src/components/apps-panel.tsx b/apps/desktop/src/renderer/src/components/apps-panel.tsx index f94fc43..125b959 100644 --- a/apps/desktop/src/renderer/src/components/apps-panel.tsx +++ b/apps/desktop/src/renderer/src/components/apps-panel.tsx @@ -90,14 +90,19 @@ export function AppsPanel({ setError(refused) } + // Busy flags read at once (a double click within one frame sees the first). + const pickingNow = useRef(false) + const addingNow = useRef(false) const pick = async () => { - if (picking) return + if (pickingNow.current || addingNow.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) { @@ -123,12 +128,20 @@ export function AppsPanel({ } const add = async () => { - if (card === null || adding) return + if (card === null || addingNow.current) return + const token = card.token + addingNow.current = true setAdding(true) const refused = await api() - .invoke("apps:desktop-add", { session: project.session, token: card.token }) + .invoke("apps:desktop-add", { session: project.session, token }) .catch((e: unknown) => String(e)) + addingNow.current = false 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 @@ -191,7 +204,7 @@ export function AppsPanel({