Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"scripts": {
"build": "esbuild src/index.ts --bundle --platform=node --format=cjs --external:esbuild --external:@babel/core --external:@babel/preset-typescript --external:babel-preset-solid --external:bufferutil --external:utf-8-validate --outfile=dist/index.js && chmod +x dist/index.js",
"check": "tsc --noEmit",
"test:browser-companion": "node --test --experimental-strip-types tests/browser-companion-protocol.test.ts",
"symlink:remove": "rm -f /opt/homebrew/bin/dapi",
"symlink:create": "npm run build && ln -sf \"$PWD/dist/index.js\" /opt/homebrew/bin/dapi"
},
Expand Down
81 changes: 81 additions & 0 deletions apps/cli/src/browser-companion-protocol.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
export type BrowserCompanionCapabilities = {
readOnly: true;
browserDapi: false;
cloudAi: false;
persistentEdits: false;
htmlPaint: false;
media: "unsupported-phase-a";
webgpu: "browser-dependent";
fonts: "browser-dependent";
trustedProjectCode: true;
};

export type BrowserCompanionCommand =
| { kind: "browser-companion"; action: "prepare"; projectDir: string }
| { kind: "browser-companion"; action: "start"; projectDir: string; projectId?: string }
| { kind: "browser-companion"; action: "status" }
| { kind: "browser-companion"; action: "logs" }
| { kind: "browser-companion"; action: "stop" };

export type BrowserCompanionLog = {
seq: number;
ts: number;
level: "debug" | "info" | "warning" | "error";
event: string;
data?: Record<string, unknown>;
};

export type BrowserCompanionRevisionIdentity = {
sessionId: string;
revision: number;
bundleHash: string;
};

export type BrowserCompanionStatus = {
active: boolean;
sessionId?: string;
origin?: string;
appVersion?: string;
buildHash?: string;
protocol?: number;
project?: { id: string; name: string; displayName: string };
rendererConnected?: boolean;
hostWindowVisible?: boolean;
hostWindowMode?: "hidden" | "minimized-fallback";
hostLocalOnly?: boolean;
revision?: number;
bundleHash?: string;
canonicalCompiled?: BrowserCompanionRevisionIdentity;
hostApplied?: BrowserCompanionRevisionIdentity;
browserApplied?: BrowserCompanionRevisionIdentity;
lifecycle?: "awaiting-renderer" | "awaiting-host-apply" | "awaiting-browser-apply" | "ready" | "disconnected-fresh-session-required" | "failed";
mountError?: string;
egressAttempts?: number;
};

export type BrowserCompanionStart = BrowserCompanionStatus & {
active: true;
url: string;
capabilities: BrowserCompanionCapabilities;
humanStep: string;
};

export type BrowserCompanionReply =
| { ok: true; data: BrowserCompanionStart | BrowserCompanionStatus | BrowserCompanionLog[] }
| { ok: false; error: string };

export function isBrowserCompanionCommand(value: unknown): value is BrowserCompanionCommand {
if (!value || typeof value !== "object") return false;
const command = value as { kind?: unknown; action?: unknown; projectDir?: unknown; projectId?: unknown };
if (command.kind !== "browser-companion") return false;
if (command.action !== "prepare" && command.action !== "start" && command.action !== "status" && command.action !== "logs" && command.action !== "stop") return false;
const keys = Object.keys(value).sort();
if (command.action !== "prepare" && command.action !== "start") {
return keys.length === 2 && keys[0] === "action" && keys[1] === "kind";
}
const exactKeys = command.action === "prepare"
? keys.join(",") === "action,kind,projectDir"
: keys.join(",") === "action,kind,projectDir" || keys.join(",") === "action,kind,projectDir,projectId";
return exactKeys && typeof command.projectDir === "string" && command.projectDir.length > 0 && command.projectDir.length <= 32_768 && !command.projectDir.includes("\0") &&
(command.action === "prepare" || command.projectId === undefined || (typeof command.projectId === "string" && command.projectId.length > 0 && command.projectId.length <= 256 && !command.projectId.includes("\0")));
}
23 changes: 22 additions & 1 deletion apps/cli/src/cli-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { observable } from "@trpc/server/observable";
import { SOCKET_PATH } from "./protocol";
import type { CliHandshake, CliHandshakeReply, CliReply, CliRequest } from "./protocol";
import type { AppRouter } from "../../web/src/context/dapi";
import type { BrowserCompanionCommand, BrowserCompanionReply } from "./browser-companion-protocol";

const DEFAULT_TIMEOUT_MS = 60000;
export const GENERATE_TIMEOUT_MS = 600000;
Expand Down Expand Up @@ -138,7 +139,7 @@ export const editor = createTRPCClient<AppRouter>({ links: [cliLink] });
// Transport failures surface as TRPCClientError wrapping the socket error;
// unwrap to reach errno codes like ENOENT/ECONNREFUSED.
export function errnoCode(e: unknown): string | undefined {
if (!(e instanceof TRPCClientError)) return undefined;
if (!(e instanceof TRPCClientError)) return (e as NodeJS.ErrnoException | undefined)?.code;
return (e.cause as NodeJS.ErrnoException | undefined)?.code;
}

Expand All @@ -165,3 +166,23 @@ export async function waitForCliSocket(timeoutMs = 30000): Promise<void> {
? lastError
: new Error("Timed out waiting for the app to start");
}

/** Main-owned companion management; no request is forwarded to either renderer. */
export function browserCompanion(command: BrowserCompanionCommand, timeoutMs = 60_000): Promise<unknown> {
return new Promise((resolve, reject) => {
const socket = connect(SOCKET_PATH);
let buffer = "";
socket.setEncoding("utf8");
socket.setTimeout(timeoutMs, () => socket.destroy(new Error("Timed out waiting for browser companion host")));
socket.on("connect", () => socket.end(JSON.stringify(command)));
socket.on("data", (chunk) => { buffer += chunk; });
socket.on("error", reject);
socket.on("end", () => {
try {
const reply = JSON.parse(buffer) as BrowserCompanionReply;
if (!reply.ok) throw new Error(reply.error);
resolve(reply.data);
} catch (error) { reject(error); }
});
});
}
70 changes: 67 additions & 3 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
import { Command } from "commander";
import { version } from "../../../package.json";
import { parseTime, TIME_FPS } from "@diffusionstudio/jsx";
import { editor, errnoCode, EXPORT_TIMEOUT_MS, GENERATE_TIMEOUT_MS, waitForCliSocket } from "./cli-client";
import { browserCompanion, editor, errnoCode, EXPORT_TIMEOUT_MS, GENERATE_TIMEOUT_MS, waitForCliSocket } from "./cli-client";
import { listLocalFonts } from "./fonts";
import { buildIssueBody, createIssue } from "./report";
import { fetchVideo } from "./ytdlp";
Expand Down Expand Up @@ -331,10 +331,13 @@ async function checkNode(id: string): Promise<void> {
}

type OpenOptions = { background?: boolean };
type BrowserOptions = { status?: boolean; logs?: boolean; stop?: boolean };

/** `open -a` on a running app only activates it, so this is safe to always run. */
function launchApp(background: boolean): Promise<boolean> {
const args = background ? ["-g", "-a", APP_NAME, "--args", "--hidden"] : ["-a", APP_NAME];
function launchApp(background: boolean, companionHost = false): Promise<boolean> {
const args = background
? ["-g", "-a", APP_NAME, "--args", "--hidden", ...(companionHost ? ["--browser-companion-host"] : [])]
: ["-a", APP_NAME];
return new Promise((res) => execFile("open", args, (err) => res(!err)));
}

Expand All @@ -359,6 +362,58 @@ async function openProject(path: string | undefined, opts: OpenOptions): Promise
}
}

async function browserProject(path: string | undefined, opts: BrowserOptions): Promise<void> {
const actions = [opts.status, opts.logs, opts.stop].filter(Boolean).length;
if (actions > 1 || (actions && path)) {
console.error("Pass one of --status, --logs, or --stop without a project path.");
process.exit(1);
}
try {
if (opts.status) console.log(JSON.stringify(await browserCompanion({ kind: "browser-companion", action: "status" })));
else if (opts.logs) console.log(JSON.stringify(await browserCompanion({ kind: "browser-companion", action: "logs" })));
else if (opts.stop) console.log(JSON.stringify(await browserCompanion({ kind: "browser-companion", action: "stop" })));
else {
if (!path) {
console.error("A project folder is required: dapi browser <project>");
process.exit(1);
}
let running = false;
try {
await editor.ping.query();
running = true;
} catch (error) {
const code = errnoCode(error);
if (code !== "ENOENT" && code !== "ECONNREFUSED") throw error;
}
if (!running) {
const launched = process.platform === "darwin" && (await launchApp(true, true));
if (launched) await waitForCliSocket();
else await editor.ping.query();
}
const projectDir = resolve(path);
await browserCompanion({ kind: "browser-companion", action: "prepare", projectDir });
await waitForCliSocket();
const opened = await editor.open.mutate({ dir: projectDir });
const readyBy = Date.now() + 30_000;
while (true) {
const context = await editor.context.query();
if (context.projectDir === projectDir) break;
if (Date.now() >= readyBy) throw new Error("The hidden Electron renderer did not finish opening the project");
await new Promise((resolveWait) => setTimeout(resolveWait, 100));
}
const result = await browserCompanion({
kind: "browser-companion",
action: "start",
projectDir,
...(opened.id ? { projectId: opened.id } : {}),
});
console.log(JSON.stringify(result));
}
} catch (error) {
handleSocketError(error);
}
}

async function context(): Promise<void> {
try {
const result = await editor.context.query();
Expand Down Expand Up @@ -621,6 +676,15 @@ program
.option("-b, --background", "launch or keep the app in the background, without raising a window")
.action((path: string | undefined, opts: OpenOptions) => openProject(path, opts));

program
.command("browser")
.description("Open a read-only local browser companion backed by the hidden Electron host. Prints JSON; never opens an OS browser.")
.argument("[path]", "project folder for a new companion session")
.option("--status", "print companion and hidden-host status")
.option("--logs", "print structured companion logs")
.option("--stop", "stop the companion listener and release its resources")
.action((path: string | undefined, opts: BrowserOptions) => browserProject(path, opts));

program
.command("context")
.alias("ctx")
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@
// own repo.
export * from "./cli-channels";
export * from "./cli-socket-path";
export * from "./browser-companion-protocol";
38 changes: 38 additions & 0 deletions apps/cli/tests/browser-companion-protocol.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import test from "node:test";

import { isBrowserCompanionCommand } from "../src/browser-companion-protocol.ts";

test("accepts the narrow companion lifecycle commands", () => {
assert.equal(isBrowserCompanionCommand({ kind: "browser-companion", action: "status" }), true);
assert.equal(isBrowserCompanionCommand({ kind: "browser-companion", action: "prepare", projectDir: "/trusted/project" }), true);
assert.equal(isBrowserCompanionCommand({ kind: "browser-companion", action: "logs" }), true);
assert.equal(isBrowserCompanionCommand({ kind: "browser-companion", action: "stop" }), true);
assert.equal(isBrowserCompanionCommand({
kind: "browser-companion",
action: "start",
projectDir: "/trusted/project",
projectId: "project-123",
}), true);
assert.equal(isBrowserCompanionCommand({
kind: "browser-companion",
action: "start",
projectDir: "/new/project",
}), true);
});

test("rejects malformed, overbroad, and authority-bearing messages", () => {
const rejected = [
null,
{},
{ kind: "browser-companion", action: "prepare" },
{ kind: "browser-companion", action: "prepare", projectDir: "/trusted/project", projectId: "unexpected" },
{ kind: "browser-companion", action: "start", projectDir: "" },
{ kind: "browser-companion", action: "start", projectDir: "/trusted/project", projectId: "" },
{ kind: "browser-companion", action: "start", projectDir: "/trusted/project\0evil", projectId: "project-123" },
{ kind: "browser-companion", action: "status", path: "/etc/passwd" },
{ kind: "browser-companion", action: "write", projectDir: "/trusted/project", projectId: "project-123" },
{ kind: "browser-companion", action: "dapi", procedure: "export" },
];
for (const value of rejected) assert.equal(isBrowserCompanionCommand(value), false, JSON.stringify(value));
});
4 changes: 4 additions & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
"stage:docs": "node scripts/stage-docs.mjs",
"stage:skills": "node scripts/stage-skills.mjs",
"check": "tsc --noEmit",
"test:browser-companion": "node --test --experimental-strip-types tests/browser-companion-*.test.ts",
"test:browser-companion:integration": "node --test tests/browser-companion-lifecycle.integration.mjs",
"dev": "npm run build && electron-forge start",
"package": "npm run build && npm run build:web && npm run stage:cli && npm run stage:docs && npm run stage:skills && electron-forge package",
"make": "npm run build && npm run build:web && npm run stage:cli && npm run stage:docs && npm run stage:skills && electron-forge make",
Expand All @@ -31,6 +33,7 @@
"@electron-forge/publisher-github": "^7.11.1",
"@types/babel__core": "^7.20.5",
"@types/node": "^24.10.1",
"@types/ws": "^8.18.1",
"electron": "^43.1.1",
"typescript": "~5.9.3"
},
Expand All @@ -44,6 +47,7 @@
"nanoid": "^6.0.1",
"ts-morph": "^28.0.0",
"update-electron-app": "^3.0.0",
"ws": "^8.18.3",
"yaml": "^2.9.0"
}
}
88 changes: 88 additions & 0 deletions apps/desktop/src/browser-companion-capture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* One explicitly armed, root-scoped capture. A published value is retained
* only until its consumer takes it (or the deadline expires); publications
* while no capture is armed are ignored and retain nothing.
*/
export class OneShotCapture<T> {
private pending: {
key: string;
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
published: boolean;
taken: boolean;
} | null = null;

arm(key: string, timeoutMs: number, timeoutMessage: string): void {
this.cancel("Companion bundle capture was superseded");

let resolveCapture!: (value: T) => void;
let rejectCapture!: (error: Error) => void;
const promise = new Promise<T>((resolve, reject) => {
resolveCapture = resolve;
rejectCapture = reject;
});
// A prepare/start client can disappear before consuming the capture.
// Keep that bounded rejection from becoming an unhandled process error.
void promise.catch(() => {});

const capture = {
key,
promise,
resolve: resolveCapture,
reject: rejectCapture,
timer: undefined as unknown as ReturnType<typeof setTimeout>,
published: false,
taken: false,
};
capture.timer = setTimeout(() => {
if (this.pending !== capture) return;
this.pending = null;
capture.reject(new Error(timeoutMessage));
}, timeoutMs);
this.pending = capture;
}

isArmedFor(key: string): boolean {
return this.pending?.key === key && !this.pending.published;
}

publish(key: string, value: T): boolean {
const capture = this.pending;
if (!capture || capture.key !== key || capture.published) return false;
capture.published = true;
capture.resolve(value);
if (capture.taken) this.release(capture);
return true;
}

take(key: string): Promise<T> {
const capture = this.pending;
if (!capture || capture.key !== key || capture.taken) {
return Promise.reject(new Error("No canonical renderer bundle capture is armed for this project"));
}
capture.taken = true;
if (capture.published) this.release(capture);
return capture.promise;
}

cancel(message = "Companion bundle capture was cancelled"): void {
const capture = this.pending;
if (!capture) return;
this.release(capture);
capture.reject(new Error(message));
}

inspect(): { armed: boolean; retainedValues: number } {
return {
armed: !!this.pending && !this.pending.published,
retainedValues: this.pending?.published ? 1 : 0,
};
}

private release(capture: NonNullable<OneShotCapture<T>["pending"]>): void {
if (this.pending === capture) this.pending = null;
clearTimeout(capture.timer);
}
}
Loading