@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/README.md +27 -3
- 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 +29 -60
- package/dist/index.js.map +6 -6
- package/dist/shell/bridge.d.ts +33 -0
- package/dist/shell/bridge.d.ts.map +1 -0
- package/dist/shell/preload.d.ts +2 -0
- package/dist/shell/preload.d.ts.map +1 -0
- package/dist/shell/runner.d.ts +60 -0
- package/dist/shell/runner.d.ts.map +1 -0
- package/dist/shell/window.d.ts +14 -8
- package/dist/shell/window.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/generate.ts +42 -5
- package/src/index.ts +10 -2
- package/src/shell/bridge.ts +41 -0
- package/src/shell/main.ts +153 -12
- package/src/shell/preload.ts +47 -0
- package/src/shell/runner.ts +296 -0
- package/src/shell/window.ts +25 -39
|
@@ -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,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
|
-
`
|