diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b09df56..dfe5206 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,13 +31,19 @@ jobs: - run: pnpm lint - run: pnpm format:check - run: pnpm typecheck - - run: pnpm test:coverage - # The desktop app, built and launched under a virtual display. Ubuntu 24 restricts the user - # namespaces Chromium's sandbox needs: allowed back, so the app runs sandboxed as shipped. + # Ubuntu 24 restricts the user namespaces Chromium's sandbox needs: allowed back (Electron runs + # sandboxed as shipped). The tests run under a virtual display: the Electron target's tests + # launch a fixture desktop app. + - run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + - run: xvfb-run -a pnpm test:coverage + env: + # Slow shutdown phases of a desktop app's launch, and its failures' detail, said (the + # Electron target's evidence). + KIFRAME_ELECTRON_TIMING: "1" + KIFRAME_ELECTRON_DEBUG: "1" + # The desktop app, built and launched under a virtual display. - name: Desktop end-to-end - run: | - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - xvfb-run -a pnpm --filter @kiframe/desktop e2e + run: xvfb-run -a pnpm --filter @kiframe/desktop e2e - name: Upload coverage to Codecov if: ${{ !cancelled() }} uses: codecov/codecov-action@v5 @@ -45,3 +51,23 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage/lcov.info fail_ci_if_error: false + + # Desktop apps run on macOS only (Seatbelt, codesign, plutil): their suite, where it holds. + electron-macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + - run: pnpm install --frozen-lockfile + # A desktop app's takes are encoded with ffmpeg (as on Linux). + - run: brew install ffmpeg + # The studio's desktop tests drive a web page beside the app too. + - run: pnpm --filter @kiframe/runtime exec playwright install chromium + - run: pnpm exec vitest run --project electron + env: + KIFRAME_ELECTRON_TIMING: "1" + KIFRAME_ELECTRON_DEBUG: "1" diff --git a/apps/desktop/e2e/desktop-app.e2e.ts b/apps/desktop/e2e/desktop-app.e2e.ts new file mode 100644 index 0000000..6915b99 --- /dev/null +++ b/apps/desktop/e2e/desktop-app.e2e.ts @@ -0,0 +1,149 @@ +// "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, writeFileSync } 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 + // Kif, scripted: a step in the desktop app once it's added (launched from its approval). + const model = join(root, "model.json") + writeFileSync( + model, + JSON.stringify([ + { + kind: "tool_calls", + calls: [ + { + id: "d1", + name: "run_step", + arguments: JSON.stringify({ + scene: "tour", + start_app: "notes", + step: { + id: "add", + action: "click", + target: { by: "role", role: "button", name: "Add note" }, + }, + }), + }, + ], + }, + { kind: "text", text: "Added a note in Notes." }, + ]), + ) + env.KIFRAME_TEST_MODEL = model + 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) + + it("lets Kif work in it: launched from its approval, confined, closed with the project", async () => { + // The Apps panel closed (the chat beside it). + await page.keyboard.press("Escape") + const box = page.getByLabel("Message Kif") + await box.fill("Add a note in Notes") + await box.press("Enter") + await expect + .poll(() => page.getByText("Added a note in Notes.").count(), { timeout: 60_000 }) + .toBeGreaterThan(0) + // Its live app: one confined launch in the test's work area. + const live = () => readdirSync(join(work, "sandboxes")).filter((n) => !n.endsWith(".canary")) + expect(live()).toHaveLength(1) + // The project closed: Kif's app ends with it. + await page.getByRole("button", { name: /Desktop demo/ }).click() + await page.getByRole("menuitem", { name: "Close project" }).click() + await expect.poll(() => live().length, { timeout: 20_000 }).toBe(0) + }, 120_000) +}) diff --git a/apps/desktop/src/main/agent.ts b/apps/desktop/src/main/agent.ts index ca681ef..73a919d 100644 --- a/apps/desktop/src/main/agent.ts +++ b/apps/desktop/src/main/agent.ts @@ -12,6 +12,7 @@ import { notesBlock, runNotes, Studio, + type StudioOptions, studioTools, systemPrompt, } from "@kiframe/studio" @@ -99,6 +100,8 @@ export interface AgentHostOptions { projectChanged: () => void /** Where the files the agent replaces or deletes are kept first (none: not kept). */ versions?: FileVersions + /** Launches the project's desktop apps (main's launcher, from their approvals). None: none. */ + launchDesktop?: StudioOptions["launchDesktop"] } /** A message as sent: its text as the user wrote it, as the history keeps it, its files. */ @@ -515,6 +518,9 @@ export class AgentHost { takes, browser, ...(this.#options.afterRecord !== undefined && { afterRecord: this.#options.afterRecord }), + ...(this.#options.launchDesktop !== undefined && { + launchDesktop: this.#options.launchDesktop, + }), requestUser: (request, signal) => this.#ask(request, signal), // What the agent read of the project's files: this host's (outlives a studio remade). fileReads: this.#fileReads, diff --git a/apps/desktop/src/main/desktop-apps.ts b/apps/desktop/src/main/desktop-apps.ts new file mode 100644 index 0000000..c8b2b6f --- /dev/null +++ b/apps/desktop/src/main/desktop-apps.ts @@ -0,0 +1,625 @@ +// 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, + InspectError, + type Signer, + type TrialOptions, + type TrialOutcome, +} from "@kiframe/runtime" +import { BUNDLE_ID, type ElectronApp } from "@kiframe/schema" +import { z } from "zod" + +const SignerSchema = z.union([ + z.strictObject({ + kind: z.literal("team"), + team: z.string().max(64), + identifier: z.string().max(1024), + }), + z.strictObject({ kind: z.literal("pinned"), digest: z.string().regex(/^[0-9a-f]{64}$/) }), +]) + +/** One copy of an app approved on this Mac (where it was picked, who stands behind it). */ +const Approval = z.strictObject({ + /** Where the app was picked (its real path). */ + path: z.string().max(4096), + name: z.string().max(200), + signer: SignerSchema, + approvedAt: z.string(), + /** + * Per project folder (the registry's scope): what it opens with the app, approved, and the build + * it tried (each project's own: another's update never changes its status). + */ + scopes: z.record( + z.string(), + z.strictObject({ + opens: z.string().regex(/^[0-9a-f]{64}$/), + version: z.string().max(100).optional(), + }), + ), +}) +type Approval = z.infer + +/** At most this many copies of one app kept (the oldest let go): the file always reads back. */ +const MAX_COPIES = 20 + +/** A version as kept (cut: compared the same way). */ +export const versionOf = (app: Pick) => app.version?.slice(0, 100) + +/** + * Each bundle id's copies (a release in Applications, a dev build elsewhere: one id, several + * apps): a project uses one of them. + */ +const File = z.strictObject({ + version: z.literal(1), + apps: z.record(z.string().regex(BUNDLE_ID), z.array(Approval).max(MAX_COPIES)), +}) +type File = z.infer + +/** + * What a project opens with an app (its arguments, sites, terminals), as one digest: what its + * approval covers. Terminals only when on (an approval from before them keeps its digest). + */ +export function opensOf(app: Pick): string { + return createHash("sha256") + .update( + JSON.stringify({ + args: app.args ?? [], + origins: app.origins ?? [], + ...(app.terminals === true && { terminals: true }), + }), + ) + .digest("hex") +} + +/** The store's key: bundle ids compare without case (as the schema's project does). */ +const keyOf = (bundleId: string) => bundleId.toLowerCase() + +/** + * The approvals file (`desktop-apps.json` in app data, 0600, written whole: on disk first, then + * kept). One that doesn't read is set aside (renamed, said once), never overwritten. + */ +export class DesktopApprovals { + readonly #path: string + #file: File + #problem: string | null = null + + /** Why the file was set aside: given once (the panel says it), then null. */ + takeProblem(): string | null { + const problem = this.#problem + this.#problem = null + return problem + } + + constructor(dir: string) { + this.#path = join(dir, "desktop-apps.json") + this.#file = { version: 1, apps: {} } + if (!existsSync(this.#path)) return + try { + this.#file = File.parse(JSON.parse(readFileSync(this.#path, "utf8"))) + } catch { + const aside = `${this.#path}.bad-${Date.now()}` + renameSync(this.#path, aside) + this.#problem = `the desktop apps' approvals didn't read: set aside as ${aside}; add them again` + } + } + + /** The app's copies approved on this Mac. */ + copies(bundleId: string): readonly Approval[] { + const key = keyOf(bundleId) + return Object.hasOwn(this.#file.apps, key) ? (this.#file.apps[key] ?? []) : [] + } + + /** The copy this project folder uses (one at most). */ + copyFor(bundleId: string, scope: string): Approval | undefined { + return this.copies(bundleId).find((copy) => Object.hasOwn(copy.scopes, scope)) + } + + /** + * The app (the build that was tried) approved for this project folder and what it opens: its + * copy (by place) kept with its other projects when it's the same build or developer, started + * afresh when another is there now; the project's other copy of that id let go. + */ + approve(app: DesktopApp, scope: string, opens: string): void { + const atPlace = (copy: Approval) => copy.path === app.path + const here = this.copies(app.bundleId).find(atPlace) + const others = this.copies(app.bundleId) + .filter((copy) => !atPlace(copy)) + .map((copy) => ({ ...copy, scopes: without(copy.scopes, scope) })) + .filter((copy) => Object.keys(copy.scopes).length > 0) + const kept = here !== undefined && sameSigner(here.signer, app.signer) ? here.scopes : {} + const version = versionOf(app) + const copy: Approval = { + path: app.path, + name: app.name.slice(0, 200), + signer: app.signer, + approvedAt: new Date().toISOString(), + scopes: { ...kept, [scope]: { opens, ...(version !== undefined && { version }) } }, + } + try { + this.#write({ + ...this.#file.apps, + [keyOf(app.bundleId)]: [...others, copy].slice(-MAX_COPIES), + }) + } catch (error) { + if ((error as Error).name !== "ZodError") throw error + throw new Error("this app's details can't be kept (too long?)", { cause: error }) + } + } + + /** This project folder's approval of the app taken back (the app removed from the project). */ + drop(bundleId: string, scope: string): void { + if (this.copyFor(bundleId, scope) === undefined) return + const copies = this.copies(bundleId) + .map((copy) => ({ ...copy, scopes: without(copy.scopes, scope) })) + .filter((copy) => Object.keys(copy.scopes).length > 0) + this.#write({ ...this.#file.apps, [keyOf(bundleId)]: copies }) + } + + /** Written whole, then kept (a write that fails changes nothing here either). */ + #write(apps: File["apps"]): void { + // What reads back (never a file that would be set aside at the next start). + const next: File = File.parse({ version: 1, apps }) + mkdirSync(dirname(this.#path), { recursive: true }) + const tmp = `${this.#path}.${randomBytes(6).toString("hex")}.tmp` + writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 }) + renameSync(tmp, this.#path) + this.#file = next + } +} + +function without(record: Readonly>, key: string): Record { + return Object.fromEntries(Object.entries(record).filter(([k]) => k !== key)) +} + +/** The same app: its developer (a team app updated is still theirs), or the same pinned build. */ +export 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"; app: DesktopApp } + | { status: "allow" | "opens-changed" | "changed" | "not-found"; why: string } + +/** The functions that look at an app (the runtime's; fakes in tests). */ +export interface Looks { + inspect: (path: string, signal?: AbortSignal) => Promise + trial: (app: DesktopApp, opts: TrialOptions) => Promise +} + +/** + * The project's desktop app's status, static only (opening the panel never launches anything): its + * approval here first (what needs no look at the app), then the copy it uses, inspected, compared. + */ +export async function desktopStatus( + entry: ElectronApp, + scope: string, + approvals: DesktopApprovals, + looks: Pick, + signal?: AbortSignal, +): Promise { + const copy = approvals.copyFor(entry.bundleId, scope) + if (copy === undefined) { + return approvals.copies(entry.bundleId).length === 0 + ? { status: "allow", why: "not added on this Mac: add it from Applications to use it here" } + : { status: "allow", why: "not allowed in this project yet: pick it to allow it here" } + } + if (copy.scopes[scope]?.opens !== opensOf(entry)) { + return { + status: "opens-changed", + why: "the project changed what it opens with it: allow it again", + } + } + let now: DesktopApp + try { + now = await looks.inspect(copy.path, signal) + } catch (error) { + if (signal?.aborted === true) throw error + if (!existsSync(copy.path)) { + return { status: "not-found", why: "not where it was picked any more: add it again" } + } + // An inspection's own refusal (worded, path-free), else just that it changed. + return { + status: "changed", + why: error instanceof InspectError ? error.message : "it can't be looked at any more", + } + } + if (keyOf(now.bundleId) !== keyOf(entry.bundleId) || !sameSigner(copy.signer, now.signer)) { + return { status: "changed", why: "another app is there now (or another build): add it again" } + } + return versionOf(now) !== copy.scopes[scope]?.version && now.signer.kind === "team" + ? { status: "updated", app: now } + : { status: "ready", app: now } +} + +/** An app's name in the project: its own, in the app-name form, made unique. */ +export function appNameFor(name: string, taken: readonly string[]): string { + const base = + name + .normalize("NFKD") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^[^a-z]+/, "") + .replace(/-+$/, "") + .slice(0, 32) + .replace(/-+$/, "") || "app" + if (!taken.includes(base)) return base + for (let i = 2; ; i++) if (!taken.includes(`${base}-${i}`)) return `${base}-${i}` +} + +/** The app written to the project as `name` (refused by the project's own rules: said in words). */ +function save(opened: OpenedProject, name: string, entry: ElectronApp): void { + try { + saveProject(opened, { ...opened.project, apps: { ...opened.project.apps, [name]: entry } }) + } catch (error) { + // Only the project's rules reworded (a file changed on disk, a write failed: said as is). + if ((error as Error).name !== "ZodError") throw error + const issue = (error as { issues?: { message: string }[] }).issues?.[0]?.message + throw new Error(`the project can't take it so${issue === undefined ? "" : ` (${issue})`}`, { + cause: error, + }) + } +} + +/** What the window shows of a picked app (never its path). */ +export interface DesktopCard { + token: string + name: string + bundleId: string + version: string | undefined + electron: string + /** Who stands behind it: a developer's team id, or none (pinned to this build). */ + signer: { kind: "team"; team: string } | { kind: "pinned" } + /** The project already names it (a project from someone else): adding approves it here. */ + existing: string | undefined + /** + * What that project opens with it, shown before it's allowed: its arguments (paths in files/, + * not passed to the check), the sites it shows as its own (the check runs with them), and + * whether it opens terminals (never in the check: a few seconds need no shell). + */ + opens: { args: string[]; origins: string[]; terminals: boolean } | undefined +} + +/** + * What the add flow needs of the app around it (main's; a fake in tests): read fresh at each step, + * never a snapshot. + */ +export interface AddHost { + /** The open project's session (none when no project is open). */ + session(): string | undefined + opened(): OpenedProject | null + /** Why nothing may change now (Kif working), or undefined. */ + busy(): string | undefined + /** The app the user picks (main's dialog): undefined when cancelled. */ + pickApp(): Promise + /** The app approved for this project with what it opens. */ + approve(app: DesktopApp, opened: OpenedProject, opens: string): void + /** The project's apps changed (its agent and the window told, if it's still the open one). */ + changed(opened: OpenedProject): void +} + +interface Pending { + owner: number + session: string + app: DesktopApp + existing: string | undefined + /** What the project opened the app with when picked (an add after a change: refused). */ + opensAtPick: string | undefined + /** The site the last trial named (kept here: the window never names one). */ + site: string | undefined + /** The sites it runs with (the project's, and any the user allowed after a trial named one). */ + origins: string[] + /** Sites the user allowed in its checks (written to the project with it). */ + allowed: string[] + /** A trial worked with the sites it runs with now (they changed since: not tried). */ + tried: boolean + trial: AbortController | undefined + adding: boolean + /** Ends what's under way for it (its add's look at the app) when it's dropped. */ + stopping: AbortController +} + +/** Refused: said to the user as is. */ +const refused = (why: string) => new Error(why) +const GONE = "that app isn't being added any more: pick it again" + +/** + * The add flow, owned whole (PR 3b-2, redesigned after three review rounds): the app picked (main's + * dialog; its path stays here: the window has a token), inspected, tried confined, added. One + * gate before every change and after every wait: the add still under way, its window's, its + * project the open one, Kif not working. A project switch ends every add at once (main's hook); + * the gate is the guarantee whatever path changed it. + */ +export class DesktopAdds { + readonly #pending = new Map() + /** Each window's pick in progress (a cancel ends it: its answer dropped). */ + readonly #picks = new Map() + #generation = 0 + readonly #looks: Looks + readonly #host: AddHost + readonly #workDir: string | undefined + + constructor(looks: Looks, host: AddHost, workDir?: string) { + this.#looks = looks + this.#host = host + this.#workDir = workDir + } + + /** + * The user picks an app (one pick per window at a time), inspected: its card, null when the + * dialog was cancelled or the pick given up meanwhile; refused: thrown, said. + */ + async pick(owner: number, session: string): Promise { + if (this.#picks.has(owner)) throw refused("an app is being picked already") + this.#current(session) + this.dropFor(owner) + const generation = ++this.#generation + const stopping = new AbortController() + this.#picks.set(owner, { generation, stopping }) + const still = () => + this.#picks.get(owner)?.generation === generation && this.#isCurrent(session) + try { + const path = await this.#host.pickApp() + if (path === undefined || !still()) return null + let app: DesktopApp + try { + app = await this.#looks.inspect(path, stopping.signal) + } catch (error) { + if (stopping.signal.aborted) return null + throw error + } + if (!still()) return null + this.#current(session) + const opened = this.#host.opened() + if (opened === null) return null + const named = Object.entries(opened.project.apps).find( + ([, a]) => a.kind === "electron" && keyOf(a.bundleId) === keyOf(app.bundleId), + ) + const existing = named?.[0] + // Named already: tried with the sites the project lists for it (what it'll run with), shown. + const entry = named?.[1].kind === "electron" ? named[1] : undefined + const listed = [...(entry?.origins ?? [])] + const token = randomUUID() + this.#pending.set(token, { + owner, + session, + app, + existing, + opensAtPick: entry === undefined ? undefined : opensOf(entry), + site: undefined, + origins: listed, + allowed: [], + tried: false, + trial: undefined, + adding: false, + stopping: new AbortController(), + }) + return { + token, + name: app.name, + bundleId: app.bundleId, + version: app.version, + electron: app.electron, + signer: + app.signer.kind === "team" ? { kind: "team", team: app.signer.team } : { kind: "pinned" }, + existing, + opens: + entry === undefined + ? undefined + : { + args: [...(entry.args ?? [])], + origins: listed, + terminals: entry.terminals === true, + }, + } + } finally { + if (this.#picks.get(owner)?.generation === generation) this.#picks.delete(owner) + } + } + + /** + * The picked app tried, confined. `allowSite`: with the site the last trial named (kept here), + * as the app's own: tried again before it can be added. + */ + async check( + owner: number, + session: string, + token: string, + allowSite: boolean, + ): Promise { + const pending = this.#own(owner, session, token) + if (pending.trial !== undefined) throw refused("it's being checked already") + if (pending.adding) throw refused("it's being added already") + if (allowSite) { + if (pending.site === undefined) throw refused("no site to allow: check it first") + pending.origins = [...new Set([...pending.origins, pending.site])] + pending.allowed = [...new Set([...pending.allowed, pending.site])] + } + // What it runs with now is untried until this trial says so. + pending.tried = false + const stopping = new AbortController() + pending.trial = stopping + try { + const outcome = await this.#looks + .trial(pending.app, { + origins: pending.origins, + signal: stopping.signal, + ...(this.#workDir !== undefined && { workDir: this.#workDir }), + }) + .catch((error: unknown) => { + // Ended (Kif started, given up): said in words, never a raw stop. + if (!stopping.signal.aborted) throw error + this.#own(owner, session, token) + throw refused("the check was ended (Kif started?): check it again") + }) + this.#own(owner, session, token) + pending.tried = "ok" in outcome + pending.site = "site" in outcome ? outcome.site : undefined + return outcome + } finally { + pending.trial = undefined + } + } + + /** + * The tried app added to the project (or, named already, allowed here): its build checked again + * (the one tried), the project written (its changed-on-disk refusal), then approved. Once the + * project is written the add is done (an approval that fails then: said, allowed again later). + * `terminals`: a new app's, as the user chose on its card (an app the project names keeps the + * project's: shown on its card, never changed here). + */ + async add(owner: number, session: string, token: string, terminals = false): Promise { + const pending = this.#own(owner, session, token) + if (terminals && pending.existing !== undefined) { + throw refused("the project says whether it opens terminals: remove it to change that") + } + if (pending.trial !== undefined) throw refused("it's being checked: wait for it") + if (!pending.tried) throw refused("check it first: it's added once it ran here") + if (pending.adding) throw refused("it's being added already") + pending.adding = true + try { + const now = await this.#looks + .inspect(pending.app.path, pending.stopping.signal) + .catch((error: unknown) => { + // Given up meanwhile: said as such (the gate's words); else its own reason. + if (pending.stopping.signal.aborted) this.#own(owner, session, token) + throw error + }) + this.#own(owner, session, token) + if (!sameSigner(now.signer, pending.app.signer)) { + throw refused("the app changed since it was checked: pick it again") + } + const opened = this.#host.opened() + if (opened === null) throw refused(GONE) + let name = pending.existing + let entry: ElectronApp + if (name !== undefined) { + const named = opened.project.apps[name] + // What the user saw at the pick, still (a project changed meanwhile: never approved untried). + if (named?.kind !== "electron" || opensOf(named) !== pending.opensAtPick) { + throw refused("the project changed meanwhile: pick it again") + } + entry = named + // A site allowed in a check: the project's too (its runs need it). + if (pending.allowed.length > 0) { + entry = { + ...named, + origins: [...new Set([...(named.origins ?? []), ...pending.allowed])], + } + save(opened, name, entry) + } + } else { + name = appNameFor(now.name, Object.keys(opened.project.apps)) + entry = { + kind: "electron", + bundleId: now.bundleId, + ...(pending.origins.length > 0 && { origins: pending.origins }), + ...(terminals && { terminals: true }), + viewport: { width: 1440, height: 900, deviceScaleFactor: 1 }, + } + save(opened, name, entry) + } + // Written: done, whatever follows. + this.#pending.delete(token) + this.#host.changed(opened) + try { + // The build that was tried (a newer one since, same developer: "updated", tried at its run). + this.#host.approve(pending.app, opened, opensOf(entry)) + } catch (error) { + throw refused( + `added as ${name}, but not approved on this Mac (${(error as Error).message}): pick it again to allow it`, + ) + } + return name + } finally { + pending.adding = false + } + } + + /** A window's pick and add given up (cancelled, the window closed or reloaded). */ + dropFor(owner: number): void { + this.#picks.get(owner)?.stopping.abort() + this.#picks.delete(owner) + for (const [token, pending] of this.#pending) { + if (pending.owner !== owner) continue + pending.trial?.abort() + pending.stopping.abort() + this.#pending.delete(token) + } + } + + /** Every check in progress ended (Kif starting): picks and cards kept, checked again later. */ + endChecks(): void { + for (const pending of this.#pending.values()) pending.trial?.abort() + } + + /** Every pick and add given up, their trials ended (a project switch, a quit). */ + dropAll(): void { + for (const pick of this.#picks.values()) pick.stopping.abort() + this.#picks.clear() + for (const pending of this.#pending.values()) { + pending.trial?.abort() + pending.stopping.abort() + } + this.#pending.clear() + } + + #isCurrent(session: string): boolean { + return this.#host.session() === session + } + + /** The session the window acts in is the open project's, and nothing may stop a change now. */ + #current(session: string): void { + if (!this.#isCurrent(session)) throw refused("the project changed: pick the app again") + const busy = this.#host.busy() + if (busy !== undefined) throw refused(busy) + } + + /** The gate: this window's add, still under way, its project the open one, Kif not working. */ + #own(owner: number, session: string, token: string): Pending { + const pending = this.#pending.get(token) + if (pending !== undefined && !this.#isCurrent(pending.session)) { + pending.trial?.abort() + pending.stopping.abort() + this.#pending.delete(token) + } + const still = this.#pending.get(token) + if (still === undefined || still.owner !== owner || still.session !== session) + throw refused(GONE) + const busy = this.#host.busy() + if (busy !== undefined) throw refused(busy) + return still + } +} + +/** The add flow's host from the workspace (main's, and tests' with a real one). */ +export function addHostOf( + workspace: { + readonly session: string | undefined + readonly opened: OpenedProject | null + readonly agent: { readonly running: boolean } | undefined + }, + deps: Pick, +): AddHost { + return { + session: () => workspace.session, + opened: () => workspace.opened, + busy: () => (workspace.agent?.running === true ? "Kif is working: stop it first" : undefined), + ...deps, + } +} diff --git a/apps/desktop/src/main/desktop-launch.ts b/apps/desktop/src/main/desktop-launch.ts new file mode 100644 index 0000000..821c3e4 --- /dev/null +++ b/apps/desktop/src/main/desktop-launch.ts @@ -0,0 +1,233 @@ +// A desktop app launched for the agent (PR 4, design reviewed 2026-10-10): only an app approved +// on this Mac for this project and what it opens, from the copy that was approved. One the same +// developer updated is tried confined first, quietly (the user's decision, 2026-10-09), and that +// build approved for the project only if nothing changed meanwhile. Anything else is refused for +// the user to settle in the Apps panel (never something the agent can fix). +import { existsSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" +import type { OpenedProject } from "@kiframe/project" +import { + type DesktopApp, + type ElectronLaunch, + ElectronLaunchError, + type ElectronTarget, + type TrialOutcome, +} from "@kiframe/runtime" +import type { ElectronApp, TakeMeta } from "@kiframe/schema" +import { + type DesktopApprovals, + desktopStatus, + type Looks, + opensOf, + sameSigner, + versionOf, +} from "./desktop-apps.ts" + +/** What a take keeps of the app it filmed: its build and what it opened (the take's own shape). */ +export type AppBuild = NonNullable + +/** A launch refused: the user settles it (the Apps panel), the agent says so and never retries. */ +export class DesktopRefused extends Error { + readonly needsUser = true +} + +export interface LauncherDeps { + approvals: () => DesktopApprovals + looks: Looks + launch: (opts: ElectronLaunch) => Promise + /** The open project, read at each step (a switch meanwhile: refused). */ + opened: () => OpenedProject | null + scope: (dir: string) => string + workDir?: string +} + +/** + * Launches the project's desktop apps, each from its approval (an updated build tried quietly by + * the launch itself first). + */ +export class DesktopLauncher { + readonly #deps: LauncherDeps + #lifetime = new AbortController() + + constructor(deps: LauncherDeps) { + this.#deps = deps + } + + /** Every launch's quiet trial ended (a project switch, a quit): their confined apps killed. */ + stopAll(): void { + this.#lifetime.abort() + this.#lifetime = new AbortController() + } + + /** + * The app `name` of the open project, launched confined. Refused by its approval: + * `DesktopRefused` (the user's to settle); any other failure an Error; either said without a + * local path (the agent reads it); a stop thrown as the stop. + */ + async launch( + name: string, + signal?: AbortSignal, + ): Promise<{ target: ElectronTarget; build: AppBuild }> { + try { + return await this.#launch(name, signal) + } catch (error) { + if (signal?.aborted === true) throw error + throw new DesktopRefused(this.#said(name, error), { cause: error }) + } + } + + /** + * A launch's failure as the agent reads it: its own worded refusals and the runtime's launch + * errors (none names a local path, by construction) as they are; anything else by its code + * only. A message naming a path Kiframe knows anyway (never meant to): replaced whole. + */ + #said(name: string, error: unknown): string { + const said = + error instanceof DesktopRefused + ? error.message + : error instanceof ElectronLaunchError + ? `${name}: ${error.message}` + : `${name} couldn't be launched (${(error as { code?: string }).code ?? "an error"})` + const opened = this.#deps.opened() + const known = [ + homedir(), + ...(this.#deps.workDir !== undefined ? [this.#deps.workDir] : []), + ...(opened !== null ? [opened.dir] : []), + ] + return known.some((path) => path.length > 1 && said.includes(path)) + ? `${name} couldn't be launched (details kept out: they name a local folder)` + : said + } + + async #launch( + name: string, + signal?: AbortSignal, + ): Promise<{ target: ElectronTarget; build: AppBuild }> { + const opened = this.#deps.opened() + const entry = opened?.project.apps[name] + if (opened === null || entry?.kind !== "electron") { + throw new DesktopRefused(`"${name}" isn't a desktop app of the open project`) + } + const scope = this.#deps.scope(opened.dir) + // The launcher's stop as it was when this launch began (a switch meanwhile ends it too). + const lifetime = this.#lifetime.signal + let status = await desktopStatus(entry, scope, this.#deps.approvals(), this.#deps.looks, signal) + if (status.status === "updated") { + signal?.throwIfAborted() + const stops = signal === undefined ? lifetime : AbortSignal.any([signal, lifetime]) + await this.#quietTrial(name, entry, opened, scope, status.app, stops, signal) + // Asked again: what's there now must be the build just approved (an update landing during + // the trial, an app moved or replaced: refused for the user, never launched untried). + status = await desktopStatus(entry, scope, this.#deps.approvals(), this.#deps.looks, signal) + if (status.status === "updated") { + throw new DesktopRefused( + `${name} updated again while it was checked: ask again in a moment`, + ) + } + } + if ("why" in status) throw new DesktopRefused(`${name}: ${status.why} (in the Apps panel)`) + signal?.throwIfAborted() + // Still approved here, the project still open and naming it the same (removed or switched + // while it was looked at: never launched). + this.#still(name, entry, opened, scope, status.app) + const app = status.app + const files = join(opened.dir, "files") + const target = await this.#deps.launch({ + executable: app.executable, + bundle: app.path, + ...(existsSync(files) && { files }), + ...(entry.args !== undefined && { args: entry.args }), + ...(entry.origins !== undefined && { origins: entry.origins }), + ...(entry.terminals === true && { terminals: true }), + viewport: entry.viewport, + ...(signal !== undefined && { signal }), + ...(this.#deps.workDir !== undefined && { workDir: this.#deps.workDir }), + }) + const version = versionOf(app) + return { target, build: { ...(version !== undefined && { version }), opens: opensOf(entry) } } + } + + /** The approval and the project as they were looked at, still (else refused, nothing written). */ + #still( + name: string, + entry: ElectronApp, + opened: OpenedProject, + scope: string, + app: DesktopApp, + approvedBuild = true, + ): void { + const copy = this.#deps.approvals().copyFor(entry.bundleId, scope) + const now = this.#deps.opened() + const named = now?.project.apps[name] + const same = + copy !== undefined && + copy.path === app.path && + sameSigner(copy.signer, app.signer) && + copy.scopes[scope]?.opens === opensOf(entry) && + (!approvedBuild || + app.signer.kind !== "team" || + copy.scopes[scope]?.version === versionOf(app)) && + now === opened && + named?.kind === "electron" && + opensOf(named) === opensOf(entry) + if (!same) { + throw new DesktopRefused(`${name} changed meanwhile: allow it again (in the Apps panel)`) + } + } + + /** + * An updated build tried confined by this launch, then approved for its project compare-and-set + * (the same copy, developer and opens, the project still the one it looked at: two launches that + * both tried it approve the same thing; anything changed: refused). Its stop: the launch's own + * (thrown as it is), or the launcher's (refused: the project closed). + */ + async #quietTrial( + name: string, + entry: ElectronApp, + opened: OpenedProject, + scope: string, + app: DesktopApp, + stops: AbortSignal, + own: AbortSignal | undefined, + ): Promise { + let outcome: TrialOutcome + try { + outcome = await this.#deps.looks.trial(app, { + origins: entry.origins ?? [], + signal: stops, + ...(this.#deps.workDir !== undefined && { workDir: this.#deps.workDir }), + }) + } catch (error) { + if (own?.aborted === true) throw error + if (stops.aborted) + throw new DesktopRefused(`${name}: the project closed while it was checked`) + throw error + } + if (stops.aborted && own?.aborted !== true) { + throw new DesktopRefused(`${name}: the project closed while it was checked`) + } + own?.throwIfAborted() + if (!("ok" in outcome)) throw new DesktopRefused(`${name} updated: ${refusalOf(outcome)}`) + this.#still(name, entry, opened, scope, app, false) + try { + this.#deps.approvals().approve(app, scope, opensOf(entry)) + } catch (error) { + // Its code only (a write error names the approvals file's path). + throw new DesktopRefused( + `${name} ran confined but couldn't be approved here (${(error as { code?: string }).code ?? "not saved"})`, + { cause: error }, + ) + } + } +} + +/** What a quiet trial that didn't work says (in words the user acts on). */ +function refusalOf(outcome: Exclude): string { + if ("site" in outcome) { + return `its window is now the site ${outcome.site}: allow it again (in the Apps panel)` + } + if ("quit" in outcome) + return "it quit at once when tried confined: check it again (in the Apps panel)" + return `it couldn't be tried confined (${outcome.failed}): check it again (in the Apps panel)` +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index a1217c8..226e3ac 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -3,18 +3,33 @@ 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, + launchElectron, + 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 type { AppStatus, DesktopStatusView } from "../shared/ipc.ts" import { errorMessage } from "../shared/util.ts" import { AgentHost } from "./agent.ts" +import { DesktopLauncher } from "./desktop-launch.ts" +import { + addHostOf, + DesktopAdds, + DesktopApprovals, + desktopStatus, + type Looks, +} from "./desktop-apps.ts" import { FileVersions } from "./file-versions.ts" import { resolveAppAddress } from "./app-address.ts" import { emit, registerHandlers } from "./ipc.ts" import { DEFAULT_MODEL, imageInput, modelConfig } from "./model.ts" import { - appOriginOf, + secretOriginOf, appRemovalRefused, newProjectDir, projectFileName, @@ -201,8 +216,9 @@ function start(): void { if (takes === undefined) throw new Error("the app isn't ready yet") return { registry, takes } } - /** One of the open project's apps for the window's request (`appOriginOf`). */ - const appOrigin = (session: string, app: string) => appOriginOf(workspace.apps(), session, app) + /** A secret's origin: a web app's (a desktop app takes none). */ + const secretOrigin = (session: string, app: string) => + secretOriginOf(workspace.apps(), session, app) /** * The open project and its agent, switched as one. An agent's events reach the window only while @@ -229,6 +245,10 @@ function start(): void { sceneKey: (sceneId) => registry.sceneKey(opened.dir, sceneId), takes, browser: launch, + // Desktop apps (macOS): launched from their approval for this project, confined. + ...(process.platform === "darwin" && { + launchDesktop: (name: string, signal: AbortSignal) => launcher.launch(name, signal), + }), llm: model, model: DEFAULT_MODEL, seesImages, @@ -236,7 +256,11 @@ function start(): void { // The files the agent replaces or deletes, kept first (by the host's scope for the folder). versions: new FileVersions(join(app.getPath("userData"), "versions", scope)), item: (item) => current() && emit(window, "chat:item", item), - running: (running) => current() && emit(window, "chat:running", running), + running: (running) => { + // Kif starting ends an "Add desktop app…" check in progress (never beside its run). + if (running && current()) adds.endChecks() + return current() && emit(window, "chat:running", running) + }, frame: (frame) => current() && emit(window, "live:frame", frame), projectChanged: () => { // Checked again once read: a switch meanwhile makes this one stale. @@ -260,6 +284,65 @@ function start(): void { return status() } + // Desktop apps (macOS): inspected and tried by the runtime; approvals in app data. + const looks: Looks = { inspect: inspectDesktopApp, trial: trialDesktopApp } + // Kiframe's work area (~/.kiframe); a test's own (never the user's) when it names one. + const workDir = process.env.KIFRAME_WORK_DIR ?? defaultWorkDir() + const adds: DesktopAdds = new DesktopAdds( + looks, + addHostOf(workspace, { + pickApp: async () => { + const picked = await dialog.showOpenDialog(parent(), { + title: "Add a desktop app", + buttonLabel: "Choose", + defaultPath: "/Applications", + properties: ["openFile"], + filters: [{ name: "Applications", extensions: ["app"] }], + }) + return picked.canceled ? undefined : picked.filePaths[0] + }, + approve: (picked, opened, opens) => + desktopApprovals().approve(picked, ids().scope(opened.dir), opens), + changed: (opened) => appsChanged(opened), + }), + workDir, + ) + // Every add ends, its trial too, as the project switches. + // Every add ends, its trial too, as the project switches; every launch's quiet trial too. + workspace.onSwitch(() => adds.dropAll()) + let approvals: DesktopApprovals | undefined + const desktopApprovals = (): DesktopApprovals => + (approvals ??= new DesktopApprovals(app.getPath("userData"))) + // Launches a project's desktop app for the agent, from its approval (every launch's quiet + // trial of an update ends as the project switches). + const launcher = new DesktopLauncher({ + approvals: desktopApprovals, + looks, + launch: launchElectron, + opened: () => workspace.opened, + scope: (dir) => ids().scope(dir), + workDir, + }) + workspace.onSwitch(() => launcher.stopAll()) + /** A status as the window has it (never the app's path or details). */ + const view = (s: Awaited>) => + "why" in s ? { status: s.status, why: s.why } : { status: s.status } + /** The open project, if it's this session's (else why not). */ + const sessionProject = (session: string): OpenedProject | string => { + const opened = workspace.opened + if (opened === null || workspace.session !== session) return "the project changed" + return opened + } + /** The project's apps changed: its agent and the window told (never another project's). */ + const appsChanged = (opened: OpenedProject): void => { + if (workspace.opened !== opened) return + workspace.agent?.appsChanged(opened.project.apps) + void status().then((now) => { + if (workspace.opened === opened) emit(window, "status", now) + }) + } + const owner = () => window?.webContents.id ?? 0 + /** The window a dialog belongs to (modal to it: never opened behind it). */ const parent = (): BrowserWindow => { if (window === null) throw new Error("no window to show the dialog in") @@ -269,7 +352,14 @@ function start(): void { const showWindow = () => { if (window === null) { window = createWindow(join(here, "../preload"), icon, devServer) + const closing = window.webContents.id + // A reload keeps the id: its add given up all the same (the window starts afresh). + window.webContents.on("did-start-navigation", (event) => { + if (event.isMainFrame && !event.isSameDocument) adds.dropFor(closing) + }) window.on("closed", () => { + // Its add dropped, its trial ended (macOS keeps the app open without a window). + adds.dropFor(closing) window = null }) return @@ -294,6 +384,9 @@ 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() + launcher.stopAll() 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. @@ -394,14 +487,16 @@ function start(): void { "secrets:list": async () => { const secrets = vault() await secrets.ready() - return (workspace.apps()?.apps ?? []).map(({ name, origin }) => ({ + // Web apps only: a desktop app takes no secrets. + const apps = (workspace.apps()?.apps ?? []).filter((a) => a.kind === "web") + return apps.map(({ name, origin }) => ({ app: name, origin, secrets: secrets.list(origin), })) }, "secrets:add": async ({ session, app, ...form }) => { - const at = appOrigin(session, app) + const at = secretOrigin(session, app) if ("why" in at) return at.why try { await vault().add(form, at.origin) @@ -412,7 +507,7 @@ function start(): void { } }, "secrets:remove": async ({ session, app, name }) => { - const at = appOrigin(session, app) + const at = secretOrigin(session, app) if ("why" in at) return at.why try { await vault().remove(name, at.origin) @@ -421,12 +516,12 @@ function start(): void { return message(e) } }, - "apps:remove": async ({ session, name, origin }) => { + "apps:remove": async ({ session, name, identity }) => { // Checked before asking and again after (the dialog waits on the user meanwhile). const refused = () => appRemovalRefused( workspace.apps(), - { session, name, origin }, + { session, name, identity }, workspace.agent?.running === true, ) const before = refused() @@ -446,7 +541,7 @@ function start(): void { : ` ${rules.length === 1 ? "The interrupt rule" : "The interrupt rules"} ${rules.map((r) => `“${r}”`).join(", ")} ${rules.length === 1 ? "goes" : "go"} there: ${rules.length === 1 ? "it" : "they"} will fail until changed.` const { response } = await dialog.showMessageBox(parent(), { type: "warning", - message: `Remove ${name} (${origin}) from the project?`, + message: `Remove ${name} (${identity}) from the project?`, detail: (uses.length === 0 ? "No scene uses it. Its saved secrets stay; its approvals in this project go." @@ -462,8 +557,10 @@ function start(): void { if (after !== null || workspace.opened !== opened) return after ?? "the project changed" // This project's approvals on that site go first (the secrets stay: other projects may // use them): a failure below leaves the app with its approvals asked again, never kept. + // A desktop app has none (it takes no secrets). + const removed = workspace.apps()?.apps.find((a) => a.name === name) try { - vault().revokeAt(ids().scope(opened.dir), origin) + if (removed?.kind === "web") vault().revokeAt(ids().scope(opened.dir), removed.origin) } catch (e) { return `not removed: its approvals couldn't be revoked: ${message(e)}` } @@ -472,12 +569,61 @@ function start(): void { } catch (e) { return message(e) } - workspace.agent?.appsChanged(opened.project.apps) - void status().then((now) => { - if (workspace.opened === opened) emit(window, "status", now) - }) + // A desktop app's approval in this project goes with it (its other projects' stay). + if (removed?.kind === "electron") { + try { + desktopApprovals().drop(removed.bundleId, ids().scope(opened.dir)) + } catch (e) { + say(`its approval on this Mac couldn't be removed: ${message(e)}`) + } + } + appsChanged(opened) return null }, + "apps:desktop-pick": async ({ session }) => { + if (process.platform !== "darwin") { + return { refused: "desktop apps run on macOS only (they're confined there)" } + } + try { + const card = await adds.pick(owner(), session) + return card === null ? null : { card } + } catch (e) { + return { refused: message(e) } + } + }, + "apps:desktop-check": async ({ session, token, allowSite }) => { + try { + return await adds.check(owner(), session, token, allowSite) + } catch (e) { + return { failed: message(e) } + } + }, + "apps:desktop-add": async ({ session, token, terminals }) => { + try { + await adds.add(owner(), session, token, terminals) + return null + } catch (e) { + return message(e) + } + }, + "apps:desktop-cancel": () => adds.dropFor(owner()), + "apps:desktop-status": async ({ session }) => { + const opened = sessionProject(session) + const apps: DesktopStatusView["apps"] = {} + if (typeof opened === "string") return { apps, problem: null } + const store = desktopApprovals() + const scope = ids().scope(opened.dir) + await Promise.all( + Object.entries(opened.project.apps).map(async ([name, entry]) => { + if (entry.kind !== "electron") return + apps[name] = + process.platform === "darwin" + ? view(await desktopStatus(entry, scope, store, looks)) + : { status: "not-found", why: "desktop apps run on macOS only" } + }), + ) + return { apps, problem: store.takeProblem() } + }, "preview:open": (sceneId) => { const opened = workspace.opened if (opened === null) return { ok: false, why: "No project is open." } @@ -501,6 +647,12 @@ function start(): void { // After the window's first read: pushed to it (not an action's result). say(`couldn't clean up old recordings: ${message(e)}`) } + // What a crash left of desktop apps' launches (never another running Kiframe's). + if (process.platform === "darwin") { + sweepWorkArea(workDir).catch((e: unknown) => + say(`couldn't clean up desktop apps' leftovers: ${message(e)}`), + ) + } // Sealed, then scratch beyond the budget (said if it fails). void sealTakes().then(() => keeper?.evict().catch((e: unknown) => say(`couldn't tidy old recordings: ${message(e)}`)), diff --git a/apps/desktop/src/main/project.ts b/apps/desktop/src/main/project.ts index c3a3ea7..0df5e91 100644 --- a/apps/desktop/src/main/project.ts +++ b/apps/desktop/src/main/project.ts @@ -3,15 +3,22 @@ import { createHash } from "node:crypto" import { existsSync, readdirSync, statSync } from "node:fs" import { type OpenedProject, saveProject, scenesNaming } from "@kiframe/project" -import { App, appOf, appsNamedByPreset, firstApp, unlistedApps } from "@kiframe/schema" -import type { ProjectView, SceneView } from "../shared/ipc.ts" +import { type App, appOf, appsNamedByPreset, firstApp, unlistedApps, WebApp } from "@kiframe/schema" +import { type AppView, appViewIdentity, type ProjectView, type SceneView } from "../shared/ipc.ts" /** A project folder's extension (a new project's folder gets it). */ export const PROJECT_EXTENSION = ".kiframe" +/** An app as the window has it: a web app by its exact origin, a desktop app by its bundle id. */ +export function appView(name: string, app: App): AppView { + return app.kind === "web" + ? { name, kind: "web", origin: new URL(app.url).origin } + : { name, kind: "electron", bundleId: app.bundleId } +} + /** The app's address, by the project schema's own rule (http(s), no credentials in it). */ export function targetUrl(url: string): string { - const parsed = App.shape.url.safeParse(url.trim()) + const parsed = WebApp.shape.url.safeParse(url.trim()) if (!parsed.success) { throw new Error(`App address: ${parsed.error.issues[0]?.message ?? "not a URL"}`) } @@ -122,11 +129,7 @@ export function projectView(opened: OpenedProject, session: string): ProjectView session, name: project.name, dir: opened.dir, - url: firstApp(project).app.url, - apps: Object.entries(project.apps).map(([name, app]) => ({ - name, - origin: new URL(app.url).origin, - })), + apps: Object.entries(project.apps).map(([name, app]) => appView(name, app)), scenes: views, problems: problems.map((p) => `${p.sceneId}: ${p.message}`), } @@ -141,13 +144,30 @@ export function appOriginOf( view: Pick | undefined, session: string, app: string, -): { origin: string } | { why: string } { +): AppView | { why: string } { if (view === undefined) return { why: "open a project first" } if (view.session !== session) return { why: "the project changed meanwhile: try again" } const found = view.apps.find((a) => a.name === app) return found === undefined ? { why: `"${app}" isn't one of the project's apps` } : found } +/** + * The origin a secret of `app` is kept for: a web app's; a desktop app takes none in v0 (the user + * signs in by hand there), said. + */ +export function secretOriginOf( + view: Pick | undefined, + session: string, + app: string, +): { origin: string } | { why: string } { + const at = appOriginOf(view, session, app) + if ("why" in at) return at + if (at.kind !== "web") { + return { why: `"${app}" is a desktop app: it takes no secrets (sign in by hand when Kif asks)` } + } + return { origin: at.origin } +} + /** The titles of the scenes that name `app` (`scenesNaming`: every stored scene). */ export const scenesUsing = scenesNaming @@ -175,13 +195,13 @@ export function removeApp(opened: OpenedProject, app: string): void { */ export function appRemovalRefused( view: Pick | undefined, - request: { session: string; name: string; origin: string }, + request: { session: string; name: string; identity: string }, running: boolean, ): string | null { if (running) return "Kif is working: stop it first" const at = appOriginOf(view, request.session, request.name) if ("why" in at) return at.why - if (at.origin !== request.origin) return "that app changed meanwhile: try again" + if (appViewIdentity(at) !== request.identity) return "that app changed meanwhile: try again" if (view?.apps[0]?.name === request.name) { return `"${request.name}" is where scenes start: it can't be removed` } diff --git a/apps/desktop/src/main/workspace.ts b/apps/desktop/src/main/workspace.ts index a3e63fd..ba89ba2 100644 --- a/apps/desktop/src/main/workspace.ts +++ b/apps/desktop/src/main/workspace.ts @@ -5,7 +5,7 @@ import { randomBytes } from "node:crypto" import { realpathSync } from "node:fs" import { createProject, openProject, type OpenedProject } from "@kiframe/project" import type { ProjectView } from "../shared/ipc.ts" -import { projectView } from "./project.ts" +import { appView, projectView } from "./project.ts" /** What the workspace needs of an agent. */ export interface Agent { @@ -18,6 +18,7 @@ export class Workspace { #session = "" #agent: A | undefined #switching: Promise = Promise.resolve() + readonly #onSwitch: (() => void)[] = [] readonly #ready: () => void @@ -39,15 +40,22 @@ export class Workspace { return this.#opened } + /** This opening's session; none when no project is open. */ + get session(): string | undefined { + return this.#opened === null ? undefined : this.#session + } + + /** Told as a project is opened, created or closed (what belonged to the last one ends). */ + onSwitch(listener: () => void): void { + this.#onSwitch.push(listener) + } + /** The open project's apps and this opening's session, without building its whole view. */ apps(): Pick | undefined { if (this.#opened === null) return undefined return { session: this.#session, - apps: Object.entries(this.#opened.project.apps).map(([name, app]) => ({ - name, - origin: new URL(app.url).origin, - })), + apps: Object.entries(this.#opened.project.apps).map(([name, app]) => appView(name, app)), } } @@ -89,6 +97,14 @@ export class Workspace { this.#opened = opened this.#agent = agent this.#session = randomBytes(6).toString("hex") + // A listener that fails never keeps the old agent from closing. + for (const listener of this.#onSwitch) { + try { + listener() + } catch { + // said by the listener itself, if anywhere + } + } try { await old?.close() } finally { diff --git a/apps/desktop/src/renderer/src/components/apps-panel.tsx b/apps/desktop/src/renderer/src/components/apps-panel.tsx index d162bf6..6c05162 100644 --- a/apps/desktop/src/renderer/src/components/apps-panel.tsx +++ b/apps/desktop/src/renderer/src/components/apps-panel.tsx @@ -1,11 +1,34 @@ -// 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 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"] + +/** What an app allowed its own terminals is open to (said on its card before it's added). */ +const TERMINALS_WARNING = + "Its terminals work. While Kiframe runs it, a terminal you open (in any app) can be read and " + + "typed into by it: only for apps you trust, and review what Kif does with it." + +/** 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 +37,20 @@ export function AppsPanel({ onClose: () => void }) { const running = useChat((s) => s.running) + const mac = api().platform === "darwin" const [error, setError] = useState(null) const [busy, setBusy] = useState(false) + const [statuses, setStatuses] = useState({}) + const [card, setCard] = useState(null) + const [outcome, setOutcome] = useState(null) + const [checking, setChecking] = useState(false) + const [picking, setPicking] = useState(false) + const [adding, setAdding] = useState(false) + // A new app's terminals (off unless the user ticks it for this card). + const [terminals, setTerminals] = useState(false) + // The card a check's answer belongs to (an answer for a card given up: dropped). + const current = useRef(null) + current.current = card?.token ?? null const close = useRef(onClose) close.current = onClose useEffect(() => { @@ -26,16 +61,124 @@ export function AppsPanel({ return () => document.removeEventListener("keydown", onKey) }, []) - const remove = async (name: string, origin: string) => { + const hasDesktop = project.apps.some((a) => a.kind === "electron") + const refresh = useCallback(async () => { + if (!hasDesktop) return + const view = await api() + .invoke("apps:desktop-status", { session: project.session }) + .catch((e: unknown) => { + setError(`the desktop apps' status couldn't be read: ${String(e)}`) + return null + }) + if (view === null) return + setStatuses(view.apps) + if (view.problem !== null) setError(view.problem) + }, [hasDesktop, project.session]) + useEffect(() => { + void refresh() + }, [refresh]) + + // An add given up when the panel closes (its trial ended in main). + useEffect( + () => () => + void api() + .invoke("apps:desktop-cancel") + .catch(() => undefined), + [], + ) + + const remove = async (name: string, identity: string) => { if (busy) return setBusy(true) const refused = await api() - .invoke("apps:remove", { session: project.session, name, origin }) + .invoke("apps:remove", { session: project.session, name, identity }) .catch((e: unknown) => String(e)) setBusy(false) setError(refused) } + // Busy flags read at once (a double click within one frame sees the first). + const pickingNow = useRef(false) + // The card an add is under way for (a card given up frees the panel at once). + const addingNow = useRef(null) + // The card a check is under way for (another card's Check is its own). + const checkingNow = useRef(null) + const pick = async () => { + if (pickingNow.current || (addingNow.current !== null && addingNow.current === current.current)) + return + pickingNow.current = true + setError(null) + setOutcome(null) + setPicking(true) + const picked = await api() + .invoke("apps:desktop-pick", { session: project.session }) + .catch((e: unknown) => ({ refused: String(e) })) + pickingNow.current = false + setPicking(false) + if (picked === null) return + if ("refused" in picked) { + setCard(null) + setError(picked.refused) + return + } + setTerminals(false) + setCard(picked.card) + } + + const check = async (allowSite: boolean) => { + if (card === null || checkingNow.current === card.token) return + const token = card.token + checkingNow.current = token + setChecking(true) + setOutcome(null) + const result = await api() + .invoke("apps:desktop-check", { session: project.session, token, allowSite }) + .catch((e: unknown) => ({ failed: String(e) })) + if (checkingNow.current === token) checkingNow.current = null + // Given up meanwhile (cancelled, another app picked): never shown on another card. + if (current.current !== token) return + setChecking(false) + setOutcome(result) + } + + const add = async () => { + if (card === null || addingNow.current === card.token) return + const token = card.token + addingNow.current = token + setAdding(true) + const refused = await api() + .invoke("apps:desktop-add", { + session: project.session, + token, + terminals: card.existing === undefined && terminals, + }) + .catch((e: unknown) => String(e)) + if (addingNow.current === token) addingNow.current = null + setAdding(false) + // Given up meanwhile (cancelled, another app picked): never said on another card. + if (current.current !== token) { + if (refused === null) await refresh() + return + } + if (refused !== null) { + setError(refused) + return + } + setCard(null) + setOutcome(null) + await refresh() + } + + const cancel = () => { + setCard(null) + setOutcome(null) + setChecking(false) + setAdding(false) + void api() + .invoke("apps:desktop-cancel") + .catch(() => undefined) + } + return (
@@ -47,14 +190,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} - {app.origin} + {appViewIdentity(app)} + {app.kind === "electron" && ( + {statusText(statuses[app.name])} + )}
    {i === 0 ? ( Where scenes start @@ -64,7 +211,7 @@ export function AppsPanel({ className="btn btn-ghost" disabled={busy || running} title={running ? "Kif is working: stop it first" : undefined} - onClick={() => void remove(app.name, app.origin)} + onClick={() => void remove(app.name, appViewIdentity(app))} > Remove {app.name} @@ -72,6 +219,92 @@ export function AppsPanel({
  • ))}
+ {mac && card === null && ( + + )} + {card !== null && ( +
+

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

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

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

+ )} + {card.existing === undefined && ( + + )} + {(card.existing === undefined ? terminals : card.opens?.terminals === true) && ( +

+ {TERMINALS_WARNING} +

+ )} +

+ 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 +315,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/components/secrets-panel.tsx b/apps/desktop/src/renderer/src/components/secrets-panel.tsx index dfeb75a..374fbd6 100644 --- a/apps/desktop/src/renderer/src/components/secrets-panel.tsx +++ b/apps/desktop/src/renderer/src/components/secrets-panel.tsx @@ -20,7 +20,9 @@ export function SecretsPanel({ project: Pick onClose: () => void }) { - const { session, apps } = project + const { session } = project + // Web apps only: a desktop app takes no secrets (the user signs in by hand there). + const apps = project.apps.filter((a) => a.kind === "web") const several = apps.length > 1 const [groups, setGroups] = useState(null) const [app, setApp] = useState(apps[0]?.name ?? "") @@ -105,152 +107,163 @@ export function SecretsPanel({
-

- Logins and keys Kif may type - {several ? ( - ", app by app" - ) : ( - <> - {" "} - on {apps[0]?.origin} - - )} - . It only ever sees their names: values stay in your system’s keychain, and are blurred in - every take. -

-

- Use a throwaway demo account. Kif sees the page as text and screenshots, with your secrets - hidden where the page shows them as text; a value drawn in an image or a canvas can’t be - hidden. -

- {groups === null ? null : groups.every((g) => g.secrets.length === 0) ? ( -

No secrets yet.

+ {apps.length === 0 ? ( +

+ None of this project’s apps takes secrets: in a desktop app, you sign in yourself when + Kif asks. +

) : ( - groups.map((group) => - group.secrets.length === 0 ? null : ( -
- {several && ( -

- {group.app} {group.origin} -

- )} -