diff --git a/.github/workflows/react-doctor.yml b/.github/workflows/react-doctor.yml index 8635b6a..b96cedd 100644 --- a/.github/workflows/react-doctor.yml +++ b/.github/workflows/react-doctor.yml @@ -29,8 +29,12 @@ jobs: react-doctor: runs-on: ubuntu-latest steps: + # Full history: the scan derives the PR's changed files via merge-base. + # A shallow checkout leaves the base commit unreachable and the scan + # crashes before producing a report. - uses: actions/checkout@v5 with: + persist-credentials: false # Full history so the action can diff against the merge base and # report only PR-introduced issues instead of every pre-existing one. fetch-depth: 0 diff --git a/.gitignore b/.gitignore index 5a6899a..61f77fd 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,18 @@ startup-benchmark-results/ # Benchmark results benchmarks + +# Swift / Xcode (App Intents, native helpers) +.build/ +DerivedData/ +*.xcuserdata/ +*.xcworkspace/xcuserdata/ +**/xcuserdata/ +native/pipper-intents/PreviewApp/build/ +native/pipper-intents/preview-dist/ +native/pipper-intents/PreviewApp/native/ +native/pipper-remote-ios/build/ +.swiftpm/ + +# Local script output +.tmp-check/ diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist index 16569b1..5208375 100644 --- a/build/entitlements.mac.plist +++ b/build/entitlements.mac.plist @@ -10,7 +10,7 @@ com.apple.security.cs.allow-jit - com.apple.security.cs.allow-dyld-environment-variables - + com.apple.security.cs.allow-dyld-environment-variables + diff --git a/contracts/remote.ts b/contracts/remote.ts index 5cb3441..71e5609 100644 --- a/contracts/remote.ts +++ b/contracts/remote.ts @@ -17,6 +17,18 @@ export interface RemoteModel { provider?: string; } +/** A model offered inside one agent (the ACP session's model option). */ +export interface RemoteAgentModel { + id: string; + name: string; +} + +/** The thread's current model and what it can switch to. */ +export interface RemoteThreadModel { + current: string | null; + options: RemoteAgentModel[]; +} + export interface RemoteThreadSummary { id: string; projectId: string; @@ -40,17 +52,48 @@ export interface RemoteReport { /** False when worktree creation failed and the task ran in project root. */ isolated: boolean; isolationNote: string | null; + permissions: RemotePermission[]; + request: RemoteRequestStatus | null; + /** Null when the thread isn't loaded on the Mac or its agent has no model choice. */ + model: RemoteThreadModel | null; +} + +export interface RemotePermission { + id: string; + title: string; + detail: string | null; + options: Array<{ optionId: string; name: string; kind: string }>; +} + +export interface RemoteRequestStatus { + id: string; + threadId: string | null; + state: "preparing" | "running" | "completed" | "failed" | "interrupted"; + error: string | null; + updatedAt: number; +} + +export interface RemoteDiagnostics { + paired: true; + agentReady: boolean; + availableAgents: number; + projects: number; } export interface RemoteCreateThreadInput { + requestId: string; projectId: string; - /** Agent/model id from the desktop registry; null = desktop default. */ + /** Agent instance id from the desktop registry (named `modelId` for + * compatibility with shipped clients); null = desktop default. */ modelId?: string | null; + /** Model inside that agent, from `/api/remote/agent-models`; null = agent default. */ + model?: string | null; prompt: string; images?: PromptImagePayload[]; } export interface RemotePromptInput { + requestId: string; prompt: string; images?: PromptImagePayload[]; } diff --git a/electron-builder.yml b/electron-builder.yml index 01171f0..f966534 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -2,6 +2,7 @@ appId: com.maker-or.omni productName: Pipper Code (Alpha) directories: output: release +afterPack: scripts/after-pack.js files: - out/**/* - package.json @@ -10,9 +11,10 @@ asarUnpack: - "**/node_modules/node-pty/**/*" mac: artifactName: pipper-${version}-${arch}.${ext} - # Keep local and CI artifacts unsigned. The Sleepless daemon authenticates - # unsigned builds using the exact bundled executable path and peer user. - identity: null + # Use an ad-hoc signature so macOS can load the embedded App Intents + # extension. The Sleepless daemon still authenticates the exact bundled + # executable path and peer user; no developer certificate is required. + identity: "-" icon: pipper.icon target: - target: dmg @@ -22,6 +24,11 @@ mac: hardenedRuntime: true entitlements: build/entitlements.mac.plist entitlementsInherit: build/entitlements.mac.inherit.plist + # The App Intents extension is signed explicitly by scripts/after-pack.js + # with its App Sandbox + App Group entitlements. Do not replace that + # signature with Electron's generic inherited entitlements. + signIgnore: + - PipperIntents\.appex extraFiles: - from: native/sleepless/dist/omni-sleeplessctl to: MacOS/omni-sleeplessctl @@ -29,6 +36,8 @@ mac: to: Resources/sleepless/omni-sleeplessd - from: native/sleepless/com.maker-or.omni.sleeplessd.plist to: Library/LaunchDaemons/com.maker-or.omni.sleeplessd.plist + - from: native/pipper-intents/dist/PipperIntents.appex + to: Extensions/PipperIntents.appex binaries: - Contents/MacOS/omni-sleeplessctl - Contents/Resources/sleepless/omni-sleeplessd diff --git a/electron/agent-connection-manager.ts b/electron/agent-connection-manager.ts index fe2ffa6..1c0d59c 100644 --- a/electron/agent-connection-manager.ts +++ b/electron/agent-connection-manager.ts @@ -270,6 +270,7 @@ export class AgentConnectionManager { threadDisplayTitle: (threadId) => this.threadDisplayTitle(threadId), }); private readonly prompts = new PromptScheduler(); + private readonly abortGenerations = new Map(); private readonly terminalManager: TerminalManager; private broadcaster!: RendererBroadcaster; private lifecycle!: ConnectionLifecycle; @@ -934,6 +935,29 @@ export class AgentConnectionManager { * Returns accumulated agent_text + user_text so the phone can show the * entire final message at once when the turn ends. Falls back to the * persisted snapshot when the thread is not resident in memory. */ + /** The thread's model selector, for the phone. Null when the thread is not + * loaded in memory or its agent exposes no model choice. */ + getThreadModel(threadId: string): { + configId: string; + current: string | null; + options: Array<{ id: string; name: string }>; + } | null { + const runtime = this.sessions.get(threadId); + if (!runtime) return null; + const option = runtime.slice.configOptions.find( + (o) => o.category === "model" || o.id === "model", + ); + if (!option) return null; + const options = modelOptionsFromConfig([option]).map((m) => ({ id: m.modelId, name: m.name })); + if (!options.length) return null; + const current = (option as { currentValue?: unknown }).currentValue; + return { + configId: option.id, + current: typeof current === "string" ? current : null, + options, + }; + } + getThreadTranscript(threadId: string): { finalText: string | null; messages: Array<{ role: "user" | "agent"; text: string }>; @@ -1444,9 +1468,10 @@ export class AgentConnectionManager { this.emit({ type: "thread-tool-calls", threadId: runtime.threadId, - toolCalls: changedToolCall - ? { [updateToolCallId]: changedToolCall } - : runtime.slice.toolCalls, + toolCalls: + changedToolCall && updateToolCallId + ? { [updateToolCallId]: changedToolCall } + : runtime.slice.toolCalls, replace: !changedToolCall, }); } else { @@ -1470,6 +1495,19 @@ export class AgentConnectionManager { return this.permissions.handle(params, requestId ?? null); } + getRemotePermissions(threadId: string) { + return this.permissions.listForThread(threadId); + } + + respondToRemotePermission( + threadId: string, + decisionId: string, + optionId?: string, + cancelled = false, + ) { + return this.permissions.respondForThread(threadId, decisionId, optionId, cancelled); + } + respondToPermission(response: { sessionId: string; requestId?: string | number; @@ -2346,7 +2384,7 @@ export class AgentConnectionManager { agentId?: string | null, worktreePath?: string | null, initialModelId?: string | null, - opts?: { background?: boolean }, + opts?: { background?: boolean; requireWorktree?: boolean }, ): Promise { return this.enqueueThreadActivation(() => this.createThreadInternal( @@ -2368,7 +2406,7 @@ export class AgentConnectionManager { agentId?: string | null, worktreePath?: string | null, initialModelId?: string | null, - opts?: { background?: boolean }, + opts?: { background?: boolean; requireWorktree?: boolean }, ): Promise { const project = getProject(projectId); if (!project) throw new Error(`Project not found: ${projectId}`); @@ -2377,6 +2415,11 @@ export class AgentConnectionManager { // stale/invalid path is never persisted as this thread's worktree. const cwd = this.resolveThreadCwd(worktreePath, project.path); const boundWorktree = cwd === project.path ? null : cwd; + if (opts?.requireWorktree && !boundWorktree) { + throw new Error( + "An isolated workspace is required. Restore the worktree on your Mac before retrying.", + ); + } const targetAgentId = agentId ?? this.preferredAgentId; // Background creation must not flip the desktop-active agent: spawning @@ -2385,6 +2428,9 @@ export class AgentConnectionManager { const live = opts?.background ? await this.acquireConnection(targetAgentId) : await this.ensureConnection(targetAgentId); + if (opts?.requireWorktree && !isLiveWorktree(cwd, project.path)) { + throw new Error("The isolated workspace is no longer available. No task was started."); + } const created = await this.sessionNew(live, cwd); this.registerWorkspaceRoot(created.sessionId, cwd); @@ -2481,14 +2527,13 @@ export class AgentConnectionManager { // Seed model after the session exists so the first prompt lands on the // user's chosen model. Best-effort: a failed seed still leaves a usable thread. - // Skipped for background threads: setConfigOption targets the active - // thread, so seeding here would hit the desktop's thread, not this one. - if (initialModelId && !opts?.background) { + // Thread-scoped, so a background (phone) seed never hits the desktop's thread. + if (initialModelId) { try { const modelOpt = created.configOptions.find( (option) => option.id === "model" || option.category === "model", ); - await this.setConfigOption(modelOpt?.id ?? "model", initialModelId); + await this.setThreadConfigOption(thread.id, modelOpt?.id ?? "model", initialModelId); } catch (err) { console.warn("[createThread] initial model seed failed:", err); } @@ -2628,17 +2673,41 @@ export class AgentConnectionManager { return thread; } - async sendPrompt(input: AcpPromptInput, opts?: { background?: boolean }): Promise { + async sendPrompt( + input: AcpPromptInput, + opts?: { background?: boolean; requireWorktree?: boolean }, + ): Promise { return this.sendPromptInternal(input, true, opts); } private async sendPromptInternal( input: AcpPromptInput, appendUserMessage: boolean, - opts?: { background?: boolean }, + opts?: { background?: boolean; requireWorktree?: boolean }, ): Promise { const threadId = input.threadId ?? this.activeThreadId; if (!threadId) throw new Error("No active thread"); + const abortGeneration = this.abortGenerations.get(threadId) ?? 0; + const assertIsolation = () => { + if ((this.abortGenerations.get(threadId) ?? 0) !== abortGeneration) { + throw new Error("Task stopped before the agent was ready."); + } + if (!opts?.requireWorktree) return; + const thread = getThread(threadId); + const project = thread ? getProject(thread.project_id) : null; + const runtime = this.sessions.get(threadId); + if ( + !thread?.worktree_path || + !project || + !isLiveWorktree(thread.worktree_path, project.path) || + (runtime && runtime.cwd !== thread.worktree_path) + ) { + throw new Error( + "This thread has no live isolated workspace. Restore its worktree on your Mac before sending more work.", + ); + } + }; + assertIsolation(); if (!this.sessions.has(threadId)) { // Background senders (phone) must not leave desktop focus behind on a // restored thread: remember the active thread and put it back after. @@ -2695,6 +2764,7 @@ export class AgentConnectionManager { throw new Error("A prompt is already in flight; choose follow-up or steer to queue it."); } + assertIsolation(); const caps = live.agentCapabilities.promptCapabilities; const blocks = assemblePromptBlocks({ message: input.message, @@ -2806,8 +2876,12 @@ export class AgentConnectionManager { } async abort(): Promise { - const threadId = this.activeThreadId; + return this.abortThread(this.activeThreadId); + } + + async abortThread(threadId: string | null): Promise { if (!threadId) return; + this.abortGenerations.set(threadId, (this.abortGenerations.get(threadId) ?? 0) + 1); const runtime = this.sessions.get(threadId); const owner = runtime ? this.connectionForAgent(runtime.agentId) : null; if (!runtime || !owner) return; @@ -2822,12 +2896,22 @@ export class AgentConnectionManager { this.subagents.cancelRunsForParent(runtime.agentSessionId); // Cascade cancel to ACP agent terminals (session/cancel → terminal/kill). // Kill keeps terminalIds valid for final output queries; release is agent-owned. - this.terminalManager.killRunning(); + this.terminalManager.killRunning(runtime.agentSessionId); } async setConfigOption(configId: string, value: string | boolean): Promise { const threadId = this.activeThreadId; if (!threadId) return []; + return this.setThreadConfigOption(threadId, configId, value); + } + + /** Thread-scoped config change, so background (phone) threads can switch + * models without touching whatever the desktop has open. */ + async setThreadConfigOption( + threadId: string, + configId: string, + value: string | boolean, + ): Promise { const runtime = this.sessions.get(threadId); const owner = runtime ? this.connectionForAgent(runtime.agentId) : null; if (!runtime || !owner) return []; diff --git a/electron/agents/config.json b/electron/agents/config.json index c381a0c..8f118e8 100644 --- a/electron/agents/config.json +++ b/electron/agents/config.json @@ -10,7 +10,7 @@ "icon": "cursor", "docsUrl": "https://cursor.com/docs/cli/acp", "authHint": "Run `agent login` in your terminal (or set CURSOR_API_KEY) before connecting.", - "installHint": "Install Cursor CLI, then ensure `agent` is on your PATH (often ~/.local/bin/agent).", + "installHint": "Install the Cursor CLI (no GUI needed): `curl https://cursor.com/install -fsS | bash`, then ensure `agent` is on your PATH.", "installKind": "binary", "detectCommands": ["agent"] }, @@ -23,8 +23,8 @@ "args": [], "icon": "openai-codex", "docsUrl": "https://github.com/agentclientprotocol/codex-acp", - "authHint": "Sign in with ChatGPT or provide CODEX_API_KEY / OPENAI_API_KEY.", - "installHint": "npm install -g @agentclientprotocol/codex-acp (or use npx on first launch)", + "authHint": "Run `codex` once and choose Sign in with ChatGPT (or set CODEX_API_KEY / OPENAI_API_KEY).", + "installHint": "Install the Codex CLI (no GUI needed): `curl -fsSL https://chatgpt.com/codex/install.sh | sh`, then run `codex` once to sign in. Pipper connects over ACP automatically.", "installKind": "npx", "npmPackage": "@agentclientprotocol/codex-acp", "detectCommands": ["codex-acp"] @@ -38,8 +38,8 @@ "args": [], "icon": "anthropic", "docsUrl": "https://github.com/agentclientprotocol/claude-agent-acp", - "authHint": "Authenticate Claude Code / set ANTHROPIC_API_KEY before connecting.", - "installHint": "npm install -g @agentclientprotocol/claude-agent-acp (or use npx on first launch)", + "authHint": "Run `claude` once and complete the login prompt (or set ANTHROPIC_API_KEY).", + "installHint": "Install the Claude CLI (no GUI needed): `curl -fsSL https://claude.ai/install.sh | bash` (native install auto-updates). Pipper connects over ACP automatically.", "installKind": "npx", "npmPackage": "@agentclientprotocol/claude-agent-acp", "detectCommands": ["claude-agent-acp"] @@ -98,8 +98,8 @@ "args": ["agent", "stdio"], "icon": "xai", "docsUrl": "https://www.npmjs.com/package/@xai-official/grok", - "authHint": "Run `grok login` to sign in with your xAI account before connecting.", - "installHint": "npm install -g @xai-official/grok (or use npx on first launch)", + "authHint": "Run `grok login` to sign in via browser (or `grok login --device-auth` on headless machines).", + "installHint": "Install the Grok CLI (no GUI needed): `curl -fsSL https://x.ai/cli/install.sh | bash`, then verify with `grok --version`.", "installKind": "npx", "npmPackage": "@xai-official/grok", "detectCommands": ["grok"] diff --git a/electron/agents/registry.ts b/electron/agents/registry.ts index 84e7767..7cbc348 100644 --- a/electron/agents/registry.ts +++ b/electron/agents/registry.ts @@ -47,7 +47,7 @@ export const BUILTIN_ACP_AGENTS: AcpAgentDescriptor[] = [ docsUrl: "https://cursor.com/docs/cli/acp", authHint: "Run `agent login` in your terminal (or set CURSOR_API_KEY) before connecting.", installHint: - "Install Cursor CLI, then ensure `agent` is on your PATH (often ~/.local/bin/agent).", + "Install the Cursor CLI (no GUI needed): `curl https://cursor.com/install -fsS | bash`, then ensure `agent` is on your PATH.", installKind: "binary", detectCommands: ["agent"], }, @@ -60,8 +60,10 @@ export const BUILTIN_ACP_AGENTS: AcpAgentDescriptor[] = [ args: [], icon: "openai-codex", docsUrl: "https://github.com/agentclientprotocol/codex-acp", - authHint: "Sign in with ChatGPT or provide CODEX_API_KEY / OPENAI_API_KEY.", - installHint: "npm install -g @agentclientprotocol/codex-acp (or use npx on first launch)", + authHint: + "Run `codex` once and choose Sign in with ChatGPT (or set CODEX_API_KEY / OPENAI_API_KEY).", + installHint: + "Install the Codex CLI (no GUI needed): `curl -fsSL https://chatgpt.com/codex/install.sh | sh`, then run `codex` once to sign in. Pipper connects over ACP automatically.", installKind: "npx", npmPackage: "@agentclientprotocol/codex-acp", detectCommands: ["codex-acp"], @@ -75,9 +77,9 @@ export const BUILTIN_ACP_AGENTS: AcpAgentDescriptor[] = [ args: [], icon: "anthropic", docsUrl: "https://github.com/agentclientprotocol/claude-agent-acp", - authHint: "Authenticate Claude Code / set ANTHROPIC_API_KEY before connecting.", + authHint: "Run `claude` once and complete the login prompt (or set ANTHROPIC_API_KEY).", installHint: - "npm install -g @agentclientprotocol/claude-agent-acp (or use npx on first launch)", + "Install the Claude CLI (no GUI needed): `curl -fsSL https://claude.ai/install.sh | bash` (native install auto-updates). Pipper connects over ACP automatically.", installKind: "npx", npmPackage: "@agentclientprotocol/claude-agent-acp", detectCommands: ["claude-agent-acp"], @@ -106,8 +108,10 @@ export const BUILTIN_ACP_AGENTS: AcpAgentDescriptor[] = [ args: ["agent", "stdio"], icon: "xai", docsUrl: "https://www.npmjs.com/package/@xai-official/grok", - authHint: "Run `grok login` to sign in with your xAI account before connecting.", - installHint: "npm install -g @xai-official/grok (or use npx on first launch)", + authHint: + "Run `grok login` to sign in via browser (or `grok login --device-auth` on headless machines).", + installHint: + "Install the Grok CLI (no GUI needed): `curl -fsSL https://x.ai/cli/install.sh | bash`, then verify with `grok --version`.", installKind: "npx", npmPackage: "@xai-official/grok", detectCommands: ["grok"], @@ -475,6 +479,16 @@ export function resolveAgentSpawn(agent: AcpAgentDescriptor): { // Drop ambient provider credentials for isolated accounts so the child can't // authenticate as the machine's default login instead of the chosen account. for (const name of agent.unsetEnv ?? []) delete env[name]; + // `bun run --bun` prepends a temp dir whose `node` symlinks to bun. npx-based + // agents are `#!/usr/bin/env node` scripts; run under bun, npm derives its + // global prefix from the bun binary (~/.bun) and fails with ENOENT on + // ~/.bun/lib. Strip the shim so agents run under real node. + const pathKey = process.platform === "win32" ? "Path" : "PATH"; + const sep = process.platform === "win32" ? ";" : ":"; + env[pathKey] = (env[pathKey] ?? env.PATH ?? "") + .split(sep) + .filter((dir) => !/[\\/]bun-node-[^\\/]+$/.test(dir)) + .join(sep); if (agent.id === "pipper-mock" || agent.installKind === "mock") { const mockPath = join(registryDir, "mock-agent.mjs"); diff --git a/electron/isolated-agent-task.ts b/electron/isolated-agent-task.ts new file mode 100644 index 0000000..8dc6919 --- /dev/null +++ b/electron/isolated-agent-task.ts @@ -0,0 +1,93 @@ +import { randomBytes } from "node:crypto"; +import type { AgentManager } from "./agent-connection-manager.ts"; +import type { PromptImagePayload } from "../contracts/prompt-images.ts"; +import { getProject } from "./projects.ts"; +import { listThreads } from "./threads.ts"; +import { listAgentInstanceDescriptors } from "./agent-instances.ts"; +import { buildSiriCatalog } from "./siri/siri-catalog.ts"; +import { createWorktree, isLiveWorktree, removeWorktreeBestEffort } from "./worktree-manager.ts"; +import { RemoteTaskError } from "./remote-requests.ts"; + +/** All external entry points bind to a verified worktree before dispatch. */ +export async function prepareIsolatedAgentTask( + am: AgentManager, + projectId: string, + requestedAgentId: string | null | undefined, + prompt: string, + /** Model inside the agent (ACP model option); null keeps the agent default. */ + model: string | null = null, + images: PromptImagePayload[] = [], +) { + const project = getProject(projectId); + if (!project) throw new RemoteTaskError("Project not found. Refresh your project list."); + // The phone/PWA sends a provider *instance* id as `modelId` + // (listAgentInstanceDescriptors; default instances reuse the driver id), so + // validate against live instances — not the driver-only Siri catalog — or a + // task routed to a secondary account is wrongly rejected. + const instances = listAgentInstanceDescriptors(); + const agentId = + requestedAgentId ?? + instances.find((a) => a.id === buildSiriCatalog().defaultAgentId)?.id ?? + instances[0]?.id ?? + null; + if (!agentId || !instances.some((a) => a.id === agentId)) { + throw new RemoteTaskError( + "The selected agent is unavailable. Choose an installed agent on your Mac.", + ); + } + let worktree; + try { + worktree = createWorktree({ + projectPath: project.path, + projectId: project.id, + name: `phone-${randomBytes(8).toString("hex")}`, + }); + } catch (error) { + // The git error names local paths; it stays in the laptop log. + console.error("[Remote] isolated worktree creation failed:", error); + throw new RemoteTaskError( + "Could not create an isolated workspace. No task was started. Check that the project has a Git commit and a writable worktree directory on your Mac.", + ); + } + try { + const thread = await am.createThread( + project.id, + prompt.slice(0, 80), + null, + agentId, + worktree.path, + model, + { background: true, requireWorktree: true }, + ); + if (thread.worktree_path !== worktree.path || !isLiveWorktree(worktree.path, project.path)) { + throw new RemoteTaskError( + "The thread could not bind to its isolated workspace. No prompt was sent. Check Pipper on your Mac.", + ); + } + return { + threadId: thread.id, + result: { + thread: { + id: thread.id, + projectId: thread.project_id, + worktreePath: thread.worktree_path, + title: thread.title, + running: true, + lastUsedAt: thread.last_used_at, + }, + }, + execute: () => + !prompt && images.length === 0 + ? Promise.resolve() + : am.sendPrompt( + { threadId: thread.id, message: prompt, images }, + { background: true, requireWorktree: true }, + ), + }; + } catch (error) { + if (!listThreads().some((t) => t.worktree_path === worktree.path)) { + removeWorktreeBestEffort(project.path, worktree.path, worktree.branch); + } + throw error; + } +} diff --git a/electron/main.ts b/electron/main.ts index f5f6f66..40e5ae9 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -8,7 +8,7 @@ import { powerMonitor, safeStorage, } from "electron"; -import { join, dirname } from "node:path"; +import { join, dirname, relative, resolve, isAbsolute } from "node:path"; import http from "node:http"; import { fileURLToPath, pathToFileURL } from "node:url"; import { randomBytes } from "node:crypto"; @@ -85,6 +85,8 @@ import { deleteAgentInstance, } from "./agent-instances"; import { AgentManager } from "./agent"; +import { RemoteTaskError, getRemoteRequests } from "./remote-requests.ts"; +import { prepareIsolatedAgentTask } from "./isolated-agent-task.ts"; import { createElectronOsNotifier } from "./os-notifications"; import { WindowVisibilityGate } from "./window-visibility"; import { MonitorService } from "./monitor/service.ts"; @@ -334,6 +336,30 @@ if (!gotSingleInstanceLock) { app.quit(); } +if (!app.isDefaultProtocolClient("pipper")) { + app.setAsDefaultProtocolClient("pipper"); +} + +app.on("second-instance", (_event, argv) => { + const url = argv.find((arg) => arg.startsWith("pipper://")); + if (url) { + void handlePipperDeepLink(url).catch((err) => { + console.error("[Main] Failed to handle deep link:", err); + }); + } + if (mainWindow && !mainWindow.isDestroyed()) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); + } +}); + +app.on("open-url", (event, url) => { + event.preventDefault(); + void handlePipperDeepLink(url).catch((err) => { + console.error("[Main] Failed to handle deep link:", err); + }); +}); + // Without these, an uncaught error anywhere in the main process (e.g. handling // an ACP session/update from an agent) crashes the whole process and takes // every window down with it. Log and keep running instead. @@ -482,6 +508,263 @@ function requireAgentManager(): AgentManager { return agentManager; } +/** + * Deep links that arrived before the agent manager was ready (cold launch). + * Drained once initialization completes in `app.whenReady()`. + */ +const pendingDeepLinks: string[] = []; + +/** + * Single-flight guard: concurrent deliveries of the same staged request + * (startup scan, activation, deep link, renderer, IPC) share one promise. + */ +const inFlightSiriRequests = new Map>(); + +/** Staged requests are durably claimed before creating a session. Retried + * activations reopen the same thread; uncertain prompt delivery is never replayed. */ +async function consumeSiriRequest(requestId: string, preferredDir?: string): Promise { + if (typeof requestId !== "string" || !/^[A-Za-z0-9-]{1,123}$/.test(requestId)) { + return null; + } + const existing = inFlightSiriRequests.get(requestId); + if (existing) return existing; + const task = consumeSiriRequestInner(requestId, preferredDir).finally(() => { + if (inFlightSiriRequests.get(requestId) === task) inFlightSiriRequests.delete(requestId); + }); + inFlightSiriRequests.set(requestId, task); + return task; +} + +async function consumeSiriRequestInner(requestId: string, preferredDir?: string): Promise { + const { getSiriRequestsDir, getSiriRequestsDirs } = await import("./siri/siri-catalog.ts"); + // Resolve the request from every supported directory so legacy-only + // staged requests are delivered, not orphaned. The caller's `preferredDir` + // (which replica was newest) wins so the scan consumes the same file it + // ranked, not whichever dir happens to be listed first. + let dir = resolve(getSiriRequestsDir()); + let file: string | null = null; + const dirs = getSiriRequestsDirs().map((rawDir) => resolve(rawDir)); + const preferred = preferredDir ? resolve(preferredDir) : null; + const orderedDirs = + preferred && dirs.includes(preferred) + ? [preferred, ...dirs.filter((d) => d !== preferred)] + : dirs; + for (const candidateDir of orderedDirs) { + const candidate = resolve(join(candidateDir, `${requestId}.json`)); + const rel = relative(candidateDir, candidate); + if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) continue; + if (fs.existsSync(candidate)) { + dir = candidateDir; + file = candidate; + break; + } + } + const requests = getRemoteRequests(join(app.getPath("userData"), "remote-requests")); + const id = `siri-${requestId}`; + if (!file) { + const previous = requests.get(id); + return previous?.threadId ? getThread(previous.threadId) : null; + } + const raw = fs.readFileSync(file, "utf8"); + let parsed: { projectId: string; agentId?: string; prompt?: string }; + try { + parsed = JSON.parse(raw) as typeof parsed; + } catch { + fs.rmSync(file, { force: true }); + return null; + } + if (!parsed || typeof parsed.projectId !== "string" || !parsed.projectId) { + fs.rmSync(file, { force: true }); + return null; + } + if ( + (parsed.agentId != null && typeof parsed.agentId !== "string") || + (parsed.prompt != null && typeof parsed.prompt !== "string") + ) + return null; + const markerFile = + orderedDirs + .map((candidate) => resolve(join(candidate, `.done-${requestId}.json`))) + .find((candidate) => fs.existsSync(candidate)) ?? + resolve(join(dir, `.done-${requestId}.json`)); + const receipt = await requests.submit( + id, + { + kind: "siri", + projectId: parsed.projectId, + agentId: parsed.agentId ?? null, + prompt: parsed.prompt ?? "", + }, + async () => { + // Old builds could have sent the prompt without confirming delivery. + // Preserve their thread, but never replay that ambiguous request. + if (fs.existsSync(markerFile)) { + const marker = JSON.parse(fs.readFileSync(markerFile, "utf8")) as { + threadId?: string; + delivered?: boolean; + }; + if (!marker.threadId || !getThread(marker.threadId)) + throw new RemoteTaskError( + "An older Siri request could not be recovered. Check Pipper before starting another task.", + ); + return { + threadId: marker.threadId, + result: { ok: true }, + execute: async () => { + if (!marker.delivered) + throw new RemoteTaskError( + "This Siri request was handled by an older Pipper version. Check its thread before resending the task; delivery could not be confirmed.", + ); + }, + }; + } + return prepareIsolatedAgentTask( + requireAgentManager(), + parsed.projectId, + parsed.agentId, + parsed.prompt ?? "", + ); + }, + ); + if (receipt.threadId) { + // The receipt now owns recovery, so staged prompt replicas can be removed. + finalizeSiriRequest(file, markerFile, dir, requestId); + return getThread(receipt.threadId); + } + throw new Error(receipt.error ?? "Siri request is still being prepared."); +} + +/** + * Remove a consumed request and its marker, then clean up replicas in the + * other candidate directories. Safe to call more than once. + */ +function finalizeSiriRequest( + file: string, + markerFile: string, + dir: string, + requestId: string, +): void { + try { + fs.rmSync(file, { force: true }); + } catch { + // Best-effort. + } + try { + fs.rmSync(markerFile, { force: true }); + } catch { + // Best-effort. + } + void cleanupSiriRequestReplicas(dir, requestId); +} + +async function cleanupSiriRequestReplicas(dir: string, requestId: string): Promise { + const { getSiriRequestsDirs } = await import("./siri/siri-catalog.ts"); + for (const rawDir of getSiriRequestsDirs()) { + const otherDir = resolve(rawDir); + if (otherDir === dir) continue; + try { + fs.rmSync(resolve(join(otherDir, `${requestId}.json`)), { force: true }); + fs.rmSync(resolve(join(otherDir, `.done-${requestId}.json`)), { force: true }); + } catch { + // Best-effort replica cleanup. + } + } +} + +async function openSiriThread(thread: unknown): Promise { + const threadId = (thread as { id?: string })?.id; + if (!threadId) return; + if (mainWindow && !mainWindow.isDestroyed()) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.show(); + mainWindow.focus(); + } + const next = await openThreadTab(threadId); + broadcastOpenTabsChanged(mainWindow, next); + await requireAgentManager().switchThread(threadId); +} + +/** + * App Intents extensions cannot launch Electron directly. They leave a + * request in the shared directory and open `pipper://siri/`. Consume the + * pending files (oldest first) whenever Pipper starts or is activated from + * Shortcuts. Callers must not block window creation on this. + */ +async function consumePendingSiriRequests(): Promise { + if (!agentManager) return; + const { getSiriRequestsDirs } = await import("./siri/siri-catalog.ts"); + // Key by request id, but keep the winning replica's dir so the consume + // step reads the newest copy rather than whichever dir is listed first. + const seen = new Map(); + for (const rawDir of getSiriRequestsDirs()) { + const dir = resolve(rawDir); + if (!fs.existsSync(dir)) continue; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (!entry.isFile() || !/^[A-Za-z0-9-]{1,128}\.json$/.test(entry.name)) continue; + const requestId = entry.name.slice(0, -5); + const mtimeMs = fs.statSync(join(dir, entry.name)).mtimeMs; + const previous = seen.get(requestId); + if (!previous || mtimeMs > previous.mtimeMs) seen.set(requestId, { mtimeMs, dir }); + } + } + const requests = [...seen.entries()] + .map(([requestId, info]) => ({ requestId, mtimeMs: info.mtimeMs, dir: info.dir })) + .sort((a, b) => a.mtimeMs - b.mtimeMs); + + for (const { requestId, dir } of requests) { + try { + const thread = await consumeSiriRequest(requestId, dir); + if (thread) await openSiriThread(thread); + } catch (error) { + // Leave failed requests on disk so a later app activation can retry. + console.error(`[Main] Failed to consume pending Siri request ${requestId}:`, error); + } + } +} + +/** + * Route a `pipper://siri/` deep link (opened by the Swift + * StartThreadIntent): focus the main window, consume the staged request, and + * land the user on the new thread. Returns true when the URL was handled. + */ +async function handlePipperDeepLink(url: string): Promise { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + if (parsed.protocol !== "pipper:") return false; + const match = /^siri\/([A-Za-z0-9-]{1,128})\/?$/.exec(`${parsed.host}${parsed.pathname}`); + if (!match?.[1]) return false; + if (!agentManager) { + // Cold launch: the open-url/second-instance event can arrive before + // initialization. Queue it; it drains once the manager is ready. + if (!pendingDeepLinks.includes(url)) pendingDeepLinks.push(url); + return true; + } + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); + } + const thread = await consumeSiriRequest(match[1]); + if (thread) await openSiriThread(thread); + return true; +} + +async function drainStartupSiriRequests(): Promise { + try { + await consumePendingSiriRequests(); + } catch (err) { + console.error("[Main] Failed to consume pending Siri requests at startup:", err); + } + for (const queued of pendingDeepLinks.splice(0)) { + await handlePipperDeepLink(queued).catch((err) => { + console.error("[Main] Failed to handle queued deep link:", err); + }); + } +} + function requireLauncherUpdateManager(): LauncherUpdateManager { if (!launcherUpdateManager) throw new Error("Launcher update manager is not initialized."); return launcherUpdateManager; @@ -1547,6 +1830,24 @@ function registerIpc(): void { ipcMain.handle("projects:list", () => listProjects()); + ipcMain.handle("siri:getCatalog", async () => { + const { refreshSiriCatalog } = await import("./siri/siri-catalog.ts"); + return refreshSiriCatalog(); + }); + + ipcMain.handle("siri:consumeRequest", async (_event, requestId: string) => { + const thread = await consumeSiriRequest(requestId); + if (thread) { + const threadId = (thread as { id?: string })?.id; + if (threadId) { + const next = await openThreadTab(threadId); + broadcastOpenTabsChanged(mainWindow, next); + await requireAgentManager().switchThread(threadId); + } + } + return thread; + }); + ipcMain.handle("projects:getActive", () => { const id = getActiveProjectId(); return id ? getProject(id) : null; @@ -1584,9 +1885,15 @@ function registerIpc(): void { ipcMain.handle( "projects:create", - (_event, input: { name: string; path: string; icon: string }) => { + async (_event, input: { name: string; path: string; icon: string }) => { requireAuthenticatedUserForLaunch(); const project = createProject(input); + try { + const { refreshSiriCatalog } = await import("./siri/siri-catalog.ts"); + refreshSiriCatalog(); + } catch (err) { + console.warn("[Main] Siri catalog refresh failed after project create:", err); + } captureAnalytics("project_created", { windowType: "launch", properties: { @@ -2363,8 +2670,14 @@ function registerIpc(): void { requireAgentManager().setPreferredAgentId(agentId); }); ipcMain.handle("agent:getSelectedAgentIds", () => getSelectedAgentIds()); - ipcMain.handle("agent:setSelectedAgentIds", (_event, agentIds: string[]) => { + ipcMain.handle("agent:setSelectedAgentIds", async (_event, agentIds: string[]) => { setSelectedAgentIds(agentIds); + try { + const { refreshSiriCatalog } = await import("./siri/siri-catalog.ts"); + refreshSiriCatalog(); + } catch (err) { + console.warn("[Main] Siri catalog refresh failed after agent selection change:", err); + } }); ipcMain.handle("agent:listInstances", () => listAgentInstancesForRenderer()); ipcMain.handle("agent:getAccountSchemas", () => listAgentAccountSchemas()); @@ -2800,6 +3113,12 @@ app.whenReady().then(async () => { // Seed per-driver default instances and wire instance→descriptor resolution // into the agent registry before anything spawns an agent. installAgentInstanceProvider(); + try { + const { refreshSiriCatalog } = await import("./siri/siri-catalog.ts"); + refreshSiriCatalog(); + } catch (err) { + console.warn("[Main] Siri catalog refresh failed at startup:", err); + } await prepareBenchmarkLaunchState(); const authUser = getAuthenticatedUserForLaunch(); if (authUser) { @@ -2934,10 +3253,29 @@ app.whenReady().then(async () => { (process.env.PIPPER_ENABLE_LAUNCHER_UPDATES_IN_DEV === "1" && launcherManifestUrl != null); if (!launcherManifestUrl) console.info("[LauncherUpdate] Disabled: manifest URL is not configured."); + // One-time migration: previous releases stored launcher-update state + // under ~/Library/pipper; the App Group relocation must reuse it instead + // of stranding completed downloads. + let launcherRoot = join(getPipperLibraryPath(), "launcher-updates"); + if (process.platform === "darwin" && !process.env.PIPPER_LIBRARY_PATH) { + try { + const legacyRoot = join(os.homedir(), "Library", "pipper", "launcher-updates"); + if ( + legacyRoot !== launcherRoot && + fs.existsSync(legacyRoot) && + !fs.existsSync(join(launcherRoot, "state.json")) + ) { + fs.mkdirSync(launcherRoot, { recursive: true }); + fs.cpSync(legacyRoot, launcherRoot, { recursive: true, force: false }); + } + } catch (err) { + console.warn("[LauncherUpdate] Legacy migration failed:", err); + } + } launcherUpdateManager = new LauncherUpdateManager({ currentVersion: app.getVersion(), manifestUrl: launcherManifestUrl, - rootPath: join(getPipperLibraryPath(), "launcher-updates"), + rootPath: launcherRoot, enabled: launcherUpdatesEnabled, broadcastState: (state) => broadcastToWindows("launcher-update:stateChanged", state), broadcastProgress: (progress) => broadcastToWindows("launcher-update:progress", progress), @@ -2954,6 +3292,11 @@ app.whenReady().then(async () => { } logStartupMilestone("main-window:starting-before-agent-activation"); void createMainWindow(); + // Siri/Shortcuts requests staged before launch (and deep links that + // arrived before the agent manager existed) are consumed only once the + // window is on its way up, and never awaited: consumption creates threads + // and talks to agents, which must not gate first paint. + void drainStartupSiriRequests(); if (state.projectId) { // ACP activation spawns and handshakes with child processes. It must not // overlap Chromium's renderer bootstrap or first paint. The renderer's @@ -2974,6 +3317,9 @@ app.whenReady().then(async () => { } app.on("activate", async () => { + await consumePendingSiriRequests().catch((err) => { + console.error("[Main] Failed to consume pending Siri requests on activation:", err); + }); const hasMain = mainWindow && !mainWindow.isDestroyed(); const hasLaunch = launchWindow && !launchWindow.isDestroyed(); if (!hasMain && !hasLaunch) { diff --git a/electron/paths.ts b/electron/paths.ts index a7bd5d3..fb5c0a9 100644 --- a/electron/paths.ts +++ b/electron/paths.ts @@ -2,20 +2,15 @@ import { app } from "electron"; import { join } from "node:path"; import os from "node:os"; +/** Primary user-owned storage (ad-hoc-friendly, no Team ID required). */ +export function getPipperSharedPath(): string { + if (process.env.PIPPER_LIBRARY_PATH) return process.env.PIPPER_LIBRARY_PATH; + return process.platform === "darwin" + ? join(os.homedir(), "Library", "pipper") + : join(app.getPath("appData"), "pipper"); +} + /** User-owned storage that remains for launcher update artifacts and state. */ export function getPipperLibraryPath(): string { - if (process.env.PIPPER_LIBRARY_PATH) return process.env.PIPPER_LIBRARY_PATH; - try { - return process.platform === "darwin" - ? join(os.homedir(), "Library/pipper") - : join(app.getPath("appData"), "pipper"); - } catch { - const home = os.homedir(); - if (process.platform === "win32") { - return join(process.env.APPDATA || join(home, "AppData/Roaming"), "pipper"); - } - return process.platform === "darwin" - ? join(home, "Library/pipper") - : join(process.env.XDG_CONFIG_HOME || join(home, ".config"), "pipper"); - } + return getPipperSharedPath(); } diff --git a/electron/permission-coordinator.test.ts b/electron/permission-coordinator.test.ts index 582ac6c..584e051 100644 --- a/electron/permission-coordinator.test.ts +++ b/electron/permission-coordinator.test.ts @@ -5,7 +5,7 @@ import { PermissionCoordinator } from "./permission-coordinator.ts"; /** * Pending-permission lifecycle: requests surface as bridge events, settle via - * user response, timeout to allow_once, displace duplicates, and cancel when + * user response, timeout without approval, displace duplicates, and cancel when * their session goes away. */ @@ -79,14 +79,12 @@ describe("PermissionCoordinator", () => { }); }); - test("times out to allow_once so an agent never blocks forever", async () => { + test("expires unanswered prompts without approving them", async () => { const { coordinator, events } = makeCoordinator(); const promise = coordinator.handle(requestParams(), "r1"); await vi.advanceTimersByTimeAsync(121_000); - await expect(promise).resolves.toEqual({ - outcome: { outcome: "selected", optionId: "allow" }, - }); + await expect(promise).resolves.toEqual({ outcome: { outcome: "cancelled" } }); expect(events.at(-1)?.type).toBe("permission-resolved"); }); @@ -133,4 +131,21 @@ describe("PermissionCoordinator", () => { await expect(coordinator.handle(requestParams(), "r1")).resolves.toBe(auto); expect(events).toEqual([]); }); + test("remote decisions validate thread ownership, options and stale request generations", async () => { + const { coordinator } = makeCoordinator(); + const first = coordinator.handle(requestParams(), "r1"); + const decision = coordinator.listForThread("t1")[0]!; + expect(coordinator.listForThread("another-thread")).toEqual([]); + expect(await coordinator.respondForThread("another-thread", decision.id, "allow")).toBe(false); + expect(await coordinator.respondForThread("t1", decision.id, "invented")).toBe(false); + const replacement = coordinator.handle(requestParams(), "r1"); + await expect(first).resolves.toEqual({ outcome: { outcome: "cancelled" } }); + expect(await coordinator.respondForThread("t1", decision.id, "allow")).toBe(false); + const current = coordinator.listForThread("t1")[0]!; + expect(await coordinator.respondForThread("t1", current.id, "deny")).toBe(true); + await expect(replacement).resolves.toEqual({ + outcome: { outcome: "selected", optionId: "deny" }, + }); + expect(await coordinator.respondForThread("t1", current.id, "deny")).toBe(false); + }); }); diff --git a/electron/permission-coordinator.ts b/electron/permission-coordinator.ts index 9f3f3ed..a49ba8b 100644 --- a/electron/permission-coordinator.ts +++ b/electron/permission-coordinator.ts @@ -1,11 +1,14 @@ +import { randomUUID } from "node:crypto"; +import type { RemotePermission } from "../contracts/remote.ts"; import type * as acp from "@agentclientprotocol/sdk"; import type { AcpBridgeEvent, AcpPermissionRequest } from "../contracts/acp.ts"; import type { AgentOsNotification } from "./os-notifications.ts"; -/** Default allow_once after this long if the UI never responds. */ +/** Unanswered decisions expire without granting permission. */ const PERMISSION_TIMEOUT_MS = 120_000; interface PendingPermission { + decisionId: string; resolve: (response: acp.RequestPermissionResponse) => void; request: AcpPermissionRequest; timer: ReturnType; @@ -66,15 +69,8 @@ export class PermissionCoordinator { const timer = setTimeout(() => { const pending = this.pending.get(key); if (!pending) return; - const allow = request.options.find((o) => o.kind === "allow_once") ?? request.options[0]; this.pending.delete(key); - if (allow) { - resolve({ - outcome: { outcome: "selected", optionId: allow.optionId }, - }); - } else { - resolve({ outcome: { outcome: "cancelled" } }); - } + resolve({ outcome: { outcome: "cancelled" } }); this.deps.emit({ type: "permission-resolved", sessionId, requestId: stableRequestId }); }, PERMISSION_TIMEOUT_MS); const displaced = this.pending.get(key); @@ -83,7 +79,7 @@ export class PermissionCoordinator { displaced.resolve({ outcome: { outcome: "cancelled" } }); this.deps.emit({ type: "permission-resolved", sessionId, requestId: stableRequestId }); } - this.pending.set(key, { resolve, request, timer }); + this.pending.set(key, { decisionId: randomUUID(), resolve, request, timer }); this.deps.emit({ type: "permission-request", request }); // An agent blocked on permissions while the user is away is dead time; // the in-app prompt cannot be seen, so escalate to the OS. @@ -98,6 +94,40 @@ export class PermissionCoordinator { }); } + listForThread(threadId: string): RemotePermission[] { + return [...this.pending.values()] + .filter((p) => p.request.threadId === threadId) + .map((p) => ({ + id: p.decisionId, + title: p.request.toolCall.title ?? "Agent needs your input", + detail: + p.request.toolCall.rawInput == null + ? null + : JSON.stringify(p.request.toolCall.rawInput).slice(0, 8000), + options: p.request.options.map((o) => ({ ...o })), + })); + } + + async respondForThread( + threadId: string, + decisionId: string, + optionId?: string, + cancelled = false, + ): Promise { + const pending = [...this.pending.values()].find( + (p) => p.decisionId === decisionId && p.request.threadId === threadId, + ); + if (!pending) return false; + if (!cancelled && !pending.request.options.some((o) => o.optionId === optionId)) return false; + await this.respond({ + sessionId: pending.request.sessionId, + requestId: pending.request.requestId, + optionId, + cancelled, + }); + return true; + } + async respond(response: { sessionId: string; requestId?: string | number; diff --git a/electron/preload.ts b/electron/preload.ts index e2637aa..580f8d3 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -98,6 +98,11 @@ const api = { ipcRenderer.send("window:reportVisibility", visible); }, }, + siri: { + getCatalog: (): Promise => ipcRenderer.invoke("siri:getCatalog"), + consumeRequest: (requestId: string): Promise => + ipcRenderer.invoke("siri:consumeRequest", requestId), + }, sleepless: { getStatus: (): Promise => ipcRenderer.invoke("sleepless:getStatus"), setEnabled: (enabled: boolean): Promise => diff --git a/electron/remote-requests.test.ts b/electron/remote-requests.test.ts new file mode 100644 index 0000000..1280916 --- /dev/null +++ b/electron/remote-requests.test.ts @@ -0,0 +1,57 @@ +import { mkdtempSync, rmSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { RemoteRequests } from "./remote-requests.ts"; + +let dir: string; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "remote-receipts-")); +}); +afterEach(() => rmSync(dir, { recursive: true, force: true })); + +it("recovers accepted results after restart without dispatching again", async () => { + const first = new RemoteRequests(dir); + const execute = vi.fn(async () => {}); + const prepare = vi.fn(async () => ({ + threadId: "t1", + result: { thread: { id: "t1" } }, + execute, + })); + await first.submit("r1", { prompt: "private prompt" }, prepare); + await vi.waitFor(() => expect(first.get("r1")?.state).toBe("completed")); + const restarted = new RemoteRequests(dir); + expect((await restarted.submit("r1", { prompt: "private prompt" }, prepare)).result).toEqual({ + thread: { id: "t1" }, + }); + expect(prepare).toHaveBeenCalledTimes(1); + expect(execute).toHaveBeenCalledTimes(1); + expect(readFileSync(join(dir, readdirSync(dir)[0]!), "utf8")).not.toContain("private prompt"); +}); + +it("never replays an uncertain dispatch after restart", async () => { + const execute = vi.fn(() => new Promise(() => {})); + const prepare = vi.fn(async () => ({ threadId: "t1", result: { ok: true }, execute })); + await new RemoteRequests(dir).submit("r1", "payload", prepare); + const retry = await new RemoteRequests(dir).submit("r1", "payload", prepare); + expect(retry.state).toBe("interrupted"); + expect(retry.threadId).toBe("t1"); + expect(retry.error).toContain("will not run again"); + expect(execute).toHaveBeenCalledTimes(1); +}); + +it("does not repeat creation after a crash before a thread ID was recorded", async () => { + const prepare = vi.fn(() => new Promise(() => {})); + void new RemoteRequests(dir).submit("r1", "payload", prepare); + const retry = await new RemoteRequests(dir).submit("r1", "payload", prepare); + expect(retry.state).toBe("interrupted"); + expect(retry.threadId).toBeNull(); + expect(prepare).toHaveBeenCalledTimes(1); +}); + +it("fails closed if request storage cannot be read", async () => { + writeFileSync(join(dir, "r1.json"), "broken JSON"); + const prepare = vi.fn(); + await expect(new RemoteRequests(dir).submit("r1", "payload", prepare)).rejects.toThrow(); + expect(prepare).not.toHaveBeenCalled(); +}); diff --git a/electron/remote-requests.ts b/electron/remote-requests.ts new file mode 100644 index 0000000..ea4484f --- /dev/null +++ b/electron/remote-requests.ts @@ -0,0 +1,203 @@ +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { RemoteRequestStatus } from "../contracts/remote.ts"; + +interface Receipt extends RemoteRequestStatus { + fingerprint: string; + result: Record | null; + createdAt: number; +} + +export type RemoteRequestReceipt = Receipt; + +/** + * A pre-dispatch failure whose message was written for the user. Any other + * error is logged on the laptop and stored as a generic message, so a phone + * never learns internal paths or stack details. + */ +export class RemoteTaskError extends Error {} + +const GENERIC_PREPARE_ERROR = + "Pipper couldn't prepare this task, so nothing was started. Check Pipper on your Mac."; + +export class RemoteRequestError extends Error { + readonly status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +/** Durable at-most-once dispatch. An interrupted dispatch is never replayed: + * ACP has no idempotency key, so after a crash we cannot prove it didn't run. + * Receipts retain payload hashes and response metadata, not full prompt payloads. + */ +export class RemoteRequests { + private receipts: Map | null = null; + private readonly preparing = new Map>(); + private readonly active = new Set(); + private lastCreatedAt = 0; + + private readonly directory: string; + constructor(directory: string) { + this.directory = directory; + } + + private all(): Map { + if (this.receipts) return this.receipts; + mkdirSync(this.directory, { recursive: true, mode: 0o700 }); + const receipts = new Map(); + for (const name of readdirSync(this.directory)) { + if (!/^[A-Za-z0-9-]{1,128}\.json$/.test(name)) continue; + // Fail closed on corrupt storage; never discard a deduplication record. + const receipt = JSON.parse(readFileSync(join(this.directory, name), "utf8")) as Receipt; + if (receipt.id !== name.slice(0, -5) || !receipt.fingerprint) { + throw new Error("Remote request history is unreadable. Check Pipper on your Mac."); + } + receipts.set(receipt.id, receipt); + this.lastCreatedAt = Math.max(this.lastCreatedAt, receipt.createdAt ?? receipt.updatedAt); + } + this.receipts = receipts; + return receipts; + } + + private save(receipt: Receipt): void { + const file = join(this.directory, `${receipt.id}.json`); + writeFileSync(`${file}.tmp`, JSON.stringify(receipt), { mode: 0o600, flush: true }); + renameSync(`${file}.tmp`, file); + this.all().set(receipt.id, receipt); + } + + private recovered(receipt: Receipt): Receipt { + if ( + (receipt.state === "preparing" || receipt.state === "running") && + !this.active.has(receipt.id) + ) { + return { + ...receipt, + state: "interrupted", + error: + "Pipper restarted before this request was confirmed. Check the thread on your Mac before starting another task. This request will not run again.", + }; + } + return receipt; + } + + get(id: string): Receipt | null { + const receipt = this.all().get(id); + return receipt ? this.recovered(receipt) : null; + } + + latestForThread(threadId: string): RemoteRequestStatus | null { + const receipt = [...this.all().values()] + .filter((r) => r.threadId === threadId) + .sort((a, b) => (b.createdAt ?? b.updatedAt) - (a.createdAt ?? a.updatedAt))[0]; + return receipt ? this.status(this.recovered(receipt)) : null; + } + + status(receipt: Receipt): RemoteRequestStatus { + const { id, threadId, state, error, updatedAt } = receipt; + return { id, threadId, state, error, updatedAt }; + } + + async submit( + id: unknown, + payload: unknown, + prepare: () => Promise<{ + threadId: string; + result: Record; + execute: () => Promise; + }>, + ): Promise { + if (typeof id !== "string" || !/^[A-Za-z0-9-]{1,128}$/.test(id)) { + throw new RemoteRequestError( + 400, + "A requestId is required. Update the phone app and try again.", + ); + } + const fingerprint = createHash("sha256").update(JSON.stringify(payload)).digest("hex"); + const previous = this.all().get(id); + if (previous) { + if (previous.fingerprint !== fingerprint) { + throw new RemoteRequestError(409, "This request ID already belongs to a different task."); + } + return this.preparing.get(id) ?? this.recovered(previous); + } + const createdAt = Math.max(Date.now(), this.lastCreatedAt + 1); + this.lastCreatedAt = createdAt; + const receipt: Receipt = { + id, + fingerprint, + threadId: null, + state: "preparing", + error: null, + result: null, + updatedAt: Date.now(), + createdAt, + }; + // Claim before ANY worktree/session/prompt side effect. + this.save(receipt); + this.active.add(id); + const task = (async () => { + try { + const prepared = await prepare(); + const accepted: Receipt = { + ...receipt, + threadId: prepared.threadId, + result: prepared.result, + state: "running", + updatedAt: Date.now(), + }; + this.save(accepted); + // Store acceptance before dispatch; respond without waiting for the turn. + void Promise.resolve() + .then(prepared.execute) + .then( + () => this.settle(accepted, null), + (error: unknown) => this.settle(accepted, error), + ) + .catch((error) => console.error("[Remote] Could not persist request outcome:", error)) + .finally(() => this.active.delete(id)); + return accepted; + } catch (error) { + this.active.delete(id); + if (!(error instanceof RemoteTaskError)) { + console.error(`[Remote] request ${id} failed before dispatch:`, error); + } + const failed: Receipt = { + ...receipt, + state: "failed", + updatedAt: Date.now(), + error: error instanceof RemoteTaskError ? error.message : GENERIC_PREPARE_ERROR, + }; + this.save(failed); + return failed; + } finally { + this.preparing.delete(id); + } + })(); + this.preparing.set(id, task); + return task; + } + + private settle(receipt: Receipt, error: unknown): void { + this.save({ + ...receipt, + state: error == null ? "completed" : "failed", + updatedAt: Date.now(), + error: error == null ? null : error instanceof Error ? error.message : String(error), + }); + } +} + +// HTTP and Mac Siri share the same in-process ownership of the durable journal. +const stores = new Map(); +export function getRemoteRequests(directory: string): RemoteRequests { + let store = stores.get(directory); + if (!store) { + store = new RemoteRequests(directory); + stores.set(directory, store); + } + return store; +} diff --git a/electron/remote-server-requests.test.ts b/electron/remote-server-requests.test.ts new file mode 100644 index 0000000..9485a14 --- /dev/null +++ b/electron/remote-server-requests.test.ts @@ -0,0 +1,410 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// The remote server reaches into SQLite-backed modules; stub them so the test +// exercises the HTTP contract the phone apps depend on, not the database. +const catalog = { + version: 1 as const, + updatedAt: "2026-09-13T00:00:00.000Z", + defaultAgentId: "codex-acp", + projects: [{ id: "p1", name: "FolkLore", path: "/tmp/folklore" }], + agents: [ + { id: "codex-acp", displayName: "Codex", available: true }, + { id: "opencode-acp", displayName: "opencode", available: false }, + ], +}; + +const mocks = vi.hoisted(() => ({ + threads: new Map< + string, + { + id: string; + project_id: string; + worktree_path: string | null; + title: string; + last_used_at: number; + } + >(), + createWorktree: vi.fn(), + removeWorktreeBestEffort: vi.fn(), + isLiveWorktree: vi.fn(), +})); +let manager: Record> | null = null; + +vi.mock("./siri/siri-catalog.ts", () => ({ buildSiriCatalog: () => catalog })); +vi.mock("./projects.ts", () => ({ + listProjects: () => catalog.projects, + getProject: (id: string) => catalog.projects.find((p) => p.id === id) ?? null, +})); +vi.mock("./agents/registry.ts", () => ({ + listRegisteredAgents: () => catalog.agents.map((a) => ({ id: a.id, displayName: a.displayName })), +})); +vi.mock("./threads.ts", () => ({ + listThreads: () => [...mocks.threads.values()], + getThread: (id: string) => mocks.threads.get(id) ?? null, +})); +vi.mock("./worktree-manager.ts", () => ({ + createWorktree: mocks.createWorktree, + removeWorktreeBestEffort: mocks.removeWorktreeBestEffort, + isLiveWorktree: mocks.isLiveWorktree, + gitBinary: () => "git", +})); +vi.mock("./agent-instances.ts", () => ({ + listAgentInstanceDescriptors: () => + catalog.agents + .filter((a) => a.available) + .map((a) => ({ id: a.id, displayName: a.displayName })), +})); + +import { RemoteServer } from "./remote-server.ts"; +import { RemoteDeviceStore } from "./remote-devices.ts"; +import type { RemoteReport, RemoteThreadSummary } from "../contracts/remote.ts"; +function responseBody(response: Response): Promise<{ + thread: RemoteThreadSummary; + report: RemoteReport; + ok: boolean; + error: string; + retryable: boolean; +}> { + return response.json() as ReturnType; +} + +let userData: string; +let server: RemoteServer; +let base: string; +let token: string; + +/** Bind loopback on an ephemeral port and return the real address. */ +async function startServer(s: RemoteServer): Promise { + await s.start(); + const h = (s as unknown as { servers: import("node:http").Server[] }).servers[0]!; + return `http://127.0.0.1:${(h.address() as { port: number }).port}`; +} + +/** Pair a device through the real one-time-code flow; returns its token. */ +async function pairDevice(s: RemoteServer, at: string): Promise { + const offer = s.createPairingOffer(["read", "run"]); + const res = await fetch(`${at}/api/remote/pair`, { + method: "POST", + body: JSON.stringify({ code: offer.code, deviceName: "Test phone" }), + }); + return ((await res.json()) as { token: string }).token; +} + +beforeEach(async () => { + manager = null; + mocks.threads.clear(); + mocks.createWorktree + .mockReset() + .mockReturnValue({ path: "/tmp/isolated-test", branch: "phone-test" }); + mocks.removeWorktreeBestEffort.mockReset(); + mocks.isLiveWorktree.mockReset().mockReturnValue(true); + userData = mkdtempSync(join(tmpdir(), "pipper-remote-test-")); + process.env.PIPPER_REMOTE_HOST = "127.0.0.1"; + server = new RemoteServer( + { + agentManager: () => + manager as unknown as import("./agent-connection-manager.ts").AgentManager | null, + getUserDataPath: () => userData, + getRendererDir: () => userData, + devices: new RemoteDeviceStore(new DatabaseSync(":memory:")), + }, + { port: 0 }, + ); + base = await startServer(server); + token = await pairDevice(server, base); +}); + +afterEach(async () => { + delete process.env.PIPPER_REMOTE_HOST; + await server.stop(); + rmSync(userData, { recursive: true, force: true }); +}); + +describe("GET /api/remote/catalog", () => { + it("requires a paired device", async () => { + const res = await fetch(`${base}/api/remote/catalog`); + expect(res.status).toBe(401); + }); + + it("returns the same catalog Siri reads on the Mac, uncached", async () => { + const res = await fetch(`${base}/api/remote/catalog`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(res.status).toBe(200); + expect(res.headers.get("cache-control")).toBe("no-store"); + await expect(res.json()).resolves.toEqual(catalog); + }); + + it("is a live paired-phone signal for standby", async () => { + const seen: boolean[] = []; + const s = new RemoteServer( + { + agentManager: () => null, + getUserDataPath: () => userData, + getRendererDir: () => userData, + devices: new RemoteDeviceStore(new DatabaseSync(":memory:")), + onRemoteActiveChanged: (active) => seen.push(active), + }, + { port: 0 }, + ); + const at = await startServer(s); + try { + const t2 = await pairDevice(s, at); + expect(seen).not.toContain(true); + await fetch(`${at}/api/remote/catalog`, { + headers: { Authorization: `Bearer ${t2}` }, + }); + expect(seen).toContain(true); + expect(s.hasLiveLease()).toBe(true); + } finally { + await s.stop(); + } + }); +}); + +function installAgent() { + manager = { + createThread: vi.fn(async (_project, title, _after, _agent, path) => { + const thread = { + id: `t${mocks.threads.size + 1}`, + project_id: "p1", + worktree_path: path, + title, + last_used_at: Date.now(), + }; + mocks.threads.set(thread.id, thread); + return thread; + }), + sendPrompt: vi.fn(async () => {}), + abortThread: vi.fn(async () => {}), + getRunningThreadIds: vi.fn(() => []), + getThreadTranscript: vi.fn(() => ({ finalText: null, messages: [] })), + getRemotePermissions: vi.fn(() => []), + respondToRemotePermission: vi.fn(async () => true), + getThreadModel: vi.fn( + (): { + configId: string; + current: string | null; + options: Array<{ id: string; name: string }>; + } | null => null, + ), + setThreadConfigOption: vi.fn(async () => []), + getModelCatalogs: vi.fn(async () => ({ + "codex-acp": [{ modelId: "gpt-5", name: "GPT-5" }], + })), + }; + return manager; +} + +const input = { + requestId: "request-1", + projectId: "p1", + modelId: "codex-acp", + prompt: "Fix login", +}; +function post(path: string, body: unknown, auth = token) { + return fetch(`${base}/api/remote/${path}`, { + method: "POST", + headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +it("deduplicates concurrent creation and returns the original thread on retry", async () => { + const am = installAgent(); + const responses = await Promise.all([post("threads", input), post("threads", input)]); + expect(responses.map((r) => r.status)).toEqual([202, 202]); + const bodies = await Promise.all(responses.map(responseBody)); + expect(bodies[0].thread.id).toBe(bodies[1].thread.id); + const retry = await responseBody(await post("threads", input)); + expect(retry.thread.id).toBe(bodies[0].thread.id); + expect(am.createThread).toHaveBeenCalledTimes(1); + expect(am.sendPrompt).toHaveBeenCalledTimes(1); + expect(mocks.createWorktree).toHaveBeenCalledTimes(1); +}); + +it("rejects malformed or conflicting IDs without side effects", async () => { + const am = installAgent(); + expect((await post("threads", { ...input, requestId: "not/valid" })).status).toBe(400); + await post("threads", input); + expect((await post("threads", { ...input, prompt: "Different task" })).status).toBe(409); + expect(am.sendPrompt).toHaveBeenCalledTimes(1); +}); + +it("still accepts phone apps that predate request IDs, without deduplicating them", async () => { + const am = installAgent(); + const legacy = { ...input, requestId: undefined }; + expect((await post("threads", legacy)).status).toBe(202); + expect((await post("threads", legacy)).status).toBe(202); + expect(am.createThread).toHaveBeenCalledTimes(2); +}); + +it("tells the phone why a task failed only with user-facing text", async () => { + const am = installAgent(); + am.createThread.mockRejectedValue(new Error("/Users/secret/path exploded")); + const response = await post("threads", input); + expect(response.status).toBe(409); + const text = await response.text(); + expect(text).not.toContain("/Users/secret"); + expect(text).toContain("nothing was started"); +}); + +it("never runs in the project root when worktree creation fails", async () => { + const am = installAgent(); + mocks.createWorktree.mockImplementation(() => { + throw new Error("no commits"); + }); + const response = await post("threads", input); + expect(response.status).toBe(409); + const rejection = await responseBody(response); + expect(rejection.error).toContain("No task was started"); + expect(rejection.retryable).toBe(true); + expect(am.createThread).not.toHaveBeenCalled(); + expect(am.sendPrompt).not.toHaveBeenCalled(); +}); + +it("permits a new attempt after a confirmed pre-dispatch rejection, while preserving the old receipt", async () => { + const am = installAgent(); + mocks.createWorktree.mockImplementationOnce(() => { + throw new Error("no commits"); + }); + expect((await post("threads", input)).status).toBe(409); + // A lost failure response followed by retry never changes the old outcome. + expect((await post("threads", input)).status).toBe(409); + expect(am.sendPrompt).not.toHaveBeenCalled(); + expect((await post("threads", { ...input, requestId: "new-attempt" })).status).toBe(202); + expect(am.sendPrompt).toHaveBeenCalledTimes(1); +}); + +it("does not dispatch if the agent fails to bind the requested worktree", async () => { + const am = installAgent(); + am.createThread.mockResolvedValue({ id: "bad", project_id: "p1", worktree_path: null }); + expect((await post("threads", input)).status).toBe(409); + expect(am.sendPrompt).not.toHaveBeenCalled(); +}); + +it("acknowledges follow-ups before the turn completes and records asynchronous failure", async () => { + const am = installAgent(); + await post("threads", input); + let rejectTurn!: (error: Error) => void; + am.sendPrompt.mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectTurn = reject; + }), + ); + const response = await post("threads/t1/prompt", { requestId: "followup-1", prompt: "Continue" }); + expect(response.status).toBe(202); + expect((await responseBody(response)).ok).toBe(true); + await post("threads/t1/prompt", { requestId: "followup-1", prompt: "Continue" }); + expect(am.sendPrompt).toHaveBeenCalledTimes(2); // initial + one follow-up + rejectTurn(new Error("Agent disconnected")); + await new Promise((resolve) => setTimeout(resolve, 0)); + const report = await fetch(`${base}/api/remote/threads/t1/report`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect((await responseBody(report)).report.request?.error).toBe("Agent disconnected"); +}); + +it("refuses follow-ups after the worktree disappears", async () => { + const am = installAgent(); + await post("threads", input); + mocks.isLiveWorktree.mockReturnValue(false); + expect( + (await post("threads/t1/prompt", { requestId: "followup-1", prompt: "Continue" })).status, + ).toBe(409); + expect(am.sendPrompt).toHaveBeenCalledTimes(1); +}); + +it("authenticates controls and scopes answers and cancellation to the named thread", async () => { + const am = installAgent(); + await post("threads", input); + expect((await post("threads/t1/stop", {}, "wrong-token")).status).toBe(401); + expect(am.abortThread).not.toHaveBeenCalled(); + expect((await post("threads/t1/stop", {})).status).toBe(200); + expect(am.abortThread).toHaveBeenCalledWith("t1"); + expect((await post("threads/t1/permission", { decisionId: "d1", optionId: "deny" })).status).toBe( + 200, + ); + expect(am.respondToRemotePermission).toHaveBeenCalledWith("t1", "d1", "deny", false); + am.respondToRemotePermission.mockResolvedValue(false); + expect( + (await post("threads/t1/permission", { decisionId: "expired", optionId: "allow" })).status, + ).toBe(409); +}); + +it("checks authentication and agent availability separately from public health", async () => { + const unauthorized = await fetch(`${base}/api/remote/diagnostics`); + expect(unauthorized.status).toBe(401); + const response = await fetch(`${base}/api/remote/diagnostics`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(await response.json()).toEqual({ + paired: true, + agentReady: false, + availableAgents: 1, + projects: 1, + }); +}); + +it("seeds the chosen model on the new thread and keeps old fingerprints stable", async () => { + const am = installAgent(); + await post("threads", { ...input, model: "gpt-5" }); + expect(am.createThread).toHaveBeenCalledWith( + "p1", + "Fix login", + null, + "codex-acp", + expect.any(String), + "gpt-5", + { background: true, requireWorktree: true }, + ); + // Same request id with a different model is a different task. + expect((await post("threads", { ...input, model: "o3" })).status).toBe(409); + await post("threads", { ...input, requestId: "request-2" }); + expect(am.createThread).toHaveBeenLastCalledWith( + "p1", + "Fix login", + null, + "codex-acp", + expect.any(String), + null, + { background: true, requireWorktree: true }, + ); +}); + +it("lists models per agent and caches the probe", async () => { + const am = installAgent(); + const get = () => + fetch(`${base}/api/remote/agent-models`, { headers: { Authorization: `Bearer ${token}` } }); + expect(await (await get()).json()).toEqual({ + models: { "codex-acp": [{ id: "gpt-5", name: "GPT-5" }] }, + }); + await get(); + expect(am.getModelCatalogs).toHaveBeenCalledTimes(1); +}); + +it("switches a thread's model only to an offered option", async () => { + const am = installAgent(); + await post("threads", input); + expect((await post("threads/t1/model", { model: "gpt-5" })).status).toBe(409); + am.getThreadModel.mockReturnValue({ + configId: "model", + current: "gpt-5", + options: [ + { id: "gpt-5", name: "GPT-5" }, + { id: "o3", name: "o3" }, + ], + }); + expect((await post("threads/t1/model", { model: "nope" })).status).toBe(400); + expect((await post("threads/t1/model", { model: "o3" })).status).toBe(200); + expect(am.setThreadConfigOption).toHaveBeenCalledWith("t1", "model", "o3"); + const report = await fetch(`${base}/api/remote/threads/t1/report`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect((await responseBody(report)).report.model?.options).toHaveLength(2); +}); diff --git a/electron/remote-server.test.ts b/electron/remote-server.test.ts index ec13c0a..97fb2c3 100644 --- a/electron/remote-server.test.ts +++ b/electron/remote-server.test.ts @@ -12,19 +12,23 @@ vi.mock("./projects.ts", () => ({ id === "p1" ? { id: "p1", name: "Demo", path: "/work/demo" } : undefined, })); const listRegisteredAgents = vi.fn((): unknown[] => []); -const listAgentInstanceDescriptors = vi.fn((): unknown[] => []); +// New tasks are validated against live agent instances. +const DEFAULT_INSTANCES = [{ id: "codex-acp", name: "codex", displayName: "Codex" }]; +const listAgentInstanceDescriptors = vi.fn((): unknown[] => DEFAULT_INSTANCES); vi.mock("./agents/registry.ts", () => ({ listRegisteredAgents: () => listRegisteredAgents() })); vi.mock("./agent-instances.ts", () => ({ listAgentInstanceDescriptors: () => listAgentInstanceDescriptors(), })); vi.mock("./threads.ts", () => ({ listThreads: () => [], getThread: () => undefined })); vi.mock("./worktree-manager.ts", () => ({ - createWorktree: () => { - throw new Error("no worktrees in tests"); - }, + createWorktree: () => ({ path: "/work/demo-wt", branch: "phone-test" }), gitBinary: () => "git", + isLiveWorktree: () => true, removeWorktreeBestEffort: () => undefined, })); +vi.mock("./siri/siri-catalog.ts", () => ({ + buildSiriCatalog: () => ({ defaultAgentId: "codex-acp", projects: [], agents: [] }), +})); const { RemoteServer } = await import("./remote-server.ts"); const { RemoteDeviceStore } = await import("./remote-devices.ts"); @@ -77,7 +81,7 @@ describe("RemoteServer security", () => { createThread: vi.fn(async () => ({ id: "t1", project_id: "p1", - worktree_path: null, + worktree_path: "/work/demo-wt", title: "task", last_used_at: 0, })), @@ -293,7 +297,7 @@ describe("RemoteServer security", () => { headers: authed, body: JSON.stringify({ projectId: "p1", prompt: "do it" }), }); - expect(res.status).toBe(500); + expect(res.ok).toBe(false); const text = await res.text(); expect(text).not.toContain("/Users/secret"); }); @@ -308,7 +312,7 @@ describe("RemoteServer security", () => { images: [{ data: PNG_BASE64, mimeType: "image/png" }], }), }); - expect(res.status).toBe(201); + expect(res.status).toBe(202); await vi.waitFor(() => expect(agent.sendPrompt).toHaveBeenCalled()); expect(agent.sendPrompt.mock.calls[0]?.[0]).toMatchObject({ threadId: "t1", @@ -349,7 +353,7 @@ describe("RemoteServer security", () => { images: [{ data: big.toString("base64"), mimeType: "image/png" }], }), }); - expect(res.status).toBe(201); + expect(res.status).toBe(202); }); it("caps how fast tasks can start", async () => { @@ -362,7 +366,7 @@ describe("RemoteServer security", () => { }); statuses.push(res.status); } - expect(statuses.slice(0, 60).every((s) => s === 201)).toBe(true); + expect(statuses.slice(0, 60).every((s) => s === 202)).toBe(true); expect(statuses[60]).toBe(429); expect(agent.createThread).toHaveBeenCalledTimes(60); }); diff --git a/electron/remote-server.ts b/electron/remote-server.ts index 55b81bc..7095547 100644 --- a/electron/remote-server.ts +++ b/electron/remote-server.ts @@ -1,5 +1,5 @@ import http from "node:http"; -import { randomBytes } from "node:crypto"; +import { randomUUID } from "node:crypto"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { existsSync, readFileSync, rmSync } from "node:fs"; @@ -8,10 +8,20 @@ import { extname, join } from "node:path"; import type { AgentManager } from "./agent-connection-manager.ts"; import { listProjects, getProject } from "./projects.ts"; import { listRegisteredAgents } from "./agents/registry.ts"; +import { buildSiriCatalog } from "./siri/siri-catalog.ts"; import { listAgentInstanceDescriptors } from "./agent-instances.ts"; import { getThread, listThreads } from "./threads.ts"; -import { createWorktree, gitBinary, removeWorktreeBestEffort } from "./worktree-manager.ts"; +import { prepareIsolatedAgentTask } from "./isolated-agent-task.ts"; +import { + RemoteRequestError, + RemoteTaskError, + type RemoteRequestReceipt, + type RemoteRequests, + getRemoteRequests, +} from "./remote-requests.ts"; +import { gitBinary, isLiveWorktree } from "./worktree-manager.ts"; import type { + RemoteAgentModel, RemoteDevice, RemoteDevicesState, RemoteModel, @@ -126,6 +136,14 @@ function removeLegacySharedToken(userDataPath: string): void { } } +/** + * The phone's idempotency key. Phone apps older than request receipts send + * none; give those a one-off id so they still work, just without retry dedup. + */ +function requestIdFrom(body: Record): unknown { + return body.requestId ?? `legacy-${randomUUID()}`; +} + async function filesTouched(cwd: string | null): Promise { if (!cwd || !existsSync(cwd)) return []; try { @@ -185,6 +203,10 @@ const MAX_PROMPT_BODY_BYTES = const HEADERS_TIMEOUT_MS = 10_000; const REQUEST_TIMEOUT_MS = 120_000; +/** Building model catalogs spawns every selected agent and probes sessions, + * so the phone shares one recent result instead of re-probing per request. */ +const AGENT_MODELS_TTL_MS = 5 * 60_000; + export class RemoteServer { private servers: http.Server[] = []; /** Hosts with a live listener — the only ones safe to advertise. */ @@ -200,7 +222,11 @@ export class RemoteServer { private readonly pairing = new PairingCodes(); readonly port: number; private readonly deps: RemoteServerDeps; - private readonly isolationNotes = new Map(); + private readonly requests: RemoteRequests; + private agentModels: { + at: number; + value: Promise>; + } | null = null; private readonly routes: Route[]; private lastAuthedAt = 0; /** Set by start(): loopback-only means the public path is a tunnel. */ @@ -214,6 +240,7 @@ export class RemoteServer { constructor(deps: RemoteServerDeps, opts?: { port?: number }) { this.deps = deps; + this.requests = getRemoteRequests(join(deps.getUserDataPath(), "remote-requests")); this.port = opts?.port ?? Number(process.env.PIPPER_REMOTE_PORT ?? 4173); this.routes = this.buildRoutes(); removeLegacySharedToken(deps.getUserDataPath()); @@ -435,15 +462,6 @@ export class RemoteServer { return this.boundHosts.size > 0; } - /** True when a thread row already binds this worktree (keep it for retry). */ - private threadExistsForWorktree(worktreePath: string): boolean { - try { - return listThreads().some((t) => t.worktree_path === worktreePath); - } catch { - return true; - } - } - private assertTaskAllowance(): void { const wait = this.taskStarts.retryAfterSec("tasks"); if (wait > 0) throw new HttpError(429, "Too many tasks", { "Retry-After": String(wait) }); @@ -462,6 +480,10 @@ export class RemoteServer { res.destroy(); return; } + if (error instanceof RemoteRequestError) { + sendApi(res, error.status, { error: error.message }); + return; + } if (error instanceof HttpError) { // An unread (oversize/aborted) body leaves the socket mid-request; // close it after the response instead of reusing it. @@ -740,6 +762,40 @@ export class RemoteServer { sendApi(res, 200, { models }); }, }, + { + // Models *inside* each agent instance (the ACP model option), keyed by + // the same instance ids `/models` returns. + method: "GET", + pattern: /^\/api\/remote\/agent-models$/, + scope: "read", + handle: async ({ res, am }) => { + if (!am) return sendApi(res, 503, { error: "Agent not ready" }); + sendApi(res, 200, { models: await this.loadAgentModels(am) }); + }, + }, + { + // Same shape as siri-catalog.json on the laptop, so the iOS app's + // Siri intents resolve projects/agents against an identical catalog + // (selected agents only, with availability) without a network hop. + method: "GET", + pattern: /^\/api\/remote\/catalog$/, + scope: "read", + handle: ({ res }) => sendApi(res, 200, buildSiriCatalog()), + }, + { + method: "GET", + pattern: /^\/api\/remote\/diagnostics$/, + scope: "read", + handle: ({ res, am }) => { + const catalog = buildSiriCatalog(); + sendApi(res, 200, { + paired: true, + agentReady: am != null, + availableAgents: catalog.agents.filter((a) => a.available).length, + projects: catalog.projects.length, + }); + }, + }, { method: "GET", pattern: /^\/api\/remote\/threads$/, @@ -766,25 +822,90 @@ export class RemoteServer { scope: "run", handle: (ctx) => this.createTask(ctx), }, + { + method: "GET", + pattern: /^\/api\/remote\/requests\/([A-Za-z0-9-]{1,128})$/, + scope: "read", + handle: ({ res, params }) => { + const receipt = this.requests.get(params[0]!); + if (!receipt) return sendApi(res, 404, { error: "Request not found" }); + sendApi(res, 200, { request: this.requests.status(receipt) }); + }, + }, { method: "POST", pattern: /^\/api\/remote\/threads\/([^/]+)\/prompt$/, scope: "run", + handle: (ctx) => this.sendFollowUp(ctx), + }, + { + method: "POST", + pattern: /^\/api\/remote\/threads\/([^/]+)\/model$/, + scope: "run", handle: async ({ req, res, params, am }) => { if (!am) return sendApi(res, 503, { error: "Agent not ready" }); - const thread = getThread(params[0]!); - if (!thread) return sendApi(res, 404, { error: "Thread not found" }); - // Refuse before reading: a prompt body can be tens of MB of images. - this.assertTaskAllowance(); - const body = parseJsonObject(await readBody(req, MAX_PROMPT_BODY_BYTES)); - const prompt = typeof body.prompt === "string" ? body.prompt : ""; - if (!prompt.trim()) return sendApi(res, 400, { error: "prompt is required" }); - const images = parsePromptImages(body.images); - this.consumeTaskAllowance(); - await am.sendPrompt( - { threadId: thread.id, message: prompt, images }, - { background: true }, + const threadId = params[0]!; + if (!getThread(threadId)) return sendApi(res, 404, { error: "Thread not found" }); + const body = parseJsonObject(await readBody(req, MAX_BODY_BYTES)); + if (typeof body.model !== "string" || !body.model) { + return sendApi(res, 400, { error: "model is required" }); + } + const current = am.getThreadModel(threadId); + if (!current) { + return sendApi(res, 409, { + error: + "This thread can't change models right now. Open it on your Mac and try again.", + }); + } + if (!current.options.some((o) => o.id === body.model)) { + return sendApi(res, 400, { error: "That model isn't offered by this thread's agent." }); + } + await am.setThreadConfigOption(threadId, current.configId, body.model); + const next = am.getThreadModel(threadId); + sendApi(res, 200, { + model: next ? { current: next.current, options: next.options } : null, + }); + }, + }, + { + method: "POST", + pattern: /^\/api\/remote\/threads\/([^/]+)\/stop$/, + scope: "run", + handle: async ({ res, params, am }) => { + if (!am) return sendApi(res, 503, { error: "Agent not ready" }); + const threadId = params[0]!; + if (!getThread(threadId)) return sendApi(res, 404, { error: "Thread not found" }); + await am.abortThread(threadId); + sendApi(res, 200, { ok: true }); + }, + }, + { + method: "POST", + pattern: /^\/api\/remote\/threads\/([^/]+)\/permission$/, + scope: "run", + handle: async ({ req, res, params, am }) => { + if (!am) return sendApi(res, 503, { error: "Agent not ready" }); + const threadId = params[0]!; + if (!getThread(threadId)) return sendApi(res, 404, { error: "Thread not found" }); + const body = parseJsonObject(await readBody(req, MAX_BODY_BYTES)); + if ( + typeof body.decisionId !== "string" || + (body.optionId != null && typeof body.optionId !== "string") || + (body.cancelled != null && typeof body.cancelled !== "boolean") + ) { + return sendApi(res, 400, { error: "A valid decision and option are required" }); + } + const answered = await am.respondToRemotePermission( + threadId, + body.decisionId, + (body.optionId as string | null | undefined) ?? undefined, + body.cancelled === true, ); + if (!answered) { + return sendApi(res, 409, { + error: "This decision expired or was already answered. Refresh the thread.", + }); + } sendApi(res, 200, { ok: true }); }, }, @@ -811,7 +932,13 @@ export class RemoteServer { filesTouched: await filesTouched(cwd), worktreePath: thread.worktree_path ?? null, isolated: Boolean(thread.worktree_path), - isolationNote: this.isolationNotes.get(thread.id) ?? null, + isolationNote: null, + permissions: am?.getRemotePermissions(thread.id) ?? [], + request: this.requests.latestForThread(thread.id), + model: (() => { + const m = am?.getThreadModel(thread.id); + return m ? { current: m.current, options: m.options } : null; + })(), }; sendApi(res, 200, { report }); }, @@ -819,111 +946,128 @@ export class RemoteServer { ]; } - /** New phone chat = fresh worktree + fresh background thread + first turn. */ + /** + * New phone chat = fresh isolated worktree + fresh background thread + + * first turn. Dispatched at most once per requestId, so a phone retrying + * after a dropped reply gets the original thread instead of a duplicate. + */ private async createTask({ req, res, am }: RouteContext): Promise { - if (!am) return sendApi(res, 503, { error: "Agent not ready" }); // Refuse before reading: a prompt body can be tens of MB of images. this.assertTaskAllowance(); const body = parseJsonObject(await readBody(req, MAX_PROMPT_BODY_BYTES)); const projectId = typeof body.projectId === "string" ? body.projectId : ""; - const prompt = typeof body.prompt === "string" ? body.prompt : ""; - const modelId = typeof body.modelId === "string" ? body.modelId : null; - const images = parsePromptImages(body.images); - if (!projectId || !prompt.trim()) { + const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; + if ( + !projectId || + !prompt || + (body.modelId != null && typeof body.modelId !== "string") || + (body.model != null && typeof body.model !== "string") + ) { return sendApi(res, 400, { error: "projectId and prompt are required" }); } - const project = getProject(projectId); - if (!project) return sendApi(res, 404, { error: "Project not found" }); + const agentId = (body.modelId as string | null | undefined) ?? null; + const model = (body.model as string | null | undefined) || null; + const images = parsePromptImages(body.images); this.consumeTaskAllowance(); - // If the repo can't take a worktree (e.g. no commits yet), fall back to - // the project root so the task still runs. - let worktreePath: string | null = null; - let worktreeBranch: string | null = null; - let isolationNote: string | null = null; - try { - // Fixed-length random name: never derived from the prompt text, so - // long/unicode/identical prompts can't produce ugly, colliding, or - // confusing worktree + branch names. `phone-` prefix keeps the - // origin identifiable in `git worktree list`. - let created = null; - let lastError: unknown = null; - for (let attempt = 0; attempt < 5 && !created; attempt++) { - const slug = `phone-${randomBytes(4).toString("hex")}`; - try { - created = createWorktree({ - projectPath: project.path, - projectId: project.id, - name: slug, - }); - } catch (err) { - lastError = err; + const receipt = await this.requests.submit( + requestIdFrom(body), + // `model` and `images` join the fingerprint only when set, so receipts + // written before they existed still match their retries. + { + kind: "create", + projectId, + agentId, + ...(model ? { model } : {}), + prompt, + ...(images.length ? { images } : {}), + }, + async () => { + if (!am) + throw new RemoteTaskError("Pipper is still starting on your Mac. No task was started."); + console.log( + `[Remote] new phone thread project=${projectId} promptLen=${prompt.length} images=${images.length}`, + ); + return prepareIsolatedAgentTask(am, projectId, agentId, prompt, model, images); + }, + ); + this.sendReceipt(res, receipt); + } + + /** Follow-up turn on an existing phone thread, bound to its live worktree. */ + private async sendFollowUp({ req, res, params }: RouteContext): Promise { + const threadId = params[0]!; + if (!getThread(threadId)) return sendApi(res, 404, { error: "Thread not found" }); + // Refuse before reading: a prompt body can be tens of MB of images. + this.assertTaskAllowance(); + const body = parseJsonObject(await readBody(req, MAX_PROMPT_BODY_BYTES)); + const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; + if (!prompt) return sendApi(res, 400, { error: "prompt is required" }); + const images = parsePromptImages(body.images); + this.consumeTaskAllowance(); + const receipt = await this.requests.submit( + requestIdFrom(body), + { kind: "prompt", threadId, prompt, ...(images.length ? { images } : {}) }, + async () => { + const am = this.deps.agentManager(); + if (!am) throw new RemoteTaskError("Pipper is still starting on your Mac."); + const thread = getThread(threadId); + const project = thread ? getProject(thread.project_id) : null; + if ( + !thread?.worktree_path || + !project || + !isLiveWorktree(thread.worktree_path, project.path) + ) { + throw new RemoteTaskError( + "This thread has no live isolated workspace. Restore its worktree on your Mac, or start a new thread.", + ); } - } - if (!created) throw lastError ?? new Error("worktree creation failed"); - worktreePath = created.path; - worktreeBranch = created.branch; - console.log(`[Remote] worktree created: ${worktreePath}`); - } catch (err) { - isolationNote = err instanceof Error ? err.message : String(err); - console.warn(`[Remote] worktree fallback to project root: ${isolationNote}`); - } - console.log( - `[Remote] new phone thread project=${project.id} worktree=${worktreePath ?? ""} promptLen=${prompt.length} images=${images.length}`, + return { + threadId, + result: { ok: true }, + execute: () => + am.sendPrompt( + { threadId, message: prompt, images }, + { background: true, requireWorktree: true }, + ), + }; + }, ); - // modelId from the phone is a provider *instance* id - // (listAgentInstanceDescriptors; driver id when default). Use it to pick - // the connection, but never as a model name — the agent's own default - // model applies (e.g. antigravity has no implicit default; the user's - // desktop default is used). - try { - const thread = await am.createThread( - project.id, - prompt.slice(0, 80), - null, - modelId, - worktreePath, - null, - { background: true }, - ); - if (isolationNote) this.isolationNotes.set(thread.id, isolationNote); - console.log( - `[Remote] prompt accepted thread=${thread.id} boundWorktree=${thread.worktree_path ?? ""}`, + this.sendReceipt(res, receipt); + } + + /** 202 once accepted (the turn runs in the background); 409 while unsettled or failed. */ + private sendReceipt(res: http.ServerResponse, receipt: RemoteRequestReceipt): void { + sendApi(res, receipt.result ? 202 : 409, { + ...receipt.result, + request: this.requests.status(receipt), + ...(receipt.result + ? {} + : { + error: receipt.error ?? "Request is still being prepared.", + retryable: receipt.state === "failed", + }), + }); + } + + private loadAgentModels(am: AgentManager): Promise> { + const cached = this.agentModels; + if (cached && Date.now() - cached.at < AGENT_MODELS_TTL_MS) return cached.value; + const value = am + .getModelCatalogs() + .then((catalogs) => + Object.fromEntries( + Object.entries(catalogs).map(([agentId, models]) => [ + agentId, + models.map((m) => ({ id: m.modelId, name: m.name })), + ]), + ), ); - if (!thread.worktree_path) { - console.warn( - `[Remote] thread=${thread.id} running on PROJECT ROOT (no isolated workspace). ` + - `requested=${worktreePath ?? ""} reason=${isolationNote ?? "worktree rejected as not-live"}`, - ); - } - // Respond before the turn runs so the phone shows progress immediately; - // the turn streams into the thread in the background and the report - // poll picks it up. A prompt failure is logged server-side — the phone - // sees an idle thread with no reply. - void am - .sendPrompt({ threadId: thread.id, message: prompt, images }, { background: true }) - .then(() => console.log(`[Remote] turn completed thread=${thread.id}`)) - .catch((promptError) => { - console.error(`[Remote] prompt failed, keeping thread=${thread.id}:`, promptError); - }); - sendApi(res, 201, { - thread: { - id: thread.id, - projectId: thread.project_id, - worktreePath: thread.worktree_path ?? null, - title: thread.title, - running: true, - lastUsedAt: thread.last_used_at, - }, - }); - } catch (error) { - // Roll back the worktree only when thread creation itself failed — - // once the thread row exists the worktree is retained for retry. - if (worktreePath && !this.threadExistsForWorktree(worktreePath)) { - console.warn(`[Remote] rolling back worktree: ${worktreePath}`); - removeWorktreeBestEffort(project.path, worktreePath, worktreeBranch); - } - throw error; - } + this.agentModels = { at: Date.now(), value }; + // A failed probe must not be served for the whole TTL. + value.catch(() => { + if (this.agentModels?.value === value) this.agentModels = null; + }); + return value; } private serveFile( diff --git a/electron/siri/siri-catalog.ts b/electron/siri/siri-catalog.ts new file mode 100644 index 0000000..378cff6 --- /dev/null +++ b/electron/siri/siri-catalog.ts @@ -0,0 +1,104 @@ +import { mkdirSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import os from "node:os"; +import { getSelectedAgentIds } from "../db.ts"; +import { getPipperLibraryPath } from "../paths.ts"; +import { listProjects } from "../projects.ts"; +import { listRegisteredAgents, getDefaultAgentId } from "../agents/registry.ts"; + +export interface SiriCatalogProject { + id: string; + name: string; + path: string; +} + +export interface SiriCatalogAgent { + id: string; + displayName: string; + available: boolean; +} + +export interface SiriCatalog { + version: 1; + updatedAt: string; + defaultAgentId: string; + projects: SiriCatalogProject[]; + agents: SiriCatalogAgent[]; +} + +/** + * Every location the Swift extension may read from. Must match + * `SiriCatalogStore.candidateDirs()` in native/pipper-intents. + */ +export function getSiriLibraryDirs(): string[] { + const dirs = [getPipperLibraryPath()]; + if (process.env.PIPPER_LIBRARY_PATH) return dirs; + if (process.platform === "darwin") { + const appSupport = join(os.homedir(), "Library/Application Support/Pipper"); + if (appSupport !== dirs[0]) dirs.push(appSupport); + } + return dirs; +} +export function getSiriCatalogPath(): string { + return join(getPipperLibraryPath(), "siri-catalog.json"); +} + +/** Directory where the Swift intent stages pending thread requests. */ +export function getSiriRequestsDir(): string { + return join(getPipperLibraryPath(), "siri-requests"); +} + +/** All request dirs the extension may stage into (dual-write targets). */ +export function getSiriRequestsDirs(): string[] { + return getSiriLibraryDirs().map((d) => join(d, "siri-requests")); +} + +/** Snapshot the current projects and agents into the shared catalog shape. */ +export function buildSiriCatalog(): SiriCatalog { + const projects = listProjects().map((p) => ({ + id: p.id, + name: p.name, + path: p.path, + })); + const selectedAgentIds = new Set(getSelectedAgentIds()); + const selectedAgents = listRegisteredAgents().filter((a) => selectedAgentIds.has(a.id)); + const agents = selectedAgents.map((a) => ({ + id: a.id, + displayName: a.displayName, + available: a.available ?? false, + })); + const configuredDefaultAgentId = getDefaultAgentId(); + const defaultAgentId = selectedAgents.some((a) => a.id === configuredDefaultAgentId) + ? configuredDefaultAgentId + : (selectedAgents[0]?.id ?? ""); + return { + version: 1, + updatedAt: new Date().toISOString(), + defaultAgentId, + projects, + agents, + }; +} + +/** + * Write the shared catalog atomically (temp file + rename) so Siri never + * observes truncated JSON mid-refresh. + */ +export function refreshSiriCatalog(): SiriCatalog { + const catalog = buildSiriCatalog(); + const payload = JSON.stringify(catalog, null, 2); + // Dual-write so the extension finds the catalog in either sandboxed path. + for (const dir of getSiriLibraryDirs()) { + try { + mkdirSync(dir, { recursive: true }); + mkdirSync(join(dir, "siri-requests"), { recursive: true }); + const target = join(dir, "siri-catalog.json"); + const tmp = join(dirname(target), `.siri-catalog.${process.pid}.tmp`); + writeFileSync(tmp, payload, "utf8"); + renameSync(tmp, target); + } catch (err) { + console.warn(`[Siri] Catalog write failed for ${dir}:`, err); + } + } + return catalog; +} diff --git a/electron/terminal-manager.test.ts b/electron/terminal-manager.test.ts index 0f9dd25..b454e35 100644 --- a/electron/terminal-manager.test.ts +++ b/electron/terminal-manager.test.ts @@ -54,4 +54,18 @@ describe("TerminalManager", () => { expect(result.text.startsWith("😀")).toBe(true); expect(result.truncated).toBe(true); }); + test("stopping one session leaves other sessions' terminals running", async () => { + const manager = new TerminalManager(); + const command = { command: process.execPath, args: ["-e", "setInterval(() => {}, 1000)"] }; + const target = manager.create({ ...command, sessionId: "phone-session" }); + const other = manager.create({ ...command, sessionId: "desktop-session" }); + try { + manager.killRunning("phone-session"); + await manager.waitForExit(target); + expect(manager.getOutput(target).exitStatus).not.toBeNull(); + expect(manager.getOutput(other).exitStatus).toBeNull(); + } finally { + manager.killAll(); + } + }); }); diff --git a/electron/terminal-manager.ts b/electron/terminal-manager.ts index 819a665..6ad5ab4 100644 --- a/electron/terminal-manager.ts +++ b/electron/terminal-manager.ts @@ -221,9 +221,9 @@ export class TerminalManager { } /** Kill all running processes without releasing (ids stay valid for output). */ - killRunning(): void { - for (const id of Array.from(this.terminals.keys())) { - this.kill(id); + killRunning(sessionId?: string): void { + for (const [id, terminal] of this.terminals) { + if (sessionId == null || terminal.sessionId === sessionId) this.kill(id); } } diff --git a/native/pipper-intents/Extension/AppIntentsExtension.swift b/native/pipper-intents/Extension/AppIntentsExtension.swift new file mode 100644 index 0000000..3737a53 --- /dev/null +++ b/native/pipper-intents/Extension/AppIntentsExtension.swift @@ -0,0 +1,6 @@ +import AppIntents +import ExtensionFoundation + +@main +struct PipperIntentsExtension: AppIntentsExtension { +} diff --git a/native/pipper-intents/Extension/Info.plist b/native/pipper-intents/Extension/Info.plist new file mode 100644 index 0000000..12f66a5 --- /dev/null +++ b/native/pipper-intents/Extension/Info.plist @@ -0,0 +1,25 @@ + + + + + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleDisplayName + Pipper + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + EXAppExtensionAttributes + + EXExtensionPointIdentifier + com.apple.appintents-extension + + + diff --git a/native/pipper-intents/Extension/PipperIntents.entitlements b/native/pipper-intents/Extension/PipperIntents.entitlements new file mode 100644 index 0000000..c2a8ef4 --- /dev/null +++ b/native/pipper-intents/Extension/PipperIntents.entitlements @@ -0,0 +1,13 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.temporary-exception.files.home-relative-path.read-write + + /Library/pipper/ + /Library/Application Support/Pipper/ + + + diff --git a/native/pipper-intents/Package.swift b/native/pipper-intents/Package.swift new file mode 100644 index 0000000..f5723f0 --- /dev/null +++ b/native/pipper-intents/Package.swift @@ -0,0 +1,14 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "PipperIntents", + platforms: [.macOS(.v13)], + products: [ + .library(name: "PipperIntents", targets: ["PipperIntents"]), + ], + targets: [ + .target(name: "PipperIntents", path: "Sources"), + .testTarget(name: "PipperIntentsTests", dependencies: ["PipperIntents"], path: "Tests/PipperIntentsTests"), + ] +) diff --git a/native/pipper-intents/PreviewApp/PipperIntentsPreview.entitlements b/native/pipper-intents/PreviewApp/PipperIntentsPreview.entitlements new file mode 100644 index 0000000..6631ffa --- /dev/null +++ b/native/pipper-intents/PreviewApp/PipperIntentsPreview.entitlements @@ -0,0 +1,6 @@ + + + + + + diff --git a/native/pipper-intents/PreviewApp/PipperIntentsPreview.xcodeproj/project.pbxproj b/native/pipper-intents/PreviewApp/PipperIntentsPreview.xcodeproj/project.pbxproj new file mode 100644 index 0000000..937b287 --- /dev/null +++ b/native/pipper-intents/PreviewApp/PipperIntentsPreview.xcodeproj/project.pbxproj @@ -0,0 +1,400 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + A10000010000000000000001 /* PipperIntentsPreviewApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000001 /* PipperIntentsPreviewApp.swift */; }; + A10000010000000000000002 /* PipperIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000002 /* PipperIntents.swift */; }; + A10000010000000000000003 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A10000020000000000000003 /* SwiftUI.framework */; }; + A10000010000000000000004 /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A10000020000000000000004 /* AppIntents.framework */; }; + A10000010000000000000005 /* AppKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A10000020000000000000005 /* AppKit.framework */; }; + A10000010000000000000006 /* AppIntentsExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000007 /* AppIntentsExtension.swift */; }; + A10000010000000000000007 /* PipperIntents.swift in Extension Sources */ = {isa = PBXBuildFile; fileRef = A10000020000000000000002 /* PipperIntents.swift */; }; + A10000010000000000000008 /* AppIntents.framework in Extension Frameworks */ = {isa = PBXBuildFile; fileRef = A10000020000000000000004 /* AppIntents.framework */; }; + A10000010000000000000009 /* AppKit.framework in Extension Frameworks */ = {isa = PBXBuildFile; fileRef = A10000020000000000000005 /* AppKit.framework */; }; + A1000001000000000000000A /* ExtensionFoundation.framework in Extension Frameworks */ = {isa = PBXBuildFile; fileRef = A10000020000000000000009 /* ExtensionFoundation.framework */; }; + A1000001000000000000000B /* PipperIntents.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = A1000002000000000000000A /* PipperIntents.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + A100000A0000000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A10000070000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = A10000050000000000000002; + remoteInfo = PipperIntents; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + A10000020000000000000001 /* PipperIntentsPreviewApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PipperIntentsPreviewApp.swift; sourceTree = ""; }; + A10000020000000000000002 /* PipperIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PipperIntents.swift; path = ../Sources/PipperIntents.swift; sourceTree = ""; }; + A10000020000000000000003 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + A10000020000000000000004 /* AppIntents.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppIntents.framework; path = System/Library/Frameworks/AppIntents.framework; sourceTree = SDKROOT; }; + A10000020000000000000005 /* AppKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppKit.framework; path = System/Library/Frameworks/AppKit.framework; sourceTree = SDKROOT; }; + A10000020000000000000006 /* PipperIntentsPreview.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PipperIntentsPreview.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A10000020000000000000007 /* AppIntentsExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppIntentsExtension.swift; path = ../Extension/AppIntentsExtension.swift; sourceTree = ""; }; + A10000020000000000000008 /* AppIntentsExtension-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = ../Extension/Info.plist; sourceTree = ""; }; + A10000020000000000000009 /* ExtensionFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ExtensionFoundation.framework; path = System/Library/Frameworks/ExtensionFoundation.framework; sourceTree = SDKROOT; }; + A1000002000000000000000A /* PipperIntents.appex */ = {isa = PBXFileReference; explicitFileType = wrapper.app-extension; includeInIndex = 0; path = PipperIntents.appex; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + A10000030000000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A10000010000000000000003 /* SwiftUI.framework in Frameworks */, + A10000010000000000000004 /* AppIntents.framework in Frameworks */, + A10000010000000000000005 /* AppKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A10000030000000000000004 /* Extension Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A10000010000000000000008 /* AppIntents.framework in Extension Frameworks */, + A10000010000000000000009 /* AppKit.framework in Extension Frameworks */, + A1000001000000000000000A /* ExtensionFoundation.framework in Extension Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + A10000040000000000000001 = { + isa = PBXGroup; + children = ( + A10000040000000000000002 /* PreviewApp */, + A10000040000000000000003 /* Frameworks */, + A10000040000000000000004 /* Products */, + ); + sourceTree = ""; + }; + A10000040000000000000002 /* PreviewApp */ = { + isa = PBXGroup; + children = ( + A10000020000000000000001 /* PipperIntentsPreviewApp.swift */, + A10000020000000000000002 /* PipperIntents.swift */, + ); + path = .; + sourceTree = ""; + }; + A10000040000000000000005 /* Extension */ = { + isa = PBXGroup; + children = ( + A10000020000000000000007 /* AppIntentsExtension.swift */, + A10000020000000000000008 /* AppIntentsExtension-Info.plist */, + ); + path = ../Extension; + sourceTree = ""; + }; + A10000040000000000000003 /* Frameworks */ = { + isa = PBXGroup; + children = ( + A10000020000000000000003 /* SwiftUI.framework */, + A10000020000000000000004 /* AppIntents.framework */, + A10000020000000000000005 /* AppKit.framework */, + A10000020000000000000009 /* ExtensionFoundation.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + A10000040000000000000004 /* Products */ = { + isa = PBXGroup; + children = ( + A10000020000000000000006 /* PipperIntentsPreview.app */, + A1000002000000000000000A /* PipperIntents.appex */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A10000050000000000000001 /* PipperIntentsPreview */ = { + isa = PBXNativeTarget; + buildConfigurationList = A10000060000000000000002 /* Build configuration list for PBXNativeTarget "PipperIntentsPreview" */; + buildPhases = ( + A10000030000000000000002 /* Sources */, + A10000030000000000000001 /* Frameworks */, + A10000030000000000000003 /* Resources */, + A10000030000000000000007 /* Embed App Extensions */, + ); + buildRules = ( + ); + dependencies = ( + A100000B0000000000000001 /* PBXTargetDependency */, + ); + name = PipperIntentsPreview; + productName = PipperIntentsPreview; + productReference = A10000020000000000000006 /* PipperIntentsPreview.app */; + productType = "com.apple.product-type.application"; + }; + A10000050000000000000002 /* PipperIntents */ = { + isa = PBXNativeTarget; + buildConfigurationList = A10000060000000000000003 /* Build configuration list for PBXNativeTarget "PipperIntents" */; + buildPhases = ( + A10000030000000000000005 /* Extension Sources */, + A10000030000000000000004 /* Extension Frameworks */, + A10000030000000000000006 /* Extension Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = PipperIntents; + productName = PipperIntents; + productReference = A1000002000000000000000A /* PipperIntents.appex */; + productType = "com.apple.product-type.extensionkit-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A10000070000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastUpgradeCheck = 2700; + TargetAttributes = { + A10000050000000000000001 = { + CreatedOnToolsVersion = 27.0; + }; + A10000050000000000000002 = { + CreatedOnToolsVersion = 27.0; + }; + }; + }; + buildConfigurationList = A10000060000000000000001 /* Build configuration list for PBXProject "PipperIntentsPreview" */; + compatibilityVersion = "Xcode 16.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = A10000040000000000000001; + productRefGroup = A10000040000000000000004 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A10000050000000000000001 /* PipperIntentsPreview */, + A10000050000000000000002 /* PipperIntents */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A10000030000000000000003 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A10000030000000000000006 /* Extension Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXCopyFilesBuildPhase section */ + A10000030000000000000007 /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(EXTENSIONS_FOLDER_PATH)"; + dstSubfolderSpec = 16; + files = ( + A1000001000000000000000B /* PipperIntents.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A10000030000000000000002 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A10000010000000000000001 /* PipperIntentsPreviewApp.swift in Sources */, + A10000010000000000000002 /* PipperIntents.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A10000030000000000000005 /* Extension Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A10000010000000000000006 /* AppIntentsExtension.swift in Sources */, + A10000010000000000000007 /* PipperIntents.swift in Extension Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + A100000B0000000000000001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A10000050000000000000002 /* PipperIntents */; + targetProxy = A100000A0000000000000001 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + A10000080000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + APP_SHORTCUTS_ENABLE_FLEXIBLE_MATCHING = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGNING_ALLOWED = YES; + CODE_SIGNING_REQUIRED = NO; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_ENTITLEMENTS = PipperIntentsPreview.entitlements; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Pipper; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.pipper.PipperIntentsPreview; + PRODUCT_NAME = PipperIntentsPreview; + SDKROOT = macosx; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = apple-generic; + }; + name = Debug; + }; + A10000080000000000000002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + APP_SHORTCUTS_ENABLE_FLEXIBLE_MATCHING = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGNING_ALLOWED = YES; + CODE_SIGNING_REQUIRED = NO; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_ENTITLEMENTS = PipperIntentsPreview.entitlements; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Pipper; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.pipper.PipperIntentsPreview; + PRODUCT_NAME = PipperIntentsPreview; + SDKROOT = macosx; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = apple-generic; + }; + name = Release; + }; + A10000080000000000000003 /* Debug target */ = { + isa = XCBuildConfiguration; + buildSettings = { + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + A10000080000000000000004 /* Release target */ = { + isa = XCBuildConfiguration; + buildSettings = { + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; + A10000080000000000000005 /* Debug extension */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGNING_ALLOWED = YES; + CODE_SIGNING_REQUIRED = NO; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_ENTITLEMENTS = PipperIntentsPreviewExtension.entitlements; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = ../Extension/Info.plist; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.pipper.PipperIntentsPreview.PipperIntents; + PRODUCT_NAME = PipperIntents; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + A10000080000000000000006 /* Release extension */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGNING_ALLOWED = YES; + CODE_SIGNING_REQUIRED = NO; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_ENTITLEMENTS = PipperIntentsPreviewExtension.entitlements; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = ../Extension/Info.plist; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.pipper.PipperIntentsPreview.PipperIntents; + PRODUCT_NAME = PipperIntents; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A10000060000000000000001 /* Build configuration list for PBXProject "PipperIntentsPreview" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A10000080000000000000001 /* Debug */, + A10000080000000000000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A10000060000000000000002 /* Build configuration list for PBXNativeTarget "PipperIntentsPreview" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A10000080000000000000003 /* Debug target */, + A10000080000000000000004 /* Release target */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A10000060000000000000003 /* Build configuration list for PBXNativeTarget "PipperIntents" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A10000080000000000000005 /* Debug extension */, + A10000080000000000000006 /* Release extension */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = A10000070000000000000001 /* Project object */; +} diff --git a/native/pipper-intents/PreviewApp/PipperIntentsPreview.xcodeproj/xcshareddata/xcschemes/PipperIntentsPreview.xcscheme b/native/pipper-intents/PreviewApp/PipperIntentsPreview.xcodeproj/xcshareddata/xcschemes/PipperIntentsPreview.xcscheme new file mode 100644 index 0000000..58e62d4 --- /dev/null +++ b/native/pipper-intents/PreviewApp/PipperIntentsPreview.xcodeproj/xcshareddata/xcschemes/PipperIntentsPreview.xcscheme @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/native/pipper-intents/PreviewApp/PipperIntentsPreviewApp.swift b/native/pipper-intents/PreviewApp/PipperIntentsPreviewApp.swift new file mode 100644 index 0000000..54e0d08 --- /dev/null +++ b/native/pipper-intents/PreviewApp/PipperIntentsPreviewApp.swift @@ -0,0 +1,19 @@ +import SwiftUI + +/// Minimal macOS host used to make the package's App Shortcuts visible to +/// Xcode's Product > App Shortcuts Preview. +@main +struct PipperIntentsPreviewApp: App { + var body: some Scene { + WindowGroup("Pipper App Shortcuts Preview") { + VStack(spacing: 12) { + Text("Pipper App Shortcuts") + .font(.title2) + Text("Build this host, then open Product > App Shortcuts Preview.") + .foregroundStyle(.secondary) + } + .padding(32) + .frame(minWidth: 420, minHeight: 180) + } + } +} diff --git a/native/pipper-intents/PreviewApp/PipperIntentsPreviewExtension.entitlements b/native/pipper-intents/PreviewApp/PipperIntentsPreviewExtension.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/native/pipper-intents/PreviewApp/PipperIntentsPreviewExtension.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/native/pipper-intents/README.md b/native/pipper-intents/README.md new file mode 100644 index 0000000..85280a2 --- /dev/null +++ b/native/pipper-intents/README.md @@ -0,0 +1,58 @@ +# PipperIntents (Siri / Shortcuts) + +Swift Package providing the Siri / Shortcuts / Spotlight entry point for +Pipper: `StartThreadIntent` with Project / Agent pickers backed by the shared +catalog `siri-catalog.json` (written by Electron, see +`electron/siri/siri-catalog.ts`). + +Flow: the intent stages a request in `siri-requests/.json` under both +`~/Library/pipper/` and `~/Library/Application Support/Pipper/`, then opens +`pipper://siri/`. Electron consumes the pending request during startup, +activation, or the deep link; creates the thread; hands off the prompt; and +lands on it. macOS runs App Intents extensions in the App Sandbox, and ad-hoc +builds have no Team ID for App Groups, so the extension reaches those two +directories via `temporary-exception.files.home-relative-path` entitlements. + +## Packaging + +`swift build` here only validates compilation. The packaged macOS app is built +with the `PipperIntents` Xcode extension target, which emits +`Metadata.appintents`; `scripts/build.js` builds it automatically before +`electron-builder` embeds it in the app. + +## App Shortcuts Preview host + +The minimal `PreviewApp/PipperIntentsPreview.xcodeproj` host reuses +`Sources/PipperIntents.swift` directly and exists only to generate an app +bundle for inspecting the App Intents metadata. Open the project with Xcode 27 +beta, select the `PipperIntentsPreview` scheme, and build it once. The preview +extension uses a unique bundle identifier and supplies one Demo Project and +Preview Agent when no Electron catalog is present. + +Important: Apple does not support App Shortcuts or flexible phrase matching on +macOS. The Mac target will therefore show `No Flexible Matching Assets` in +Product > App Shortcuts Preview. The generated metadata still verifies that +the intent and its exact registered phrases are present. On macOS, the intent +is available as an action in the Shortcuts app instead. + +The registered phrases are: + +- `Start a thread in Pipper` +- `New thread in Pipper` +- `Start a thread with Pipper` + +The App Intents extension target in this project is embedded into the packaged +Electron app by `electron-builder.yml`. The preview app target remains useful +for inspecting metadata independently. + +## Handoff reliability + +Starting a task from this extension requires macOS 15.2 or later, where +`OpenURLIntent` can open the ad-hoc-signed desktop app. Earlier versions report +an error before staging any request. The metadata's `openAppWhenRun` value stays +constant as required by App Intents. + +Electron claims staged request IDs in its durable remote request history before +creating an isolated worktree. Repeated activations reopen the same thread. +Interrupted or legacy delivery with an uncertain outcome is not replayed; +inspect the original thread before sending a new task. diff --git a/native/pipper-intents/Sources/PipperIntents.swift b/native/pipper-intents/Sources/PipperIntents.swift new file mode 100644 index 0000000..e4d5a0e --- /dev/null +++ b/native/pipper-intents/Sources/PipperIntents.swift @@ -0,0 +1,452 @@ +import AppIntents +import Foundation +import OSLog + +enum SiriDiagnostics { + private static let logger = Logger( + subsystem: "com.maker-or.omni.pipper", + category: "AppIntents" + ) + + /// File logging is a development aid: it adds disk I/O to every intent run + /// and can accumulate prompt metadata on user machines. Keep it for debug + /// builds only, with an explicit env override for release-build triage. + private static let fileLoggingEnabled: Bool = { + if ProcessInfo.processInfo.environment["PIPPER_INTENTS_FILE_LOG"] == "1" { return true } + #if DEBUG + return true + #else + return false + #endif + }() + + /// Cap the on-disk log so a long-running build can't grow it without bound. + /// Once exceeded, the file is reset with the newest line. + private static let maxFileLogBytes = 1 * 1024 * 1024 + + static func log(_ message: String) { + logger.info("\(message, privacy: .public)") + guard fileLoggingEnabled else { return } + + let url = SiriCatalogStore.realHomeDirectory() + .appendingPathComponent("Library/pipper/intents-debug.log") + do { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = Data("[PipperIntents] \(message)\n".utf8) + let size = + (try? FileManager.default.attributesOfItem(atPath: url.path))?[.size] as? Int ?? 0 + if size + data.count > maxFileLogBytes { + // Truncate rather than append: the log is a rolling diagnostic aid. + try data.write(to: url, options: .atomic) + } else if FileManager.default.fileExists(atPath: url.path), + let handle = try? FileHandle(forWritingTo: url) + { + handle.seekToEndOfFile() + handle.write(data) + try? handle.close() + } else { + try data.write(to: url, options: .atomic) + } + } catch { + logger.error("file log failed: \(error.localizedDescription, privacy: .public)") + } + } +} + +// MARK: - Shared catalog (written by Electron, read by Siri) + +struct SiriCatalogProject: Codable, Sendable { + var id: String + var name: String + var path: String +} + +struct SiriCatalogAgent: Codable, Sendable { + var id: String + var displayName: String + var available: Bool +} + +struct SiriCatalog: Codable, Sendable { + var version: Int + var updatedAt: String + var defaultAgentId: String + var projects: [SiriCatalogProject] + var agents: [SiriCatalogAgent] +} + +enum SiriCatalogStore { + /// The Xcode preview host has no Electron process to populate the live + /// catalog. Keep its metadata/action preview useful without exposing these + /// sample entities in the packaged app. + static var isPreviewExtension: Bool { + Bundle.main.bundleIdentifier?.hasPrefix("dev.pipper.PipperIntentsPreview") == true + } + + /// Real user home. homeDirectoryForCurrentUser returns the sandbox + /// container inside an App Intents extension, not /Users/. + static func realHomeDirectory() -> URL { + if let pw = getpwuid(getuid()), let dir = pw.pointee.pw_dir { + let path = String(cString: dir) + if !path.isEmpty { return URL(fileURLWithPath: path, isDirectory: true) } + } + return FileManager.default.homeDirectoryForCurrentUser + } + + /// Ad-hoc builds have no Team ID, so App Group containers are denied; the + /// extension reaches these home-relative paths via temporary-exception + /// entitlements instead. Primary is ~/Library/pipper. + static func candidateDirs() -> [URL] { + if let overridePath = ProcessInfo.processInfo.environment["PIPPER_LIBRARY_PATH"], + !overridePath.isEmpty + { + return [URL(fileURLWithPath: overridePath, isDirectory: true)] + } + let home = realHomeDirectory() + var dirs: [URL] = [] + dirs.append( + home.appendingPathComponent("Library/pipper", isDirectory: true)) + dirs.append( + home.appendingPathComponent("Library/Application Support/Pipper", isDirectory: true)) + return dirs + } + + static func baseDir() -> URL { + candidateDirs()[0] + } + + static func catalogURL() -> URL { + baseDir().appendingPathComponent("siri-catalog.json") + } + + static func requestsDir() -> URL { + baseDir().appendingPathComponent("siri-requests", isDirectory: true) + } + + static func load() -> SiriCatalog? { + SiriDiagnostics.log("catalog load begin dirs=\(candidateDirs().map(\.path))") + for dir in candidateDirs() { + let url = dir.appendingPathComponent("siri-catalog.json") + guard let data = try? Data(contentsOf: url) else { + SiriDiagnostics.log("catalog load miss path=\(url.path)") + continue + } + do { + let catalog = try JSONDecoder().decode(SiriCatalog.self, from: data) + SiriDiagnostics.log( + "catalog load success path=\(url.path) projects=\(catalog.projects.count) agents=\(catalog.agents.count)" + ) + return catalog + } catch { + SiriDiagnostics.log("catalog decode failed path=\(url.path) error=\(error)") + } + } + guard isPreviewExtension else { + SiriDiagnostics.log("catalog load failed: no usable catalog") + return nil + } + SiriDiagnostics.log("catalog load using preview catalog") + return SiriCatalog( + version: 1, + updatedAt: "preview", + defaultAgentId: "preview-agent", + projects: [ + SiriCatalogProject( + id: "preview-project", + name: "Demo Project", + path: "/tmp/pipper-preview-project" + ) + ], + agents: [ + SiriCatalogAgent( + id: "preview-agent", + displayName: "Preview Agent", + available: true + ) + ] + ) + } +} + +// MARK: - Options (String values + pickers) +// +// AppEntity parameters fail to decode before perform() on ad-hoc builds +// (LNPerformActionErrorCodeUnsupportedValueType), so parameters are Strings +// with DynamicOptionsProviders. Spotlight renders the raw String value and +// ignores IntentItem titles, so the value must be the human-readable label; +// perform() resolves it back to the catalog id. Raw ids are still accepted +// so Shortcuts saved before this change keep working. + +enum SiriCatalogLabels { + /// Project label: the name, disambiguated with the path when two projects + /// share a name. + static func projectLabel(_ project: SiriCatalogProject, in all: [SiriCatalogProject]) -> String { + let duplicates = all.filter { $0.name == project.name }.count > 1 + return duplicates ? "\(project.name) (\(project.path))" : project.name + } + + /// Split a rendered label back into its base name and disambiguation suffix: + /// `"app (/tmp/a)"` → `("app", "/tmp/a")`, `"app"` → `("app", nil)`. + static func split(_ label: String) -> (base: String, suffix: String?) { + guard label.hasSuffix(")"), let open = label.range(of: " (", options: .backwards) + else { return (label, nil) } + let base = String(label[label.startIndex.. SiriCatalogProject? { + if let byId = all.first(where: { $0.id == value }) { return byId } + if let byLabel = all.first(where: { projectLabel($0, in: all) == value }) { return byLabel } + if let byPath = all.first(where: { $0.path == value }) { return byPath } + let (base, suffix) = split(value) + let named = all.filter { $0.name == base } + guard !named.isEmpty else { return nil } + if let suffix, let bySuffix = named.first(where: { $0.id == suffix || $0.path == suffix }) { + return bySuffix + } + return named.count == 1 ? named[0] : nil + } + + static func agentLabel(_ agent: SiriCatalogAgent, in all: [SiriCatalogAgent]) -> String { + let duplicates = all.filter { $0.displayName == agent.displayName }.count > 1 + return duplicates ? "\(agent.displayName) (\(agent.id))" : agent.displayName + } + + /// Agent counterpart to `resolveProject`: tolerate a stored label whose + /// disambiguation suffix no longer matches the live catalog. + static func resolveAgent(_ value: String, in all: [SiriCatalogAgent]) -> SiriCatalogAgent? { + if let byId = all.first(where: { $0.id == value }) { return byId } + if let byLabel = all.first(where: { agentLabel($0, in: all) == value }) { return byLabel } + let (base, suffix) = split(value) + let named = all.filter { $0.displayName == base } + guard !named.isEmpty else { return nil } + if let suffix, let bySuffix = named.first(where: { $0.id == suffix }) { + return bySuffix + } + return named.count == 1 ? named[0] : nil + } +} + +struct ProjectOptionsProvider: DynamicOptionsProvider { + typealias Result = IntentItemCollection + typealias DefaultValue = String + + func results() async throws -> IntentItemCollection { + let projects = SiriCatalogStore.load()?.projects ?? [] + let items = projects.map { + IntentItem( + SiriCatalogLabels.projectLabel($0, in: projects), + title: LocalizedStringResource(stringLiteral: $0.name), + subtitle: LocalizedStringResource(stringLiteral: $0.path), + image: .init(systemName: "folder") + ) + } + return IntentItemCollection(sections: [IntentItemSection(items: items)]) + } +} + +struct AgentOptionsProvider: DynamicOptionsProvider { + typealias Result = IntentItemCollection + typealias DefaultValue = String + + func results() async throws -> IntentItemCollection { + let agents = SiriCatalogStore.load()?.agents.filter(\.available) ?? [] + let items = agents.map { + IntentItem( + SiriCatalogLabels.agentLabel($0, in: agents), + title: LocalizedStringResource(stringLiteral: $0.displayName), + image: .init(systemName: "cpu") + ) + } + return IntentItemCollection(sections: [IntentItemSection(items: items)]) + } +} + +enum SiriRequestError: Error, CustomLocalizedStringResourceConvertible { + case encodingFailed + case stagingFailed + case projectUnavailable(String) + case agentUnavailable(String) + + var localizedStringResource: LocalizedStringResource { + switch self { + case .encodingFailed: return "Couldn't prepare the thread request." + case .stagingFailed: return "Couldn't save the thread request. Please try again." + case .projectUnavailable(let id): + return "The project \(id) isn't available. Pick an available project." + case .agentUnavailable(let name): + return "The agent \(name) isn't available. Pick an installed agent." + } + } +} + +// MARK: - Intent: start a thread (confirm-then-create) + +struct StartThreadIntent: AppIntent { + static var title: LocalizedStringResource = "Start Pipper thread" + static var description = IntentDescription( + "Starts a new thread in a Pipper project with a chosen agent.", + categoryName: "Productivity" + ) + static var isDiscoverable: Bool = true + // App Intents metadata requires a compile-time constant. Ad-hoc builds use + // OpenURLIntent instead of the host-app launch handshake. + static var openAppWhenRun: Bool = false + + static func debugLog(_ message: String) { + SiriDiagnostics.log(message) + } + + @Parameter(title: "Project", description: "name of the project to run the agent in", optionsProvider: ProjectOptionsProvider()) var projectId: String + @Parameter(title: "Agent", description: "the agent to run", optionsProvider: AgentOptionsProvider()) var agentId: String + // Required (not `String?`): App Intents only asks for required parameters, + // so an optional task was silently skipped when run from Spotlight/Siri. + @Parameter(title: "Task", description: "what action or a task to perform in a project with an agent", requestValueDialog: "What should the thread work on?") var prompt: String + + // Spotlight (macOS 26+) renders this sentence inline in the search bar with + // each parameter as a fillable token, like Mail's "Send [Message] with + // [Subject] to [Recipients]". Free text goes first so the cursor lands there. + static var parameterSummary: some ParameterSummary { + Summary("Start \(\.$prompt) in \(\.$projectId) with \(\.$agentId)") + } + + func perform() async throws -> some IntentResult & ProvidesDialog { + guard #available(macOS 15.2, *) else { + throw NSError(domain: "PipperIntents", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Starting Pipper from Shortcuts requires macOS 15.2 or later. Open Pipper to start this task."]) + } + Self.debugLog("perform entered") + Self.debugLog("perform start projectId=\(projectId) agentId=\(agentId) promptLen=\(prompt.count)") + Self.debugLog("candidateDirs=\(SiriCatalogStore.candidateDirs().map { $0.path })") + let catalog = SiriCatalogStore.load() + Self.debugLog("catalog loaded: projects=\(catalog?.projects.count ?? -1) agents=\(catalog?.agents.count ?? -1)") + guard + let chosenProject = SiriCatalogLabels.resolveProject(projectId, in: catalog?.projects ?? []) + else { + Self.debugLog("perform rejected project=\(projectId) reason=unavailable") + throw SiriRequestError.projectUnavailable(projectId) + } + // Revalidate availability at run time: the catalog may have changed + // between picking and perform(). + let agents = catalog?.agents ?? [] + guard let chosenAgent = SiriCatalogLabels.resolveAgent(agentId, in: agents), chosenAgent.available + else { + let agentName = SiriCatalogLabels.resolveAgent(agentId, in: agents)?.displayName ?? agentId + Self.debugLog("perform rejected agent=\(agentId) reason=unavailable") + throw SiriRequestError.agentUnavailable(agentName) + } + Self.debugLog("perform validation passed projectId=\(chosenProject.id) agentId=\(chosenAgent.id)") + if SiriCatalogStore.isPreviewExtension { + return .result( + dialog: "Preview: would start a thread in \(chosenProject.name)." + ) + } + // Stage a pending request. The host app is opened automatically after the + // intent completes and Electron consumes this file during activation. + let requestId = UUID().uuidString + let payload: [String: String] = [ + "requestId": requestId, + "projectId": chosenProject.id, + "agentId": chosenAgent.id, + "prompt": prompt, + ] + // Stage into every candidate dir so Electron finds the request no + // matter which location it consumes from. One location failing must + // not fail the whole intent. + var stagedCount = 0 + var lastError: Error? + for base in SiriCatalogStore.candidateDirs() { + do { + let target = base.appendingPathComponent("siri-requests", isDirectory: true) + try FileManager.default.createDirectory(at: target, withIntermediateDirectories: true) + let url = target.appendingPathComponent("\(requestId).json") + guard let data = try? JSONSerialization.data(withJSONObject: payload) else { + throw SiriRequestError.encodingFailed + } + try data.write(to: url, options: .atomic) + stagedCount += 1 + Self.debugLog("stage succeeded dir=\(base.path) requestId=\(requestId)") + } catch { + Self.debugLog("stage failed dir=\(base.path) error=\(error)") + lastError = error + } + } + Self.debugLog("stagedCount=\(stagedCount) lastError=\(String(describing: lastError))") + if stagedCount == 0 { + if let siriError = lastError as? SiriRequestError { + throw siriError + } + throw SiriRequestError.stagingFailed + } + if #available(macOS 15.2, *) { + let openURL = URL(string: "pipper://siri/\(requestId)")! + return .result( + opensIntent: OpenURLIntent(openURL), + dialog: "Starting a thread in \(chosenProject.name)." + ) + } + return .result(dialog: "Starting a thread in \(chosenProject.name).") + } +} + +// MARK: - Diagnostic intents (debug builds only) +// +// These exist to bisect App Intents failures and have no user value, so they +// must not be discoverable in release builds. + +#if DEBUG + struct PingIntent: AppIntent { + static var title: LocalizedStringResource = "Ping Pipper" + static var description = IntentDescription( + "Temporary diagnostic action. Always succeeds.", + categoryName: "Productivity" + ) + + func perform() async throws -> some IntentResult & ProvidesDialog { + return .result(dialog: "Pipper is reachable.") + } + } + + struct PingWithTextIntent: AppIntent { + static var title: LocalizedStringResource = "Ping Pipper With Text" + static var description = IntentDescription( + "Temporary diagnostic action with a text parameter.", + categoryName: "Productivity" + ) + + @Parameter(title: "Task") var prompt: String? + + func perform() async throws -> some IntentResult & ProvidesDialog { + StartThreadIntent.debugLog("pingWithText entered promptLen=\(prompt?.count ?? -1)") + // Static dialog: bisects interpolated-dialog failure vs parameter failure. + return .result(dialog: "Text received.") + } + } +#endif + +// MARK: - Shortcuts registration (Siri phrases must contain applicationName) + +struct PipperShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: StartThreadIntent(), + phrases: [ + "Start a thread in \(.applicationName)", + "New thread in \(.applicationName)", + "Start a thread with \(.applicationName)", + ], + shortTitle: "Start thread", + systemImageName: "bubble.left.and.text.bubble.right" + ) + } +} diff --git a/native/pipper-intents/Tests/PipperIntentsTests/PipperIntentsTests.swift b/native/pipper-intents/Tests/PipperIntentsTests/PipperIntentsTests.swift new file mode 100644 index 0000000..5a52713 --- /dev/null +++ b/native/pipper-intents/Tests/PipperIntentsTests/PipperIntentsTests.swift @@ -0,0 +1,165 @@ +import Foundation +import Testing +@testable import PipperIntents + +private func withTempCatalog(_ catalog: SiriCatalog, perform: () async throws -> Void) async throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("pipper-test-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + // Use PIPPER_LIBRARY_PATH override so SiriCatalogStore reads from temp dir + setenv("PIPPER_LIBRARY_PATH", dir.path, 1) + defer { unsetenv("PIPPER_LIBRARY_PATH"); try? FileManager.default.removeItem(at: dir) } + let data = try JSONEncoder().encode(catalog) + try data.write(to: dir.appendingPathComponent("siri-catalog.json"), options: .atomic) + try await perform() +} + +private func makeCatalog(projects: [SiriCatalogProject] = [], agents: [SiriCatalogAgent] = [], defaultAgentId: String = "codex-acp") -> SiriCatalog { + SiriCatalog(version: 1, updatedAt: ISO8601DateFormatter().string(from: Date()), defaultAgentId: defaultAgentId, projects: projects, agents: agents) +} + +@Suite("PipperIntents end-to-end", .serialized) +struct PipperIntentsTests { + @Test func projectOptionsProviderListsProjects() async throws { + let catalog = makeCatalog(projects: [ + SiriCatalogProject(id: "p1", name: "My App", path: "/tmp/a"), + SiriCatalogProject(id: "p2", name: "Other", path: "/tmp/b"), + ], agents: [SiriCatalogAgent(id: "codex-acp", displayName: "Codex", available: true)]) + try await withTempCatalog(catalog) { + let items = try await ProjectOptionsProvider().results() + #expect(items.sections.first?.items.count == 2) + // Spotlight shows the raw value, so it must be the project name. + #expect(items.sections.first?.items.map(\.value) == ["My App", "Other"]) + } + } + + @Test func projectOptionsDisambiguateDuplicateNames() async throws { + let catalog = makeCatalog(projects: [ + SiriCatalogProject(id: "p1", name: "app", path: "/tmp/a"), + SiriCatalogProject(id: "p2", name: "app", path: "/tmp/b"), + ]) + try await withTempCatalog(catalog) { + let values = try await ProjectOptionsProvider().results().sections.first?.items.map(\.value) + #expect(values == ["app (/tmp/a)", "app (/tmp/b)"]) + #expect(SiriCatalogLabels.resolveProject("app (/tmp/b)", in: catalog.projects)?.id == "p2") + } + } + + @Test func savedProjectLabelsSurviveDisambiguationChanges() { + let projects = [ + SiriCatalogProject(id: "p1", name: "app", path: "/tmp/a"), + SiriCatalogProject(id: "p2", name: "other", path: "/tmp/b"), + ] + // Saved while a duplicate "app" existed, so the label carried the path. + #expect(SiriCatalogLabels.resolveProject("app (/tmp/a)", in: projects)?.id == "p1") + // Saved before the project moved: only the label's suffix is stale, but + // the base name is still unique, so it resolves. + #expect(SiriCatalogLabels.resolveProject("other (/old)", in: projects)?.id == "p2") + // Raw ids and raw paths keep working. + #expect(SiriCatalogLabels.resolveProject("p1", in: projects)?.id == "p1") + #expect(SiriCatalogLabels.resolveProject("/tmp/b", in: projects)?.id == "p2") + // An ambiguous base name is rejected rather than guessed. + let duplicates = [ + SiriCatalogProject(id: "p1", name: "app", path: "/tmp/a"), + SiriCatalogProject(id: "p2", name: "app", path: "/tmp/b"), + ] + #expect(SiriCatalogLabels.resolveProject("app", in: duplicates) == nil) + } + + @Test func savedAgentLabelsSurviveDisambiguationChanges() { + let agents = [ + SiriCatalogAgent(id: "codex-acp", displayName: "Codex", available: true), + SiriCatalogAgent(id: "other-acp", displayName: "opencode", available: true), + ] + #expect(SiriCatalogLabels.resolveAgent("Codex (old-id)", in: agents)?.id == "codex-acp") + #expect(SiriCatalogLabels.resolveAgent("opencode", in: agents)?.id == "other-acp") + #expect(SiriCatalogLabels.resolveAgent("codex-acp", in: agents)?.id == "codex-acp") + } + + @Test func agentOptionsProviderFiltersUnavailable() async throws { + let catalog = makeCatalog(agents: [ + SiriCatalogAgent(id: "codex-acp", displayName: "Codex", available: true), + SiriCatalogAgent(id: "opencode-acp", displayName: "opencode", available: false), + ]) + try await withTempCatalog(catalog) { + let items = try await AgentOptionsProvider().results() + #expect(items.sections.first?.items.count == 1) + #expect(items.sections.first?.items.first?.value == "Codex") + } + } + + @Test func startThreadIntentStagesRequest() async throws { + let catalog = makeCatalog( + projects: [SiriCatalogProject(id: "p1", name: "My App", path: "/tmp/a")], + agents: [SiriCatalogAgent(id: "codex-acp", displayName: "Codex", available: true)], + defaultAgentId: "codex-acp" + ) + try await withTempCatalog(catalog) { + let intent = StartThreadIntent() + // Picker values are labels; the staged payload must carry catalog ids. + intent.projectId = "My App" + intent.agentId = "Codex" + intent.prompt = "Fix login bug" + let result = try await intent.perform() + // Verify file staged + let dir = URL(fileURLWithPath: ProcessInfo.processInfo.environment["PIPPER_LIBRARY_PATH"]!) + let requests = try FileManager.default.contentsOfDirectory(at: dir.appendingPathComponent("siri-requests"), includingPropertiesForKeys: nil) + #expect(requests.count == 1) + let data = try Data(contentsOf: requests[0]) + let json = try JSONSerialization.jsonObject(with: data) as! [String: String] + #expect(json["projectId"] == "p1") + #expect(json["agentId"] == "codex-acp") + #expect(json["prompt"] == "Fix login bug") + _ = result + } + } + + @Test func startThreadIntentStillAcceptsRawIds() async throws { + let catalog = makeCatalog( + projects: [SiriCatalogProject(id: "p1", name: "My App", path: "/tmp/a")], + agents: [SiriCatalogAgent(id: "codex-acp", displayName: "Codex", available: true)], + defaultAgentId: "codex-acp" + ) + try await withTempCatalog(catalog) { + let intent = StartThreadIntent() + intent.projectId = "p1" + intent.agentId = "codex-acp" + intent.prompt = "Hello" + _ = try await intent.perform() + let dir = URL(fileURLWithPath: ProcessInfo.processInfo.environment["PIPPER_LIBRARY_PATH"]!) + let file = try FileManager.default.contentsOfDirectory(at: dir.appendingPathComponent("siri-requests"), includingPropertiesForKeys: nil).first! + let json = try JSONSerialization.jsonObject(with: Data(contentsOf: file)) as! [String: String] + #expect(json["projectId"] == "p1") + #expect(json["agentId"] == "codex-acp") + } + } + + @Test func startThreadIntentRejectsUnavailableAgent() async throws { + let catalog = makeCatalog( + projects: [SiriCatalogProject(id: "p1", name: "My App", path: "/tmp/a")], + agents: [SiriCatalogAgent(id: "opencode-acp", displayName: "opencode", available: false)] + ) + try await withTempCatalog(catalog) { + let intent = StartThreadIntent() + intent.projectId = "My App" + intent.agentId = "opencode" + intent.prompt = "x" + await #expect(throws: SiriRequestError.self) { try await intent.perform() } + } + } +} + +extension PipperIntentsTests { + @Test func optionsProvidersResolveRealIds() async throws { + let catalog = SiriCatalog( + version: 1, updatedAt: "2026-09-09T09:03:09.283Z", defaultAgentId: "cursor-acp", + projects: [SiriCatalogProject(id: "bae0366a-9087-4e4c-966d-bd4ef5ce1295", name: "FolkLore-LiveLore-", path: "/Users/me/code/FolkLore-LiveLore-")], + agents: [SiriCatalogAgent(id: "opencode-acp", displayName: "opencode", available: true)] + ) + try await withTempCatalog(catalog) { + let projects = try await ProjectOptionsProvider().results() + #expect(projects.sections.first?.items.count == 1) + let agents = try await AgentOptionsProvider().results() + #expect(agents.sections.first?.items.count == 1) + } + } +} diff --git a/native/pipper-remote-ios/App/Info.plist b/native/pipper-remote-ios/App/Info.plist new file mode 100644 index 0000000..f08cdc7 --- /dev/null +++ b/native/pipper-remote-ios/App/Info.plist @@ -0,0 +1,64 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + + CFBundleDisplayName + Pipper + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + NSCameraUsageDescription + Scan the pairing QR code shown by Pipper on your Mac. + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + CFBundleURLTypes + + + CFBundleURLName + com.maker-or.omni.remote + CFBundleURLSchemes + + pipper-remote + + + + + diff --git a/native/pipper-remote-ios/App/Intents/CatalogEntities.swift b/native/pipper-remote-ios/App/Intents/CatalogEntities.swift new file mode 100644 index 0000000..b541bf6 --- /dev/null +++ b/native/pipper-remote-ios/App/Intents/CatalogEntities.swift @@ -0,0 +1,85 @@ +import AppIntents +import Foundation + +/// Reads the catalog for Siri parameter resolution. Cached copy first (must +/// work offline and fast); falls back to a network fetch only when the cache +/// is empty, so a fresh install still works right after pairing. +enum IntentCatalog { + static func current() async -> RemoteCatalog { + let session = await MainActor.run { RemoteSession.shared } + let cached = await MainActor.run { session.catalog } + if !cached.projects.isEmpty { return cached } + return await session.refreshCatalog() ?? cached + } +} + +struct ProjectEntity: AppEntity { + static var typeDisplayRepresentation: TypeDisplayRepresentation = "Project" + static var defaultQuery = ProjectQuery() + + var id: String + var name: String + var path: String + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation( + title: "\(name)", + subtitle: "\(path)", + image: .init(systemName: "folder")) + } + + init(_ project: RemoteCatalogProject) { + id = project.id + name = project.name + path = project.path + } +} + +struct ProjectQuery: EntityStringQuery { + func entities(for identifiers: [String]) async throws -> [ProjectEntity] { + let catalog = await IntentCatalog.current() + return identifiers.compactMap { catalog.project(id: $0) }.map(ProjectEntity.init) + } + + func entities(matching string: String) async throws -> [ProjectEntity] { + await IntentCatalog.current().projects(matching: string).map(ProjectEntity.init) + } + + func suggestedEntities() async throws -> [ProjectEntity] { + await IntentCatalog.current().projects.map(ProjectEntity.init) + } +} + +struct AgentEntity: AppEntity { + static var typeDisplayRepresentation: TypeDisplayRepresentation = "Agent" + static var defaultQuery = AgentQuery() + + var id: String + var displayName: String + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(displayName)", image: .init(systemName: "cpu")) + } + + init(_ agent: RemoteCatalogAgent) { + id = agent.id + displayName = agent.displayName + } +} + +struct AgentQuery: EntityStringQuery { + func entities(for identifiers: [String]) async throws -> [AgentEntity] { + let catalog = await IntentCatalog.current() + // Accept unavailable ids here so a saved Shortcut still decodes; perform() + // re-checks availability at run time. + return identifiers.compactMap { catalog.agent(id: $0) }.map(AgentEntity.init) + } + + func entities(matching string: String) async throws -> [AgentEntity] { + await IntentCatalog.current().agents(matching: string).map(AgentEntity.init) + } + + func suggestedEntities() async throws -> [AgentEntity] { + await IntentCatalog.current().availableAgents.map(AgentEntity.init) + } +} diff --git a/native/pipper-remote-ios/App/Intents/PipperRemoteShortcuts.swift b/native/pipper-remote-ios/App/Intents/PipperRemoteShortcuts.swift new file mode 100644 index 0000000..62ea3bf --- /dev/null +++ b/native/pipper-remote-ios/App/Intents/PipperRemoteShortcuts.swift @@ -0,0 +1,37 @@ +import AppIntents + +/// Phrases Siri listens for without any Shortcuts setup. `\(.applicationName)` +/// is required in every phrase, and App Shortcuts allows at most one entity +/// parameter per phrase, so project and agent get separate phrase families. +/// Siri then asks for the (required) task; an unnamed agent resolves to the +/// Mac's default in `perform()`. +struct PipperRemoteShortcuts: AppShortcutsProvider { + static var shortcutTileColor: ShortcutTileColor = .navy + + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: StartThreadIntent(), + phrases: [ + "Start a thread in \(.applicationName)", + "Start a \(.applicationName) thread", + "New \(.applicationName) thread", + "Start a \(.applicationName) thread in \(\.$project)", + "Start a thread in \(\.$project) with \(.applicationName)", + "New \(.applicationName) thread in \(\.$project)", + "Start a \(.applicationName) thread with \(\.$agent)", + "Ask \(\.$agent) in \(.applicationName)", + ], + shortTitle: "Start thread", + systemImageName: "bubble.left.and.text.bubble.right" + ) + AppShortcut( + intent: CheckMacIntent(), + phrases: [ + "Is my Mac reachable in \(.applicationName)", + "Check my Mac with \(.applicationName)", + ], + shortTitle: "Check Mac", + systemImageName: "laptopcomputer.and.arrow.down" + ) + } +} diff --git a/native/pipper-remote-ios/App/Intents/StartThreadIntent.swift b/native/pipper-remote-ios/App/Intents/StartThreadIntent.swift new file mode 100644 index 0000000..dbadbc8 --- /dev/null +++ b/native/pipper-remote-ios/App/Intents/StartThreadIntent.swift @@ -0,0 +1,115 @@ +import AppIntents +import Foundation + +enum StartThreadError: Error, CustomLocalizedStringResourceConvertible { + case notPaired + case noAgent + case agentUnavailable(String) + case remote(String) + + var localizedStringResource: LocalizedStringResource { + switch self { + case .notPaired: + return "Pipper isn't paired with your Mac. Open the app and scan the pairing code from your Mac's Settings → Remote." + case .noAgent: + return "No agent is installed on your Mac. Pick one in Pipper on the Mac first." + case .agentUnavailable(let name): + return "\(name) isn't available on your Mac right now. Pick another agent." + case .remote(let message): + return "\(message)" + } + } +} + +/// Siri-first entry point: "Start a Pipper thread in FolkLore with Codex". +/// Runs in the app process in the background (no app launch), POSTs to the +/// laptop (through its Pipper tunnel), and speaks the result. +struct StartThreadIntent: AppIntent { + static var title: LocalizedStringResource = "Start Pipper thread" + static var description = IntentDescription( + "Starts a new agent thread on your Mac in a chosen project.", + categoryName: "Productivity") + static var openAppWhenRun: Bool = false + + @Parameter(title: "Project", description: "The project on your Mac to work in") + var project: ProjectEntity + + @Parameter(title: "Agent", description: "The coding agent to run. Uses your Mac's default when omitted.") + var agent: AgentEntity? + + @Parameter( + title: "Task", + description: "What the agent should do", + requestValueDialog: "What should the thread work on?") + var prompt: String + + static var parameterSummary: some ParameterSummary { + Summary("Start \(\.$prompt) in \(\.$project)") { + \.$agent + } + } + + func perform() async throws -> some IntentResult & ProvidesDialog & ReturnsValue { + let session = await MainActor.run { RemoteSession.shared } + guard await MainActor.run(body: { session.isPaired }) else { + throw StartThreadError.notPaired + } + // Re-validate against the freshest catalog we can get: availability may + // have changed since the parameter was picked. + var catalog = await session.refreshCatalog() + if catalog == nil { catalog = await MainActor.run { session.catalog } } + guard let catalog else { throw StartThreadError.notPaired } + let chosenAgent: RemoteCatalogAgent + if let agent { + guard let live = catalog.agent(id: agent.id), live.available else { + throw StartThreadError.agentUnavailable(agent.displayName) + } + chosenAgent = live + } else { + guard let preferred = catalog.preferredAgent else { throw StartThreadError.noAgent } + chosenAgent = preferred + } + let task = prompt.trimmingCharacters(in: .whitespacesAndNewlines) + guard !task.isEmpty else { + throw $prompt.needsValueError("What should the thread work on?") + } + + let thread: RemoteThreadSummary + do { + thread = try await session.createThread(projectId: project.id, agentId: chosenAgent.id, prompt: task) + } catch { + throw StartThreadError.remote(error.localizedDescription) + } + await MainActor.run { session.lastSiriThreadId = thread.id } + return .result( + value: thread.id, + dialog: "Created a thread in \(project.name) with \(chosenAgent.displayName). Open Pipper to check progress or answer the agent.") + } +} + +/// "Is my Mac reachable?" — quick diagnostic that also proves pairing works. +struct CheckMacIntent: AppIntent { + static var title: LocalizedStringResource = "Check Pipper Mac" + static var description = IntentDescription( + "Checks whether your Mac is reachable and how many threads are running.", + categoryName: "Productivity") + static var openAppWhenRun: Bool = false + + func perform() async throws -> some IntentResult & ProvidesDialog { + let session = await MainActor.run { RemoteSession.shared } + guard let client = await MainActor.run(body: { session.client }) else { + throw StartThreadError.notPaired + } + do { + let threads = try await client.listThreads() + let running = threads.filter(\.running).count + await session.refreshCatalog() + if running == 0 { + return .result(dialog: "Your Mac is reachable. Nothing is running right now.") + } + return .result(dialog: "Your Mac is reachable. \(running) thread\(running == 1 ? " is" : "s are") running.") + } catch { + throw StartThreadError.remote(error.localizedDescription) + } + } +} diff --git a/native/pipper-remote-ios/App/Keychain.swift b/native/pipper-remote-ios/App/Keychain.swift new file mode 100644 index 0000000..5da4a14 --- /dev/null +++ b/native/pipper-remote-ios/App/Keychain.swift @@ -0,0 +1,67 @@ +import Foundation +import Security + +/// Minimal generic-password wrapper for the pairing token. Uses the app's +/// default keychain access group, which needs no extra entitlement. +enum Keychain { + private static let service = "com.maker-or.omni.remote" + + static func read(_ account: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data + else { return nil } + return String(data: data, encoding: .utf8) + } + + static func write(_ account: String, value: String) throws { + let add: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecValueData as String: Data(value.utf8), + // Intents may run while the phone is locked (Siri from lock screen). + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, + ] + var status = SecItemAdd(add as CFDictionary, nil) + if status == errSecDuplicateItem { + let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, kSecAttrAccount as String: account] + status = SecItemUpdate(query as CFDictionary, [kSecValueData as String: Data(value.utf8)] as CFDictionary) + } + guard status == errSecSuccess else { + throw NSError(domain: NSOSStatusErrorDomain, code: Int(status), + userInfo: [NSLocalizedDescriptionKey: "Could not save pairing securely. \(Self.hint(for: status))"]) + } + } + + /// A signed-but-unentitled app fails with `errSecMissingEntitlement`, which + /// "unlock your phone" misleadingly hid; name the real cause when we know it. + private static func hint(for status: OSStatus) -> String { + switch status { + case errSecInteractionNotAllowed: + return "Unlock your phone and try again." + case errSecMissingEntitlement: + return "The app is missing its keychain entitlement — reinstall the signed build." + default: + return "Keychain error \(status)." + } + } + + static func delete(_ account: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + SecItemDelete(query as CFDictionary) + } +} + diff --git a/native/pipper-remote-ios/App/PipperRemoteApp.swift b/native/pipper-remote-ios/App/PipperRemoteApp.swift new file mode 100644 index 0000000..f7c5cb8 --- /dev/null +++ b/native/pipper-remote-ios/App/PipperRemoteApp.swift @@ -0,0 +1,115 @@ +import AppIntents +import SwiftUI + +@main +struct PipperRemoteApp: App { + @State private var session = RemoteSession.shared + @Environment(\.scenePhase) private var scenePhase + + init() { + // Register phrases + entity names with Siri as early as possible so a + // "Start a Pipper thread in X" works before the UI is ever opened. + PipperRemoteShortcuts.updateAppShortcutParameters() + } + + var body: some Scene { + WindowGroup { + RootView() + .environment(session) + .task { await session.refreshCatalogIfStale() } + .onChange(of: scenePhase) { _, phase in + if phase == .active { Task { await session.refreshCatalogIfStale() } } + } + } + } +} + +struct RootView: View { + @Environment(RemoteSession.self) private var session + + var body: some View { + if session.isPaired { + MainTabView() + } else { + PairingView() + } + } +} + +/// Paired shell: Threads and Settings, with New split off as its own +/// trailing "+" button (the tab bar's separated search-role slot). +struct MainTabView: View { + @Environment(RemoteSession.self) private var session + enum TabID: Hashable { case threads, new, settings } + @State private var tab: TabID = .threads + /// Same key Home uses for its selected project. + @AppStorage("home.projectId") private var homeProjectId = "" + + /// iOS 27's prominent role is the separated trailing action; on earlier + /// systems the search role gets the same split-off placement. + /// Drawn rather than palette-tinted so the tab bar can't recolor it. + private static let newTabIcon: UIImage = { + let size = CGSize(width: 34, height: 34) + let image = UIGraphicsImageRenderer(size: size).image { _ in + UIColor.systemIndigo.setFill() + UIBezierPath(ovalIn: CGRect(origin: .zero, size: size)).fill() + let plus = UIImage( + systemName: "plus", + withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .bold))? + .withTintColor(.white, renderingMode: .alwaysOriginal) + if let plus { + plus.draw(at: CGPoint(x: (size.width - plus.size.width) / 2, y: (size.height - plus.size.height) / 2)) + } + } + return image.withRenderingMode(.alwaysOriginal) + }() + + private static var newTabRole: TabRole { + if #available(iOS 27.0, *) { return .prominent } + return .search + } + + var body: some View { + TabView(selection: $tab) { + Tab("Threads", systemImage: "square.grid.2x2", value: TabID.threads) { + ThreadListView() + } + Tab("Settings", systemImage: "gearshape", value: TabID.settings) { + SettingsView(inTab: true) + } + Tab(value: TabID.new, role: Self.newTabRole) { + NewThreadSheet( + onCreated: { thread in + // Home opens this thread via the same hand-off Siri uses. + homeProjectId = thread.projectId + session.lastSiriThreadId = thread.id + tab = .threads + }, + initialProjectId: homeProjectId.isEmpty ? nil : homeProjectId, + inTab: true) + } label: { + // Tab bars template-render SF Symbols; a pre-colored image keeps the + // filled indigo circle so the action stands out. + Label { + Text("New") + } icon: { + Image(uiImage: Self.newTabIcon) + } + } + } + // Colors the prominent "+" and the selected tab. + .tint(.indigo) + } +} + +#if DEBUG +#Preview("Root — unpaired") { + RootView() + .environment(RemoteSession.preview()) +} + +#Preview("Root — paired") { + RootView() + .environment(RemoteSession.preview(catalog: PreviewData.catalog, paired: true)) +} +#endif diff --git a/native/pipper-remote-ios/App/RemoteSession.swift b/native/pipper-remote-ios/App/RemoteSession.swift new file mode 100644 index 0000000..e80abbe --- /dev/null +++ b/native/pipper-remote-ios/App/RemoteSession.swift @@ -0,0 +1,354 @@ +import AppIntents +import Foundation +import Observation +import UIKit + +/// Process-wide state shared by the SwiftUI app and the in-process App +/// Intents: pairing config, the cached catalog, and a client built from them. +@MainActor +@Observable +final class RemoteSession { + static let shared = RemoteSession() + + private enum Keys { + /// The paired laptop (address, name, owner) without its token. + static let laptop = "remote.laptop" + /// This phone's device token for that laptop. + static let deviceToken = "remote.deviceToken" + static let lastSiriThreadId = "remote.lastSiriThreadId" + /// Before device pairing: one shared Tailscale token per laptop. The + /// laptop no longer accepts it, so it is only ever cleaned up. + static let legacyHost = "remote.host" + static let legacyPort = "remote.port" + static let legacyToken = "remote.token" + } + + private(set) var config: RemoteConfig? + /// Why the phone is back on the pairing screen (revoked, or an old pairing). + private(set) var pairNotice: String? + private(set) var catalog: RemoteCatalog + private(set) var catalogError: String? + private(set) var lastCatalogRefresh: Date? + /// Models inside each agent, keyed by agent id. Empty until fetched, and on + /// Macs that predate model choice. + private(set) var agentModels: [String: [RemoteAgentModel]] = [:] + private(set) var loadingAgentModels = false + /// False once the Mac answered 404: its Pipper predates model choice. + private(set) var agentModelsSupported = true + private(set) var agentModelsError: String? + + let catalogStore = CatalogStore.standard() + private let submissions = RemoteSubmissionStore() + #if DEBUG + private var isPreview = false + #endif + + func createThread( + projectId: String, agentId: String?, model: String? = nil, prompt: String + ) async throws -> RemoteThreadSummary { + guard let client, let config else { throw RemoteClientError.notPaired } + // `model` joins the scope only when set, keeping pending IDs from before + // model choice stable. + let scope = [config.baseURL.absoluteString, "create", projectId, agentId ?? ""] + + (model.map { ["model:\($0)"] } ?? []) + [prompt] + let id = try submissions.requestId(for: scope) + do { + let thread = try await client.createThread( + projectId: projectId, agentId: agentId, model: model, prompt: prompt, requestId: id) + try submissions.acknowledge(scope, requestId: id) + return thread + } catch RemoteClientError.rejected(let message) { + // The Mac confirmed it never dispatched this request. A deliberate + // retry after fixing setup may use a new ID; timeouts retain the old ID. + try submissions.acknowledge(scope, requestId: id) + throw RemoteClientError.rejected(message) + } + } + + func sendPrompt(threadId: String, prompt: String) async throws { + guard let client, let config else { throw RemoteClientError.notPaired } + let scope = [config.baseURL.absoluteString, "prompt", threadId, prompt] + let id = try submissions.requestId(for: scope) + do { + try await client.sendPrompt(threadId: threadId, prompt: prompt, requestId: id) + try submissions.acknowledge(scope, requestId: id) + } catch RemoteClientError.rejected(let message) { + try submissions.acknowledge(scope, requestId: id) + throw RemoteClientError.rejected(message) + } + } + + private init() { + let defaults = UserDefaults.standard + lastSiriThreadId = defaults.string(forKey: Keys.lastSiriThreadId) + if let data = defaults.data(forKey: Keys.laptop), + var stored = try? JSONDecoder().decode(RemoteConfig.self, from: data), + let token = Keychain.read(Keys.deviceToken), !token.isEmpty + { + stored.token = token + config = stored + } + catalog = catalogStore.load() ?? .empty + lastCatalogRefresh = catalogStore.modifiedAt + if defaults.string(forKey: Keys.legacyHost) != nil || Keychain.read(Keys.legacyToken) != nil { + defaults.removeObject(forKey: Keys.legacyHost) + defaults.removeObject(forKey: Keys.legacyPort) + Keychain.delete(Keys.legacyToken) + if config == nil { + catalogStore.clear() + catalog = .empty + lastCatalogRefresh = nil + pairNotice = + "Pipper now pairs each phone with a one-time code. On your Mac, open Settings → Remote → Pair a phone, then scan the new QR." + } + } + } + + #if DEBUG + /// Preview-only initializer: seeds a catalog without touching the Keychain, + /// UserDefaults, or the shared on-disk cache. The paired state uses a dummy + /// config, while `client` remains nil so previews never make network requests. + init(previewCatalog: RemoteCatalog, paired: Bool = false, agentModels: [String: [RemoteAgentModel]] = [:]) { + self.agentModels = agentModels + config = + paired + ? RemoteConfig( + baseURL: URL(string: "https://lt-preview.pipper.dev")!, token: "preview", laptopName: "Studio", + owner: LaptopOwner(sub: "preview", email: "me@example.com", name: nil)) + : nil + catalog = previewCatalog + catalogError = nil + lastCatalogRefresh = nil + lastSiriThreadId = nil + isPreview = true + } + + /// A detached session for SwiftUI previews. + static func preview( + catalog: RemoteCatalog = .empty, paired: Bool = false, agentModels: [String: [RemoteAgentModel]] = [:] + ) -> RemoteSession { + RemoteSession(previewCatalog: catalog, paired: paired, agentModels: agentModels) + } + #endif + + var isPaired: Bool { config?.isComplete == true } + + var client: RemoteClient? { + #if DEBUG + if isPreview { return nil } + #endif + guard let config, config.isComplete else { return nil } + let token = config.token + return RemoteClient(config: config) { + // The laptop revoked this phone (or its access expired). Only drop the + // pairing that was refused, not one made since the request started. + Task { @MainActor in + let session = RemoteSession.shared + guard session.config?.token == token else { return } + session.forget(notice: "Your Mac removed this phone, or its access expired. Pair again to use it.") + } + } + } + + /// Name this phone gets in the laptop's device list. + static var deviceName: String { "\(UIDevice.current.model) · Pipper app" } + + /// Redeem a confirmed pairing link and keep this phone's device token. + /// The token goes to the Keychain; the laptop's address and owner to defaults. + func pair(_ link: PairingLink, owner: LaptopOwner?) async throws { + let paired = try await RemoteClient.redeemPairing(link, deviceName: Self.deviceName) + let next = RemoteConfig( + baseURL: link.baseURL, token: paired.token, laptopName: paired.laptop?.name, owner: owner, + deviceName: paired.device.name) + try Keychain.write(Keys.deviceToken, value: next.token) + var stored = next + stored.token = "" + UserDefaults.standard.set(try JSONEncoder().encode(stored), forKey: Keys.laptop) + if config?.baseURL != next.baseURL { clearCatalog() } + config = next + pairNotice = nil + await refreshCatalog() + } + + /// Unpair from Settings: revoke on the laptop first (best effort), so the + /// token is dead even if it leaked, then forget it here. + func unpair() async { + try? await client?.revokeThisDevice() + forget(notice: nil) + } + + private func forget(notice: String?) { + UserDefaults.standard.removeObject(forKey: Keys.laptop) + Keychain.delete(Keys.deviceToken) + lastSiriThreadId = nil + clearCatalog() + config = nil + pairNotice = notice + } + + private func clearCatalog() { + catalogStore.clear() + catalog = .empty + lastCatalogRefresh = nil + agentModels = [:] + Task { PipperRemoteShortcuts.updateAppShortcutParameters() } + } + + /// Pull the laptop catalog, cache it for Siri, and tell App Shortcuts the + /// entity names changed so spoken phrases like "in FolkLore" resolve. + @discardableResult + func refreshCatalog() async -> RemoteCatalog? { + guard let client else { return nil } + do { + let fresh = try await client.fetchCatalog() + try? catalogStore.save(fresh) + catalog = fresh + catalogError = nil + lastCatalogRefresh = Date() + PipperRemoteShortcuts.updateAppShortcutParameters() + return fresh + } catch { + catalogError = error.localizedDescription + return nil + } + } + + /// Best effort: an older Mac (404) or a slow agent probe leaves the last + /// list in place, and the picker falls back to the agent's default. + func refreshAgentModels() async { + guard let client, !loadingAgentModels else { return } + loadingAgentModels = true + defer { loadingAgentModels = false } + do { + agentModels = try await client.agentModels() + agentModelsSupported = true + agentModelsError = nil + } catch RemoteClientError.http(status: 404, _) { + agentModelsSupported = false + agentModelsError = nil + } catch { + agentModelsError = error.localizedDescription + } + } + + /// Refresh only when the cache is missing or older than `maxAge`. + func refreshCatalogIfStale(maxAge: TimeInterval = 10 * 60) async { + if let last = lastCatalogRefresh, Date().timeIntervalSince(last) < maxAge, !catalog.projects.isEmpty { + return + } + await refreshCatalog() + } + + // MARK: Siri → app handoff + + /// Thread most recently created by Siri; the app lands on it next open. + var lastSiriThreadId: String? { + didSet { UserDefaults.standard.set(lastSiriThreadId, forKey: Keys.lastSiriThreadId) } + } +} + +#if DEBUG +/// Sample data for SwiftUI previews. DEBUG-only and never persisted. +enum PreviewData { + static let catalog = RemoteCatalog( + updatedAt: "2026-09-27T00:00:00Z", + defaultAgentId: "codex", + projects: [ + RemoteCatalogProject(id: "folklore", name: "FolkLore", path: "~/code/folklore"), + RemoteCatalogProject(id: "omni", name: "Omni", path: "~/code/omni"), + ], + agents: [ + RemoteCatalogAgent(id: "codex", displayName: "Codex", available: true), + RemoteCatalogAgent(id: "claude", displayName: "Claude Code", available: true), + RemoteCatalogAgent(id: "cursor", displayName: "Cursor", available: false), + ]) + + /// Milliseconds since 1970, like `lastUsedAt` from the Mac. + private static func minutesAgo(_ minutes: Double) -> Double { + (Date().timeIntervalSince1970 - minutes * 60) * 1000 + } + + static let threads: [RemoteThreadSummary] = [ + RemoteThreadSummary( + id: "a1b2c3d4-0000-0000-0000-000000000001", projectId: "folklore", + worktreePath: "~/code/folklore/.worktrees/a1b2c3d4", title: "Add dark mode toggle", + running: true, lastUsedAt: minutesAgo(1)), + RemoteThreadSummary( + id: "e5f6a7b8-0000-0000-0000-000000000002", projectId: "omni", + worktreePath: nil, title: "Summarize the launch plan", running: false, lastUsedAt: minutesAgo(25)), + RemoteThreadSummary( + id: "c9d0e1f2-0000-0000-0000-000000000003", projectId: "folklore", + worktreePath: "~/code/folklore/.worktrees/c9d0e1f2", title: nil, running: false, + lastUsedAt: minutesAgo(90)), + RemoteThreadSummary( + id: "d3e4f5a6-0000-0000-0000-000000000004", projectId: "omni", + worktreePath: "~/code/omni/.worktrees/d3e4f5a6", title: "Read the codebase", running: false, + lastUsedAt: minutesAgo(60 * 26)), + RemoteThreadSummary( + id: "b7c8d9e0-0000-0000-0000-000000000005", projectId: "folklore", + worktreePath: "~/code/folklore/.worktrees/b7c8d9e0", title: "Fix onboarding crash", running: false, + lastUsedAt: minutesAgo(60 * 24 * 3)), + ] + + static let agentModels: [String: [RemoteAgentModel]] = [ + "codex": [ + RemoteAgentModel(id: "gpt-5", name: "GPT-5"), + RemoteAgentModel(id: "gpt-5-codex", name: "GPT-5 Codex"), + ], + "claude": [ + RemoteAgentModel(id: "sonnet", name: "Sonnet"), + RemoteAgentModel(id: "opus", name: "Opus"), + ], + ] + + static let report = RemoteReport( + threadId: threads[0].id, + running: true, + summary: "Add dark mode toggle", + finalText: nil, + messages: [ + RemoteMessage(role: .user, text: "Add a dark mode toggle to Settings and remember the choice."), + RemoteMessage( + role: .agent, + text: + "## Dark mode\n\nDone. I added a **Dark mode** toggle to `SettingsView`, bound to `@AppStorage(\"darkMode\")`.\n\n- Toggle in Settings\n- Applies `preferredColorScheme` at the root\n\n```swift\n@AppStorage(\"darkMode\") private var darkMode = false\n```\n\n| File | Change |\n| --- | --- |\n| SettingsView.swift | Added toggle |\n| PipperRemoteApp.swift | Applies scheme |" + ), + RemoteMessage(role: .user, text: "Nice — does it survive relaunch?"), + RemoteMessage(role: .agent, text: "Yes, `@AppStorage` persists it across launches."), + ], + projectName: "FolkLore", + filesTouched: ["App/Views/SettingsView.swift", "App/PipperRemoteApp.swift"], + worktreePath: "~/code/folklore/.worktrees/a1b2c3d4", + isolated: true, + isolationNote: nil, + permissions: nil, + request: nil, + model: RemoteThreadModel(current: "gpt-5", options: agentModels["codex"] ?? [])) + + static let reportNeedsInput = RemoteReport( + threadId: threads[2].id, + running: false, + summary: "Refactor the settings screen", + finalText: nil, + messages: [ + RemoteMessage(role: .user, text: "Refactor SettingsView and delete the old file."), + RemoteMessage(role: .agent, text: "I can delete `OldSettingsView.swift`. Confirm before I proceed."), + ], + projectName: "FolkLore", + filesTouched: ["App/Views/SettingsView.swift"], + worktreePath: "~/code/folklore/.worktrees/c9d0e1f2", + isolated: false, + isolationNote: "No Git worktree was available.", + permissions: [ + RemotePermission( + id: "perm-1", + title: "Delete file?", + detail: "rm App/Views/OldSettingsView.swift", + options: [ + RemotePermission.Option(optionId: "allow", name: "Allow", kind: "allow"), + RemotePermission.Option(optionId: "deny", name: "Deny", kind: "deny"), + ]) + ], + request: nil) +} +#endif diff --git a/native/pipper-remote-ios/App/Simulator.entitlements b/native/pipper-remote-ios/App/Simulator.entitlements new file mode 100644 index 0000000..f4b88aa --- /dev/null +++ b/native/pipper-remote-ios/App/Simulator.entitlements @@ -0,0 +1,19 @@ + + + + + + application-identifier + com.maker-or.omni.remote + keychain-access-groups + + com.maker-or.omni.remote + + get-task-allow + + + diff --git a/native/pipper-remote-ios/App/Views/PairingView.swift b/native/pipper-remote-ios/App/Views/PairingView.swift new file mode 100644 index 0000000..bb4394d --- /dev/null +++ b/native/pipper-remote-ios/App/Views/PairingView.swift @@ -0,0 +1,220 @@ +import SwiftUI + +/// Pair with the laptop: scan the QR from Pipper → Settings → Remote → Pair a +/// phone, or paste its pairing link (or type the address and code). Either +/// way the laptop is shown for confirmation before the code is redeemed. +struct PairingView: View { + @Environment(RemoteSession.self) private var session + @State private var address = "" + @State private var code = "" + @State private var scanning = false + @State private var error: String? + /// A pairing link held until the user confirms it. Links, scans, and the + /// custom URL scheme can come from anyone, so nothing pairs silently. + @State private var pending: PairingLink? + + private var manual: PairingLink? { PairingLink.manual(address: address, code: code) } + + var body: some View { + NavigationStack { + Form { + if let notice = session.pairNotice { + Section { + Label(notice, systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + } + } + Section { + Button { + scanning = true + } label: { + Label("Scan pairing QR", systemImage: "qrcode.viewfinder") + } + } footer: { + Text("On your Mac: Pipper → Settings → Remote → Pair a phone.") + } + Section { + TextField("Pairing link or laptop address", text: $address) + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .onChange(of: address) { _, value in + // A pasted pairing link carries its own code: show it. + if let link = PairingLink.parse(value) { code = link.code } + } + TextField("Code (XXXXX-XXXXX)", text: $code) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .textContentType(.oneTimeCode) + } header: { + Text("Or enter manually") + } footer: { + Text("Paste the pairing link from your Mac, or type its address (lt-….pipper.dev) and the code shown under the QR.") + } + if let error { + Section { + Text(error).foregroundStyle(.red) + } + } + Section { + Button("Continue") { + error = nil + pending = manual + } + .disabled(manual == nil) + } + } + .navigationTitle("Pair with your Mac") + .sheet(isPresented: $scanning) { + QRScannerSheet { text in + scanning = false + if let link = PairingLink.parse(text) { + error = nil + pending = link + } else { + error = "That QR isn't a Pipper pairing code. Make a new one on your Mac: Settings → Remote → Pair a phone." + } + } + } + .sheet(item: $pending) { link in + ConfirmPairingView(link: link) + } + .onOpenURL { url in + // pipper-remote://pair?url= — still only offers + // the laptop for confirmation. + guard let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems, + let raw = items.first(where: { $0.name == "url" })?.value, + let link = PairingLink.parse(raw) + else { return } + pending = link + } + } + } +} + +/// Who the laptop is, before this phone redeems the code: a pairing link +/// could point at anyone's laptop, and pairing makes it the destination for +/// every task sent from this phone. +struct ConfirmPairingView: View { + let link: PairingLink + @Environment(RemoteSession.self) private var session + @Environment(\.dismiss) private var dismiss + @State private var laptop: RemoteLaptopIdentity? + /// Nil when the laptop isn't on a named tunnel, so no owner can be checked. + @State private var owner: LaptopAttestation.Check? + @State private var error: String? + @State private var pairing = false + + var body: some View { + NavigationStack { + Form { + Section { + if let laptop { + LabeledContent("Laptop", value: laptop.name) + LabeledContent("Address", value: link.host) + ownerRow + } else if error == nil { + HStack { + ProgressView() + Text("Checking the laptop…").foregroundStyle(.secondary) + } + } + } footer: { + Text("After pairing, the tasks you send from this phone and Siri go to this laptop.") + } + if let error { + Section { + Text(error).foregroundStyle(.red) + } + } + Section { + Button { + Task { await pair() } + } label: { + if pairing { + ProgressView() + } else { + Text("Pair with \(laptop?.name ?? "this laptop")") + } + } + .disabled(laptop == nil || pairing) + } + } + .navigationTitle("Pair with this laptop?") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + .disabled(pairing) + } + } + .task { await load() } + } + .interactiveDismissDisabled(pairing) + } + + @ViewBuilder private var ownerRow: some View { + switch owner { + case .verified(let who): + Label("Belongs to \(who.label) — verified by Pipper", systemImage: "checkmark.seal.fill") + .foregroundStyle(.green) + case .unverified(let reason): + Label( + "Unverified: \(reason) Pair only if you just made this code on your own Mac.", + systemImage: "exclamationmark.triangle.fill" + ) + .foregroundStyle(.orange) + case nil: + Label( + "This laptop isn't on Pipper's network, so its owner can't be checked. Pair only if you just made this code on your own Mac.", + systemImage: "exclamationmark.triangle" + ) + .foregroundStyle(.secondary) + } + } + + private func load() async { + #if DEBUG + if session.client == nil, link.host.hasPrefix("lt-preview") { + laptop = RemoteLaptopIdentity(name: "Studio", host: link.host, attestation: nil) + owner = .verified(LaptopOwner(sub: "preview", email: "me@example.com", name: nil)) + return + } + #endif + do { + let identity = try await RemoteClient.previewPairing(link) + if link.isNamedTunnel { + owner = LaptopAttestation.verify(identity.attestation, host: link.host) + } + laptop = identity + } catch { + self.error = error.localizedDescription + } + } + + private func pair() async { + guard !pairing else { return } + pairing = true + error = nil + defer { pairing = false } + let verified: LaptopOwner? = if case .verified(let who) = owner { who } else { nil } + do { + try await session.pair(link, owner: verified) + dismiss() + } catch { + self.error = error.localizedDescription + } + } +} + +#if DEBUG +#Preview("Pairing") { + PairingView() + .environment(RemoteSession.preview()) +} + +#Preview("Confirm") { + ConfirmPairingView(link: PairingLink(code: "ABCDE12345", baseURL: URL(string: "https://lt-preview.pipper.dev")!)!) + .environment(RemoteSession.preview()) +} +#endif diff --git a/native/pipper-remote-ios/App/Views/QRScannerSheet.swift b/native/pipper-remote-ios/App/Views/QRScannerSheet.swift new file mode 100644 index 0000000..b1f99a7 --- /dev/null +++ b/native/pipper-remote-ios/App/Views/QRScannerSheet.swift @@ -0,0 +1,137 @@ +import AVFoundation +import SwiftUI + +/// Camera QR reader built on AVCaptureMetadataOutput — works on every device +/// and needs only NSCameraUsageDescription. +struct QRScannerSheet: View { + let onCode: (String) -> Void + @Environment(\.dismiss) private var dismiss + @State private var denied = false + #if DEBUG + private var previewCamera = false + + init(onCode: @escaping (String) -> Void, previewCamera: Bool = false) { + self.onCode = onCode + self.previewCamera = previewCamera + } + #endif + + var body: some View { + NavigationStack { + ZStack { + if denied { + ContentUnavailableView( + "Camera access needed", + systemImage: "camera.fill", + description: Text("Allow camera access in Settings, or enter the token manually.")) + } else { + #if DEBUG + if previewCamera { + Color.black.ignoresSafeArea() + } else { + QRScannerView(onCode: onCode, onDenied: { denied = true }) + .ignoresSafeArea() + } + #else + QRScannerView(onCode: onCode, onDenied: { denied = true }) + .ignoresSafeArea() + #endif + RoundedRectangle(cornerRadius: 16) + .strokeBorder(.white.opacity(0.8), lineWidth: 2) + .frame(width: 240, height: 240) + } + } + .navigationTitle("Scan pairing code") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + } +} + +private struct QRScannerView: UIViewControllerRepresentable { + let onCode: (String) -> Void + let onDenied: () -> Void + + func makeUIViewController(context: Context) -> ScannerController { + let vc = ScannerController() + vc.onCode = onCode + vc.onDenied = onDenied + return vc + } + + func updateUIViewController(_ uiViewController: ScannerController, context: Context) {} +} + +final class ScannerController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { + var onCode: ((String) -> Void)? + var onDenied: (() -> Void)? + private let session = AVCaptureSession() + private var preview: AVCaptureVideoPreviewLayer? + private var fired = false + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in + DispatchQueue.main.async { + guard let self else { return } + if granted { self.configure() } else { self.onDenied?() } + } + } + } + + private func configure() { + guard let device = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: device), + session.canAddInput(input) + else { + onDenied?() + return + } + session.addInput(input) + let output = AVCaptureMetadataOutput() + guard session.canAddOutput(output) else { return } + session.addOutput(output) + output.setMetadataObjectsDelegate(self, queue: .main) + output.metadataObjectTypes = [.qr] + let layer = AVCaptureVideoPreviewLayer(session: session) + layer.videoGravity = .resizeAspectFill + layer.frame = view.bounds + view.layer.addSublayer(layer) + preview = layer + DispatchQueue.global(qos: .userInitiated).async { [session] in session.startRunning() } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + preview?.frame = view.bounds + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + if session.isRunning { session.stopRunning() } + } + + func metadataOutput( + _ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection + ) { + guard !fired, + let code = metadataObjects.compactMap({ $0 as? AVMetadataMachineReadableCodeObject }).first, + let text = code.stringValue + else { return } + fired = true + session.stopRunning() + onCode?(text) + } +} + +#if DEBUG +#Preview("Scan QR") { + QRScannerSheet(onCode: { _ in }, previewCamera: true) +} +#endif diff --git a/native/pipper-remote-ios/App/Views/SettingsView.swift b/native/pipper-remote-ios/App/Views/SettingsView.swift new file mode 100644 index 0000000..dc19e05 --- /dev/null +++ b/native/pipper-remote-ios/App/Views/SettingsView.swift @@ -0,0 +1,203 @@ +import AppIntents +import SwiftUI + +struct SettingsView: View { + @Environment(RemoteSession.self) private var session + @Environment(\.dismiss) private var dismiss + @State private var refreshing = false + @State private var unpairing = false + /// Shown as a tab rather than a sheet, so there's nothing to dismiss. + var inTab = false + + var body: some View { + NavigationStack { + Form { + Section("Mac") { + LabeledContent("Name", value: session.config?.laptopName ?? "—") + LabeledContent("Address", value: session.config?.address ?? "—") + if let owner = session.config?.owner { + Label("Belongs to \(owner.label) — verified by Pipper", systemImage: "checkmark.seal.fill") + .foregroundStyle(.green) + } + if let device = session.config?.deviceName { + LabeledContent("This phone", value: device) + } + } + ConnectionCheckView() + Section { + Button { + Task { + refreshing = true + await session.refreshCatalog() + refreshing = false + } + } label: { + HStack { + Text("Refresh catalog") + Spacer() + if refreshing { ProgressView() } + } + } + if let err = session.catalogError { + Text(err).font(.footnote).foregroundStyle(.red) + } + if let last = session.lastCatalogRefresh { + LabeledContent("Updated", value: last.formatted(date: .abbreviated, time: .shortened)) + } + } header: { + Text("Siri catalog") + } footer: { + Text("Siri resolves project and agent names from this cached list, so it works even when the Mac is asleep. It refreshes automatically when the app opens.") + } + Section("Projects (\(session.catalog.projects.count))") { + ForEach(session.catalog.projects) { p in + VStack(alignment: .leading) { + Text(p.name) + Text(p.path).font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + } + } + Section("Agents") { + ForEach(session.catalog.agents) { a in + HStack { + Text(a.displayName) + if a.id == session.catalog.defaultAgentId { + Text("default").font(.caption).foregroundStyle(.secondary) + } + Spacer() + Text(a.available ? "available" : "not installed") + .font(.caption) + .foregroundStyle(a.available ? .green : .secondary) + } + } + } + Section("Siri") { + ShortcutsLink() + Text("Try: “Start a Pipper thread in \(session.catalog.projects.first?.name ?? "FolkLore") with \(session.catalog.preferredAgent?.displayName ?? "Codex")”") + .font(.footnote) + .foregroundStyle(.secondary) + } + Section { + Button(role: .destructive) { + Task { + unpairing = true + await session.unpair() + unpairing = false + dismiss() + } + } label: { + HStack { + Text("Unpair") + Spacer() + if unpairing { ProgressView() } + } + } + .disabled(unpairing) + } footer: { + Text("Removes this phone from your Mac too, so its access ends everywhere.") + } + } + .navigationTitle("Settings") + .toolbar { + if !inTab { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + } + } + + } + +/// Shared by setup and settings; authentication is checked by diagnostics, +/// unlike the public reachability endpoint. +struct ConnectionCheckView: View { + @Environment(RemoteSession.self) private var session + @State private var diagnostics: RemoteDiagnostics? + @State private var error: String? + @State private var checking = false + @State private var checkedAt: Date? + @State private var showSample = false + /// The Mac answered but predates /api/remote/diagnostics (404). The + /// pairing works; only the newer endpoints are missing. + @State private var macOutdated = false + + var body: some View { + Section("Connection checks") { + // Modifiers on a Section inside a List/Form are applied to every row, + // which would register one sheet (and one task) per row. Anchor them to + // this always-present row instead. + Button(checking ? "Checking…" : "Test connection") { Task { await check() } } + .disabled(checking) + .task { await check() } + .sheet(isPresented: $showSample) { + NewThreadSheet(onCreated: { thread in + session.lastSiriThreadId = thread.id + showSample = false + }, sampleTask: true) + } + if macOutdated { + Label("Mac reachable · pairing accepted", systemImage: "checkmark.circle") + Text("Pipper on your Mac is older than this app, so connection checks and Siri project names aren't available. Update Pipper on your Mac.") + .font(.footnote).foregroundStyle(.orange) + } else if let error { + Text(error).font(.footnote).foregroundStyle(.red) + Text("Keep Pipper open on your Mac. If you changed how it connects in Settings → Remote, pair this phone again.") + .font(.footnote).foregroundStyle(.secondary) + } + if let diagnostics { + Label("Mac reachable · pairing accepted", systemImage: "checkmark.circle") + Label(diagnostics.agentReady ? "Pipper is ready" : "Pipper is starting", + systemImage: diagnostics.agentReady ? "checkmark.circle" : "clock") + Text("\(diagnostics.availableAgents) available agents · \(diagnostics.projects) projects") + Text(diagnostics.guidance).font(.footnote).foregroundStyle(.secondary) + Button("Run a sample task") { showSample = true } + .disabled(!diagnostics.ready || error != nil || checking) + } + if let checkedAt { + Text("Last checked \(checkedAt.formatted(date: .omitted, time: .standard))") + .font(.caption).foregroundStyle(.secondary) + } + } + } + + private func check() async { + guard let client = session.client, !checking else { return } + checking = true + error = nil + macOutdated = false + defer { checking = false } + do { + diagnostics = try await client.diagnostics() + checkedAt = Date() + await session.refreshCatalog() + } catch RemoteClientError.http(status: 404, _) { + // Older Macs lack diagnostics; confirm the pairing against an + // endpoint every version serves. + do { + _ = try await client.listThreads() + macOutdated = true + checkedAt = Date() + } catch { + self.error = error.localizedDescription + } + } catch { + self.error = error.localizedDescription + } + } +} + +#if DEBUG +#Preview("Settings") { + SettingsView() + .environment(RemoteSession.preview(catalog: PreviewData.catalog)) +} + +#Preview("Connection check") { + Form { + ConnectionCheckView() + } + .environment(RemoteSession.preview(catalog: PreviewData.catalog)) +} +#endif diff --git a/native/pipper-remote-ios/App/Views/ThreadDetailView.swift b/native/pipper-remote-ios/App/Views/ThreadDetailView.swift new file mode 100644 index 0000000..00171a1 --- /dev/null +++ b/native/pipper-remote-ios/App/Views/ThreadDetailView.swift @@ -0,0 +1,370 @@ +import SwiftUI +import Textual + +/// One thread's transcript (polled every 3s — the laptop holds back +/// in-progress agent text, so replies arrive whole) plus a follow-up box. +struct ThreadDetailView: View { + @Environment(RemoteSession.self) private var session + @Environment(\.scenePhase) private var scenePhase + let threadId: String + + #if DEBUG + /// Preview-only seed; `previewReport` defaults to nil so the production + /// call site (`ThreadDetailView(threadId:)`) is unchanged. + init(threadId: String, previewReport: RemoteReport? = nil) { + self.threadId = threadId + if let previewReport { + _report = State(initialValue: previewReport) + } + } + #endif + + @State private var report: RemoteReport? + @State private var loadError: String? + @State private var controlling = false + @State private var switchingModel = false + @State private var loading = false + @State private var draft = "" + @State private var sending = false + @State private var sendError: String? + /// Optimistic bubble until the transcript includes the message. + @State private var pending: (text: String, known: Int)? + /// Sticky-bottom: auto-scroll only while the user is already at the end, + /// or right after they send. Never yank someone who scrolled up to read. + @State private var nearBottom = true + @State private var forceScroll = false + /// The Mac answered 404: the thread (or its worktree) was deleted there. + /// Terminal — stop polling and don't offer a follow-up that can't land. + @State private var missing = false + + var body: some View { + if missing { + ContentUnavailableView { + Label("Thread no longer exists", systemImage: "trash") + } description: { + Text("It was deleted on your Mac, or its worktree was removed. Start a new thread from the list.") + } + .navigationTitle("Thread") + .navigationBarTitleDisplayMode(.inline) + } else { + content + } + } + + private var content: some View { + VStack(spacing: 0) { + ScrollViewReader { proxy in + ScrollView { + // Plain VStack: Markdown rows settle their height after layout, and + // a lazy stack's height estimates for off-screen rows make scrolling jump. + VStack(alignment: .leading, spacing: 10) { + if let report { + if let loadError { + Text("Connection lost. Showing the last update. \(loadError)") + .font(.footnote).foregroundStyle(.orange) + Button("Retry connection") { Task { await load() } } + } + if let error = report.request?.error { + Text(error).font(.footnote).foregroundStyle(.red) + } + ForEach(report.permissions ?? []) { decision in + VStack(alignment: .leading, spacing: 8) { + Text(decision.title).font(.headline) + if let detail = decision.detail { + Text(detail).font(.caption.monospaced()).textSelection(.enabled) + } + ForEach(decision.options) { option in + Button(option.name) { + Task { await control(decision: decision.id, option: option.optionId) } + }.buttonStyle(.bordered).disabled(controlling || loadError != nil) + } + Button("Dismiss request", role: .destructive) { + Task { await control(decision: decision.id) } + }.disabled(controlling || loadError != nil) + } + .padding().background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12)) + } + ForEach(Array(report.messages.enumerated()), id: \.offset) { _, m in + Bubble(role: m.role, text: m.text) + } + if let pendingText = pendingVisible { + Bubble(role: .user, text: pendingText, footnote: sending ? "Sending…" : "Sent · waiting for Mac…") + } + if report.running { + HStack { + ProgressView().controlSize(.small) + Text("Working on your Mac…").font(.footnote).foregroundStyle(.secondary) + } + .padding(.horizontal, 4) + } + } else { + Text(loadError ?? "Loading…").foregroundStyle(.secondary).padding() + } + Color.clear.frame(height: 1).id("bottom") + } + .padding(12) + } + // Start at the bottom only. Pinning on every size change would yank + // readers back down as Markdown re-lays out; sticky-bottom below + // handles following new content. + .defaultScrollAnchor(.bottom, for: .initialOffset) + .scrollDismissesKeyboard(.interactively) + .onScrollGeometryChange(for: Bool.self) { geo in + geo.contentOffset.y + geo.containerSize.height >= geo.contentSize.height - 120 + } action: { _, isNear in + nearBottom = isNear + } + .onChange(of: scrollSignature) { _, _ in + guard nearBottom || forceScroll else { return } + forceScroll = false + // Let the new rows lay out before asking for the bottom anchor. + Task { @MainActor in + await Task.yield() + withAnimation(.easeOut(duration: 0.2)) { proxy.scrollTo("bottom", anchor: .bottom) } + } + } + } + composer + } + .navigationTitle(report?.summary ?? "Thread") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if let model = report?.model { + ToolbarItem(placement: .topBarTrailing) { modelMenu(model) } + } + } + .task(id: "\(threadId)-\(scenePhase == .active)") { + if scenePhase == .active { await poll() } + } + } + + /// Applies to the next turn, so it's locked while the Mac is working. + private func modelMenu(_ model: RemoteThreadModel) -> some View { + Menu { + Picker("Model", selection: Binding( + get: { model.current ?? "" }, + set: { next in Task { await setModel(next) } } + )) { + ForEach(model.options) { m in + Text(m.name).tag(m.id) + } + } + } label: { + HStack(spacing: 3) { + Text(model.currentName ?? "Model") + Image(systemName: "chevron.up.chevron.down").imageScale(.small) + } + .font(.footnote) + } + .disabled(report?.running == true || switchingModel || controlling) + .accessibilityLabel("Model: \(model.currentName ?? "default")") + } + + private func setModel(_ next: String) async { + guard let client = session.client, !switchingModel, next != report?.model?.current else { return } + switchingModel = true + sendError = nil + defer { switchingModel = false } + do { + let updated = try await client.setModel(threadId: threadId, model: next) + report?.model = updated + } catch { + sendError = error.localizedDescription + } + } + + private var canSend: Bool { + !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !sending && report?.running != true + } + + /// Message-style composer: one rounded field with the action button inside. + /// While the Mac is working the button stops the thread instead of sending. + private var composer: some View { + VStack(alignment: .leading, spacing: 6) { + if let sendError { + Text(sendError).font(.footnote).foregroundStyle(.red).padding(.horizontal, 4) + } + HStack(alignment: .bottom, spacing: 6) { + TextField(report?.running == true ? "Working on your Mac…" : "Follow up…", text: $draft, axis: .vertical) + .lineLimit(1...6) + .padding(.leading, 16) + .padding(.vertical, 10) + composerButton + .padding(5) + } + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 22, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .strokeBorder(Color(.separator).opacity(0.6), lineWidth: 0.5) + ) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.bar) + } + + @ViewBuilder private var composerButton: some View { + if report?.running == true { + Button { + Task { await control() } + } label: { + Image(systemName: "stop.fill") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(Color(.systemBackground)) + .frame(width: 32, height: 32) + .background(Color.primary, in: Circle()) + } + .disabled(controlling) + .accessibilityLabel("Stop this thread") + } else { + Button { + Task { await send() } + } label: { + Image(systemName: "arrow.up") + .font(.system(size: 15, weight: .bold)) + .foregroundStyle(.white) + .frame(width: 32, height: 32) + .background(canSend ? Color.accentColor : Color(.systemGray4), in: Circle()) + } + .disabled(!canSend) + .animation(.easeOut(duration: 0.15), value: canSend) + .accessibilityLabel("Send follow-up") + } + } + + private var pendingVisible: String? { + guard let pending, let report else { return nil } + let count = report.messages.filter { $0.role == .user && $0.text == pending.text }.count + return count > pending.known ? nil : pending.text + } + + private var scrollSignature: String { + [ + String(report?.messages.count ?? 0), + String(report?.messages.last?.text.count ?? 0), + pendingVisible ?? "", + report?.running == true ? "run" : "idle", + ].joined(separator: "|") + } + + private func poll() async { + while !Task.isCancelled && !missing { + await load() + try? await Task.sleep(for: .seconds(3)) + } + } + + private func load() async { + guard let client = session.client, !loading else { return } + loading = true + defer { loading = false } + do { + let next = try await client.report(threadId: threadId) + guard !Task.isCancelled else { return } + if next != report { report = next } + if next.request?.state == "failed" || next.request?.state == "interrupted" { pending = nil } + loadError = nil + if let p = pending, next.messages.filter({ $0.role == .user && $0.text == p.text }).count > p.known { + pending = nil + } + } catch RemoteClientError.http(status: 404, _) { + missing = true + } catch { + loadError = error.localizedDescription + } + } + + private func control(decision: String? = nil, option: String? = nil) async { + guard let client = session.client, !controlling else { return } + controlling = true + sendError = nil + defer { controlling = false } + do { + if let decision { + try await client.answer(threadId: threadId, decisionId: decision, optionId: option, cancelled: option == nil) + } else { + try await client.stop(threadId: threadId) + } + } catch { + sendError = error.localizedDescription + } + await load() + } + + private func send() async { + guard session.isPaired, !sending else { return } + let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + sending = true + sendError = nil + draft = "" + let known = (report?.messages ?? []).filter { $0.role == .user && $0.text == text }.count + forceScroll = true + pending = (text, known) + defer { sending = false } + do { + try await session.sendPrompt(threadId: threadId, prompt: text) + await load() + } catch { + draft = text + pending = nil + sendError = error.localizedDescription + } + } +} + +private struct Bubble: View { + let role: RemoteMessage.Role + let text: String + var footnote: String? = nil + + var body: some View { + if role == .user { + HStack { + Spacer(minLength: 40) + VStack(alignment: .trailing, spacing: 3) { + Text(text) + .textSelection(.enabled) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.accentColor, in: RoundedRectangle(cornerRadius: 16)) + .foregroundStyle(.white) + if let footnote { + Text(footnote).font(.caption2).foregroundStyle(.secondary) + } + } + } + } else { + // Agent replies are full Markdown (headings, lists, code, tables) and + // get the whole width, with no bubble, so wide blocks have room. + StructuredText(markdown: text) + .textual.textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + } + } +} + +#if DEBUG +#Preview("Thread — running") { + NavigationStack { + ThreadDetailView(threadId: PreviewData.report.threadId, previewReport: PreviewData.report) + } + .environment(RemoteSession.preview(catalog: PreviewData.catalog)) +} + +#Preview("Thread — needs input") { + NavigationStack { + ThreadDetailView( + threadId: PreviewData.reportNeedsInput.threadId, previewReport: PreviewData.reportNeedsInput) + } + .environment(RemoteSession.preview(catalog: PreviewData.catalog)) +} + +#Preview("Thread — loading") { + NavigationStack { + ThreadDetailView(threadId: "00000000-0000-0000-0000-000000000000") + } + .environment(RemoteSession.preview(catalog: PreviewData.catalog)) +} +#endif diff --git a/native/pipper-remote-ios/App/Views/ThreadListView.swift b/native/pipper-remote-ios/App/Views/ThreadListView.swift new file mode 100644 index 0000000..eef29f4 --- /dev/null +++ b/native/pipper-remote-ios/App/Views/ThreadListView.swift @@ -0,0 +1,382 @@ +import AppIntents +import SwiftUI + +/// Home: one project's threads on the Mac (polled) as a staggered card grid. +/// The large title is the project picker. Siri-created threads are opened +/// automatically on next foreground. +struct ThreadListView: View { + @Environment(RemoteSession.self) private var session + @Environment(\.scenePhase) private var scenePhase + @State private var threads: [RemoteThreadSummary] = [] + @State private var loadError: String? + @State private var path: [String] = [] + /// "" until chosen; then falls back to the most recently active project. + @AppStorage("home.projectId") private var storedProjectId = "" + + #if DEBUG + /// Preview-only seed so the grid is shown populated; the default `[]` + /// keeps the production call site (`ThreadListView()`) unchanged. + init(previewThreads: [RemoteThreadSummary] = []) { + _threads = State(initialValue: previewThreads) + } + #endif + + var body: some View { + NavigationStack(path: $path) { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .center) { + ProfileAvatar() + Spacer(minLength: 12) + + projectMenu + } + if let loadError { + Text(loadError).foregroundStyle(.red).font(.footnote) + } + if visibleThreads.isEmpty && loadError == nil { + ContentUnavailableView { + Label("No threads yet", systemImage: "bubble.left.and.text.bubble.right") + } description: { + Text("Ask Siri: “Start a Pipper thread in \(selectedProjectName ?? "your project")”, or tap +.") + } + .padding(.top, 40) + } else { + ThreadGrid(threads: visibleThreads) + } + } + .padding(.horizontal, 16) + .padding(.bottom, 24) + } + // The project menu in the content is the title; keep the bar empty. + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) + .navigationDestination(for: String.self) { id in + // Reading a thread is full-screen: the composer owns the bottom edge. + ThreadDetailView(threadId: id) + .toolbar(.hidden, for: .tabBar) + } + .refreshable { await load() } + .task(id: scenePhase == .active) { if scenePhase == .active { await poll() } } + .onAppear { openSiriThreadIfAny() } + .onChange(of: session.lastSiriThreadId) { _, _ in openSiriThreadIfAny() } + // Siri may have run while the app sat in the background; land on the + // thread it created the next time the app comes forward. + .onChange(of: scenePhase) { _, phase in + if phase == .active { openSiriThreadIfAny() } + } + } + } + + /// Large project name with an up/down chevron; tapping switches project. + private var projectMenu: some View { + Menu { + Picker("Project", selection: projectBinding) { + ForEach(projectChoices, id: \.id) { p in + Text(p.name).tag(p.id) + } + } + } label: { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(selectedProjectName ?? "Pipper") + .font(.largeTitle.bold()) + .fontDesign(.rounded) + .lineLimit(1) + Image(systemName: "chevron.up.chevron.down") + .font(.title3.weight(.semibold)) + .foregroundStyle(.secondary) + } + .foregroundStyle(.primary) + } + .tint(.primary) + .disabled(projectChoices.count < 2) + .padding(.top, 4) + } + + /// Newest first; the Mac's order isn't guaranteed to be by recency. + private var sortedThreads: [RemoteThreadSummary] { + threads.sorted { $0.lastUsedAt > $1.lastUsedAt } + } + + /// Catalog projects, plus any project a thread references that the catalog + /// doesn't list yet, ordered by most recent activity. + private var projectChoices: [(id: String, name: String)] { + var ids: [String] = [] + for t in sortedThreads where !ids.contains(t.projectId) { ids.append(t.projectId) } + for p in session.catalog.projects where !ids.contains(p.id) { ids.append(p.id) } + return ids.map { ($0, session.catalog.project(id: $0)?.name ?? $0) } + } + + /// The stored choice while it still exists, else the most active project. + private var selectedProjectId: String? { + if projectChoices.contains(where: { $0.id == storedProjectId }) { return storedProjectId } + return projectChoices.first?.id + } + + private var selectedProjectName: String? { + selectedProjectId.flatMap { id in projectChoices.first { $0.id == id }?.name } + } + + private var projectBinding: Binding { + Binding(get: { selectedProjectId ?? "" }, set: { storedProjectId = $0 }) + } + + private var visibleThreads: [RemoteThreadSummary] { + sortedThreads.filter { $0.projectId == selectedProjectId } + } + + private func openSiriThreadIfAny() { + guard let id = session.lastSiriThreadId else { return } + session.lastSiriThreadId = nil + path = [id] + } + + private func load() async { + guard let client = session.client else { return } + do { + let next = try await client.listThreads() + if next != threads { threads = next } + loadError = nil + } catch { + loadError = error.localizedDescription + } + } + + private func poll() async { + while !Task.isCancelled { + await load() + try? await Task.sleep(for: .seconds(5)) + } + } +} + +/// Signed-in user's avatar. Hardcoded until accounts exist. +private struct ProfileAvatar: View { + private let initials = "HP" + + var body: some View { + Text(initials) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white) + .frame(width: 38, height: 38) + .background( + LinearGradient(colors: [.blue, .blue], startPoint: .topLeading, endPoint: .bottomTrailing), + in: Circle()) + .accessibilityLabel("Profile") + } +} + +/// Two columns of equal cards; the right column starts lower so the grid +/// reads as staggered. Threads fill left, right, left… in recency order. +private struct ThreadGrid: View { + let threads: [RemoteThreadSummary] + private let spacing: CGFloat = 14 + + var body: some View { + HStack(alignment: .top, spacing: spacing) { + column(stride(from: 0, to: threads.count, by: 2).map { threads[$0] }) + column(stride(from: 1, to: threads.count, by: 2).map { threads[$0] }) + .padding(.top, 36) + } + } + + private func column(_ items: [RemoteThreadSummary]) -> some View { + VStack(spacing: spacing) { + ForEach(items) { t in + NavigationLink(value: t.id) { ThreadCard(thread: t) } + .buttonStyle(.plain) + } + } + .frame(maxWidth: .infinity, alignment: .top) + } +} + +private struct ThreadCard: View { + let thread: RemoteThreadSummary + + var body: some View { + VStack(spacing: 8) { + Text(thread.title ?? String(thread.id.prefix(8))) + .font(.headline) + .fontDesign(.rounded) + .multilineTextAlignment(.center) + .lineLimit(5) + .frame(maxWidth: .infinity) + Spacer(minLength: 0) + Text(subtitle) + .font(.caption) + .foregroundStyle(thread.running ? Color.green : Color.secondary) + .multilineTextAlignment(.center) + .lineLimit(2) + } + .padding(14) + .frame(height: 200) + .background(Color(.secondarySystemBackground), in: Self.shape) + .contentShape(Self.shape) + } + + /// One fixed radius. ConcentricRectangle resolves against the display + /// corners, so in a scroll view cards would change shape as they pass the + /// screen's rounded corners; scrolling content keeps a constant radius. + private static let shape = RoundedRectangle(cornerRadius: 28, style: .continuous) + + private var subtitle: String { + var parts = [ + thread.running + ? "Running" + : Date(timeIntervalSince1970: thread.lastUsedAt / 1000).formatted(.relative(presentation: .named)) + ] + if thread.worktreePath == nil { parts.append("project root") } + return parts.joined(separator: " · ") + } +} + +/// Manual equivalent of the Siri intent: pick project + agent, type a task. +struct NewThreadSheet: View { + @Environment(RemoteSession.self) private var session + @Environment(\.dismiss) private var dismiss + let onCreated: (RemoteThreadSummary) -> Void + /// Preselected project (the one shown on Home); nil picks the first. + var initialProjectId: String? = nil + /// Shown as a tab rather than a sheet: no Cancel, and the form resets + /// after starting so the tab is ready for the next task. + var inTab = false + + @State private var projectId = "" + @State private var agentId = "" + /// Model inside the agent; "" keeps the agent's default. + @State private var model = "" + @State private var prompt = "" + var sampleTask = false + @State private var sending = false + @State private var error: String? + + var body: some View { + NavigationStack { + Form { + Section { + Picker("Project", selection: $projectId) { + Text("Choose…").tag("") + ForEach(session.catalog.projects) { p in + Text(p.name).tag(p.id) + } + } + Picker("Agent", selection: $agentId) { + Text("Mac default").tag("") + ForEach(session.catalog.availableAgents) { a in + Text(a.displayName).tag(a.id) + } + } + if !models.isEmpty { + Picker("Model", selection: $model) { + Text("Agent default").tag("") + ForEach(models) { m in + Text(m.name).tag(m.id) + } + } + } else if session.loadingAgentModels { + LabeledContent("Model") { ProgressView().controlSize(.small) } + } else { + LabeledContent("Model", value: "Agent default") + } + } footer: { + if !session.agentModelsSupported { + Text("Update Pipper on your Mac to choose a model.") + } else if let modelError = session.agentModelsError, models.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("Couldn't load models: \(modelError)") + Button("Retry") { Task { await session.refreshAgentModels() } } + .font(.footnote) + } + } + } + .onChange(of: agentId) { _, _ in model = "" } + Section("Task") { + TextField("What should the agent do?", text: $prompt, axis: .vertical) + .lineLimit(3...8) + } + if let error { + Section { Text(error).foregroundStyle(.red) } + } + if session.catalog.projects.isEmpty { + Section { + Text(session.catalogError ?? "No projects loaded from the Mac yet.") + .foregroundStyle(.secondary) + Button("Refresh catalog") { Task { await session.refreshCatalog() } } + } + } + } + .navigationTitle(sampleTask ? "Sample task" : "New thread") + .navigationBarTitleDisplayMode(.inline) + .interactiveDismissDisabled(sending) + .toolbar { + if !inTab { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() }.disabled(sending) + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Start") { Task { await start() } } + .disabled(projectId.isEmpty || prompt.trimmingCharacters(in: .whitespaces).isEmpty || sending) + } + } + .task { + if sampleTask && prompt.isEmpty { + prompt = "Describe this project's purpose in one sentence. Do not change files or run commands." + } + await session.refreshCatalogIfStale() + if projectId.isEmpty { + if let initialProjectId, session.catalog.project(id: initialProjectId) != nil { + projectId = initialProjectId + } else if let first = session.catalog.projects.first { + projectId = first.id + } + } + await session.refreshAgentModels() + } + } + } + + private var effectiveAgentId: String? { + agentId.isEmpty ? session.catalog.preferredAgent?.id : agentId + } + + private var models: [RemoteAgentModel] { + effectiveAgentId.flatMap { session.agentModels[$0] } ?? [] + } + + private func start() async { + guard session.isPaired, !sending else { return } + sending = true + defer { sending = false } + error = nil + do { + let thread = try await session.createThread( + projectId: projectId, + agentId: effectiveAgentId, + model: model.isEmpty ? nil : model, + prompt: prompt.trimmingCharacters(in: .whitespacesAndNewlines)) + if inTab { prompt = "" } + onCreated(thread) + } catch { + self.error = error.localizedDescription + } + } +} + +#if DEBUG +#Preview("Threads") { + ThreadListView(previewThreads: PreviewData.threads) + .environment(RemoteSession.preview(catalog: PreviewData.catalog)) +} + +#Preview("Threads — empty") { + ThreadListView() + .environment(RemoteSession.preview(catalog: PreviewData.catalog)) +} + +#Preview("New thread") { + NewThreadSheet { _ in } + .environment(RemoteSession.preview(catalog: PreviewData.catalog, agentModels: PreviewData.agentModels)) +} +#endif diff --git a/native/pipper-remote-ios/Package.swift b/native/pipper-remote-ios/Package.swift new file mode 100644 index 0000000..3614361 --- /dev/null +++ b/native/pipper-remote-ios/Package.swift @@ -0,0 +1,21 @@ +// swift-tools-version: 6.0 +import PackageDescription + +// `swift test` here validates the Foundation-only core (catalog, HTTP client, +// pairing URL parsing) on the Mac. The iOS app in PipperRemote.xcodeproj +// compiles these same sources directly, mirroring native/pipper-intents. +let package = Package( + name: "PipperRemoteCore", + platforms: [.iOS(.v18), .macOS(.v13)], + products: [ + .library(name: "PipperRemoteCore", targets: ["PipperRemoteCore"]), + ], + targets: [ + .target(name: "PipperRemoteCore", path: "Sources/PipperRemoteCore"), + .testTarget( + name: "PipperRemoteCoreTests", + dependencies: ["PipperRemoteCore"], + path: "Tests/PipperRemoteCoreTests" + ), + ] +) diff --git a/native/pipper-remote-ios/PipperRemote.xcodeproj/project.pbxproj b/native/pipper-remote-ios/PipperRemote.xcodeproj/project.pbxproj new file mode 100644 index 0000000..6deb836 --- /dev/null +++ b/native/pipper-remote-ios/PipperRemote.xcodeproj/project.pbxproj @@ -0,0 +1,387 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + B20A00000000000000000003 /* Textual in Frameworks */ = {isa = PBXBuildFile; productRef = B20A00000000000000000002 /* Textual */; }; + B20100000000000000000001 /* RemoteModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20200000000000000000001 /* RemoteModels.swift */; }; + B20100000000000000000002 /* RemoteConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20200000000000000000002 /* RemoteConfig.swift */; }; + B20100000000000000000003 /* CatalogStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20200000000000000000003 /* CatalogStore.swift */; }; + B20100000000000000000004 /* RemoteClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20200000000000000000004 /* RemoteClient.swift */; }; + B20100000000000000000005 /* PipperRemoteApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20200000000000000000005 /* PipperRemoteApp.swift */; }; + B20100000000000000000006 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20200000000000000000006 /* Keychain.swift */; }; + B20100000000000000000007 /* RemoteSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20200000000000000000007 /* RemoteSession.swift */; }; + B20100000000000000000008 /* CatalogEntities.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20200000000000000000008 /* CatalogEntities.swift */; }; + B20100000000000000000009 /* StartThreadIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20200000000000000000009 /* StartThreadIntent.swift */; }; + B2010000000000000000000A /* PipperRemoteShortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2020000000000000000000A /* PipperRemoteShortcuts.swift */; }; + B2010000000000000000000B /* PairingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2020000000000000000000B /* PairingView.swift */; }; + B2010000000000000000000C /* QRScannerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2020000000000000000000C /* QRScannerSheet.swift */; }; + B2010000000000000000000D /* ThreadListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2020000000000000000000D /* ThreadListView.swift */; }; + B2010000000000000000000E /* ThreadDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2020000000000000000000E /* ThreadDetailView.swift */; }; + B2010000000000000000000F /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2020000000000000000000F /* SettingsView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + B20200000000000000000001 /* RemoteModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = RemoteModels.swift; path = Sources/PipperRemoteCore/RemoteModels.swift; sourceTree = ""; }; + B20200000000000000000002 /* RemoteConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = RemoteConfig.swift; path = Sources/PipperRemoteCore/RemoteConfig.swift; sourceTree = ""; }; + B20200000000000000000003 /* CatalogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CatalogStore.swift; path = Sources/PipperRemoteCore/CatalogStore.swift; sourceTree = ""; }; + B20200000000000000000004 /* RemoteClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = RemoteClient.swift; path = Sources/PipperRemoteCore/RemoteClient.swift; sourceTree = ""; }; + B20200000000000000000005 /* PipperRemoteApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PipperRemoteApp.swift; path = App/PipperRemoteApp.swift; sourceTree = ""; }; + B20200000000000000000006 /* Keychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Keychain.swift; path = App/Keychain.swift; sourceTree = ""; }; + B20200000000000000000007 /* RemoteSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = RemoteSession.swift; path = App/RemoteSession.swift; sourceTree = ""; }; + B20200000000000000000008 /* CatalogEntities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CatalogEntities.swift; path = App/Intents/CatalogEntities.swift; sourceTree = ""; }; + B20200000000000000000009 /* StartThreadIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StartThreadIntent.swift; path = App/Intents/StartThreadIntent.swift; sourceTree = ""; }; + B2020000000000000000000A /* PipperRemoteShortcuts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PipperRemoteShortcuts.swift; path = App/Intents/PipperRemoteShortcuts.swift; sourceTree = ""; }; + B2020000000000000000000B /* PairingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PairingView.swift; path = App/Views/PairingView.swift; sourceTree = ""; }; + B2020000000000000000000C /* QRScannerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = QRScannerSheet.swift; path = App/Views/QRScannerSheet.swift; sourceTree = ""; }; + B2020000000000000000000D /* ThreadListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ThreadListView.swift; path = App/Views/ThreadListView.swift; sourceTree = ""; }; + B2020000000000000000000E /* ThreadDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ThreadDetailView.swift; path = App/Views/ThreadDetailView.swift; sourceTree = ""; }; + B2020000000000000000000F /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SettingsView.swift; path = App/Views/SettingsView.swift; sourceTree = ""; }; + B20200000000000000000100 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = App/Info.plist; sourceTree = ""; }; + B20200000000000000000101 /* PipperRemote.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PipperRemote.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + B20300000000000000000003 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B20A00000000000000000003 /* Textual in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + B20400000000000000000001 /* Core */ = { + isa = PBXGroup; + children = ( + B20200000000000000000001 /* RemoteModels.swift */, + B20200000000000000000002 /* RemoteConfig.swift */, + B20200000000000000000003 /* CatalogStore.swift */, + B20200000000000000000004 /* RemoteClient.swift */, + ); + name = Core; + sourceTree = ""; + }; + B20400000000000000000002 /* App */ = { + isa = PBXGroup; + children = ( + B20200000000000000000005 /* PipperRemoteApp.swift */, + B20200000000000000000006 /* Keychain.swift */, + B20200000000000000000007 /* RemoteSession.swift */, + B20200000000000000000100 /* Info.plist */, + ); + name = App; + sourceTree = ""; + }; + B20400000000000000000003 /* Intents */ = { + isa = PBXGroup; + children = ( + B20200000000000000000008 /* CatalogEntities.swift */, + B20200000000000000000009 /* StartThreadIntent.swift */, + B2020000000000000000000A /* PipperRemoteShortcuts.swift */, + ); + name = Intents; + sourceTree = ""; + }; + B20400000000000000000004 /* Views */ = { + isa = PBXGroup; + children = ( + B2020000000000000000000B /* PairingView.swift */, + B2020000000000000000000C /* QRScannerSheet.swift */, + B2020000000000000000000D /* ThreadListView.swift */, + B2020000000000000000000E /* ThreadDetailView.swift */, + B2020000000000000000000F /* SettingsView.swift */, + ); + name = Views; + sourceTree = ""; + }; + B20400000000000000000010 = { + isa = PBXGroup; + children = ( + B20400000000000000000002 /* App */, + B20400000000000000000003 /* Intents */, + B20400000000000000000004 /* Views */, + B20400000000000000000001 /* Core */, + B20400000000000000000011 /* Products */, + ); + sourceTree = ""; + }; + B20400000000000000000011 /* Products */ = { + isa = PBXGroup; + children = ( + B20200000000000000000101 /* PipperRemote.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + B20500000000000000000001 /* PipperRemote */ = { + isa = PBXNativeTarget; + buildConfigurationList = B20800000000000000000002 /* Build configuration list for PBXNativeTarget "PipperRemote" */; + buildPhases = ( + B20300000000000000000001 /* Sources */, + B20300000000000000000003 /* Frameworks */, + B20300000000000000000002 /* Resources */, + B20300000000000000000004 /* Sign simulator build with Team ID */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = PipperRemote; + packageProductDependencies = ( + B20A00000000000000000002 /* Textual */, + ); + productName = PipperRemote; + productReference = B20200000000000000000101 /* PipperRemote.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + B20700000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2600; + LastUpgradeCheck = 2600; + TargetAttributes = { + B20500000000000000000001 = { + CreatedOnToolsVersion = 26.0; + }; + }; + }; + buildConfigurationList = B20800000000000000000001 /* Build configuration list for PBXProject "PipperRemote" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = B20400000000000000000010; + packageReferences = ( + B20A00000000000000000001 /* XCRemoteSwiftPackageReference "textual" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = B20400000000000000000011 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + B20500000000000000000001 /* PipperRemote */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + B20300000000000000000002 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + B20300000000000000000004 /* Sign simulator build with Team ID */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "$(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH)", + "$(TARGET_BUILD_DIR)/$(INFOPLIST_PATH)", + ); + name = "Sign simulator build with Team ID"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$SRCROOT/scripts/sign-simulator.sh\"\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + B20300000000000000000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B20100000000000000000001 /* RemoteModels.swift in Sources */, + B20100000000000000000002 /* RemoteConfig.swift in Sources */, + B20100000000000000000003 /* CatalogStore.swift in Sources */, + B20100000000000000000004 /* RemoteClient.swift in Sources */, + B20100000000000000000005 /* PipperRemoteApp.swift in Sources */, + B20100000000000000000006 /* Keychain.swift in Sources */, + B20100000000000000000007 /* RemoteSession.swift in Sources */, + B20100000000000000000008 /* CatalogEntities.swift in Sources */, + B20100000000000000000009 /* StartThreadIntent.swift in Sources */, + B2010000000000000000000A /* PipperRemoteShortcuts.swift in Sources */, + B2010000000000000000000B /* PairingView.swift in Sources */, + B2010000000000000000000C /* QRScannerSheet.swift in Sources */, + B2010000000000000000000D /* ThreadListView.swift in Sources */, + B2010000000000000000000E /* ThreadDetailView.swift in Sources */, + B2010000000000000000000F /* SettingsView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + B20900000000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + B20900000000000000000002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_NO_COMMON_BLOCKS = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + B20900000000000000000003 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + "CODE_SIGNING_ALLOWED[sdk=iphonesimulator*]" = NO; + "LD_ENTITLEMENTS_SECTION[sdk=iphonesimulator*]" = "$(SRCROOT)/App/Simulator.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = YSB9XBNUFD; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = App/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.0.23; + PRODUCT_BUNDLE_IDENTIFIER = "com.maker-or.omni.remote"; + PRODUCT_NAME = PipperRemote; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + }; + name = Debug; + }; + B20900000000000000000004 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + "CODE_SIGNING_ALLOWED[sdk=iphonesimulator*]" = NO; + "LD_ENTITLEMENTS_SECTION[sdk=iphonesimulator*]" = "$(SRCROOT)/App/Simulator.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = YSB9XBNUFD; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = App/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.0.23; + PRODUCT_BUNDLE_IDENTIFIER = "com.maker-or.omni.remote"; + PRODUCT_NAME = PipperRemote; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + B20800000000000000000001 /* Build configuration list for PBXProject "PipperRemote" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B20900000000000000000001 /* Debug */, + B20900000000000000000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + B20800000000000000000002 /* Build configuration list for PBXNativeTarget "PipperRemote" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B20900000000000000000003 /* Debug */, + B20900000000000000000004 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ +/* Begin XCRemoteSwiftPackageReference section */ + B20A00000000000000000001 /* XCRemoteSwiftPackageReference "textual" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/gonzalezreal/textual"; + requirement = { + kind = upToNextMinorVersion; + minimumVersion = 0.5.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + B20A00000000000000000002 /* Textual */ = { + isa = XCSwiftPackageProductDependency; + package = B20A00000000000000000001 /* XCRemoteSwiftPackageReference "textual" */; + productName = Textual; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = B20700000000000000000001 /* Project object */; +} diff --git a/native/pipper-remote-ios/PipperRemote.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/native/pipper-remote-ios/PipperRemote.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/native/pipper-remote-ios/PipperRemote.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/native/pipper-remote-ios/README.md b/native/pipper-remote-ios/README.md new file mode 100644 index 0000000..474ab99 --- /dev/null +++ b/native/pipper-remote-ios/README.md @@ -0,0 +1,153 @@ +# PipperRemote (iOS, Siri-first) + +Native SwiftUI companion that talks to the laptop's `RemoteServer` +(`electron/remote-server.ts`) through the laptop's Pipper tunnel +(`https://lt-….pipper.dev`, see `docs/remote-access.md`) — the same API, +device pairing, and per-phone tokens as the hosted phone app. Its reason to +exist is Siri: + +> "Start a Pipper thread in FolkLore" → Siri asks "What should the thread +> work on?" → the laptop creates a `phone-xxxx` worktree + thread and runs the +> agent. The app never has to open. + +## How it maps to the macOS Siri integration + +| macOS (`native/pipper-intents`) | iOS (this app) | +| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| Electron writes `siri-catalog.json` to `~/Library/pipper` | Laptop serves it as `GET /api/remote/catalog`; app caches it in its own Application Support | +| `String` params + `DynamicOptionsProvider` (ad-hoc workaround) | Real `AppEntity` (`ProjectEntity`, `AgentEntity`) + `EntityStringQuery` so Siri phrases can carry the project/agent | +| Intent stages `siri-requests/.json`, opens `pipper://siri/` | Intent POSTs `/api/remote/threads` directly (`modelId` = agent id) | +| App Intents **extension** (needs sandbox exceptions) | Intents run **in the app process** — no App Group, so a personal (free) team is enough | + +The catalog shape is identical on both sides (`RemoteCatalog` ⇔ `SiriCatalog` +in `electron/siri/siri-catalog.ts`): selected agents only, with `available`. +Siri resolves names against the on-disk cache (fast, works with the Mac +asleep); the app refreshes it on launch/foreground, after every intent, and on +demand from Settings, then calls `updateAppShortcutParameters()` so Siri +learns new project names. + +## Layout + +- `Sources/PipperRemoteCore/` — Foundation-only: wire models, `RemoteConfig` + (the paired laptop + this phone's token), `PairingLink` (parses the + laptop's pairing QR/link), `LaptopAttestation` (checks pipper.dev's signed + owner statement), `CatalogStore`, `RemoteClient`. `swift test` runs here on + the Mac. +- `App/` — SwiftUI app, `RemoteSession` (pairing in Keychain + defaults, + catalog), and `Intents/` (`StartThreadIntent`, `CheckMacIntent`, entities, + `PipperRemoteShortcuts` phrases). +- `PipperRemote.xcodeproj` — single app target compiling both directories. + +## Build & install + +### Device (Siri by voice) + +1. Open `PipperRemote.xcodeproj` in Xcode, select the `PipperRemote` target → + Signing & Capabilities → pick your personal team. Change the bundle id if + `com.maker-or.omni.remote` is taken on your team. +2. Run on the phone. Free provisioning profiles expire after 7 days — re-run + from Xcode to renew. +3. On the Mac, choose the Cloudflare connection in Pipper → Settings → Remote + (sign in to pipper.dev once so the laptop gets its `lt-…` address). +4. Pair: Settings → Remote → Pair a phone, then in the app tap **Scan pairing + QR**. The app shows the laptop's name and verified owner; confirm to pair. +5. Say "Start a Pipper thread in ". First-time phrase indexing can + take a minute after install. + +### Simulator (intent via the Shortcuts app; no Siri voice) + +```bash +native/pipper-remote-ios/scripts/run-simulator.sh # iPhone 17 Pro +native/pipper-remote-ios/scripts/run-simulator.sh "iPhone 17" +``` + +Then pair manually (the simulator has no camera): copy the pairing link from +the Mac's Settings → Remote and paste it into **Pairing link or laptop +address**, or type the `lt-….pipper.dev` address and the code. Test the intent from +**Shortcuts → + → "Start Pipper thread"**. + +**Why the project re-signs simulator builds.** Xcode ad-hoc signs every +simulator build, and an ad-hoc bundle has no Team ID. App Intents then cannot +register the app's `AppEntity` types (`ProjectEntity is not a registered +AppEntity identifier` in the log), every entity parameter arrives `nil`, and +Shortcuts reports "could not run because an internal error occurred" +(`LNContextErrorDomain 2004`). This is the same failure the ad-hoc macOS +extension hit. The target sets `CODE_SIGNING_ALLOWED=NO` for the simulator +SDK and `scripts/sign-simulator.sh` signs with the first `Apple Development` +identity in the keychain instead, so both `xcodebuild` and Xcode's Run button +produce a working build. You need to be signed in to Xcode → Settings → +Accounts once so that identity exists. Never pass `CODE_SIGNING_ALLOWED=NO` +on the command line: it also disables the re-sign phase. + +Core tests (no simulator needed): `cd native/pipper-remote-ios && xcrun swift test` +(`xcrun` picks Xcode's toolchain, which has CryptoKit; a standalone +swift.org toolchain does not). + +## Phrases + +App Shortcuts allow one entity parameter per phrase, so: + +- `Start a thread in Pipper` / `Start a Pipper thread` / `New Pipper thread` +- `Start a Pipper thread in ` / `New Pipper thread in ` +- `Start a Pipper thread with ` / `Ask in Pipper` +- `Is my Mac reachable in Pipper` / `Check my Mac with Pipper` + +Siri always asks for the task (it is a required `String`). When no agent is +named, `perform()` uses the Mac's default agent from the catalog, falling back +to the first available one. + +## Pairing and threat model + +Pairing follows the hosted phone app (`docs/remote-access.md`): + +1. The Mac shows a one-time code as a QR/link: + `https://remote.pipper.dev/#pair=CODE&host=lt-….pipper.dev`. + The app only ever talks to `host`, and only when it is an `lt-*` host + under pipper.dev (the zone locks those down as API-only). Laptop-served + links (`/remote#pair=CODE`) work for quick tunnels + (`*.trycloudflare.com`) and, over plain HTTP, Tailscale/loopback only. +2. Nothing is redeemed on the link's say-so. The app asks the laptop to + describe itself (`POST /api/remote/pair/preview`, which doesn't use the + code up), verifies pipper.dev's Ed25519 owner statement for that exact + host with the built-in public key (`LaptopAttestation.pipperPublicKey`, + same as `vite.remote-web.config.ts`; rotate both together), and shows + "Belongs to … — verified by Pipper" or an **Unverified** warning. +3. On confirm, `POST /api/remote/pair` returns this phone's own token. It + lives in the Keychain (`AfterFirstUnlock`, so Siri works from the lock + screen); the laptop address and owner live in defaults. +4. Unpair revokes the token on the Mac (`DELETE /api/remote/session`). If + the Mac revokes the phone instead, the next request gets a 401 and the + app returns to pairing with a notice. + +Builds before device pairing stored one shared Tailscale token; the Mac no +longer accepts it, so the app deletes it on launch and asks to pair again. + +## Remote controls and recovery + +Thread creation and follow-ups carry a `requestId` (the Mac still accepts +older phones without one, just without retry de-duplication). The phone retains an unacknowledged ID across retries and +app restarts; Pipper records it before creating a worktree or dispatching a +prompt. Follow-ups are acknowledged immediately. If Pipper restarts during an +uncertain dispatch, the existing request is shown as interrupted and is never +replayed automatically. Inspect its thread before deliberately starting new work. +A confirmed rejection before dispatch permits a new attempt after fixing setup. + +New remote and Mac Siri threads require a verified Git worktree. A worktree +failure stops creation; follow-ups also stop if their worktree has disappeared. +Existing project-root threads can be read and stopped remotely, but further work +must be started in an isolated thread. + +The thread report includes pending agent decisions. Choose the agent's offered +option, dismiss the request, or stop that thread; stale answers are rejected. +Unanswered permissions expire without approval. Stopping a thread also stops +its agent terminals and child runs without stopping another thread's terminals. + +Connection checks on the home screen and in Settings verify pairing, +Pipper availability, installed agents, and projects. The sample-task form lets +you choose a project before asking its agent for a one-sentence description. +Thread views retain the last report during disconnects and show its update time. + +Server receipts live under `userData/remote-requests/`. Do not delete this +history to retry a task: it prevents duplicate dispatch after a lost response. +ACP itself does not provide an idempotency key, so an interrupted execution +requires inspection instead of an automatic retry. diff --git a/native/pipper-remote-ios/Sources/PipperRemoteCore/CatalogStore.swift b/native/pipper-remote-ios/Sources/PipperRemoteCore/CatalogStore.swift new file mode 100644 index 0000000..e8954b3 --- /dev/null +++ b/native/pipper-remote-ios/Sources/PipperRemoteCore/CatalogStore.swift @@ -0,0 +1,43 @@ +import Foundation + +/// On-disk cache of the laptop catalog. Siri parameter resolution reads this +/// (fast, offline-safe); the app and intents refresh it opportunistically. +public struct CatalogStore: Sendable { + public let fileURL: URL + + public init(fileURL: URL) { + self.fileURL = fileURL + } + + /// `/PipperRemote/siri-catalog.json` in the app's own + /// container. Intents run in-process, so no App Group is needed. + public static func standard() -> CatalogStore { + let base = + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.temporaryDirectory + return CatalogStore( + fileURL: base.appendingPathComponent("PipperRemote", isDirectory: true) + .appendingPathComponent("siri-catalog.json")) + } + + public func load() -> RemoteCatalog? { + guard let data = try? Data(contentsOf: fileURL) else { return nil } + return try? JSONDecoder().decode(RemoteCatalog.self, from: data) + } + + public func save(_ catalog: RemoteCatalog) throws { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + let data = try JSONEncoder().encode(catalog) + try data.write(to: fileURL, options: .atomic) + } + + public func clear() { + try? FileManager.default.removeItem(at: fileURL) + } + + /// When the cache was last written, for staleness checks. + public var modifiedAt: Date? { + (try? FileManager.default.attributesOfItem(atPath: fileURL.path))?[.modificationDate] as? Date + } +} diff --git a/native/pipper-remote-ios/Sources/PipperRemoteCore/RemoteClient.swift b/native/pipper-remote-ios/Sources/PipperRemoteCore/RemoteClient.swift new file mode 100644 index 0000000..5b11305 --- /dev/null +++ b/native/pipper-remote-ios/Sources/PipperRemoteCore/RemoteClient.swift @@ -0,0 +1,319 @@ +import Foundation +import CryptoKit + +public enum RemoteClientError: Error, LocalizedError, Equatable { + case notPaired + case badURL + case unreachable(String) + case http(status: Int, body: String) + case decoding(String) + case rejected(String) + + public var errorDescription: String? { + switch self { + case .notPaired: return "Not paired with a Mac yet." + case .badURL: return "The Mac address is invalid." + case .unreachable(let why): + return "Couldn't reach your Mac (\(why)). Keep Pipper open on your Mac and check your connection." + case .http(let status, let body): + if status == 401 { return "Your Mac no longer accepts this phone. Pair again." } + if status == 429 { return "Too many attempts. Wait a minute and try again." } + if status == 503 { return "The agent on your Mac isn't ready yet." } + // 403 explains itself (e.g. a read-only phone starting work). + if status == 403, !body.isEmpty { return body } + let detail = body.trimmingCharacters(in: .whitespacesAndNewlines) + return detail.isEmpty ? "Mac returned HTTP \(status)." : "Mac returned HTTP \(status): \(detail)" + case .rejected(let message): return message + case .decoding(let why): return "Unexpected reply from the Mac (\(why))." + } + } +} + +/// Thin async client for the laptop's `/api/remote/*` routes. This phone's +/// device token on every call; short timeouts because Siri waits on `perform()`. +public struct RemoteClient: Sendable { + public let config: RemoteConfig + private let session: URLSession + /// Called when the laptop refuses this phone's token (revoked or expired). + private let onUnauthorized: (@Sendable () -> Void)? + + public init( + config: RemoteConfig, session: URLSession? = nil, + onUnauthorized: (@Sendable () -> Void)? = nil + ) { + self.config = config + self.session = session ?? Self.defaultSession() + self.onUnauthorized = onUnauthorized + } + + static func defaultSession() -> URLSession { + let c = URLSessionConfiguration.ephemeral + c.timeoutIntervalForRequest = 15 + c.timeoutIntervalForResource = 30 + c.waitsForConnectivity = false + return URLSession(configuration: c) + } + + // MARK: Routes + + public func health() async throws -> Bool { + struct Health: Decodable { var ok: Bool } + let h: Health = try await get("/api/remote/health") + return h.ok + } + + /// The laptop's view of this phone (name and scopes). + public func device() async throws -> RemoteDevice { + struct Body: Decodable { var device: RemoteDevice } + let b: Body = try await get("/api/remote/session") + return b.device + } + + /// Unpair on the laptop too, so the token is dead even if it leaked. + public func revokeThisDevice() async throws { + struct Body: Decodable { var ok: Bool } + let _: Body = try await perform(request("/api/remote/session", method: "DELETE", body: nil)) + } + + public func diagnostics() async throws -> RemoteDiagnostics { + try await get("/api/remote/diagnostics") + } + + public func stop(threadId: String) async throws { + struct Input: Encodable {} + struct Body: Decodable { var ok: Bool } + let _: Body = try await post("/api/remote/threads/\(encode(threadId))/stop", Input()) + } + + public func answer(threadId: String, decisionId: String, optionId: String?, cancelled: Bool = false) async throws { + struct Input: Encodable { var decisionId: String; var optionId: String?; var cancelled: Bool } + struct Body: Decodable { var ok: Bool } + let _: Body = try await post("/api/remote/threads/\(encode(threadId))/permission", + Input(decisionId: decisionId, optionId: optionId, cancelled: cancelled)) + } + + public func fetchCatalog() async throws -> RemoteCatalog { + try await get("/api/remote/catalog") + } + + public func listThreads() async throws -> [RemoteThreadSummary] { + struct Body: Decodable { var threads: [RemoteThreadSummary] } + let b: Body = try await get("/api/remote/threads") + return b.threads + } + + public func report(threadId: String) async throws -> RemoteReport { + struct Body: Decodable { var report: RemoteReport } + let b: Body = try await get("/api/remote/threads/\(encode(threadId))/report") + return b.report + } + + /// Models inside each agent, keyed by agent instance id. The Mac may spawn + /// agents to answer, so this gets a longer timeout than other routes. + public func agentModels() async throws -> [String: [RemoteAgentModel]] { + struct Body: Decodable { var models: [String: [RemoteAgentModel]] } + var req = try request("/api/remote/agent-models", method: "GET", body: nil) + req.timeoutInterval = 30 + let b: Body = try await perform(req) + return b.models + } + + /// `agentId` maps to the laptop's `modelId` field (it is an agent id there); + /// `model` is the model inside that agent, nil for the agent's default. + public func createThread( + projectId: String, agentId: String?, model: String? = nil, prompt: String, requestId: String + ) async throws -> RemoteThreadSummary { + struct Input: Encodable { + var requestId: String + var projectId: String + var modelId: String? + var model: String? + var prompt: String + } + struct Body: Decodable { var thread: RemoteThreadSummary } + let b: Body = try await post( + "/api/remote/threads", + Input(requestId: requestId, projectId: projectId, modelId: agentId, model: model, prompt: prompt)) + return b.thread + } + + /// Switches the model for the thread's next turn. + public func setModel(threadId: String, model: String) async throws -> RemoteThreadModel? { + struct Input: Encodable { var model: String } + struct Body: Decodable { var model: RemoteThreadModel? } + let b: Body = try await post("/api/remote/threads/\(encode(threadId))/model", Input(model: model)) + return b.model + } + + public func sendPrompt(threadId: String, prompt: String, requestId: String) async throws { + struct Input: Encodable { var requestId: String; var prompt: String } + struct Body: Decodable { var ok: Bool } + let _: Body = try await post("/api/remote/threads/\(encode(threadId))/prompt", Input(requestId: requestId, prompt: prompt)) + } + + // MARK: Transport + + /// RFC 3986 unreserved characters pass through untouched. The Mac matches + /// the raw `url.pathname` without decoding, so escaping the `-` in a UUID + /// (as `.alphanumerics` alone would, to `%2D`) makes every thread 404. + private static let pathSegmentAllowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._~")) + + private func encode(_ segment: String) -> String { + segment.addingPercentEncoding(withAllowedCharacters: Self.pathSegmentAllowed) ?? segment + } + + private func request(_ path: String, method: String, body: Data?) throws -> URLRequest { + guard config.isComplete else { throw RemoteClientError.notPaired } + guard let url = URL(string: path, relativeTo: config.baseURL) else { throw RemoteClientError.badURL } + var req = URLRequest(url: url) + req.httpMethod = method + req.setValue("Bearer \(config.token)", forHTTPHeaderField: "Authorization") + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue("application/json", forHTTPHeaderField: "Accept") + req.httpBody = body + return req + } + + private func get(_ path: String) async throws -> T { + try await perform(request(path, method: "GET", body: nil)) + } + + private func post(_ path: String, _ input: I) async throws -> T { + let data = try JSONEncoder().encode(input) + return try await perform(request(path, method: "POST", body: data)) + } + + private struct Rejection: Decodable { var error: String; var retryable: Bool? } + + // MARK: Pairing (no token yet) + + /// Ask the laptop who it is without using the code up, so the user can + /// confirm before this phone sends it anything (pairing-link hijack defense). + public static func previewPairing( + _ link: PairingLink, session: URLSession? = nil + ) async throws -> RemoteLaptopIdentity { + struct Input: Encodable { var code: String } + struct Body: Decodable { var laptop: RemoteLaptopIdentity } + let b: Body = try await pairingCall( + "/api/remote/pair/preview", link: link, input: Input(code: link.code), session: session) + return b.laptop + } + + /// Redeem the one-time code for this phone's own device token. + public static func redeemPairing( + _ link: PairingLink, deviceName: String, session: URLSession? = nil + ) async throws -> RemotePairResponse { + struct Input: Encodable { var code: String; var deviceName: String } + return try await pairingCall( + "/api/remote/pair", link: link, input: Input(code: link.code, deviceName: deviceName), + session: session) + } + + private static func pairingCall( + _ path: String, link: PairingLink, input: I, session: URLSession? + ) async throws -> T { + guard let url = URL(string: path, relativeTo: link.baseURL) else { throw RemoteClientError.badURL } + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue("application/json", forHTTPHeaderField: "Accept") + req.httpBody = try JSONEncoder().encode(input) + do { + return try await send(req, session: session ?? defaultSession()) + } catch RemoteClientError.http(status: 401, _) { + throw RemoteClientError.rejected("That code is wrong or has expired. Make a new one on your Mac.") + } catch RemoteClientError.http(status: 404, _) { + throw RemoteClientError.rejected( + "That Mac's Pipper is too old for this app. Update Pipper on your Mac, then pair again.") + } + } + + private func perform(_ req: URLRequest) async throws -> T { + do { + return try await Self.send(req, session: session) + } catch RemoteClientError.http(status: 401, let body) { + onUnauthorized?() + throw RemoteClientError.http(status: 401, body: body) + } + } + + static func send(_ req: URLRequest, session: URLSession) async throws -> T { + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: req) + } catch { + throw RemoteClientError.unreachable((error as NSError).localizedDescription) + } + guard let http = response as? HTTPURLResponse else { + throw RemoteClientError.decoding("not an HTTP response") + } + guard (200..<300).contains(http.statusCode) else { + let text = String(data: data.prefix(16_384), encoding: .utf8) ?? "" + if let rejection = try? JSONDecoder().decode(Rejection.self, from: data), rejection.retryable == true { + throw RemoteClientError.rejected(rejection.error) + } + throw RemoteClientError.http(status: http.statusCode, body: RemoteClient.errorMessage(from: text)) + } + do { + return try JSONDecoder().decode(T.self, from: data) + } catch { + throw RemoteClientError.decoding(String(describing: error)) + } + } + + /// Server errors are `{ "error": "..." }`; surface the message, not JSON. + static func errorMessage(from body: String) -> String { + struct E: Decodable { var error: String } + if let data = body.data(using: .utf8), let e = try? JSONDecoder().decode(E.self, from: data) { + return e.error + } + return body + } +} + + +/// Keeps the same ID after a timeout, app restart, or repeated Siri attempt. +/// Only hashes and UUIDs are stored. Clear after the Mac acknowledges acceptance. +@MainActor +public final class RemoteSubmissionStore { + private let fileURL: URL + + public init(fileURL: URL? = nil) { + self.fileURL = fileURL ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("remote-submissions.json") + } + + public func requestId(for scope: [String]) throws -> String { + var pending = try load() + let key = try key(scope) + if let id = pending[key] { return id } + let id = UUID().uuidString + pending[key] = id + try save(pending) + return id + } + + public func acknowledge(_ scope: [String], requestId: String) throws { + var pending = try load() + let key = try key(scope) + if pending[key] == requestId { + pending.removeValue(forKey: key) + try save(pending) + } + } + + private func key(_ scope: [String]) throws -> String { + SHA256.hash(data: try JSONEncoder().encode(scope)).map { String(format: "%02x", $0) }.joined() + } + + private func load() throws -> [String: String] { + guard FileManager.default.fileExists(atPath: fileURL.path) else { return [:] } + return try JSONDecoder().decode([String: String].self, from: Data(contentsOf: fileURL)) + } + + private func save(_ pending: [String: String]) throws { + try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try JSONEncoder().encode(pending).write(to: fileURL, options: .atomic) + } +} diff --git a/native/pipper-remote-ios/Sources/PipperRemoteCore/RemoteConfig.swift b/native/pipper-remote-ios/Sources/PipperRemoteCore/RemoteConfig.swift new file mode 100644 index 0000000..8dc74a4 --- /dev/null +++ b/native/pipper-remote-ios/Sources/PipperRemoteCore/RemoteConfig.swift @@ -0,0 +1,244 @@ +import CryptoKit +import Foundation + +/// Account that pipper.dev says owns a laptop (from its signed statement). +public struct LaptopOwner: Codable, Equatable, Sendable { + public var sub: String + public var email: String? + public var name: String? + + public init(sub: String, email: String?, name: String?) { + self.sub = sub + self.email = email + self.name = name + } + + public var label: String { email ?? name ?? "a Pipper account" } +} + +/// The paired laptop: where its `/api/remote/*` lives and this phone's own +/// device token (issued by the laptop for a one-time pairing code). +public struct RemoteConfig: Codable, Equatable, Sendable { + /// Origin of the laptop's API, e.g. `https://lt-ab12….pipper.dev`. + public var baseURL: URL + public var token: String + /// What the laptop calls itself (unverified). + public var laptopName: String? + /// Owner verified at pairing time; nil when unverified. + public var owner: LaptopOwner? + /// The name this phone has in the laptop's device list. + public var deviceName: String? + + public init( + baseURL: URL, token: String, laptopName: String? = nil, owner: LaptopOwner? = nil, + deviceName: String? = nil + ) { + self.baseURL = baseURL + self.token = token.trimmingCharacters(in: .whitespacesAndNewlines) + self.laptopName = laptopName + self.owner = owner + self.deviceName = deviceName + } + + /// Host (and port, when not the scheme's default) for display and keys. + public var address: String { + guard let host = baseURL.host else { return baseURL.absoluteString } + return baseURL.port.map { "\(host):\($0)" } ?? host + } + + public var isComplete: Bool { !token.isEmpty && baseURL.host?.isEmpty == false } + + /// "MacBook Pro · me@example.com": verified owner when there is one. + public var label: String { + let name = laptopName ?? "Your Mac" + return owner.map { "\(name) · \($0.label)" } ?? name + } +} + +/// A pairing offer from the laptop's QR or link, before it is redeemed. The +/// laptop shows it in Settings → Remote as a one-time code plus a link: +/// - named tunnel (Cloudflare): `https://remote.pipper.dev/#pair=CODE&host=lt-….pipper.dev` +/// - laptop-served (quick tunnel or Tailscale): `/remote#pair=CODE` +public struct PairingLink: Equatable, Sendable, Identifiable { + /// Domain whose reserved `lt-*` hosts are Pipper laptop tunnels. + public static let laptopDomain = "pipper.dev" + /// Port the laptop listens on when reached directly (Tailscale). + public static let defaultLaptopPort = 4173 + + public var code: String + public var baseURL: URL + + public init?(code: String, baseURL: URL) { + let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines) + guard Self.isValidCode(trimmed), Self.isAllowedBase(baseURL) else { return nil } + self.code = trimmed + self.baseURL = baseURL + } + + public var id: String { "\(baseURL.absoluteString)#\(code)" } + + public var host: String { baseURL.host?.lowercased() ?? "" } + + /// Reached through Pipper's own network (a named Cloudflare tunnel), so + /// pipper.dev's signed owner statement can be checked for this host. + public var isNamedTunnel: Bool { baseURL.scheme == "https" && Self.isLaptopTunnelHost(host) } + + /// Parses a scanned QR or pasted pairing link. Nil for anything else. + public static func parse(_ text: String) -> PairingLink? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard let components = URLComponents(string: trimmed), let fragment = components.fragment + else { return nil } + let params = fragmentParams(fragment) + guard let code = params["pair"] else { return nil } + if let host = params["host"] { + // Hosted-app link: the laptop is named by `host`, never by the page. + guard isLaptopTunnelHost(host), let base = URL(string: "https://\(host.lowercased())") + else { return nil } + return PairingLink(code: code, baseURL: base) + } + // Laptop-served link: the laptop is the page's own origin. + guard let base = origin(of: components) else { return nil } + return PairingLink(code: code, baseURL: base) + } + + /// Typed or pasted by hand: a full pairing link in `address` wins; + /// otherwise `address` names the laptop (`lt-….pipper.dev`, a Tailscale + /// IP, or a URL) and `code` is the one shown under the QR. + public static func manual(address: String, code: String) -> PairingLink? { + let text = address.trimmingCharacters(in: .whitespacesAndNewlines) + if let link = parse(text) { return link } + guard !text.isEmpty else { return nil } + let base: URL? + if text.contains("://") { + base = URLComponents(string: text).flatMap(origin(of:)) + } else { + let host = text.split(separator: "/", maxSplits: 1).first.map(String.init) ?? text + let bare = host.split(separator: ":", maxSplits: 1).first.map(String.init) ?? host + if isLaptopTunnelHost(bare) { + base = URL(string: "https://\(host.lowercased())") + } else { + base = URL(string: "http://\(host.contains(":") ? host : "\(host):\(defaultLaptopPort)")") + } + } + return base.flatMap { PairingLink(code: code, baseURL: $0) } + } + + // MARK: Rules + + static func isValidCode(_ code: String) -> Bool { + code.range(of: #"^[0-9A-Za-z-]{4,32}$"#, options: .regularExpression) != nil + } + + /// `lt-