@sixb/desktop 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/dist/generate.d.ts +17 -2
- package/dist/generate.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -2
- package/dist/index.js.map +7 -6
- package/dist/shell/bridge.d.ts +18 -1
- package/dist/shell/bridge.d.ts.map +1 -1
- package/dist/shell/runner.d.ts +60 -0
- package/dist/shell/runner.d.ts.map +1 -0
- package/dist/shell/window.d.ts +2 -0
- package/dist/shell/window.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/generate.ts +40 -2
- package/src/index.ts +10 -2
- package/src/shell/bridge.ts +27 -2
- package/src/shell/main.ts +143 -2
- package/src/shell/preload.ts +5 -2
- package/src/shell/runner.ts +296 -0
- package/src/shell/window.ts +12 -0
package/README.md
CHANGED
|
@@ -73,6 +73,28 @@ The session is an ordinary browser session, kept in the window's persistent cook
|
|
|
73
73
|
launches open straight into the app. `src/shell/navigation.ts` holds those two decisions as a pure
|
|
74
74
|
function; `src/shell/main.ts` wires them to the window.
|
|
75
75
|
|
|
76
|
+
### Running the agent on this computer
|
|
77
|
+
|
|
78
|
+
`writeDesktopApp` compiles the runner the CLI names into `.sixb/desktop/runner`, and packaging copies it
|
|
79
|
+
to `Resources/runner`, outside the archive, because a program has to be a file of its own to run.
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
page ── useDesktop().computer ──▶ main.ts (isAppUrl, native dialogs) ──▶ src/shell/runner.ts
|
|
83
|
+
│ JSON lines, stdin/stdout
|
|
84
|
+
project API ◀── sessions, turns, device sign-in ── sixb-runner ◀──────────┘
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
- **The runner holds the sign-in**, where `sixb login` keeps its own, which the agent's sandbox denies.
|
|
88
|
+
The keychain wouldn't do: `security find-generic-password` answers from inside the sandbox. The main
|
|
89
|
+
process only opens the approval page, if `isApiPage` accepts it.
|
|
90
|
+
- **The window's sign-in is denied too.** It's a plain cookie in the app's data folder, so the shell names
|
|
91
|
+
that folder to the runner, whose sandboxes deny it.
|
|
92
|
+
- **It can't outlive the app.** It ends with its stdin, and `before-quit` lets a running turn record its
|
|
93
|
+
end, within a limit. While the app is open it restarts after a wait that grows.
|
|
94
|
+
- **The agent needs a runtime** for its own `sixb`. A `bun` script beside the runner stands in, last on
|
|
95
|
+
the PATH so that a person's own wins. The PATH comes from their login shell, as an app opened from the
|
|
96
|
+
Dock is given a bare one.
|
|
97
|
+
|
|
76
98
|
### Title bar
|
|
77
99
|
|
|
78
100
|
On macOS the window has no title bar: the app draws to the top edge and the window controls float
|
package/dist/generate.d.ts
CHANGED
|
@@ -5,6 +5,11 @@ export interface DesktopAppOptions {
|
|
|
5
5
|
readonly appUrl: string;
|
|
6
6
|
/** The project's API. Its sign-in pages are where the window is approved. */
|
|
7
7
|
readonly apiOrigin: string;
|
|
8
|
+
/**
|
|
9
|
+
* What the app compiles to run the agent on this computer: the CLI names its own, as this package
|
|
10
|
+
* doesn't depend on it. Without one, the app has no way to run the agent here: see `writeRunner`.
|
|
11
|
+
*/
|
|
12
|
+
readonly runnerEntry?: string;
|
|
8
13
|
}
|
|
9
14
|
/** The Electron app `writeDesktopApp` generated, and what it is called. */
|
|
10
15
|
export interface GeneratedApp {
|
|
@@ -14,13 +19,23 @@ export interface GeneratedApp {
|
|
|
14
19
|
/** The app's icon in `dir`, when it declares one. */
|
|
15
20
|
readonly icon: string | null;
|
|
16
21
|
}
|
|
17
|
-
/**
|
|
18
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Where a project's desktop app is generated (`app`), what runs the agent is compiled (`runner`, beside
|
|
24
|
+
* the app as everything in the app's directory ships inside its archive), and it's packaged (`dist`).
|
|
25
|
+
*/
|
|
26
|
+
export declare function desktopDir(projectRoot: string, part: "app" | "dist" | "runner"): string;
|
|
19
27
|
/**
|
|
20
28
|
* Writes the Electron app that opens `appUrl` into the project's `.sixb/desktop/app`. The directory
|
|
21
29
|
* is build-owned, like the rest of `.sixb`: it is cleared on every call.
|
|
22
30
|
*/
|
|
23
31
|
export declare function writeDesktopApp(options: DesktopAppOptions): Promise<GeneratedApp>;
|
|
32
|
+
/**
|
|
33
|
+
* Compiles what runs the agent into one program, so that the app needs nothing installed, and a `bun`
|
|
34
|
+
* beside it. The agent's own `sixb` runs on Bun or Node, which a person may have neither of, and the
|
|
35
|
+
* program can stand in for Bun: last on the PATH the shell gives it, after any they have. Returns its
|
|
36
|
+
* directory: null with no entry, and on Windows, which has no local sandbox to run the agent in.
|
|
37
|
+
*/
|
|
38
|
+
export declare function writeRunner(options: DesktopAppOptions): Promise<string | null>;
|
|
24
39
|
/** The page the window shows while the app loads, and when it cannot reach the app. */
|
|
25
40
|
export declare function renderShellPage(template: string, page: {
|
|
26
41
|
readonly name: string;
|
package/dist/generate.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"generate.d.ts","sourceRoot":"","sources":["../src/generate.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"generate.d.ts","sourceRoot":"","sources":["../src/generate.ts"],"names":[],"mappings":"AASA,MAAM,WAAW,iBAAiB;IAChC,gFAAgF;IAChF,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,qGAAqG;IACrG,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,6EAA6E;IAC7E,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B;;;OAGG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAC9B;AAED,2EAA2E;AAC3E,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,qDAAqD;IACrD,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;CAC7B;AAKD;;;GAGG;AACH,wBAAgB,UAAU,CAAC,WAAW,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,GAAG,MAAM,GAAG,QAAQ,GAAG,MAAM,CAEvF;AAED;;;GAGG;AACH,wBAAsB,eAAe,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,YAAY,CAAC,CA4CvF;AAED;;;;;GAKG;AACH,wBAAsB,WAAW,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAqBpF;AAED,uFAAuF;AACvF,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GAC/E,MAAM,CAYR"}
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,KAAK,iBAAiB,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,KAAK,iBAAiB,EAA4C,MAAM,YAAY,CAAA;AAI7F,YAAY,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAA;AAEnD,MAAM,WAAW,aAAa;IAC5B,iEAAiE;IACjE,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC,CAAA;IAChC,KAAK,IAAI,IAAI,CAAA;CACd;AAED,MAAM,WAAW,YAAY;IAC3B,oFAAoF;IACpF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,2EAA2E;IAC3E,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAA;CACzB;AAMD,0EAA0E;AAC1E,wBAAsB,cAAc,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,aAAa,CAAC,CA2BvF;AAED,qFAAqF;AACrF,wBAAsB,eAAe,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,YAAY,CAAC,CAoCvF"}
|
package/dist/index.js
CHANGED
|
@@ -139,6 +139,9 @@ function run([input, output]) {
|
|
|
139
139
|
}
|
|
140
140
|
`;
|
|
141
141
|
|
|
142
|
+
// src/shell/runner.ts
|
|
143
|
+
var RUNNER_BINARY = "sixb-runner";
|
|
144
|
+
|
|
142
145
|
// src/generate.ts
|
|
143
146
|
var SHELL_SOURCE_DIR = fileURLToPath(new URL("../src/shell", import.meta.url));
|
|
144
147
|
function desktopDir(projectRoot, part) {
|
|
@@ -183,6 +186,24 @@ async function writeDesktopApp(options) {
|
|
|
183
186
|
], "Could not compile the desktop shell.");
|
|
184
187
|
return { dir, name: identity.name, version, icon };
|
|
185
188
|
}
|
|
189
|
+
async function writeRunner(options) {
|
|
190
|
+
const dir = desktopDir(options.projectRoot, "runner");
|
|
191
|
+
await rm(dir, { recursive: true, force: true });
|
|
192
|
+
if (!options.runnerEntry || process.platform === "win32")
|
|
193
|
+
return null;
|
|
194
|
+
await mkdir(dir, { recursive: true });
|
|
195
|
+
await run([
|
|
196
|
+
process.execPath,
|
|
197
|
+
"build",
|
|
198
|
+
"--compile",
|
|
199
|
+
options.runnerEntry,
|
|
200
|
+
`--outfile=${join(dir, RUNNER_BINARY)}`
|
|
201
|
+
], "Could not compile what runs the agent.");
|
|
202
|
+
await writeFile(join(dir, "bun"), `#!/bin/sh
|
|
203
|
+
BUN_BE_BUN=1 exec "$(dirname "$0")/${RUNNER_BINARY}" "$@"
|
|
204
|
+
`, { mode: 493 });
|
|
205
|
+
return dir;
|
|
206
|
+
}
|
|
186
207
|
function renderShellPage(template, page) {
|
|
187
208
|
const initial = Array.from(page.name.trim())[0]?.toUpperCase() ?? "";
|
|
188
209
|
const markup = {
|
|
@@ -222,6 +243,9 @@ var packageRequire = createRequire(import.meta.url);
|
|
|
222
243
|
var PACKAGER_SCRIPT = fileURLToPath2(new URL("../src/packager.mjs", import.meta.url));
|
|
223
244
|
async function openDesktopApp(options) {
|
|
224
245
|
const app = await writeDesktopApp(options);
|
|
246
|
+
await writeRunner(options).catch((error) => {
|
|
247
|
+
console.error("[SixbDesktop] Conversations will run in the cloud.", error);
|
|
248
|
+
});
|
|
225
249
|
const log = join2(dirname(app.dir), "electron.log");
|
|
226
250
|
const electron = Bun.spawn([electronBinary(), app.dir], {
|
|
227
251
|
stdio: ["ignore", "ignore", Bun.file(log)]
|
|
@@ -243,6 +267,7 @@ async function openDesktopApp(options) {
|
|
|
243
267
|
}
|
|
244
268
|
async function buildDesktopApp(options) {
|
|
245
269
|
const app = await writeDesktopApp(options);
|
|
270
|
+
const runner = await writeRunner(options);
|
|
246
271
|
const out = desktopDir(options.projectRoot, "dist");
|
|
247
272
|
await rm2(out, { recursive: true, force: true });
|
|
248
273
|
const mac = process.platform === "darwin";
|
|
@@ -255,6 +280,7 @@ async function buildDesktopApp(options) {
|
|
|
255
280
|
appVersion: app.version,
|
|
256
281
|
electronVersion: packageRequire("electron/package.json").version,
|
|
257
282
|
...mac && app.icon ? { icon: await macIcon(app.icon) } : {},
|
|
283
|
+
...runner ? { extraResource: [runner] } : {},
|
|
258
284
|
...notarize ? { osxSign: {}, osxNotarize: notarize } : {}
|
|
259
285
|
});
|
|
260
286
|
if (!mac)
|
|
@@ -315,7 +341,7 @@ function electronBinary() {
|
|
|
315
341
|
return packageRequire("electron");
|
|
316
342
|
} catch {
|
|
317
343
|
const electronDir = dirname(packageRequire.resolve("electron/package.json"));
|
|
318
|
-
throw new Error("[SixbDesktop] Electron's binary is missing: its install script did not run. " + `Run \`bun
|
|
344
|
+
throw new Error("[SixbDesktop] Electron's binary is missing: its install script did not run. " + `Run \`bun ${join2(electronDir, "install.js")}\`, then try again.`);
|
|
319
345
|
}
|
|
320
346
|
}
|
|
321
347
|
export {
|
|
@@ -323,4 +349,4 @@ export {
|
|
|
323
349
|
openDesktopApp
|
|
324
350
|
};
|
|
325
351
|
|
|
326
|
-
//# debugId=
|
|
352
|
+
//# debugId=D20C8A734FA0DFE264756E2164756E21
|
package/dist/index.js.map
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
|
-
"sources": ["../src/index.ts", "../src/generate.ts", "../src/identity.ts", "../src/run.ts", "../src/mac-icon.ts"],
|
|
3
|
+
"sources": ["../src/index.ts", "../src/generate.ts", "../src/identity.ts", "../src/run.ts", "../src/mac-icon.ts", "../src/shell/runner.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"import { mkdir, readdir, rm } from \"node:fs/promises\"\nimport { createRequire } from \"node:module\"\nimport { dirname, join } from \"node:path\"\nimport { fileURLToPath } from \"node:url\"\nimport { type DesktopAppOptions, desktopDir, writeDesktopApp } from \"./generate\"\nimport { bundleIdentifier } from \"./identity\"\nimport { run } from \"./run\"\n\nexport type { DesktopAppOptions } from \"./generate\"\n\nexport interface DesktopWindow {\n /** Resolves with Electron's exit code once the user quits it. */\n readonly exited: Promise<number>\n close(): void\n}\n\nexport interface DesktopBuild {\n /** What to hand to users: a zip of the app on macOS, the app's folder elsewhere. */\n readonly path: string\n /** Whether it was signed and notarized, and so opens without a warning. */\n readonly signed: boolean\n}\n\nconst packageRequire = createRequire(import.meta.url)\n// From the package's `src`, like the shell: see `SHELL_SOURCE_DIR`.\nconst PACKAGER_SCRIPT = fileURLToPath(new URL(\"../src/packager.mjs\", import.meta.url))\n\n/** Opens the app in a desktop window. `sixb dev --desktop` calls this. */\nexport async function openDesktopApp(options: DesktopAppOptions): Promise<DesktopWindow> {\n const app = await writeDesktopApp(options)\n // Chromium writes plenty to stderr when all is well, so it goes to a file, named if the window\n // quits on its own with an error.\n const log = join(dirname(app.dir), \"electron.log\")\n const electron = Bun.spawn([electronBinary(), app.dir], {\n stdio: [\"ignore\", \"ignore\", Bun.file(log)],\n })\n let closing = false\n const exited = electron.exited.then((code) => {\n if (code !== 0 && !closing) {\n console.error(`[SixbDesktop] The desktop window quit with exit code ${code}. See ${log}.`)\n }\n return code\n })\n return {\n exited,\n close: () => {\n closing = true\n electron.kill()\n },\n }\n}\n\n/** Packages the app for this machine's platform. `sixb desktop build` calls this. */\nexport async function buildDesktopApp(options: DesktopAppOptions): Promise<DesktopBuild> {\n const app = await writeDesktopApp(options)\n const out = desktopDir(options.projectRoot, \"dist\")\n await rm(out, { recursive: true, force: true })\n\n const mac = process.platform === \"darwin\"\n const notarize = mac ? appleNotarization() : null\n const packaged = await packageApp({\n dir: app.dir,\n name: app.name,\n out,\n appBundleId: bundleIdentifier(options.appUrl),\n appVersion: app.version,\n electronVersion: (packageRequire(\"electron/package.json\") as { readonly version: string })\n .version,\n ...(mac && app.icon ? { icon: await macIcon(app.icon) } : {}),\n // Signs with the Developer ID certificate in the keychain, which notarization requires.\n ...(notarize ? { osxSign: {}, osxNotarize: notarize } : {}),\n })\n if (!mac) return { path: packaged, signed: false }\n\n const name = (await readdir(packaged)).find((entry) => entry.endsWith(\".app\"))\n if (!name) throw new Error(`[SixbDesktop] The packaged app is missing from ${packaged}.`)\n const bundle = join(packaged, name)\n // Packaging breaks the ad-hoc signature Electron ships with. macOS calls a downloaded app with a\n // broken signature damaged, and offers no way to open it; signed ad hoc, it can be allowed.\n if (!notarize) {\n await run([\"codesign\", \"--force\", \"--deep\", \"--sign\", \"-\", bundle], \"Could not sign the app.\")\n }\n // `ditto`, not `zip`: it keeps the bundle's symlinks, and with them its signature.\n const zip = `${packaged}.zip`\n await run([\"ditto\", \"-c\", \"-k\", \"--keepParent\", bundle, zip], \"Could not zip the app.\")\n return { path: zip, signed: notarize !== null }\n}\n\n/**\n * An App Store Connect API key, which notarizes the app so macOS opens it without a warning.\n * Without one, the app is built unsigned.\n */\nfunction appleNotarization(): {\n readonly appleApiKey: string\n readonly appleApiKeyId: string\n readonly appleApiIssuer: string\n} | null {\n const { APPLE_API_KEY, APPLE_API_KEY_ID, APPLE_API_ISSUER } = process.env\n if (!APPLE_API_KEY && !APPLE_API_KEY_ID && !APPLE_API_ISSUER) return null\n if (!APPLE_API_KEY || !APPLE_API_KEY_ID || !APPLE_API_ISSUER) {\n throw new Error(\n \"[SixbDesktop] Set all of APPLE_API_KEY, APPLE_API_KEY_ID and APPLE_API_ISSUER to sign the \" +\n \"macOS app, or none of them to build it unsigned.\"\n )\n }\n return {\n appleApiKey: APPLE_API_KEY,\n appleApiKeyId: APPLE_API_KEY_ID,\n appleApiIssuer: APPLE_API_ISSUER,\n }\n}\n\n/**\n * The packager wants `.icns`: every size macOS shows, scaled from the 1024-pixel icon, in one file.\n * `sips` and `iconutil` ship with every Mac. Written beside the app, not in it: everything in the\n * app's directory ships inside the bundle.\n */\nasync function macIcon(png: string): Promise<string> {\n const desktop = dirname(dirname(png))\n const iconset = join(desktop, \"icon.iconset\")\n await rm(iconset, { recursive: true, force: true })\n await mkdir(iconset)\n const sizes = [16, 32, 128, 256, 512].flatMap((points) => [\n { name: `icon_${points}x${points}.png`, pixels: points },\n { name: `icon_${points}x${points}@2x.png`, pixels: points * 2 },\n ])\n await Promise.all(\n sizes.map(({ name, pixels }) =>\n run(\n [\"sips\", \"-z\", `${pixels}`, `${pixels}`, png, \"--out\", join(iconset, name)],\n \"Could not scale the icon.\"\n )\n )\n )\n const icns = join(desktop, \"icon.icns\")\n await run([\"iconutil\", \"-c\", \"icns\", iconset, \"-o\", icns], \"Could not convert the icon.\")\n return icns\n}\n\n/**\n * Runs `@electron/packager` into `options.out`, which starts empty, and resolves with the one\n * directory it wrote there. Under Electron's own binary, which doubles as the Node the packager was\n * written for: under Bun, the packager fails whenever it verifies a download (its checksum\n * dependency does not load).\n */\nasync function packageApp(\n options: { readonly out: string } & Record<string, unknown>\n): Promise<string> {\n const packager = Bun.spawn(\n [electronBinary(), PACKAGER_SCRIPT, JSON.stringify({ ...options, quiet: true })],\n {\n stdio: [\"ignore\", \"inherit\", \"inherit\"],\n // Without the second, Electron's file system reads the `app.asar` the packager writes as a\n // directory.\n env: { ...process.env, ELECTRON_RUN_AS_NODE: \"1\", ELECTRON_NO_ASAR: \"1\" },\n }\n )\n if ((await packager.exited) !== 0) {\n throw new Error(\"[SixbDesktop] Packaging failed. See the output above.\")\n }\n const [written] = await readdir(options.out)\n if (!written) throw new Error(`[SixbDesktop] The packager wrote nothing to ${options.out}.`)\n return join(options.out, written)\n}\n\n// Outside Electron, `require(\"electron\")` is the path of the binary its install script fetched.\nfunction electronBinary(): string {\n try {\n return packageRequire(\"electron\") as string\n } catch {\n const electronDir = dirname(packageRequire.resolve(\"electron/package.json\"))\n throw new Error(\n \"[SixbDesktop] Electron's binary is missing: its install script did not run. \" +\n `Run \\`bun
|
|
6
|
-
"import { mkdir, readFile, rm, writeFile } from \"node:fs/promises\"\nimport { join } from \"node:path\"\nimport { fileURLToPath } from \"node:url\"\nimport { readAppIdentity } from \"./identity\"\nimport { drawMacIcon } from \"./mac-icon\"\nimport { run } from \"./run\"\nimport type { ShellConfig } from \"./shell/navigation\"\n\nexport interface DesktopAppOptions {\n /** The Sixb project. The desktop app is generated under its `.sixb/desktop`. */\n readonly projectRoot: string\n /** Where the app is served. The window opens it; the desktop app takes its name and icon from it. */\n readonly appUrl: string\n /** The project's API. Its sign-in pages are where the window is approved. */\n readonly apiOrigin: string\n}\n\n/** The Electron app `writeDesktopApp` generated, and what it is called. */\nexport interface GeneratedApp {\n readonly dir: string\n readonly name: string\n readonly version: string\n /** The app's icon in `dir`, when it declares one. */\n readonly icon: string | null\n}\n\n// Read from the package's `src`, which ships, whether this module runs from `src` or `dist`.\nconst SHELL_SOURCE_DIR = fileURLToPath(new URL(\"../src/shell\", import.meta.url))\n\n
|
|
5
|
+
"import { mkdir, readdir, rm } from \"node:fs/promises\"\nimport { createRequire } from \"node:module\"\nimport { dirname, join } from \"node:path\"\nimport { fileURLToPath } from \"node:url\"\nimport { type DesktopAppOptions, desktopDir, writeDesktopApp, writeRunner } from \"./generate\"\nimport { bundleIdentifier } from \"./identity\"\nimport { run } from \"./run\"\n\nexport type { DesktopAppOptions } from \"./generate\"\n\nexport interface DesktopWindow {\n /** Resolves with Electron's exit code once the user quits it. */\n readonly exited: Promise<number>\n close(): void\n}\n\nexport interface DesktopBuild {\n /** What to hand to users: a zip of the app on macOS, the app's folder elsewhere. */\n readonly path: string\n /** Whether it was signed and notarized, and so opens without a warning. */\n readonly signed: boolean\n}\n\nconst packageRequire = createRequire(import.meta.url)\n// From the package's `src`, like the shell: see `SHELL_SOURCE_DIR`.\nconst PACKAGER_SCRIPT = fileURLToPath(new URL(\"../src/packager.mjs\", import.meta.url))\n\n/** Opens the app in a desktop window. `sixb dev --desktop` calls this. */\nexport async function openDesktopApp(options: DesktopAppOptions): Promise<DesktopWindow> {\n const app = await writeDesktopApp(options)\n // The window is still worth opening without it, for a project not using the agent: conversations\n // run in the cloud. A build, which people get, fails instead.\n await writeRunner(options).catch((error: unknown) => {\n console.error(\"[SixbDesktop] Conversations will run in the cloud.\", error)\n })\n // Chromium writes plenty to stderr when all is well, so it goes to a file, named if the window\n // quits on its own with an error.\n const log = join(dirname(app.dir), \"electron.log\")\n const electron = Bun.spawn([electronBinary(), app.dir], {\n stdio: [\"ignore\", \"ignore\", Bun.file(log)],\n })\n let closing = false\n const exited = electron.exited.then((code) => {\n if (code !== 0 && !closing) {\n console.error(`[SixbDesktop] The desktop window quit with exit code ${code}. See ${log}.`)\n }\n return code\n })\n return {\n exited,\n close: () => {\n closing = true\n electron.kill()\n },\n }\n}\n\n/** Packages the app for this machine's platform. `sixb desktop build` calls this. */\nexport async function buildDesktopApp(options: DesktopAppOptions): Promise<DesktopBuild> {\n const app = await writeDesktopApp(options)\n const runner = await writeRunner(options)\n const out = desktopDir(options.projectRoot, \"dist\")\n await rm(out, { recursive: true, force: true })\n\n const mac = process.platform === \"darwin\"\n const notarize = mac ? appleNotarization() : null\n const packaged = await packageApp({\n dir: app.dir,\n name: app.name,\n out,\n appBundleId: bundleIdentifier(options.appUrl),\n appVersion: app.version,\n electronVersion: (packageRequire(\"electron/package.json\") as { readonly version: string })\n .version,\n ...(mac && app.icon ? { icon: await macIcon(app.icon) } : {}),\n // Beside the app's archive, not in it: a program has to be a file of its own to run.\n ...(runner ? { extraResource: [runner] } : {}),\n // Signs with the Developer ID certificate in the keychain, which notarization requires.\n ...(notarize ? { osxSign: {}, osxNotarize: notarize } : {}),\n })\n if (!mac) return { path: packaged, signed: false }\n\n const name = (await readdir(packaged)).find((entry) => entry.endsWith(\".app\"))\n if (!name) throw new Error(`[SixbDesktop] The packaged app is missing from ${packaged}.`)\n const bundle = join(packaged, name)\n // Packaging breaks the ad-hoc signature Electron ships with. macOS calls a downloaded app with a\n // broken signature damaged, and offers no way to open it; signed ad hoc, it can be allowed.\n if (!notarize) {\n await run([\"codesign\", \"--force\", \"--deep\", \"--sign\", \"-\", bundle], \"Could not sign the app.\")\n }\n // `ditto`, not `zip`: it keeps the bundle's symlinks, and with them its signature.\n const zip = `${packaged}.zip`\n await run([\"ditto\", \"-c\", \"-k\", \"--keepParent\", bundle, zip], \"Could not zip the app.\")\n return { path: zip, signed: notarize !== null }\n}\n\n/**\n * An App Store Connect API key, which notarizes the app so macOS opens it without a warning.\n * Without one, the app is built unsigned.\n */\nfunction appleNotarization(): {\n readonly appleApiKey: string\n readonly appleApiKeyId: string\n readonly appleApiIssuer: string\n} | null {\n const { APPLE_API_KEY, APPLE_API_KEY_ID, APPLE_API_ISSUER } = process.env\n if (!APPLE_API_KEY && !APPLE_API_KEY_ID && !APPLE_API_ISSUER) return null\n if (!APPLE_API_KEY || !APPLE_API_KEY_ID || !APPLE_API_ISSUER) {\n throw new Error(\n \"[SixbDesktop] Set all of APPLE_API_KEY, APPLE_API_KEY_ID and APPLE_API_ISSUER to sign the \" +\n \"macOS app, or none of them to build it unsigned.\"\n )\n }\n return {\n appleApiKey: APPLE_API_KEY,\n appleApiKeyId: APPLE_API_KEY_ID,\n appleApiIssuer: APPLE_API_ISSUER,\n }\n}\n\n/**\n * The packager wants `.icns`: every size macOS shows, scaled from the 1024-pixel icon, in one file.\n * `sips` and `iconutil` ship with every Mac. Written beside the app, not in it: everything in the\n * app's directory ships inside the bundle.\n */\nasync function macIcon(png: string): Promise<string> {\n const desktop = dirname(dirname(png))\n const iconset = join(desktop, \"icon.iconset\")\n await rm(iconset, { recursive: true, force: true })\n await mkdir(iconset)\n const sizes = [16, 32, 128, 256, 512].flatMap((points) => [\n { name: `icon_${points}x${points}.png`, pixels: points },\n { name: `icon_${points}x${points}@2x.png`, pixels: points * 2 },\n ])\n await Promise.all(\n sizes.map(({ name, pixels }) =>\n run(\n [\"sips\", \"-z\", `${pixels}`, `${pixels}`, png, \"--out\", join(iconset, name)],\n \"Could not scale the icon.\"\n )\n )\n )\n const icns = join(desktop, \"icon.icns\")\n await run([\"iconutil\", \"-c\", \"icns\", iconset, \"-o\", icns], \"Could not convert the icon.\")\n return icns\n}\n\n/**\n * Runs `@electron/packager` into `options.out`, which starts empty, and resolves with the one\n * directory it wrote there. Under Electron's own binary, which doubles as the Node the packager was\n * written for: under Bun, the packager fails whenever it verifies a download (its checksum\n * dependency does not load).\n */\nasync function packageApp(\n options: { readonly out: string } & Record<string, unknown>\n): Promise<string> {\n const packager = Bun.spawn(\n [electronBinary(), PACKAGER_SCRIPT, JSON.stringify({ ...options, quiet: true })],\n {\n stdio: [\"ignore\", \"inherit\", \"inherit\"],\n // Without the second, Electron's file system reads the `app.asar` the packager writes as a\n // directory.\n env: { ...process.env, ELECTRON_RUN_AS_NODE: \"1\", ELECTRON_NO_ASAR: \"1\" },\n }\n )\n if ((await packager.exited) !== 0) {\n throw new Error(\"[SixbDesktop] Packaging failed. See the output above.\")\n }\n const [written] = await readdir(options.out)\n if (!written) throw new Error(`[SixbDesktop] The packager wrote nothing to ${options.out}.`)\n return join(options.out, written)\n}\n\n// Outside Electron, `require(\"electron\")` is the path of the binary its install script fetched.\nfunction electronBinary(): string {\n try {\n return packageRequire(\"electron\") as string\n } catch {\n const electronDir = dirname(packageRequire.resolve(\"electron/package.json\"))\n throw new Error(\n \"[SixbDesktop] Electron's binary is missing: its install script did not run. \" +\n `Run \\`bun ${join(electronDir, \"install.js\")}\\`, then try again.`\n )\n }\n}\n",
|
|
6
|
+
"import { mkdir, readFile, rm, writeFile } from \"node:fs/promises\"\nimport { join } from \"node:path\"\nimport { fileURLToPath } from \"node:url\"\nimport { readAppIdentity } from \"./identity\"\nimport { drawMacIcon } from \"./mac-icon\"\nimport { run } from \"./run\"\nimport type { ShellConfig } from \"./shell/navigation\"\nimport { RUNNER_BINARY } from \"./shell/runner\"\n\nexport interface DesktopAppOptions {\n /** The Sixb project. The desktop app is generated under its `.sixb/desktop`. */\n readonly projectRoot: string\n /** Where the app is served. The window opens it; the desktop app takes its name and icon from it. */\n readonly appUrl: string\n /** The project's API. Its sign-in pages are where the window is approved. */\n readonly apiOrigin: string\n /**\n * What the app compiles to run the agent on this computer: the CLI names its own, as this package\n * doesn't depend on it. Without one, the app has no way to run the agent here: see `writeRunner`.\n */\n readonly runnerEntry?: string\n}\n\n/** The Electron app `writeDesktopApp` generated, and what it is called. */\nexport interface GeneratedApp {\n readonly dir: string\n readonly name: string\n readonly version: string\n /** The app's icon in `dir`, when it declares one. */\n readonly icon: string | null\n}\n\n// Read from the package's `src`, which ships, whether this module runs from `src` or `dist`.\nconst SHELL_SOURCE_DIR = fileURLToPath(new URL(\"../src/shell\", import.meta.url))\n\n/**\n * Where a project's desktop app is generated (`app`), what runs the agent is compiled (`runner`, beside\n * the app as everything in the app's directory ships inside its archive), and it's packaged (`dist`).\n */\nexport function desktopDir(projectRoot: string, part: \"app\" | \"dist\" | \"runner\"): string {\n return join(projectRoot, \".sixb\", \"desktop\", part)\n}\n\n/**\n * Writes the Electron app that opens `appUrl` into the project's `.sixb/desktop/app`. The directory\n * is build-owned, like the rest of `.sixb`: it is cleared on every call.\n */\nexport async function writeDesktopApp(options: DesktopAppOptions): Promise<GeneratedApp> {\n const identity = await readAppIdentity(options.appUrl)\n const version = await readProjectVersion(options.projectRoot)\n const dir = desktopDir(options.projectRoot, \"app\")\n await rm(dir, { recursive: true, force: true })\n await mkdir(dir, { recursive: true })\n\n const config: ShellConfig = {\n name: identity.name,\n appUrl: new URL(options.appUrl).href,\n apiOrigin: new URL(options.apiOrigin).origin,\n }\n const manifest = {\n name: \"sixb-desktop-app\",\n // Names the folder the window's cookies are kept in, so each app has its own sign-in.\n productName: identity.name,\n version,\n private: true,\n main: \"main.js\",\n }\n const icon = identity.icon ? await writeIcon(dir, identity.icon) : null\n const template = await readFile(join(SHELL_SOURCE_DIR, \"index.html\"), \"utf-8\")\n await Promise.all([\n writeFile(join(dir, \"package.json\"), `${JSON.stringify(manifest, null, 2)}\\n`),\n writeFile(join(dir, \"shell.json\"), `${JSON.stringify(config, null, 2)}\\n`),\n writeFile(join(dir, \"index.html\"), renderShellPage(template, { ...config, icon: !!icon })),\n ])\n\n // Electron loads plain JavaScript. A child process, because an in-process `Bun.build()` that\n // wedges takes its caller down with it.\n await run(\n [\n process.execPath,\n \"build\",\n join(SHELL_SOURCE_DIR, \"main.ts\"),\n join(SHELL_SOURCE_DIR, \"preload.ts\"),\n \"--target=node\",\n \"--format=cjs\",\n \"--external=electron\",\n `--outdir=${dir}`,\n ],\n \"Could not compile the desktop shell.\"\n )\n return { dir, name: identity.name, version, icon }\n}\n\n/**\n * Compiles what runs the agent into one program, so that the app needs nothing installed, and a `bun`\n * beside it. The agent's own `sixb` runs on Bun or Node, which a person may have neither of, and the\n * program can stand in for Bun: last on the PATH the shell gives it, after any they have. Returns its\n * directory: null with no entry, and on Windows, which has no local sandbox to run the agent in.\n */\nexport async function writeRunner(options: DesktopAppOptions): Promise<string | null> {\n const dir = desktopDir(options.projectRoot, \"runner\")\n await rm(dir, { recursive: true, force: true })\n if (!options.runnerEntry || process.platform === \"win32\") return null\n await mkdir(dir, { recursive: true })\n await run(\n [\n process.execPath,\n \"build\",\n \"--compile\",\n options.runnerEntry,\n `--outfile=${join(dir, RUNNER_BINARY)}`,\n ],\n \"Could not compile what runs the agent.\"\n )\n await writeFile(\n join(dir, \"bun\"),\n `#!/bin/sh\\nBUN_BE_BUN=1 exec \"$(dirname \"$0\")/${RUNNER_BINARY}\" \"$@\"\\n`,\n { mode: 0o755 }\n )\n return dir\n}\n\n/** The page the window shows while the app loads, and when it cannot reach the app. */\nexport function renderShellPage(\n template: string,\n page: { readonly name: string; readonly appUrl: string; readonly icon: boolean }\n): string {\n const initial = Array.from(page.name.trim())[0]?.toUpperCase() ?? \"\"\n const markup: Record<string, string> = {\n name: escapeHtml(page.name),\n host: escapeHtml(new URL(page.appUrl).host),\n appUrl: escapeHtml(page.appUrl),\n // The app's icon, or the first letter of its name standing in for one.\n mark: page.icon\n ? '<img class=\"mark\" src=\"icon.png\" alt=\"\" />'\n : `<div class=\"mark\" aria-hidden=\"true\">${escapeHtml(initial)}</div>`,\n }\n return template.replaceAll(/\\{\\{(\\w+)\\}\\}/g, (_match, key: string) => markup[key] ?? \"\")\n}\n\n/** Writes the app's icon into `dir` as `icon.png`; on macOS, drawn the way macOS draws app icons. */\nasync function writeIcon(dir: string, png: Uint8Array): Promise<string> {\n const icon = join(dir, \"icon.png\")\n if (process.platform !== \"darwin\") {\n await writeFile(icon, png)\n return icon\n }\n // The original goes beside the app, not in it: everything in the app's directory ships.\n const source = join(dir, \"..\", \"icon-source.png\")\n await writeFile(source, png)\n await drawMacIcon(source, icon)\n return icon\n}\n\nasync function readProjectVersion(projectRoot: string): Promise<string> {\n try {\n const manifest: unknown = JSON.parse(await readFile(join(projectRoot, \"package.json\"), \"utf-8\"))\n const version = (manifest as { readonly version?: unknown } | null)?.version\n return typeof version === \"string\" ? version : \"0.0.0\"\n } catch {\n return \"0.0.0\"\n }\n}\n\nfunction escapeHtml(value: string): string {\n return value\n .replaceAll(\"&\", \"&\")\n .replaceAll(\"<\", \"<\")\n .replaceAll(\">\", \">\")\n .replaceAll('\"', \""\")\n}\n",
|
|
7
7
|
"/** What the desktop app takes from the web app it opens. */\nexport interface AppIdentity {\n /** The app's name: `metadata.title` in `app/layout.tsx`. */\n readonly name: string\n /**\n * The app's maskable icon, such as `app/public/icon-maskable-512.png`, or else its largest PNG\n * icon, such as `app/public/icon-512.png`.\n */\n readonly icon: Uint8Array | null\n}\n\n/**\n * Reads the app's web app manifest, the one a browser installs the app from. The desktop app is\n * the same app installed another way, so it takes the same name and icon and has no settings of\n * its own.\n */\nexport async function readAppIdentity(appUrl: string): Promise<AppIdentity> {\n const manifestUrl = new URL(\"/app.webmanifest\", appUrl)\n const manifest: unknown = await (await fetchFromApp(manifestUrl)).json()\n const { name, icons } = (typeof manifest === \"object\" && manifest !== null ? manifest : {}) as {\n readonly name?: unknown\n readonly icons?: unknown\n }\n\n const iconUrl = desktopIcon(icons, manifestUrl)\n return {\n name: typeof name === \"string\" && name.trim() ? name.trim() : \"Sixb\",\n icon: iconUrl ? new Uint8Array(await (await fetchFromApp(iconUrl)).arrayBuffer()) : null,\n }\n}\n\n/**\n * The app's id on the desktop, from the address it opens: `https://orders.acme.com` becomes\n * `com.acme.orders`. Each deployment gets its own, so a staging build installs beside production.\n */\nexport function bundleIdentifier(appUrl: string): string {\n const labels = new URL(appUrl).hostname\n .split(\".\")\n .reverse()\n .map((label) => label.toLowerCase().replaceAll(/[^a-z0-9-]/g, \"-\"))\n .filter(Boolean)\n return (labels.length > 1 ? labels : [\"local\", ...labels]).join(\".\")\n}\n\n// A maskable icon is drawn to its edges for a platform to cut its own shape from, which is what\n// macOS does with it. Any other icon may carry its own shape and margins.\nfunction desktopIcon(icons: unknown, manifestUrl: URL): URL | null {\n let best: { readonly url: URL; readonly rank: readonly [number, number] } | null = null\n for (const icon of Array.isArray(icons) ? icons : []) {\n if (typeof icon !== \"object\" || icon === null) continue\n const { src, sizes, type, purpose } = icon as Record<string, unknown>\n if (typeof src !== \"string\") continue\n const url = new URL(src, manifestUrl)\n if (type === undefined ? !url.pathname.endsWith(\".png\") : type !== \"image/png\") continue\n\n const maskable = typeof purpose === \"string\" && purpose.split(/\\s+/).includes(\"maskable\")\n const size = Math.max(\n 0,\n ...(typeof sizes === \"string\" ? sizes.split(/\\s+/) : []).map(\n (entry) => Number.parseInt(entry, 10) || 0\n )\n )\n const rank = [maskable ? 1 : 0, size] as const\n if (!best || rank[0] > best.rank[0] || (rank[0] === best.rank[0] && rank[1] > best.rank[1])) {\n best = { url, rank }\n }\n }\n return best?.url ?? null\n}\n\nasync function fetchFromApp(url: URL): Promise<Response> {\n let response: Response\n try {\n response = await fetch(url)\n } catch (error) {\n throw new Error(\n `[SixbDesktop] Could not reach ${url} (${error instanceof Error ? error.message : error}). ` +\n \"The desktop app takes its name and icon from the app, so the app must be running there.\"\n )\n }\n if (!response.ok) throw new Error(`[SixbDesktop] ${url} answered ${response.status}.`)\n return response\n}\n",
|
|
8
8
|
"/** Runs a command to completion, quietly unless it fails: then it throws `failure` and its errors. */\nexport async function run(command: readonly string[], failure: string): Promise<void> {\n const child = Bun.spawn([...command], {\n stdio: [\"ignore\", \"ignore\", \"pipe\"],\n timeout: 120_000,\n })\n const [exitCode, errors] = await Promise.all([child.exited, new Response(child.stderr).text()])\n if (exitCode !== 0) {\n throw new Error(`[SixbDesktop] ${failure}${errors.trim() ? `\\n${errors.trim()}` : \"\"}`)\n }\n}\n",
|
|
9
|
-
"import { run } from \"./run\"\n\n/**\n * Draws `png` the way macOS draws app icons, into `out`: on Apple's grid, a rounded square of 824\n * pixels with continuous corners in a canvas of 1024, over the soft shadow Apple's own icons have.\n * An icon drawn to its edges otherwise stands larger than every other icon in the Dock.\n */\nexport async function drawMacIcon(png: string, out: string): Promise<void> {\n await run(\n [\"osascript\", \"-l\", \"JavaScript\", \"-e\", MAC_ICON_SCRIPT, png, out],\n \"Could not draw the macOS icon.\"\n )\n}\n\n// AppKit, through the JavaScript `osascript` runs, which every Mac has. The corner is the curve\n// UIKit rounds continuous corners with; with this radius it matches the outline of Apple's Notes\n// icon to a few pixels, and the shadow matches its shadow.\nconst MAC_ICON_SCRIPT = `\nObjC.import(\"AppKit\")\nObjC.import(\"CoreGraphics\")\n\nfunction run([input, output]) {\n const canvas = 1024\n const body = 824\n const radius = 185.4\n const min = (canvas - body) / 2\n const max = min + body\n\n // One corner, as offsets in radii from the edge it leaves and the edge it joins.\n const corner = [\n [[1.52866483, 0]],\n [[0.66993427, 0.065496], [1.08849323, 0], [0.86840689, 0.02065986]],\n [[0.63149399, 0.074911]],\n [[0.074911, 0.63149399], [0.37282392, 0.16906933], [0.16906933, 0.37282392]],\n [[0.065496, 0.66993427]],\n [[0, 1.52866483], [0.02065986, 0.86840689], [0, 1.08849323]],\n ]\n const corners = [\n ([u, v]) => $.NSMakePoint(max - u * radius, max - v * radius),\n ([u, v]) => $.NSMakePoint(max - v * radius, min + u * radius),\n ([u, v]) => $.NSMakePoint(min + u * radius, min + v * radius),\n ([u, v]) => $.NSMakePoint(min + v * radius, max - u * radius),\n ]\n const shape = $.NSBezierPath.bezierPath\n shape.moveToPoint(corners[3]([0, 1.52866483]))\n for (const at of corners) {\n for (const [to, c1, c2] of corner) {\n if (c1) shape.curveToPointControlPoint1ControlPoint2(at(to), at(c1), at(c2))\n else shape.lineToPoint(at(to))\n }\n }\n shape.closePath\n\n const image = $.NSImage.alloc.initWithContentsOfFile(input)\n if (image.isNil()) throw new Error(\"Cannot read \" + input)\n const bitmap = $.NSBitmapImageRep.alloc\n .initWithBitmapDataPlanesPixelsWidePixelsHighBitsPerSampleSamplesPerPixelHasAlphaIsPlanarColorSpaceNameBytesPerRowBitsPerPixel(\n null, canvas, canvas, 8, 4, true, false, $.NSDeviceRGBColorSpace, 0, 0\n )\n const context = $.NSGraphicsContext.graphicsContextWithBitmapImageRep(bitmap)\n $.NSGraphicsContext.setCurrentContext(context)\n context.setImageInterpolation($.NSImageInterpolationHigh)\n\n const shadow = $.NSShadow.alloc.init\n shadow.setShadowOffset($.NSMakeSize(0, -8))\n shadow.setShadowBlurRadius(30)\n shadow.setShadowColor($.NSColor.colorWithWhiteAlpha(0, 0.22))\n shadow.set\n // A layer, so the shadow falls from the clipped icon and not from the clip.\n $.CGContextBeginTransparencyLayer(context.CGContext, null)\n shape.addClip\n image.drawInRectFromRectOperationFraction(\n $.NSMakeRect(min, min, body, body), $.NSZeroRect, $.NSCompositingOperationSourceOver, 1\n )\n $.CGContextEndTransparencyLayer(context.CGContext)\n context.flushGraphics\n\n const png = bitmap.representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $.NSDictionary.dictionary)\n if (!png.writeToFileAtomically(output, true)) throw new Error(\"Cannot write \" + output)\n}\n`\n"
|
|
9
|
+
"import { run } from \"./run\"\n\n/**\n * Draws `png` the way macOS draws app icons, into `out`: on Apple's grid, a rounded square of 824\n * pixels with continuous corners in a canvas of 1024, over the soft shadow Apple's own icons have.\n * An icon drawn to its edges otherwise stands larger than every other icon in the Dock.\n */\nexport async function drawMacIcon(png: string, out: string): Promise<void> {\n await run(\n [\"osascript\", \"-l\", \"JavaScript\", \"-e\", MAC_ICON_SCRIPT, png, out],\n \"Could not draw the macOS icon.\"\n )\n}\n\n// AppKit, through the JavaScript `osascript` runs, which every Mac has. The corner is the curve\n// UIKit rounds continuous corners with; with this radius it matches the outline of Apple's Notes\n// icon to a few pixels, and the shadow matches its shadow.\nconst MAC_ICON_SCRIPT = `\nObjC.import(\"AppKit\")\nObjC.import(\"CoreGraphics\")\n\nfunction run([input, output]) {\n const canvas = 1024\n const body = 824\n const radius = 185.4\n const min = (canvas - body) / 2\n const max = min + body\n\n // One corner, as offsets in radii from the edge it leaves and the edge it joins.\n const corner = [\n [[1.52866483, 0]],\n [[0.66993427, 0.065496], [1.08849323, 0], [0.86840689, 0.02065986]],\n [[0.63149399, 0.074911]],\n [[0.074911, 0.63149399], [0.37282392, 0.16906933], [0.16906933, 0.37282392]],\n [[0.065496, 0.66993427]],\n [[0, 1.52866483], [0.02065986, 0.86840689], [0, 1.08849323]],\n ]\n const corners = [\n ([u, v]) => $.NSMakePoint(max - u * radius, max - v * radius),\n ([u, v]) => $.NSMakePoint(max - v * radius, min + u * radius),\n ([u, v]) => $.NSMakePoint(min + u * radius, min + v * radius),\n ([u, v]) => $.NSMakePoint(min + v * radius, max - u * radius),\n ]\n const shape = $.NSBezierPath.bezierPath\n shape.moveToPoint(corners[3]([0, 1.52866483]))\n for (const at of corners) {\n for (const [to, c1, c2] of corner) {\n if (c1) shape.curveToPointControlPoint1ControlPoint2(at(to), at(c1), at(c2))\n else shape.lineToPoint(at(to))\n }\n }\n shape.closePath\n\n const image = $.NSImage.alloc.initWithContentsOfFile(input)\n if (image.isNil()) throw new Error(\"Cannot read \" + input)\n const bitmap = $.NSBitmapImageRep.alloc\n .initWithBitmapDataPlanesPixelsWidePixelsHighBitsPerSampleSamplesPerPixelHasAlphaIsPlanarColorSpaceNameBytesPerRowBitsPerPixel(\n null, canvas, canvas, 8, 4, true, false, $.NSDeviceRGBColorSpace, 0, 0\n )\n const context = $.NSGraphicsContext.graphicsContextWithBitmapImageRep(bitmap)\n $.NSGraphicsContext.setCurrentContext(context)\n context.setImageInterpolation($.NSImageInterpolationHigh)\n\n const shadow = $.NSShadow.alloc.init\n shadow.setShadowOffset($.NSMakeSize(0, -8))\n shadow.setShadowBlurRadius(30)\n shadow.setShadowColor($.NSColor.colorWithWhiteAlpha(0, 0.22))\n shadow.set\n // A layer, so the shadow falls from the clipped icon and not from the clip.\n $.CGContextBeginTransparencyLayer(context.CGContext, null)\n shape.addClip\n image.drawInRectFromRectOperationFraction(\n $.NSMakeRect(min, min, body, body), $.NSZeroRect, $.NSCompositingOperationSourceOver, 1\n )\n $.CGContextEndTransparencyLayer(context.CGContext)\n context.flushGraphics\n\n const png = bitmap.representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $.NSDictionary.dictionary)\n if (!png.writeToFileAtomically(output, true)) throw new Error(\"Cannot write \" + output)\n}\n`\n",
|
|
10
|
+
"import { spawn } from \"node:child_process\"\nimport { delimiter } from \"node:path\"\n\n/** The program `writeDesktopApp` compiles, in the runner's directory. */\nexport const RUNNER_BINARY = \"sixb-runner\"\n\n/** How long a person's shell gets to say what their PATH is. */\nconst LOGIN_SHELL_WAIT_MS = 3_000\n/** How long a page waits to hear which folders are allowed, before asking for one instead. */\nconst FOLDERS_WAIT_MS = 10_000\n/** How long the runner gets to end a running turn and close its sessions. */\nconst STOP_WAIT_MS = 12_000\nconst RESTART_WAIT_MS = { first: 2_000, longest: 60_000 } as const\n/** Running this long, the runner wasn't what failed quickly: the next restart waits the least again. */\nconst STEADY_MS = 60_000\n/** Exit status of a runner whose sign-in ended. */\nconst EXIT_SIGNED_OUT = 3\n\nexport interface RunnerProcess {\n /** One line of JSON from the runner. */\n onLine(listener: (line: string) => void): void\n readonly exited: Promise<number>\n write(line: string): void\n /** Close its stdin, which it ends with. */\n end(): void\n kill(): void\n}\n\nexport interface DesktopComputer {\n readonly id: string\n readonly name: string\n}\n\ntype RunnerCommand = {\n readonly type: \"allow\" | \"forget\"\n readonly folder: string\n}\n\n/**\n * The main process's hold on the program that runs the agent on this computer: it starts it, keeps it\n * started while the app is open, and stops it on quit. It holds no sign-in: the program does, and\n * only says where to send the person to approve one.\n */\nexport class DesktopRunner {\n private process: RunnerProcess | null = null\n private computer: DesktopComputer | null = null\n private folderList: readonly string[] = []\n private waitingForComputer: {\n readonly resolve: (computer: DesktopComputer) => void\n readonly reject: (error: Error) => void\n }[] = []\n private foldersKnown = false\n private waitingForFolders: ((folders: readonly string[]) => void)[] = []\n private unsent: RunnerCommand[] = []\n private restart: ReturnType<typeof setTimeout> | null = null\n private restartWait: number = RESTART_WAIT_MS.first\n private stopping = false\n\n constructor(\n private readonly options: {\n readonly start: () => RunnerProcess\n /** A page in the system browser to approve this computer's sign-in on. */\n readonly onSignIn: (url: string) => void\n readonly onProblem: (message: string) => void\n }\n ) {}\n\n start(): void {\n if (this.stopping) return\n const started = Date.now()\n const process = this.options.start()\n this.process = process\n process.onLine((line) => this.hear(line))\n for (const command of this.unsent.splice(0)) process.write(JSON.stringify(command))\n void process.exited.then((code) => {\n this.process = null\n if (this.stopping) return\n if (Date.now() - started >= STEADY_MS) this.restartWait = RESTART_WAIT_MS.first\n // A sign-in that ended is no problem to report: started again, it waits for a new one.\n if (code !== EXIT_SIGNED_OUT) {\n this.options.onProblem(`What runs the agent quit with exit code ${code}.`)\n }\n // Told it failed, not that there's nothing to run on: a message mustn't go elsewhere on a guess.\n for (const { reject } of this.waitingForComputer.splice(0)) {\n reject(new Error(\"[SixbDesktop] What runs the agent on this computer couldn't start.\"))\n }\n this.answerFolders()\n this.restart = setTimeout(() => this.start(), this.restartWait)\n this.restartWait = Math.min(this.restartWait * 2, RESTART_WAIT_MS.longest)\n })\n }\n\n /** This computer, once the runner has said. Rejects if it quits before it does. */\n about(): Promise<DesktopComputer> {\n if (this.computer) return Promise.resolve(this.computer)\n return new Promise((resolve, reject) => this.waitingForComputer.push({ resolve, reject }))\n }\n\n /** The folders allowed, once the runner knows: a page asking early mustn't take them for none. */\n folders(): Promise<readonly string[]> {\n if (this.foldersKnown) return Promise.resolve(this.folderList)\n return new Promise((resolve) => {\n // A runner that can't read its config keeps trying, and a message mustn't wait on that for good.\n const timer = setTimeout(() => this.answerFolders(), FOLDERS_WAIT_MS)\n this.waitingForFolders.push((folders) => {\n clearTimeout(timer)\n resolve(folders)\n })\n })\n }\n\n allow(folder: string): void {\n this.send({ type: \"allow\", folder })\n // A page asking right after choosing finds it there, before the runner has said so.\n if (!this.folderList.includes(folder)) this.folderList = [...this.folderList, folder]\n }\n\n forget(folder: string): void {\n this.send({ type: \"forget\", folder })\n this.folderList = this.folderList.filter((known) => known !== folder)\n }\n\n /** Let it end a running turn and close its sessions, within a limit. */\n async stop(): Promise<void> {\n this.stopping = true\n if (this.restart) clearTimeout(this.restart)\n const process = this.process\n if (!process) return\n process.end()\n let timer: ReturnType<typeof setTimeout> | undefined\n const late = new Promise<\"late\">((resolve) => {\n timer = setTimeout(() => resolve(\"late\"), STOP_WAIT_MS)\n })\n if ((await Promise.race([process.exited, late])) === \"late\") process.kill()\n clearTimeout(timer)\n }\n\n private send(command: RunnerCommand): void {\n if (this.process) this.process.write(JSON.stringify(command))\n else this.unsent.push(command)\n }\n\n private answerFolders(): void {\n this.foldersKnown = true\n for (const answer of this.waitingForFolders.splice(0)) answer(this.folderList)\n }\n\n private hear(line: string): void {\n let event: Record<string, unknown>\n try {\n event = JSON.parse(line)\n } catch {\n return\n }\n switch (event.type) {\n case \"computer\":\n if (typeof event.id === \"string\" && typeof event.name === \"string\") {\n const computer = { id: event.id, name: event.name }\n this.computer = computer\n for (const { resolve } of this.waitingForComputer.splice(0)) resolve(computer)\n }\n return\n case \"folders\":\n if (Array.isArray(event.folders)) this.folderList = event.folders.map(String)\n this.answerFolders()\n return\n // Nothing runs in the list on file until it's signed in: none, so the next message asks, and\n // answering signs in. It would otherwise wait on a sign-in nobody was asked for.\n case \"signed-out\":\n case \"signed-out-waiting\":\n this.folderList = []\n this.answerFolders()\n return\n // It can't say which until the project answers: the last it said, for now.\n case \"unreachable\":\n this.answerFolders()\n return\n case \"sign-in\":\n if (typeof event.url === \"string\") this.options.onSignIn(event.url)\n return\n case \"sign-in-failed\":\n this.folderList = []\n if (typeof event.message === \"string\") this.options.onProblem(event.message)\n return\n case \"session.lost\":\n case \"error\":\n if (typeof event.message === \"string\") this.options.onProblem(event.message)\n }\n }\n}\n\n/** The runner as a child process of this one, its stderr for the app's own log. */\nexport function startRunnerProcess(\n command: readonly [string, ...string[]],\n env: NodeJS.ProcessEnv\n): RunnerProcess {\n const [file, ...args] = command\n const child = spawn(file, args, { env, stdio: [\"pipe\", \"pipe\", \"inherit\"] })\n // A runner that's gone by the time this writes: its restart is what answers that.\n child.stdin.on(\"error\", () => {})\n const exited = new Promise<number>((resolve) => {\n child.once(\"error\", () => resolve(-1))\n child.once(\"exit\", (code) => resolve(code ?? -1))\n })\n return {\n onLine(listener) {\n let rest = \"\"\n child.stdout.setEncoding(\"utf8\").on(\"data\", (chunk: string) => {\n rest += chunk\n const lines = rest.split(\"\\n\")\n rest = lines.pop() ?? \"\"\n for (const line of lines) listener(line)\n })\n },\n exited,\n write: (line) => void child.stdin.write(`${line}\\n`),\n end: () => void child.stdin.end(),\n kill: () => void child.kill(\"SIGKILL\"),\n }\n}\n\n/**\n * The environment the runner, and the agent's commands, run in. An app opened from the Dock gets a\n * bare PATH, so the person's shell says what theirs is: the agent uses the tools they do. The `bun`\n * beside the runner comes last, for someone with neither Bun nor Node.\n */\nexport async function runnerEnvironment(\n runnerDir: string,\n base: NodeJS.ProcessEnv\n): Promise<NodeJS.ProcessEnv> {\n const shell = await readLoginShell(base.SHELL).catch(() => null)\n const env = Object.fromEntries(\n // What a terminal `sixb dev` was told about its own project is no business of this one.\n Object.entries(base).filter(([name]) => !name.startsWith(\"SIXB_\"))\n )\n return {\n ...env,\n PATH: [shell?.path ?? base.PATH ?? \"\", runnerDir].join(delimiter),\n // Where `sixb` keeps its sign-in, and so what the agent's commands are kept from.\n ...(shell?.configHome ? { XDG_CONFIG_HOME: shell.configHome } : {}),\n }\n}\n\n/**\n * Between markers, as a shell's startup files may print whatever they like.\n *\n * In a session of its own, with no terminal: an interactive shell takes over the one it inherits. Under\n * `sixb dev --desktop` that's the person's, and whatever reads a key or a mouse move there meanwhile is\n * stopped by it, for good, leaving the terminal as it was.\n *\n * A limit of its own, and a kill that can't be ignored: an interactive shell ignores `execFile`'s, and\n * what it started can hold the pipe open long after it's gone. A startup file that waits on a person\n * mustn't stop the agent.\n */\nfunction readLoginShell(\n shell: string | undefined\n): Promise<{ readonly path: string; readonly configHome: string } | null> {\n if (!shell) return Promise.resolve(null)\n return new Promise((resolve) => {\n // `spawn`, not `execFile`, which ignores `detached`.\n const child = spawn(\n shell,\n [\"-ilc\", 'printf \"__SIXB_SHELL__%s\\\\n%s__SIXB_SHELL__\" \"$PATH\" \"$XDG_CONFIG_HOME\"'],\n { detached: true, stdio: [\"ignore\", \"pipe\", \"ignore\"] }\n )\n let printed = \"\"\n const said = () => {\n const [path, configHome] =\n /__SIXB_SHELL__(.*)__SIXB_SHELL__/s.exec(printed)?.[1]?.split(\"\\n\") ?? []\n return path ? { path, configHome: configHome ?? \"\" } : null\n }\n const timer = setTimeout(() => {\n // The whole group: it's alone in it, with whatever its startup files began.\n if (child.pid) {\n try {\n process.kill(-child.pid, \"SIGKILL\")\n } catch {\n // Gone already.\n }\n }\n // What it said counts, whatever it left holding the pipe.\n resolve(said())\n }, LOGIN_SHELL_WAIT_MS)\n child.stdout.on(\"data\", (chunk) => {\n printed += String(chunk)\n })\n child.once(\"error\", () => {\n clearTimeout(timer)\n resolve(null)\n })\n child.once(\"close\", () => {\n clearTimeout(timer)\n resolve(said())\n })\n })\n}\n"
|
|
10
11
|
],
|
|
11
|
-
"mappings": "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
|
|
12
|
-
"debugId": "
|
|
12
|
+
"mappings": "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",
|
|
13
|
+
"debugId": "D20C8A734FA0DFE264756E2164756E21",
|
|
13
14
|
"names": []
|
|
14
15
|
}
|
package/dist/shell/bridge.d.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
/** The name a page finds the desktop app under, through `useDesktop` in `@sixb/client/hooks`. */
|
|
2
2
|
export declare const DESKTOP_BRIDGE_NAME = "sixbDesktop";
|
|
3
|
+
/** How the page's `computer` methods reach the main process. */
|
|
4
|
+
export declare const COMPUTER_CHANNELS: {
|
|
5
|
+
readonly about: "sixb:computer:about";
|
|
6
|
+
readonly folders: "sixb:computer:folders";
|
|
7
|
+
readonly pickFolder: "sixb:computer:pick-folder";
|
|
8
|
+
readonly allowFolder: "sixb:computer:allow-folder";
|
|
9
|
+
readonly forgetFolder: "sixb:computer:forget-folder";
|
|
10
|
+
};
|
|
11
|
+
type Invoke = (channel: string, ...args: unknown[]) => Promise<any>;
|
|
3
12
|
/**
|
|
4
13
|
* What the shell offers the page. `SixbDesktop` in `@sixb/client` is the page's view of this, and
|
|
5
14
|
* `tests/app.test.ts` holds the two together.
|
|
@@ -10,7 +19,15 @@ export declare const DESKTOP_BRIDGE_NAME = "sixbDesktop";
|
|
|
10
19
|
* 2. Handle it in `main.ts`, refusing any sender that `isAppUrl` does not accept.
|
|
11
20
|
* 3. Add it to `SixbDesktop` as optional, because a page can outlive the shell it runs in.
|
|
12
21
|
*/
|
|
13
|
-
export declare function desktopBridge<const Platform extends NodeJS.Platform>(platform: Platform): {
|
|
22
|
+
export declare function desktopBridge<const Platform extends NodeJS.Platform>(platform: Platform, invoke: Invoke): {
|
|
14
23
|
platform: Platform;
|
|
24
|
+
computer: {
|
|
25
|
+
about: () => Promise<any>;
|
|
26
|
+
folders: () => Promise<any>;
|
|
27
|
+
pickFolder: () => Promise<any>;
|
|
28
|
+
allowFolder: (folder: string) => Promise<any>;
|
|
29
|
+
forgetFolder: (folder: string) => Promise<any>;
|
|
30
|
+
};
|
|
15
31
|
};
|
|
32
|
+
export {};
|
|
16
33
|
//# sourceMappingURL=bridge.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bridge.d.ts","sourceRoot":"","sources":["../../src/shell/bridge.ts"],"names":[],"mappings":"AAAA,iGAAiG;AACjG,eAAO,MAAM,mBAAmB,gBAAgB,CAAA;AAEhD;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAAC,KAAK,CAAC,QAAQ,SAAS,MAAM,CAAC,QAAQ,EAAE,QAAQ,EAAE,
|
|
1
|
+
{"version":3,"file":"bridge.d.ts","sourceRoot":"","sources":["../../src/shell/bridge.ts"],"names":[],"mappings":"AAAA,iGAAiG;AACjG,eAAO,MAAM,mBAAmB,gBAAgB,CAAA;AAEhD,gEAAgE;AAChE,eAAO,MAAM,iBAAiB;;;;;;CAMpB,CAAA;AAIV,KAAK,MAAM,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC,GAAG,CAAC,CAAA;AAEnE;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAAC,KAAK,CAAC,QAAQ,SAAS,MAAM,CAAC,QAAQ,EAClE,QAAQ,EAAE,QAAQ,EAClB,MAAM,EAAE,MAAM;;;;;;8BAQY,MAAM;+BACL,MAAM;;EAGlC"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** The program `writeDesktopApp` compiles, in the runner's directory. */
|
|
2
|
+
export declare const RUNNER_BINARY = "sixb-runner";
|
|
3
|
+
export interface RunnerProcess {
|
|
4
|
+
/** One line of JSON from the runner. */
|
|
5
|
+
onLine(listener: (line: string) => void): void;
|
|
6
|
+
readonly exited: Promise<number>;
|
|
7
|
+
write(line: string): void;
|
|
8
|
+
/** Close its stdin, which it ends with. */
|
|
9
|
+
end(): void;
|
|
10
|
+
kill(): void;
|
|
11
|
+
}
|
|
12
|
+
export interface DesktopComputer {
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly name: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The main process's hold on the program that runs the agent on this computer: it starts it, keeps it
|
|
18
|
+
* started while the app is open, and stops it on quit. It holds no sign-in: the program does, and
|
|
19
|
+
* only says where to send the person to approve one.
|
|
20
|
+
*/
|
|
21
|
+
export declare class DesktopRunner {
|
|
22
|
+
private readonly options;
|
|
23
|
+
private process;
|
|
24
|
+
private computer;
|
|
25
|
+
private folderList;
|
|
26
|
+
private waitingForComputer;
|
|
27
|
+
private foldersKnown;
|
|
28
|
+
private waitingForFolders;
|
|
29
|
+
private unsent;
|
|
30
|
+
private restart;
|
|
31
|
+
private restartWait;
|
|
32
|
+
private stopping;
|
|
33
|
+
constructor(options: {
|
|
34
|
+
readonly start: () => RunnerProcess;
|
|
35
|
+
/** A page in the system browser to approve this computer's sign-in on. */
|
|
36
|
+
readonly onSignIn: (url: string) => void;
|
|
37
|
+
readonly onProblem: (message: string) => void;
|
|
38
|
+
});
|
|
39
|
+
start(): void;
|
|
40
|
+
/** This computer, once the runner has said. Rejects if it quits before it does. */
|
|
41
|
+
about(): Promise<DesktopComputer>;
|
|
42
|
+
/** The folders allowed, once the runner knows: a page asking early mustn't take them for none. */
|
|
43
|
+
folders(): Promise<readonly string[]>;
|
|
44
|
+
allow(folder: string): void;
|
|
45
|
+
forget(folder: string): void;
|
|
46
|
+
/** Let it end a running turn and close its sessions, within a limit. */
|
|
47
|
+
stop(): Promise<void>;
|
|
48
|
+
private send;
|
|
49
|
+
private answerFolders;
|
|
50
|
+
private hear;
|
|
51
|
+
}
|
|
52
|
+
/** The runner as a child process of this one, its stderr for the app's own log. */
|
|
53
|
+
export declare function startRunnerProcess(command: readonly [string, ...string[]], env: NodeJS.ProcessEnv): RunnerProcess;
|
|
54
|
+
/**
|
|
55
|
+
* The environment the runner, and the agent's commands, run in. An app opened from the Dock gets a
|
|
56
|
+
* bare PATH, so the person's shell says what theirs is: the agent uses the tools they do. The `bun`
|
|
57
|
+
* beside the runner comes last, for someone with neither Bun nor Node.
|
|
58
|
+
*/
|
|
59
|
+
export declare function runnerEnvironment(runnerDir: string, base: NodeJS.ProcessEnv): Promise<NodeJS.ProcessEnv>;
|
|
60
|
+
//# sourceMappingURL=runner.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../../src/shell/runner.ts"],"names":[],"mappings":"AAGA,yEAAyE;AACzE,eAAO,MAAM,aAAa,gBAAgB,CAAA;AAc1C,MAAM,WAAW,aAAa;IAC5B,wCAAwC;IACxC,MAAM,CAAC,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI,CAAA;IAC9C,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC,CAAA;IAChC,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,2CAA2C;IAC3C,GAAG,IAAI,IAAI,CAAA;IACX,IAAI,IAAI,IAAI,CAAA;CACb;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACtB;AAOD;;;;GAIG;AACH,qBAAa,aAAa;IAgBtB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAf1B,OAAO,CAAC,OAAO,CAA6B;IAC5C,OAAO,CAAC,QAAQ,CAA+B;IAC/C,OAAO,CAAC,UAAU,CAAwB;IAC1C,OAAO,CAAC,kBAAkB,CAGlB;IACR,OAAO,CAAC,YAAY,CAAQ;IAC5B,OAAO,CAAC,iBAAiB,CAA+C;IACxE,OAAO,CAAC,MAAM,CAAsB;IACpC,OAAO,CAAC,OAAO,CAA6C;IAC5D,OAAO,CAAC,WAAW,CAAgC;IACnD,OAAO,CAAC,QAAQ,CAAQ;gBAGL,OAAO,EAAE;QACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,aAAa,CAAA;QACnC,0EAA0E;QAC1E,QAAQ,CAAC,QAAQ,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;QACxC,QAAQ,CAAC,SAAS,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;KAC9C;IAGH,KAAK,IAAI,IAAI;IAyBb,mFAAmF;IACnF,KAAK,IAAI,OAAO,CAAC,eAAe,CAAC;IAKjC,kGAAkG;IAClG,OAAO,IAAI,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC;IAYrC,KAAK,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI;IAM3B,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI;IAK5B,wEAAwE;IAClE,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAc3B,OAAO,CAAC,IAAI;IAKZ,OAAO,CAAC,aAAa;IAKrB,OAAO,CAAC,IAAI;CA0Cb;AAED,mFAAmF;AACnF,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,SAAS,CAAC,MAAM,EAAE,GAAG,MAAM,EAAE,CAAC,EACvC,GAAG,EAAE,MAAM,CAAC,UAAU,GACrB,aAAa,CAwBf;AAED;;;;GAIG;AACH,wBAAsB,iBAAiB,CACrC,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,MAAM,CAAC,UAAU,GACtB,OAAO,CAAC,MAAM,CAAC,UAAU,CAAC,CAY5B"}
|
package/dist/shell/window.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export declare function mayNavigate(config: ShellConfig, url: string): boolean;
|
|
|
9
9
|
* and not a frame it embeds from elsewhere.
|
|
10
10
|
*/
|
|
11
11
|
export declare function isAppUrl(config: ShellConfig, url: string): boolean;
|
|
12
|
+
/** Whether `url` is a web page of the project's API, the only place a sign-in is approved. */
|
|
13
|
+
export declare function isApiPage(config: ShellConfig, url: string): boolean;
|
|
12
14
|
/**
|
|
13
15
|
* What the window paints before a page has: the loading page's own background, so the window
|
|
14
16
|
* opens, and passes from that page to the app, without a flash of white.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"window.d.ts","sourceRoot":"","sources":["../../src/shell/window.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAE/C;;;GAGG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAOrE;AAED;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,MAAM,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAMlE;AAED;;;GAGG;AACH,eAAO,MAAM,iBAAiB;;;CAAiD,CAAA;AAE/E;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM;;;;;EAEjD;AAED,qFAAqF;AACrF,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,CAAC,QAAQ,GAAG,OAAO,CAEpE;AAED,iGAAiG;AACjG,eAAO,MAAM,kBAAkB,KAAK,CAAA;AAEpC;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,oBAAoB,QAsBvB,CAAA"}
|
|
1
|
+
{"version":3,"file":"window.d.ts","sourceRoot":"","sources":["../../src/shell/window.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAE/C;;;GAGG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAOrE;AAED;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,MAAM,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAMlE;AAED,8FAA8F;AAC9F,wBAAgB,SAAS,CAAC,MAAM,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CASnE;AAED;;;GAGG;AACH,eAAO,MAAM,iBAAiB;;;CAAiD,CAAA;AAE/E;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM;;;;;EAEjD;AAED,qFAAqF;AACrF,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,CAAC,QAAQ,GAAG,OAAO,CAEpE;AAED,iGAAiG;AACjG,eAAO,MAAM,kBAAkB,KAAK,CAAA;AAEpC;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,oBAAoB,QAsBvB,CAAA"}
|
package/package.json
CHANGED
package/src/generate.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { readAppIdentity } from "./identity"
|
|
|
5
5
|
import { drawMacIcon } from "./mac-icon"
|
|
6
6
|
import { run } from "./run"
|
|
7
7
|
import type { ShellConfig } from "./shell/navigation"
|
|
8
|
+
import { RUNNER_BINARY } from "./shell/runner"
|
|
8
9
|
|
|
9
10
|
export interface DesktopAppOptions {
|
|
10
11
|
/** The Sixb project. The desktop app is generated under its `.sixb/desktop`. */
|
|
@@ -13,6 +14,11 @@ export interface DesktopAppOptions {
|
|
|
13
14
|
readonly appUrl: string
|
|
14
15
|
/** The project's API. Its sign-in pages are where the window is approved. */
|
|
15
16
|
readonly apiOrigin: string
|
|
17
|
+
/**
|
|
18
|
+
* What the app compiles to run the agent on this computer: the CLI names its own, as this package
|
|
19
|
+
* doesn't depend on it. Without one, the app has no way to run the agent here: see `writeRunner`.
|
|
20
|
+
*/
|
|
21
|
+
readonly runnerEntry?: string
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
/** The Electron app `writeDesktopApp` generated, and what it is called. */
|
|
@@ -27,8 +33,11 @@ export interface GeneratedApp {
|
|
|
27
33
|
// Read from the package's `src`, which ships, whether this module runs from `src` or `dist`.
|
|
28
34
|
const SHELL_SOURCE_DIR = fileURLToPath(new URL("../src/shell", import.meta.url))
|
|
29
35
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Where a project's desktop app is generated (`app`), what runs the agent is compiled (`runner`, beside
|
|
38
|
+
* the app as everything in the app's directory ships inside its archive), and it's packaged (`dist`).
|
|
39
|
+
*/
|
|
40
|
+
export function desktopDir(projectRoot: string, part: "app" | "dist" | "runner"): string {
|
|
32
41
|
return join(projectRoot, ".sixb", "desktop", part)
|
|
33
42
|
}
|
|
34
43
|
|
|
@@ -82,6 +91,35 @@ export async function writeDesktopApp(options: DesktopAppOptions): Promise<Gener
|
|
|
82
91
|
return { dir, name: identity.name, version, icon }
|
|
83
92
|
}
|
|
84
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Compiles what runs the agent into one program, so that the app needs nothing installed, and a `bun`
|
|
96
|
+
* beside it. The agent's own `sixb` runs on Bun or Node, which a person may have neither of, and the
|
|
97
|
+
* program can stand in for Bun: last on the PATH the shell gives it, after any they have. Returns its
|
|
98
|
+
* directory: null with no entry, and on Windows, which has no local sandbox to run the agent in.
|
|
99
|
+
*/
|
|
100
|
+
export async function writeRunner(options: DesktopAppOptions): Promise<string | null> {
|
|
101
|
+
const dir = desktopDir(options.projectRoot, "runner")
|
|
102
|
+
await rm(dir, { recursive: true, force: true })
|
|
103
|
+
if (!options.runnerEntry || process.platform === "win32") return null
|
|
104
|
+
await mkdir(dir, { recursive: true })
|
|
105
|
+
await run(
|
|
106
|
+
[
|
|
107
|
+
process.execPath,
|
|
108
|
+
"build",
|
|
109
|
+
"--compile",
|
|
110
|
+
options.runnerEntry,
|
|
111
|
+
`--outfile=${join(dir, RUNNER_BINARY)}`,
|
|
112
|
+
],
|
|
113
|
+
"Could not compile what runs the agent."
|
|
114
|
+
)
|
|
115
|
+
await writeFile(
|
|
116
|
+
join(dir, "bun"),
|
|
117
|
+
`#!/bin/sh\nBUN_BE_BUN=1 exec "$(dirname "$0")/${RUNNER_BINARY}" "$@"\n`,
|
|
118
|
+
{ mode: 0o755 }
|
|
119
|
+
)
|
|
120
|
+
return dir
|
|
121
|
+
}
|
|
122
|
+
|
|
85
123
|
/** The page the window shows while the app loads, and when it cannot reach the app. */
|
|
86
124
|
export function renderShellPage(
|
|
87
125
|
template: string,
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { mkdir, readdir, rm } from "node:fs/promises"
|
|
|
2
2
|
import { createRequire } from "node:module"
|
|
3
3
|
import { dirname, join } from "node:path"
|
|
4
4
|
import { fileURLToPath } from "node:url"
|
|
5
|
-
import { type DesktopAppOptions, desktopDir, writeDesktopApp } from "./generate"
|
|
5
|
+
import { type DesktopAppOptions, desktopDir, writeDesktopApp, writeRunner } from "./generate"
|
|
6
6
|
import { bundleIdentifier } from "./identity"
|
|
7
7
|
import { run } from "./run"
|
|
8
8
|
|
|
@@ -28,6 +28,11 @@ const PACKAGER_SCRIPT = fileURLToPath(new URL("../src/packager.mjs", import.meta
|
|
|
28
28
|
/** Opens the app in a desktop window. `sixb dev --desktop` calls this. */
|
|
29
29
|
export async function openDesktopApp(options: DesktopAppOptions): Promise<DesktopWindow> {
|
|
30
30
|
const app = await writeDesktopApp(options)
|
|
31
|
+
// The window is still worth opening without it, for a project not using the agent: conversations
|
|
32
|
+
// run in the cloud. A build, which people get, fails instead.
|
|
33
|
+
await writeRunner(options).catch((error: unknown) => {
|
|
34
|
+
console.error("[SixbDesktop] Conversations will run in the cloud.", error)
|
|
35
|
+
})
|
|
31
36
|
// Chromium writes plenty to stderr when all is well, so it goes to a file, named if the window
|
|
32
37
|
// quits on its own with an error.
|
|
33
38
|
const log = join(dirname(app.dir), "electron.log")
|
|
@@ -53,6 +58,7 @@ export async function openDesktopApp(options: DesktopAppOptions): Promise<Deskto
|
|
|
53
58
|
/** Packages the app for this machine's platform. `sixb desktop build` calls this. */
|
|
54
59
|
export async function buildDesktopApp(options: DesktopAppOptions): Promise<DesktopBuild> {
|
|
55
60
|
const app = await writeDesktopApp(options)
|
|
61
|
+
const runner = await writeRunner(options)
|
|
56
62
|
const out = desktopDir(options.projectRoot, "dist")
|
|
57
63
|
await rm(out, { recursive: true, force: true })
|
|
58
64
|
|
|
@@ -67,6 +73,8 @@ export async function buildDesktopApp(options: DesktopAppOptions): Promise<Deskt
|
|
|
67
73
|
electronVersion: (packageRequire("electron/package.json") as { readonly version: string })
|
|
68
74
|
.version,
|
|
69
75
|
...(mac && app.icon ? { icon: await macIcon(app.icon) } : {}),
|
|
76
|
+
// Beside the app's archive, not in it: a program has to be a file of its own to run.
|
|
77
|
+
...(runner ? { extraResource: [runner] } : {}),
|
|
70
78
|
// Signs with the Developer ID certificate in the keychain, which notarization requires.
|
|
71
79
|
...(notarize ? { osxSign: {}, osxNotarize: notarize } : {}),
|
|
72
80
|
})
|
|
@@ -171,7 +179,7 @@ function electronBinary(): string {
|
|
|
171
179
|
const electronDir = dirname(packageRequire.resolve("electron/package.json"))
|
|
172
180
|
throw new Error(
|
|
173
181
|
"[SixbDesktop] Electron's binary is missing: its install script did not run. " +
|
|
174
|
-
`Run \`bun
|
|
182
|
+
`Run \`bun ${join(electronDir, "install.js")}\`, then try again.`
|
|
175
183
|
)
|
|
176
184
|
}
|
|
177
185
|
}
|
package/src/shell/bridge.ts
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
/** The name a page finds the desktop app under, through `useDesktop` in `@sixb/client/hooks`. */
|
|
2
2
|
export const DESKTOP_BRIDGE_NAME = "sixbDesktop"
|
|
3
3
|
|
|
4
|
+
/** How the page's `computer` methods reach the main process. */
|
|
5
|
+
export const COMPUTER_CHANNELS = {
|
|
6
|
+
about: "sixb:computer:about",
|
|
7
|
+
folders: "sixb:computer:folders",
|
|
8
|
+
pickFolder: "sixb:computer:pick-folder",
|
|
9
|
+
allowFolder: "sixb:computer:allow-folder",
|
|
10
|
+
forgetFolder: "sixb:computer:forget-folder",
|
|
11
|
+
} as const
|
|
12
|
+
|
|
13
|
+
// Typed by `SixbDesktop` in `@sixb/client`, which the page reads: an `any` that a test holds to it.
|
|
14
|
+
// biome-ignore lint/suspicious/noExplicitAny: each channel answers its own type.
|
|
15
|
+
type Invoke = (channel: string, ...args: unknown[]) => Promise<any>
|
|
16
|
+
|
|
4
17
|
/**
|
|
5
18
|
* What the shell offers the page. `SixbDesktop` in `@sixb/client` is the page's view of this, and
|
|
6
19
|
* `tests/app.test.ts` holds the two together.
|
|
@@ -11,6 +24,18 @@ export const DESKTOP_BRIDGE_NAME = "sixbDesktop"
|
|
|
11
24
|
* 2. Handle it in `main.ts`, refusing any sender that `isAppUrl` does not accept.
|
|
12
25
|
* 3. Add it to `SixbDesktop` as optional, because a page can outlive the shell it runs in.
|
|
13
26
|
*/
|
|
14
|
-
export function desktopBridge<const Platform extends NodeJS.Platform>(
|
|
15
|
-
|
|
27
|
+
export function desktopBridge<const Platform extends NodeJS.Platform>(
|
|
28
|
+
platform: Platform,
|
|
29
|
+
invoke: Invoke
|
|
30
|
+
) {
|
|
31
|
+
return {
|
|
32
|
+
platform,
|
|
33
|
+
computer: {
|
|
34
|
+
about: () => invoke(COMPUTER_CHANNELS.about),
|
|
35
|
+
folders: () => invoke(COMPUTER_CHANNELS.folders),
|
|
36
|
+
pickFolder: () => invoke(COMPUTER_CHANNELS.pickFolder),
|
|
37
|
+
allowFolder: (folder: string) => invoke(COMPUTER_CHANNELS.allowFolder, folder),
|
|
38
|
+
forgetFolder: (folder: string) => invoke(COMPUTER_CHANNELS.forgetFolder, folder),
|
|
39
|
+
},
|
|
40
|
+
}
|
|
16
41
|
}
|
package/src/shell/main.ts
CHANGED
|
@@ -1,12 +1,28 @@
|
|
|
1
1
|
// The desktop shell's main process. `writeDesktopApp` compiles this file into the generated
|
|
2
2
|
// Electron app; nothing imports it.
|
|
3
3
|
import { existsSync, readFileSync } from "node:fs"
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { realpath, stat } from "node:fs/promises"
|
|
5
|
+
import { hostname } from "node:os"
|
|
6
|
+
import { isAbsolute, join } from "node:path"
|
|
7
|
+
import {
|
|
8
|
+
app,
|
|
9
|
+
BrowserWindow,
|
|
10
|
+
dialog,
|
|
11
|
+
type Event,
|
|
12
|
+
type IpcMainInvokeEvent,
|
|
13
|
+
ipcMain,
|
|
14
|
+
type MessageBoxOptions,
|
|
15
|
+
nativeTheme,
|
|
16
|
+
session,
|
|
17
|
+
shell,
|
|
18
|
+
} from "electron"
|
|
19
|
+
import { COMPUTER_CHANNELS } from "./bridge"
|
|
6
20
|
import { createNavigationState, isWebUrl, nextNavigationStep, type ShellConfig } from "./navigation"
|
|
21
|
+
import { DesktopRunner, RUNNER_BINARY, runnerEnvironment, startRunnerProcess } from "./runner"
|
|
7
22
|
import {
|
|
8
23
|
HIDDEN_TITLE_BAR_CSS,
|
|
9
24
|
hasHiddenTitleBar,
|
|
25
|
+
isApiPage,
|
|
10
26
|
isAppUrl,
|
|
11
27
|
mayNavigate,
|
|
12
28
|
pageWebPreferences,
|
|
@@ -85,6 +101,109 @@ function createWindow(): void {
|
|
|
85
101
|
void win.loadFile(loadingPage).then(() => load(config.appUrl))
|
|
86
102
|
}
|
|
87
103
|
|
|
104
|
+
/** Beside the app's archive once packaged, and beside the generated app before that. */
|
|
105
|
+
const runnerDir = app.isPackaged
|
|
106
|
+
? join(process.resourcesPath, "runner")
|
|
107
|
+
: join(appDir, "..", "runner")
|
|
108
|
+
const runnerProgram = join(runnerDir, RUNNER_BINARY)
|
|
109
|
+
let runnerEnv: NodeJS.ProcessEnv = process.env
|
|
110
|
+
const runner = existsSync(runnerProgram)
|
|
111
|
+
? new DesktopRunner({
|
|
112
|
+
start: () =>
|
|
113
|
+
startRunnerProcess(
|
|
114
|
+
// Its data: the window's sign-in is in there, as cookies the agent's commands mustn't read.
|
|
115
|
+
[
|
|
116
|
+
runnerProgram,
|
|
117
|
+
config.apiOrigin,
|
|
118
|
+
`${config.name} on ${hostname()}`,
|
|
119
|
+
app.getPath("userData"),
|
|
120
|
+
],
|
|
121
|
+
runnerEnv
|
|
122
|
+
),
|
|
123
|
+
// The runner holds the sign-in, and only says where to approve it: on the API, as the window's.
|
|
124
|
+
onSignIn: (url) => {
|
|
125
|
+
if (isApiPage(config, url)) void shell.openExternal(url)
|
|
126
|
+
else console.error(`[SixbDesktop] Refused to open a sign-in away from the API: ${url}`)
|
|
127
|
+
},
|
|
128
|
+
onProblem: (message) => console.error(`[SixbDesktop] ${message}`),
|
|
129
|
+
})
|
|
130
|
+
: null
|
|
131
|
+
|
|
132
|
+
/** Answer the page's `computer` methods, asking whoever calls for the app's own origin first. */
|
|
133
|
+
function answerComputer(
|
|
134
|
+
channel: string,
|
|
135
|
+
answer: (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown
|
|
136
|
+
): void {
|
|
137
|
+
ipcMain.handle(channel, (event, ...args) => {
|
|
138
|
+
if (!isAppUrl(config, event.senderFrame?.url ?? "")) {
|
|
139
|
+
throw new Error("[SixbDesktop] Only the app may ask for this.")
|
|
140
|
+
}
|
|
141
|
+
return answer(event, ...args)
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** What a page named as a folder: it gets here unchecked, so nothing is taken on its word. */
|
|
146
|
+
async function namedFolder(value: unknown): Promise<string | null> {
|
|
147
|
+
if (typeof value !== "string" || !isAbsolute(value)) return null
|
|
148
|
+
const folder = await realpath(value).catch(() => null)
|
|
149
|
+
if (!folder) return null
|
|
150
|
+
return (await stat(folder)).isDirectory() ? folder : null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function installComputerAccess(running: DesktopRunner): void {
|
|
154
|
+
answerComputer(COMPUTER_CHANNELS.about, () => running.about())
|
|
155
|
+
answerComputer(COMPUTER_CHANNELS.folders, () => running.folders())
|
|
156
|
+
answerComputer(COMPUTER_CHANNELS.pickFolder, async (event) => {
|
|
157
|
+
const options = {
|
|
158
|
+
title: "Choose a folder",
|
|
159
|
+
message:
|
|
160
|
+
"Choose where the agent works. Messages you send from here or the web run in this folder, " +
|
|
161
|
+
"as you, and its commands run without asking.",
|
|
162
|
+
buttonLabel: "Work Here",
|
|
163
|
+
properties: ["openDirectory", "createDirectory"] as ("openDirectory" | "createDirectory")[],
|
|
164
|
+
}
|
|
165
|
+
const owner = BrowserWindow.fromWebContents(event.sender)
|
|
166
|
+
const { canceled, filePaths } = owner
|
|
167
|
+
? await dialog.showOpenDialog(owner, options)
|
|
168
|
+
: await dialog.showOpenDialog(options)
|
|
169
|
+
const folder = canceled ? null : await namedFolder(filePaths[0])
|
|
170
|
+
if (folder) running.allow(folder)
|
|
171
|
+
return folder
|
|
172
|
+
})
|
|
173
|
+
answerComputer(COMPUTER_CHANNELS.allowFolder, async (event, value) => {
|
|
174
|
+
const owner = BrowserWindow.fromWebContents(event.sender)
|
|
175
|
+
const ask = (options: MessageBoxOptions) =>
|
|
176
|
+
owner ? dialog.showMessageBox(owner, options) : dialog.showMessageBox(options)
|
|
177
|
+
const folder = await namedFolder(value)
|
|
178
|
+
if (!folder) {
|
|
179
|
+
// Its button would otherwise do nothing, with nothing to say why.
|
|
180
|
+
await ask({
|
|
181
|
+
type: "warning",
|
|
182
|
+
message: "That folder isn't on this computer now.",
|
|
183
|
+
detail: "Put it back, or continue in the cloud.",
|
|
184
|
+
})
|
|
185
|
+
return false
|
|
186
|
+
}
|
|
187
|
+
const { response } = await ask({
|
|
188
|
+
type: "question",
|
|
189
|
+
message: `Let the agent work in ${folder}?`,
|
|
190
|
+
detail:
|
|
191
|
+
"Messages you send from here or the web, in any conversation in this folder, run on this " +
|
|
192
|
+
"computer, as you. The agent's commands run there without asking.",
|
|
193
|
+
buttons: ["Allow", "Cancel"],
|
|
194
|
+
// A page can ask at any moment: the Return they were about to press isn't their answer.
|
|
195
|
+
defaultId: 1,
|
|
196
|
+
cancelId: 1,
|
|
197
|
+
})
|
|
198
|
+
if (response !== 0) return false
|
|
199
|
+
running.allow(folder)
|
|
200
|
+
return true
|
|
201
|
+
})
|
|
202
|
+
answerComputer(COMPUTER_CHANNELS.forgetFolder, async (_event, value) => {
|
|
203
|
+
if (typeof value === "string") running.forget(value)
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
|
|
88
207
|
void app.whenReady().then(() => {
|
|
89
208
|
// A packaged app carries its icon in its bundle. Run from source, it would show Electron's.
|
|
90
209
|
if (icon && !app.isPackaged) app.dock?.setIcon(icon)
|
|
@@ -96,12 +215,34 @@ void app.whenReady().then(() => {
|
|
|
96
215
|
}
|
|
97
216
|
)
|
|
98
217
|
|
|
218
|
+
if (runner) {
|
|
219
|
+
installComputerAccess(runner)
|
|
220
|
+
void runnerEnvironment(runnerDir, process.env).then((env) => {
|
|
221
|
+
runnerEnv = env
|
|
222
|
+
runner.start()
|
|
223
|
+
})
|
|
224
|
+
} else {
|
|
225
|
+
answerComputer(COMPUTER_CHANNELS.about, () => null)
|
|
226
|
+
answerComputer(COMPUTER_CHANNELS.folders, () => [])
|
|
227
|
+
}
|
|
228
|
+
|
|
99
229
|
createWindow()
|
|
100
230
|
app.on("activate", () => {
|
|
101
231
|
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
|
102
232
|
})
|
|
103
233
|
})
|
|
104
234
|
|
|
235
|
+
// Waits for the runner, so that a turn running here records its end before the app is gone.
|
|
236
|
+
let runnerStopped = false
|
|
237
|
+
app.on("before-quit", (event) => {
|
|
238
|
+
if (!runner || runnerStopped) return
|
|
239
|
+
event.preventDefault()
|
|
240
|
+
void runner.stop().finally(() => {
|
|
241
|
+
runnerStopped = true
|
|
242
|
+
app.quit()
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
|
|
105
246
|
app.on("window-all-closed", () => {
|
|
106
247
|
if (process.platform !== "darwin") app.quit()
|
|
107
248
|
})
|
package/src/shell/preload.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
// Runs in every page the window shows, before the page's own scripts, in a world of its own. Electron
|
|
2
2
|
// sandboxes it: of what it imports, only `electron` may remain once compiled.
|
|
3
|
-
import { contextBridge } from "electron"
|
|
3
|
+
import { contextBridge, ipcRenderer } from "electron"
|
|
4
4
|
import { DESKTOP_BRIDGE_NAME, desktopBridge } from "./bridge"
|
|
5
5
|
import { CONTROLS_HEIGHT_PX, hasHiddenTitleBar } from "./window"
|
|
6
6
|
|
|
7
|
-
contextBridge.exposeInMainWorld(
|
|
7
|
+
contextBridge.exposeInMainWorld(
|
|
8
|
+
DESKTOP_BRIDGE_NAME,
|
|
9
|
+
desktopBridge(process.platform, (channel, ...args) => ipcRenderer.invoke(channel, ...args))
|
|
10
|
+
)
|
|
8
11
|
|
|
9
12
|
if (hasHiddenTitleBar(process.platform)) installHiddenTitleBar()
|
|
10
13
|
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"
|
|
2
|
+
import { delimiter } from "node:path"
|
|
3
|
+
|
|
4
|
+
/** The program `writeDesktopApp` compiles, in the runner's directory. */
|
|
5
|
+
export const RUNNER_BINARY = "sixb-runner"
|
|
6
|
+
|
|
7
|
+
/** How long a person's shell gets to say what their PATH is. */
|
|
8
|
+
const LOGIN_SHELL_WAIT_MS = 3_000
|
|
9
|
+
/** How long a page waits to hear which folders are allowed, before asking for one instead. */
|
|
10
|
+
const FOLDERS_WAIT_MS = 10_000
|
|
11
|
+
/** How long the runner gets to end a running turn and close its sessions. */
|
|
12
|
+
const STOP_WAIT_MS = 12_000
|
|
13
|
+
const RESTART_WAIT_MS = { first: 2_000, longest: 60_000 } as const
|
|
14
|
+
/** Running this long, the runner wasn't what failed quickly: the next restart waits the least again. */
|
|
15
|
+
const STEADY_MS = 60_000
|
|
16
|
+
/** Exit status of a runner whose sign-in ended. */
|
|
17
|
+
const EXIT_SIGNED_OUT = 3
|
|
18
|
+
|
|
19
|
+
export interface RunnerProcess {
|
|
20
|
+
/** One line of JSON from the runner. */
|
|
21
|
+
onLine(listener: (line: string) => void): void
|
|
22
|
+
readonly exited: Promise<number>
|
|
23
|
+
write(line: string): void
|
|
24
|
+
/** Close its stdin, which it ends with. */
|
|
25
|
+
end(): void
|
|
26
|
+
kill(): void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DesktopComputer {
|
|
30
|
+
readonly id: string
|
|
31
|
+
readonly name: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type RunnerCommand = {
|
|
35
|
+
readonly type: "allow" | "forget"
|
|
36
|
+
readonly folder: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The main process's hold on the program that runs the agent on this computer: it starts it, keeps it
|
|
41
|
+
* started while the app is open, and stops it on quit. It holds no sign-in: the program does, and
|
|
42
|
+
* only says where to send the person to approve one.
|
|
43
|
+
*/
|
|
44
|
+
export class DesktopRunner {
|
|
45
|
+
private process: RunnerProcess | null = null
|
|
46
|
+
private computer: DesktopComputer | null = null
|
|
47
|
+
private folderList: readonly string[] = []
|
|
48
|
+
private waitingForComputer: {
|
|
49
|
+
readonly resolve: (computer: DesktopComputer) => void
|
|
50
|
+
readonly reject: (error: Error) => void
|
|
51
|
+
}[] = []
|
|
52
|
+
private foldersKnown = false
|
|
53
|
+
private waitingForFolders: ((folders: readonly string[]) => void)[] = []
|
|
54
|
+
private unsent: RunnerCommand[] = []
|
|
55
|
+
private restart: ReturnType<typeof setTimeout> | null = null
|
|
56
|
+
private restartWait: number = RESTART_WAIT_MS.first
|
|
57
|
+
private stopping = false
|
|
58
|
+
|
|
59
|
+
constructor(
|
|
60
|
+
private readonly options: {
|
|
61
|
+
readonly start: () => RunnerProcess
|
|
62
|
+
/** A page in the system browser to approve this computer's sign-in on. */
|
|
63
|
+
readonly onSignIn: (url: string) => void
|
|
64
|
+
readonly onProblem: (message: string) => void
|
|
65
|
+
}
|
|
66
|
+
) {}
|
|
67
|
+
|
|
68
|
+
start(): void {
|
|
69
|
+
if (this.stopping) return
|
|
70
|
+
const started = Date.now()
|
|
71
|
+
const process = this.options.start()
|
|
72
|
+
this.process = process
|
|
73
|
+
process.onLine((line) => this.hear(line))
|
|
74
|
+
for (const command of this.unsent.splice(0)) process.write(JSON.stringify(command))
|
|
75
|
+
void process.exited.then((code) => {
|
|
76
|
+
this.process = null
|
|
77
|
+
if (this.stopping) return
|
|
78
|
+
if (Date.now() - started >= STEADY_MS) this.restartWait = RESTART_WAIT_MS.first
|
|
79
|
+
// A sign-in that ended is no problem to report: started again, it waits for a new one.
|
|
80
|
+
if (code !== EXIT_SIGNED_OUT) {
|
|
81
|
+
this.options.onProblem(`What runs the agent quit with exit code ${code}.`)
|
|
82
|
+
}
|
|
83
|
+
// Told it failed, not that there's nothing to run on: a message mustn't go elsewhere on a guess.
|
|
84
|
+
for (const { reject } of this.waitingForComputer.splice(0)) {
|
|
85
|
+
reject(new Error("[SixbDesktop] What runs the agent on this computer couldn't start."))
|
|
86
|
+
}
|
|
87
|
+
this.answerFolders()
|
|
88
|
+
this.restart = setTimeout(() => this.start(), this.restartWait)
|
|
89
|
+
this.restartWait = Math.min(this.restartWait * 2, RESTART_WAIT_MS.longest)
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** This computer, once the runner has said. Rejects if it quits before it does. */
|
|
94
|
+
about(): Promise<DesktopComputer> {
|
|
95
|
+
if (this.computer) return Promise.resolve(this.computer)
|
|
96
|
+
return new Promise((resolve, reject) => this.waitingForComputer.push({ resolve, reject }))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The folders allowed, once the runner knows: a page asking early mustn't take them for none. */
|
|
100
|
+
folders(): Promise<readonly string[]> {
|
|
101
|
+
if (this.foldersKnown) return Promise.resolve(this.folderList)
|
|
102
|
+
return new Promise((resolve) => {
|
|
103
|
+
// A runner that can't read its config keeps trying, and a message mustn't wait on that for good.
|
|
104
|
+
const timer = setTimeout(() => this.answerFolders(), FOLDERS_WAIT_MS)
|
|
105
|
+
this.waitingForFolders.push((folders) => {
|
|
106
|
+
clearTimeout(timer)
|
|
107
|
+
resolve(folders)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
allow(folder: string): void {
|
|
113
|
+
this.send({ type: "allow", folder })
|
|
114
|
+
// A page asking right after choosing finds it there, before the runner has said so.
|
|
115
|
+
if (!this.folderList.includes(folder)) this.folderList = [...this.folderList, folder]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
forget(folder: string): void {
|
|
119
|
+
this.send({ type: "forget", folder })
|
|
120
|
+
this.folderList = this.folderList.filter((known) => known !== folder)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Let it end a running turn and close its sessions, within a limit. */
|
|
124
|
+
async stop(): Promise<void> {
|
|
125
|
+
this.stopping = true
|
|
126
|
+
if (this.restart) clearTimeout(this.restart)
|
|
127
|
+
const process = this.process
|
|
128
|
+
if (!process) return
|
|
129
|
+
process.end()
|
|
130
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
131
|
+
const late = new Promise<"late">((resolve) => {
|
|
132
|
+
timer = setTimeout(() => resolve("late"), STOP_WAIT_MS)
|
|
133
|
+
})
|
|
134
|
+
if ((await Promise.race([process.exited, late])) === "late") process.kill()
|
|
135
|
+
clearTimeout(timer)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private send(command: RunnerCommand): void {
|
|
139
|
+
if (this.process) this.process.write(JSON.stringify(command))
|
|
140
|
+
else this.unsent.push(command)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private answerFolders(): void {
|
|
144
|
+
this.foldersKnown = true
|
|
145
|
+
for (const answer of this.waitingForFolders.splice(0)) answer(this.folderList)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private hear(line: string): void {
|
|
149
|
+
let event: Record<string, unknown>
|
|
150
|
+
try {
|
|
151
|
+
event = JSON.parse(line)
|
|
152
|
+
} catch {
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
switch (event.type) {
|
|
156
|
+
case "computer":
|
|
157
|
+
if (typeof event.id === "string" && typeof event.name === "string") {
|
|
158
|
+
const computer = { id: event.id, name: event.name }
|
|
159
|
+
this.computer = computer
|
|
160
|
+
for (const { resolve } of this.waitingForComputer.splice(0)) resolve(computer)
|
|
161
|
+
}
|
|
162
|
+
return
|
|
163
|
+
case "folders":
|
|
164
|
+
if (Array.isArray(event.folders)) this.folderList = event.folders.map(String)
|
|
165
|
+
this.answerFolders()
|
|
166
|
+
return
|
|
167
|
+
// Nothing runs in the list on file until it's signed in: none, so the next message asks, and
|
|
168
|
+
// answering signs in. It would otherwise wait on a sign-in nobody was asked for.
|
|
169
|
+
case "signed-out":
|
|
170
|
+
case "signed-out-waiting":
|
|
171
|
+
this.folderList = []
|
|
172
|
+
this.answerFolders()
|
|
173
|
+
return
|
|
174
|
+
// It can't say which until the project answers: the last it said, for now.
|
|
175
|
+
case "unreachable":
|
|
176
|
+
this.answerFolders()
|
|
177
|
+
return
|
|
178
|
+
case "sign-in":
|
|
179
|
+
if (typeof event.url === "string") this.options.onSignIn(event.url)
|
|
180
|
+
return
|
|
181
|
+
case "sign-in-failed":
|
|
182
|
+
this.folderList = []
|
|
183
|
+
if (typeof event.message === "string") this.options.onProblem(event.message)
|
|
184
|
+
return
|
|
185
|
+
case "session.lost":
|
|
186
|
+
case "error":
|
|
187
|
+
if (typeof event.message === "string") this.options.onProblem(event.message)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The runner as a child process of this one, its stderr for the app's own log. */
|
|
193
|
+
export function startRunnerProcess(
|
|
194
|
+
command: readonly [string, ...string[]],
|
|
195
|
+
env: NodeJS.ProcessEnv
|
|
196
|
+
): RunnerProcess {
|
|
197
|
+
const [file, ...args] = command
|
|
198
|
+
const child = spawn(file, args, { env, stdio: ["pipe", "pipe", "inherit"] })
|
|
199
|
+
// A runner that's gone by the time this writes: its restart is what answers that.
|
|
200
|
+
child.stdin.on("error", () => {})
|
|
201
|
+
const exited = new Promise<number>((resolve) => {
|
|
202
|
+
child.once("error", () => resolve(-1))
|
|
203
|
+
child.once("exit", (code) => resolve(code ?? -1))
|
|
204
|
+
})
|
|
205
|
+
return {
|
|
206
|
+
onLine(listener) {
|
|
207
|
+
let rest = ""
|
|
208
|
+
child.stdout.setEncoding("utf8").on("data", (chunk: string) => {
|
|
209
|
+
rest += chunk
|
|
210
|
+
const lines = rest.split("\n")
|
|
211
|
+
rest = lines.pop() ?? ""
|
|
212
|
+
for (const line of lines) listener(line)
|
|
213
|
+
})
|
|
214
|
+
},
|
|
215
|
+
exited,
|
|
216
|
+
write: (line) => void child.stdin.write(`${line}\n`),
|
|
217
|
+
end: () => void child.stdin.end(),
|
|
218
|
+
kill: () => void child.kill("SIGKILL"),
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The environment the runner, and the agent's commands, run in. An app opened from the Dock gets a
|
|
224
|
+
* bare PATH, so the person's shell says what theirs is: the agent uses the tools they do. The `bun`
|
|
225
|
+
* beside the runner comes last, for someone with neither Bun nor Node.
|
|
226
|
+
*/
|
|
227
|
+
export async function runnerEnvironment(
|
|
228
|
+
runnerDir: string,
|
|
229
|
+
base: NodeJS.ProcessEnv
|
|
230
|
+
): Promise<NodeJS.ProcessEnv> {
|
|
231
|
+
const shell = await readLoginShell(base.SHELL).catch(() => null)
|
|
232
|
+
const env = Object.fromEntries(
|
|
233
|
+
// What a terminal `sixb dev` was told about its own project is no business of this one.
|
|
234
|
+
Object.entries(base).filter(([name]) => !name.startsWith("SIXB_"))
|
|
235
|
+
)
|
|
236
|
+
return {
|
|
237
|
+
...env,
|
|
238
|
+
PATH: [shell?.path ?? base.PATH ?? "", runnerDir].join(delimiter),
|
|
239
|
+
// Where `sixb` keeps its sign-in, and so what the agent's commands are kept from.
|
|
240
|
+
...(shell?.configHome ? { XDG_CONFIG_HOME: shell.configHome } : {}),
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Between markers, as a shell's startup files may print whatever they like.
|
|
246
|
+
*
|
|
247
|
+
* In a session of its own, with no terminal: an interactive shell takes over the one it inherits. Under
|
|
248
|
+
* `sixb dev --desktop` that's the person's, and whatever reads a key or a mouse move there meanwhile is
|
|
249
|
+
* stopped by it, for good, leaving the terminal as it was.
|
|
250
|
+
*
|
|
251
|
+
* A limit of its own, and a kill that can't be ignored: an interactive shell ignores `execFile`'s, and
|
|
252
|
+
* what it started can hold the pipe open long after it's gone. A startup file that waits on a person
|
|
253
|
+
* mustn't stop the agent.
|
|
254
|
+
*/
|
|
255
|
+
function readLoginShell(
|
|
256
|
+
shell: string | undefined
|
|
257
|
+
): Promise<{ readonly path: string; readonly configHome: string } | null> {
|
|
258
|
+
if (!shell) return Promise.resolve(null)
|
|
259
|
+
return new Promise((resolve) => {
|
|
260
|
+
// `spawn`, not `execFile`, which ignores `detached`.
|
|
261
|
+
const child = spawn(
|
|
262
|
+
shell,
|
|
263
|
+
["-ilc", 'printf "__SIXB_SHELL__%s\\n%s__SIXB_SHELL__" "$PATH" "$XDG_CONFIG_HOME"'],
|
|
264
|
+
{ detached: true, stdio: ["ignore", "pipe", "ignore"] }
|
|
265
|
+
)
|
|
266
|
+
let printed = ""
|
|
267
|
+
const said = () => {
|
|
268
|
+
const [path, configHome] =
|
|
269
|
+
/__SIXB_SHELL__(.*)__SIXB_SHELL__/s.exec(printed)?.[1]?.split("\n") ?? []
|
|
270
|
+
return path ? { path, configHome: configHome ?? "" } : null
|
|
271
|
+
}
|
|
272
|
+
const timer = setTimeout(() => {
|
|
273
|
+
// The whole group: it's alone in it, with whatever its startup files began.
|
|
274
|
+
if (child.pid) {
|
|
275
|
+
try {
|
|
276
|
+
process.kill(-child.pid, "SIGKILL")
|
|
277
|
+
} catch {
|
|
278
|
+
// Gone already.
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// What it said counts, whatever it left holding the pipe.
|
|
282
|
+
resolve(said())
|
|
283
|
+
}, LOGIN_SHELL_WAIT_MS)
|
|
284
|
+
child.stdout.on("data", (chunk) => {
|
|
285
|
+
printed += String(chunk)
|
|
286
|
+
})
|
|
287
|
+
child.once("error", () => {
|
|
288
|
+
clearTimeout(timer)
|
|
289
|
+
resolve(null)
|
|
290
|
+
})
|
|
291
|
+
child.once("close", () => {
|
|
292
|
+
clearTimeout(timer)
|
|
293
|
+
resolve(said())
|
|
294
|
+
})
|
|
295
|
+
})
|
|
296
|
+
}
|
package/src/shell/window.ts
CHANGED
|
@@ -25,6 +25,18 @@ export function isAppUrl(config: ShellConfig, url: string): boolean {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/** Whether `url` is a web page of the project's API, the only place a sign-in is approved. */
|
|
29
|
+
export function isApiPage(config: ShellConfig, url: string): boolean {
|
|
30
|
+
try {
|
|
31
|
+
const { origin, protocol } = new URL(url)
|
|
32
|
+
return (
|
|
33
|
+
(protocol === "https:" || protocol === "http:") && origin === new URL(config.apiOrigin).origin
|
|
34
|
+
)
|
|
35
|
+
} catch {
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
28
40
|
/**
|
|
29
41
|
* What the window paints before a page has: the loading page's own background, so the window
|
|
30
42
|
* opens, and passes from that page to the app, without a flash of white.
|