@sixb/desktop 0.1.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/src/generate.ts CHANGED
@@ -5,7 +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 { HIDDEN_TITLE_BAR_PRELOAD } from "./shell/window"
8
+ import { RUNNER_BINARY } from "./shell/runner"
9
9
 
10
10
  export interface DesktopAppOptions {
11
11
  /** The Sixb project. The desktop app is generated under its `.sixb/desktop`. */
@@ -14,6 +14,11 @@ export interface DesktopAppOptions {
14
14
  readonly appUrl: string
15
15
  /** The project's API. Its sign-in pages are where the window is approved. */
16
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
17
22
  }
18
23
 
19
24
  /** The Electron app `writeDesktopApp` generated, and what it is called. */
@@ -28,8 +33,11 @@ export interface GeneratedApp {
28
33
  // Read from the package's `src`, which ships, whether this module runs from `src` or `dist`.
29
34
  const SHELL_SOURCE_DIR = fileURLToPath(new URL("../src/shell", import.meta.url))
30
35
 
31
- /** Where a project's desktop app is generated (`app`) and packaged (`dist`). */
32
- export function desktopDir(projectRoot: string, part: "app" | "dist"): string {
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 {
33
41
  return join(projectRoot, ".sixb", "desktop", part)
34
42
  }
35
43
 
@@ -62,7 +70,6 @@ export async function writeDesktopApp(options: DesktopAppOptions): Promise<Gener
62
70
  await Promise.all([
63
71
  writeFile(join(dir, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`),
64
72
  writeFile(join(dir, "shell.json"), `${JSON.stringify(config, null, 2)}\n`),
65
- writeFile(join(dir, "preload.js"), HIDDEN_TITLE_BAR_PRELOAD),
66
73
  writeFile(join(dir, "index.html"), renderShellPage(template, { ...config, icon: !!icon })),
67
74
  ])
68
75
 
@@ -73,16 +80,46 @@ export async function writeDesktopApp(options: DesktopAppOptions): Promise<Gener
73
80
  process.execPath,
74
81
  "build",
75
82
  join(SHELL_SOURCE_DIR, "main.ts"),
83
+ join(SHELL_SOURCE_DIR, "preload.ts"),
76
84
  "--target=node",
77
85
  "--format=cjs",
78
86
  "--external=electron",
79
- `--outfile=${join(dir, "main.js")}`,
87
+ `--outdir=${dir}`,
80
88
  ],
81
89
  "Could not compile the desktop shell."
82
90
  )
83
91
  return { dir, name: identity.name, version, icon }
84
92
  }
85
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
+
86
123
  /** The page the window shows while the app loads, and when it cannot reach the app. */
87
124
  export function renderShellPage(
88
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 run --cwd ${electronDir} postinstall\`, then try again.`
182
+ `Run \`bun ${join(electronDir, "install.js")}\`, then try again.`
175
183
  )
176
184
  }
177
185
  }
@@ -0,0 +1,41 @@
1
+ /** The name a page finds the desktop app under, through `useDesktop` in `@sixb/client/hooks`. */
2
+ export const DESKTOP_BRIDGE_NAME = "sixbDesktop"
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
+
17
+ /**
18
+ * What the shell offers the page. `SixbDesktop` in `@sixb/client` is the page's view of this, and
19
+ * `tests/app.test.ts` holds the two together.
20
+ *
21
+ * The page is remote, and anything here is open to whatever runs in it. To offer more:
22
+ * 1. Add one narrow method here, calling `ipcRenderer.invoke`, never a general way to run a command
23
+ * or read a file. The page asks; the person decides, in a native dialog, what it gets.
24
+ * 2. Handle it in `main.ts`, refusing any sender that `isAppUrl` does not accept.
25
+ * 3. Add it to `SixbDesktop` as optional, because a page can outlive the shell it runs in.
26
+ */
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
+ }
41
+ }
package/src/shell/main.ts CHANGED
@@ -1,10 +1,33 @@
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 { join } from "node:path"
5
- import { app, BrowserWindow, type Event, nativeTheme, session, shell } from "electron"
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"
7
- import { HIDDEN_TITLE_BAR_CSS, isAppUrl, mayNavigate, WINDOW_BACKGROUND } from "./window"
21
+ import { DesktopRunner, RUNNER_BINARY, runnerEnvironment, startRunnerProcess } from "./runner"
22
+ import {
23
+ HIDDEN_TITLE_BAR_CSS,
24
+ hasHiddenTitleBar,
25
+ isApiPage,
26
+ isAppUrl,
27
+ mayNavigate,
28
+ pageWebPreferences,
29
+ WINDOW_BACKGROUND,
30
+ } from "./window"
8
31
 
9
32
  // Chromium's code for a load that was replaced by another one, which is not a failure.
10
33
  const ERR_ABORTED = -3
@@ -15,8 +38,7 @@ const appDir = app.getAppPath()
15
38
  const config = JSON.parse(readFileSync(join(appDir, "shell.json"), "utf-8")) as ShellConfig
16
39
  const loadingPage = join(appDir, "index.html")
17
40
  const icon = existsSync(join(appDir, "icon.png")) ? join(appDir, "icon.png") : undefined
18
- // Only macOS draws window controls over the page; elsewhere the title bar stays.
19
- const inset = process.platform === "darwin"
41
+ const inset = hasHiddenTitleBar(process.platform)
20
42
  const state = createNavigationState()
21
43
 
22
44
  app.setName(config.name)
@@ -33,13 +55,7 @@ function createWindow(): void {
33
55
  backgroundColor: nativeTheme.shouldUseDarkColors
34
56
  ? WINDOW_BACKGROUND.dark
35
57
  : WINDOW_BACKGROUND.light,
36
- webPreferences: {
37
- // The window shows a remote page: it gets no Node, and a world apart from the preload's.
38
- sandbox: true,
39
- contextIsolation: true,
40
- nodeIntegration: false,
41
- preload: inset ? join(appDir, "preload.js") : undefined,
42
- },
58
+ webPreferences: pageWebPreferences(join(appDir, "preload.js")),
43
59
  })
44
60
  const contents = win.webContents
45
61
  win.once("ready-to-show", () => win.show())
@@ -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
  })
@@ -0,0 +1,47 @@
1
+ // Runs in every page the window shows, before the page's own scripts, in a world of its own. Electron
2
+ // sandboxes it: of what it imports, only `electron` may remain once compiled.
3
+ import { contextBridge, ipcRenderer } from "electron"
4
+ import { DESKTOP_BRIDGE_NAME, desktopBridge } from "./bridge"
5
+ import { CONTROLS_HEIGHT_PX, hasHiddenTitleBar } from "./window"
6
+
7
+ contextBridge.exposeInMainWorld(
8
+ DESKTOP_BRIDGE_NAME,
9
+ desktopBridge(process.platform, (channel, ...args) => ipcRenderer.invoke(channel, ...args))
10
+ )
11
+
12
+ if (hasHiddenTitleBar(process.platform)) installHiddenTitleBar()
13
+
14
+ /**
15
+ * Marks `<html>` the moment the element exists, so an app can read the mark on its first render,
16
+ * and adds the drag handle once there is a body to hold it.
17
+ */
18
+ function installHiddenTitleBar(): void {
19
+ const mark = () => {
20
+ const root = document.documentElement
21
+ if (!root) return false
22
+ root.dataset.sixbDesktop = "inset"
23
+ root.style.setProperty("--sixb-desktop-controls-height", `${CONTROLS_HEIGHT_PX}px`)
24
+ return true
25
+ }
26
+ if (!mark()) {
27
+ const observer = new MutationObserver(() => {
28
+ if (mark()) observer.disconnect()
29
+ })
30
+ observer.observe(document, { childList: true })
31
+ }
32
+
33
+ const addHandle = () => {
34
+ if (!document.body || document.getElementById("sixb-desktop-drag")) return
35
+ const handle = document.createElement("div")
36
+ handle.id = "sixb-desktop-drag"
37
+ handle.setAttribute("aria-hidden", "true")
38
+ // First in the body, not last: the draggable area is built in document order, so a control
39
+ // is only cut out of it if it comes after the handle.
40
+ document.body.prepend(handle)
41
+ }
42
+ if (document.readyState === "loading") {
43
+ document.addEventListener("DOMContentLoaded", addHandle, { once: true })
44
+ } else {
45
+ addHandle()
46
+ }
47
+ }