From 862195a9994419383ae40c37ed8df2f6d2272a0a Mon Sep 17 00:00:00 2001 From: Quang Lee Date: Thu, 8 Oct 2026 17:59:21 +0200 Subject: [PATCH 01/10] feat(schema)!: add desktop electron apps to a project's apps (#54) BREAKING CHANGE: App is a union of web and electron apps; code reading app.url narrows on kind first. --- apps/desktop/src/main/index.ts | 25 +- apps/desktop/src/main/project.ts | 42 ++- apps/desktop/src/main/workspace.ts | 7 +- .../renderer/src/components/apps-panel.tsx | 10 +- .../renderer/src/components/secrets-panel.tsx | 299 +++++++++--------- .../src/renderer/src/components/stage.tsx | 9 +- .../src/renderer/src/components/title-bar.tsx | 21 +- apps/desktop/src/renderer/test/app.test.tsx | 3 +- apps/desktop/src/renderer/test/apps.test.tsx | 7 +- apps/desktop/src/renderer/test/chat.test.tsx | 3 +- .../src/renderer/test/handover.test.tsx | 3 +- .../src/renderer/test/preview.test.tsx | 6 +- .../src/renderer/test/secrets.test.tsx | 43 ++- apps/desktop/src/shared/ipc.ts | 19 +- apps/desktop/src/shared/util.ts | 12 + apps/desktop/test/project.test.ts | 67 +++- docs/OBJECT-MODEL.md | 7 + packages/project/test/project.test.ts | 2 +- packages/runtime/src/recorder.ts | 5 +- packages/runtime/src/run/actions.ts | 4 +- packages/runtime/src/run/apps.ts | 22 +- packages/runtime/src/run/conditions.ts | 4 +- packages/runtime/src/run/setup.ts | 14 +- packages/runtime/src/runner.ts | 11 +- packages/runtime/test/apps.test.ts | 89 ++++++ packages/schema/src/apps.test.ts | 99 +++++- packages/schema/src/docs-examples.test.ts | 2 +- packages/schema/src/project-model.test.ts | 4 +- packages/schema/src/project.ts | 110 ++++++- packages/schema/src/resolve.ts | 4 +- packages/studio/src/add-app.ts | 4 +- packages/studio/src/prompt.ts | 16 +- packages/studio/src/studio.ts | 63 ++-- packages/studio/src/tools.ts | 7 +- packages/studio/test/desktop-app.test.ts | 96 ++++++ packages/studio/test/studio.test.ts | 7 +- scripts/m1-4/batch-check.ts | 4 +- scripts/p0-8/ground.ts | 4 +- scripts/p0-8/login-check.ts | 4 +- scripts/record.ts | 4 +- 40 files changed, 871 insertions(+), 291 deletions(-) create mode 100644 packages/studio/test/desktop-app.test.ts diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index a1217c8..b8522e2 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -14,7 +14,7 @@ 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 +201,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 @@ -394,14 +395,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 +415,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 +424,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 +449,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 +465,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)}` } 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..e0f1ce8 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 { @@ -44,10 +44,7 @@ export class Workspace { 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)), } } diff --git a/apps/desktop/src/renderer/src/components/apps-panel.tsx b/apps/desktop/src/renderer/src/components/apps-panel.tsx index d162bf6..658e77f 100644 --- a/apps/desktop/src/renderer/src/components/apps-panel.tsx +++ b/apps/desktop/src/renderer/src/components/apps-panel.tsx @@ -2,7 +2,7 @@ // 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" +import { appViewIdentity, type ProjectView } from "../../../shared/ipc.ts" import { api } from "../api.ts" import { useChat } from "../chat-store.ts" @@ -26,11 +26,11 @@ export function AppsPanel({ return () => document.removeEventListener("keydown", onKey) }, []) - const remove = async (name: string, origin: string) => { + 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) @@ -54,7 +54,7 @@ export function AppsPanel({
  • {app.name} - {app.origin} + {appViewIdentity(app)}
    {i === 0 ? ( Where scenes start @@ -64,7 +64,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} 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} -

    - )} -