@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.
@@ -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
+ }
@@ -25,12 +25,37 @@ 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.
31
43
  */
32
44
  export const WINDOW_BACKGROUND = { light: "#fafafa", dark: "#0a0a0a" } as const
33
45
 
46
+ /**
47
+ * What the window's page runs with. The page is remote: it gets no Node and a world apart from the
48
+ * preload's, on every platform, and reaches the shell only through what the preload exposes.
49
+ */
50
+ export function pageWebPreferences(preload: string) {
51
+ return { sandbox: true, contextIsolation: true, nodeIntegration: false, preload }
52
+ }
53
+
54
+ /** Only macOS draws window controls over the page; elsewhere the title bar stays. */
55
+ export function hasHiddenTitleBar(platform: NodeJS.Platform): boolean {
56
+ return platform === "darwin"
57
+ }
58
+
34
59
  /** The band at the top of a window without a title bar where macOS draws the window controls. */
35
60
  export const CONTROLS_HEIGHT_PX = 36
36
61
 
@@ -70,42 +95,3 @@ export const HIDDEN_TITLE_BAR_CSS = [
70
95
  ].join(",")}{-webkit-app-region:no-drag}`,
71
96
  `[data-slot="sidebar-inner"]{padding-top:calc(${CONTROLS_HEIGHT_PX}px - 0.5rem)}`,
72
97
  ].join("")
73
-
74
- /**
75
- * The preload of a window without a title bar: runs in every page before the page's own scripts.
76
- *
77
- * It marks `<html>` the moment the element exists, so an app can read the mark on its first
78
- * render, and adds the drag handle once there is a body to hold it. Written to a file by
79
- * `writeDesktopApp`, which is why it is source text and not a function.
80
- */
81
- export const HIDDEN_TITLE_BAR_PRELOAD = `(() => {
82
- const mark = () => {
83
- const root = document.documentElement
84
- if (!root) return false
85
- root.dataset.sixbDesktop = "inset"
86
- root.style.setProperty("--sixb-desktop-controls-height", "${CONTROLS_HEIGHT_PX}px")
87
- return true
88
- }
89
- if (!mark()) {
90
- const observer = new MutationObserver(() => {
91
- if (mark()) observer.disconnect()
92
- })
93
- observer.observe(document, { childList: true })
94
- }
95
-
96
- const addHandle = () => {
97
- if (!document.body || document.getElementById("sixb-desktop-drag")) return
98
- const handle = document.createElement("div")
99
- handle.id = "sixb-desktop-drag"
100
- handle.setAttribute("aria-hidden", "true")
101
- // First in the body, not last: the draggable area is built in document order, so a control
102
- // is only cut out of it if it comes after the handle.
103
- document.body.prepend(handle)
104
- }
105
- if (document.readyState === "loading") {
106
- document.addEventListener("DOMContentLoaded", addHandle, { once: true })
107
- } else {
108
- addHandle()
109
- }
110
- })()
111
- `