@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
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 {
|
|
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
|
-
/**
|
|
32
|
-
|
|
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
|
-
`--
|
|
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
|
|
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 {
|
|
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"
|
|
7
|
-
import {
|
|
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
|
-
|
|
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
|
+
}
|