@sixb/desktop 0.0.0-stage → 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sixb contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,82 @@
1
- # Temporary Holding Version
1
+ # `@sixb/desktop`
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Ships a Sixb project's app as a desktop app, built with [Electron](https://www.electronjs.org).
4
+ Install it in a project, and the `sixb` CLI does the rest:
5
+
6
+ ```bash
7
+ bun add -d @sixb/desktop
8
+ bun sixb dev --desktop # the dev server, and the app in a desktop window
9
+ bun sixb desktop build # the deployed app, packaged for this machine
10
+ ```
11
+
12
+ See [Desktop App](https://docs.sixb.ai/apps/desktop) for what the desktop app takes from the app,
13
+ signing, and distribution. The rest of this file is how it works.
14
+
15
+ ## How it works
16
+
17
+ The CLI does not depend on this package, because it brings Electron. It loads the project's copy
18
+ and calls its two exports, `openDesktopApp` and `buildDesktopApp`, with the app's URL and the API's
19
+ origin. Both generate the same Electron app first:
20
+
21
+ ```
22
+ <app>/app.webmanifest ─▶ name, icon (src/identity.ts)
23
+ │
24
+ ▼
25
+ .sixb/desktop/app/ rewritten on every call (src/generate.ts)
26
+ main.js the main process, compiled from src/shell/main.ts
27
+ shell.json name, app URL, API origin
28
+ index.html the page shown while the app loads
29
+ preload.js, icon.png
30
+ │
31
+ ├─ openDesktopApp electron .sixb/desktop/app
32
+ └─ buildDesktopApp @electron/packager ─▶ .sixb/desktop/dist/
33
+ ```
34
+
35
+ The window loads the app from where it is served; `app/` is not compiled into the binary. The
36
+ page runs sandboxed, with no Node and no channel into the main process, and the window stays on the
37
+ app and API origins (`src/shell/window.ts`): any other link opens in the user's browser.
38
+
39
+ ### macOS builds
40
+
41
+ - **Icon.** `src/mac-icon.ts` redraws the icon on Apple's grid, a rounded square of 824 pixels with
42
+ continuous corners in a canvas of 1024, over a soft shadow. Both were measured against Apple's
43
+ Notes icon. An icon drawn to its edges otherwise stands larger than every icon beside it. It runs
44
+ AppKit through `osascript`, and `sips` and `iconutil` turn the result into `.icns`.
45
+ - **Signature.** Packaging breaks the ad-hoc signature Electron ships with, and macOS calls a
46
+ downloaded app with a broken signature damaged, with no way to open it. An unsigned build is
47
+ re-signed ad hoc, so users can allow it. With `APPLE_API_KEY`, `APPLE_API_KEY_ID` and
48
+ `APPLE_API_ISSUER`, the packager signs with the keychain's Developer ID certificate and notarizes
49
+ instead.
50
+ - **Packager.** `@electron/packager` fails under Bun whenever it verifies a download, so
51
+ `src/packager.mjs` runs it under Electron's own binary as Node.
52
+
53
+ ### Signing in
54
+
55
+ A window like this cannot finish a sign-in on its own: a magic link opens in the user's browser,
56
+ not in the window, and identity providers refuse embedded browsers. So the shell sends the window
57
+ to `GET /auth/sign-in/device` instead of the sign-in page, and opens the approval page in the
58
+ system browser:
59
+
60
+ ```
61
+ window API system browser
62
+ │ app redirects to /auth/sign-in │
63
+ │ shell loads /auth/sign-in/device ─▶ creates an authorization│
64
+ │◀─ "Continue in your browser", cookie holds its secret │
65
+ │ shell opens /auth/device?user_code=… ──────────────────────▶│ signs in if needed,
66
+ │ ◀── Authorize ──────────│ approves
67
+ │ page reloads ─▶ approved: session cookie, back to the app │
68
+ ```
69
+
70
+ The session is an ordinary browser session, kept in the window's persistent cookie store, so later
71
+ launches open straight into the app. `src/shell/navigation.ts` holds those two decisions as a pure
72
+ function; `src/shell/main.ts` wires them to the window.
73
+
74
+ ### Title bar
75
+
76
+ On macOS the window has no title bar: the app draws to the top edge and the window controls float
77
+ over it. Two things a title bar did then come from the shell (`src/shell/window.ts`):
78
+
79
+ - A 36px strip across the top is the handle the window is dragged by. Buttons, links and form
80
+ controls under it still take their clicks; the space between them moves the window.
81
+ - `<html data-sixb-desktop="inset">` and `--sixb-desktop-controls-height` let an app make room for
82
+ the controls. The sidebar of the default `AppShell` is moved down for it already.
@@ -0,0 +1,30 @@
1
+ export interface DesktopAppOptions {
2
+ /** The Sixb project. The desktop app is generated under its `.sixb/desktop`. */
3
+ readonly projectRoot: string;
4
+ /** Where the app is served. The window opens it; the desktop app takes its name and icon from it. */
5
+ readonly appUrl: string;
6
+ /** The project's API. Its sign-in pages are where the window is approved. */
7
+ readonly apiOrigin: string;
8
+ }
9
+ /** The Electron app `writeDesktopApp` generated, and what it is called. */
10
+ export interface GeneratedApp {
11
+ readonly dir: string;
12
+ readonly name: string;
13
+ readonly version: string;
14
+ /** The app's icon in `dir`, when it declares one. */
15
+ readonly icon: string | null;
16
+ }
17
+ /** Where a project's desktop app is generated (`app`) and packaged (`dist`). */
18
+ export declare function desktopDir(projectRoot: string, part: "app" | "dist"): string;
19
+ /**
20
+ * Writes the Electron app that opens `appUrl` into the project's `.sixb/desktop/app`. The directory
21
+ * is build-owned, like the rest of `.sixb`: it is cleared on every call.
22
+ */
23
+ export declare function writeDesktopApp(options: DesktopAppOptions): Promise<GeneratedApp>;
24
+ /** The page the window shows while the app loads, and when it cannot reach the app. */
25
+ export declare function renderShellPage(template: string, page: {
26
+ readonly name: string;
27
+ readonly appUrl: string;
28
+ readonly icon: boolean;
29
+ }): string;
30
+ //# sourceMappingURL=generate.d.ts.map
@@ -0,0 +1 @@
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;CAC3B;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,gFAAgF;AAChF,wBAAgB,UAAU,CAAC,WAAW,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,GAAG,MAAM,GAAG,MAAM,CAE5E;AAED;;;GAGG;AACH,wBAAsB,eAAe,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,YAAY,CAAC,CA4CvF;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"}
@@ -0,0 +1,22 @@
1
+ /** What the desktop app takes from the web app it opens. */
2
+ export interface AppIdentity {
3
+ /** The app's name: `metadata.title` in `app/layout.tsx`. */
4
+ readonly name: string;
5
+ /**
6
+ * The app's maskable icon, such as `app/public/icon-maskable-512.png`, or else its largest PNG
7
+ * icon, such as `app/public/icon-512.png`.
8
+ */
9
+ readonly icon: Uint8Array | null;
10
+ }
11
+ /**
12
+ * Reads the app's web app manifest, the one a browser installs the app from. The desktop app is
13
+ * the same app installed another way, so it takes the same name and icon and has no settings of
14
+ * its own.
15
+ */
16
+ export declare function readAppIdentity(appUrl: string): Promise<AppIdentity>;
17
+ /**
18
+ * The app's id on the desktop, from the address it opens: `https://orders.acme.com` becomes
19
+ * `com.acme.orders`. Each deployment gets its own, so a staging build installs beside production.
20
+ */
21
+ export declare function bundleIdentifier(appUrl: string): string;
22
+ //# sourceMappingURL=identity.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"identity.d.ts","sourceRoot":"","sources":["../src/identity.ts"],"names":[],"mappings":"AAAA,4DAA4D;AAC5D,MAAM,WAAW,WAAW;IAC1B,4DAA4D;IAC5D,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,QAAQ,CAAC,IAAI,EAAE,UAAU,GAAG,IAAI,CAAA;CACjC;AAED;;;;GAIG;AACH,wBAAsB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAa1E;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAOvD"}
@@ -0,0 +1,18 @@
1
+ import { type DesktopAppOptions } from "./generate";
2
+ export type { DesktopAppOptions } from "./generate";
3
+ export interface DesktopWindow {
4
+ /** Resolves with Electron's exit code once the user quits it. */
5
+ readonly exited: Promise<number>;
6
+ close(): void;
7
+ }
8
+ export interface DesktopBuild {
9
+ /** What to hand to users: a zip of the app on macOS, the app's folder elsewhere. */
10
+ readonly path: string;
11
+ /** Whether it was signed and notarized, and so opens without a warning. */
12
+ readonly signed: boolean;
13
+ }
14
+ /** Opens the app in a desktop window. `sixb dev --desktop` calls this. */
15
+ export declare function openDesktopApp(options: DesktopAppOptions): Promise<DesktopWindow>;
16
+ /** Packages the app for this machine's platform. `sixb desktop build` calls this. */
17
+ export declare function buildDesktopApp(options: DesktopAppOptions): Promise<DesktopBuild>;
18
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,KAAK,iBAAiB,EAA+B,MAAM,YAAY,CAAA;AAIhF,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,CAsBvF;AAED,qFAAqF;AACrF,wBAAsB,eAAe,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,YAAY,CAAC,CAiCvF"}
package/dist/index.js ADDED
@@ -0,0 +1,383 @@
1
+ // @bun
2
+ // src/index.ts
3
+ import { mkdir as mkdir2, readdir, rm as rm2 } from "fs/promises";
4
+ import { createRequire } from "module";
5
+ import { dirname, join as join2 } from "path";
6
+ import { fileURLToPath as fileURLToPath2 } from "url";
7
+
8
+ // src/generate.ts
9
+ import { mkdir, readFile, rm, writeFile } from "fs/promises";
10
+ import { join } from "path";
11
+ import { fileURLToPath } from "url";
12
+
13
+ // src/identity.ts
14
+ async function readAppIdentity(appUrl) {
15
+ const manifestUrl = new URL("/app.webmanifest", appUrl);
16
+ const manifest = await (await fetchFromApp(manifestUrl)).json();
17
+ const { name, icons } = typeof manifest === "object" && manifest !== null ? manifest : {};
18
+ const iconUrl = desktopIcon(icons, manifestUrl);
19
+ return {
20
+ name: typeof name === "string" && name.trim() ? name.trim() : "Sixb",
21
+ icon: iconUrl ? new Uint8Array(await (await fetchFromApp(iconUrl)).arrayBuffer()) : null
22
+ };
23
+ }
24
+ function bundleIdentifier(appUrl) {
25
+ const labels = new URL(appUrl).hostname.split(".").reverse().map((label) => label.toLowerCase().replaceAll(/[^a-z0-9-]/g, "-")).filter(Boolean);
26
+ return (labels.length > 1 ? labels : ["local", ...labels]).join(".");
27
+ }
28
+ function desktopIcon(icons, manifestUrl) {
29
+ let best = null;
30
+ for (const icon of Array.isArray(icons) ? icons : []) {
31
+ if (typeof icon !== "object" || icon === null)
32
+ continue;
33
+ const { src, sizes, type, purpose } = icon;
34
+ if (typeof src !== "string")
35
+ continue;
36
+ const url = new URL(src, manifestUrl);
37
+ if (type === undefined ? !url.pathname.endsWith(".png") : type !== "image/png")
38
+ continue;
39
+ const maskable = typeof purpose === "string" && purpose.split(/\s+/).includes("maskable");
40
+ const size = Math.max(0, ...(typeof sizes === "string" ? sizes.split(/\s+/) : []).map((entry) => Number.parseInt(entry, 10) || 0));
41
+ const rank = [maskable ? 1 : 0, size];
42
+ if (!best || rank[0] > best.rank[0] || rank[0] === best.rank[0] && rank[1] > best.rank[1]) {
43
+ best = { url, rank };
44
+ }
45
+ }
46
+ return best?.url ?? null;
47
+ }
48
+ async function fetchFromApp(url) {
49
+ let response;
50
+ try {
51
+ response = await fetch(url);
52
+ } catch (error) {
53
+ throw new Error(`[SixbDesktop] Could not reach ${url} (${error instanceof Error ? error.message : error}). ` + "The desktop app takes its name and icon from the app, so the app must be running there.");
54
+ }
55
+ if (!response.ok)
56
+ throw new Error(`[SixbDesktop] ${url} answered ${response.status}.`);
57
+ return response;
58
+ }
59
+
60
+ // src/run.ts
61
+ async function run(command, failure) {
62
+ const child = Bun.spawn([...command], {
63
+ stdio: ["ignore", "ignore", "pipe"],
64
+ timeout: 120000
65
+ });
66
+ const [exitCode, errors] = await Promise.all([child.exited, new Response(child.stderr).text()]);
67
+ if (exitCode !== 0) {
68
+ throw new Error(`[SixbDesktop] ${failure}${errors.trim() ? `
69
+ ${errors.trim()}` : ""}`);
70
+ }
71
+ }
72
+
73
+ // src/mac-icon.ts
74
+ async function drawMacIcon(png, out) {
75
+ await run(["osascript", "-l", "JavaScript", "-e", MAC_ICON_SCRIPT, png, out], "Could not draw the macOS icon.");
76
+ }
77
+ var MAC_ICON_SCRIPT = `
78
+ ObjC.import("AppKit")
79
+ ObjC.import("CoreGraphics")
80
+
81
+ function run([input, output]) {
82
+ const canvas = 1024
83
+ const body = 824
84
+ const radius = 185.4
85
+ const min = (canvas - body) / 2
86
+ const max = min + body
87
+
88
+ // One corner, as offsets in radii from the edge it leaves and the edge it joins.
89
+ const corner = [
90
+ [[1.52866483, 0]],
91
+ [[0.66993427, 0.065496], [1.08849323, 0], [0.86840689, 0.02065986]],
92
+ [[0.63149399, 0.074911]],
93
+ [[0.074911, 0.63149399], [0.37282392, 0.16906933], [0.16906933, 0.37282392]],
94
+ [[0.065496, 0.66993427]],
95
+ [[0, 1.52866483], [0.02065986, 0.86840689], [0, 1.08849323]],
96
+ ]
97
+ const corners = [
98
+ ([u, v]) => $.NSMakePoint(max - u * radius, max - v * radius),
99
+ ([u, v]) => $.NSMakePoint(max - v * radius, min + u * radius),
100
+ ([u, v]) => $.NSMakePoint(min + u * radius, min + v * radius),
101
+ ([u, v]) => $.NSMakePoint(min + v * radius, max - u * radius),
102
+ ]
103
+ const shape = $.NSBezierPath.bezierPath
104
+ shape.moveToPoint(corners[3]([0, 1.52866483]))
105
+ for (const at of corners) {
106
+ for (const [to, c1, c2] of corner) {
107
+ if (c1) shape.curveToPointControlPoint1ControlPoint2(at(to), at(c1), at(c2))
108
+ else shape.lineToPoint(at(to))
109
+ }
110
+ }
111
+ shape.closePath
112
+
113
+ const image = $.NSImage.alloc.initWithContentsOfFile(input)
114
+ if (image.isNil()) throw new Error("Cannot read " + input)
115
+ const bitmap = $.NSBitmapImageRep.alloc
116
+ .initWithBitmapDataPlanesPixelsWidePixelsHighBitsPerSampleSamplesPerPixelHasAlphaIsPlanarColorSpaceNameBytesPerRowBitsPerPixel(
117
+ null, canvas, canvas, 8, 4, true, false, $.NSDeviceRGBColorSpace, 0, 0
118
+ )
119
+ const context = $.NSGraphicsContext.graphicsContextWithBitmapImageRep(bitmap)
120
+ $.NSGraphicsContext.setCurrentContext(context)
121
+ context.setImageInterpolation($.NSImageInterpolationHigh)
122
+
123
+ const shadow = $.NSShadow.alloc.init
124
+ shadow.setShadowOffset($.NSMakeSize(0, -8))
125
+ shadow.setShadowBlurRadius(30)
126
+ shadow.setShadowColor($.NSColor.colorWithWhiteAlpha(0, 0.22))
127
+ shadow.set
128
+ // A layer, so the shadow falls from the clipped icon and not from the clip.
129
+ $.CGContextBeginTransparencyLayer(context.CGContext, null)
130
+ shape.addClip
131
+ image.drawInRectFromRectOperationFraction(
132
+ $.NSMakeRect(min, min, body, body), $.NSZeroRect, $.NSCompositingOperationSourceOver, 1
133
+ )
134
+ $.CGContextEndTransparencyLayer(context.CGContext)
135
+ context.flushGraphics
136
+
137
+ const png = bitmap.representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $.NSDictionary.dictionary)
138
+ if (!png.writeToFileAtomically(output, true)) throw new Error("Cannot write " + output)
139
+ }
140
+ `;
141
+
142
+ // src/shell/window.ts
143
+ var CONTROLS_HEIGHT_PX = 36;
144
+ var HIDDEN_TITLE_BAR_CSS = [
145
+ `#sixb-desktop-drag{position:fixed;top:0;left:0;right:0;height:${CONTROLS_HEIGHT_PX}px;`,
146
+ "z-index:2147483647;pointer-events:none;-webkit-app-region:drag}",
147
+ `${[
148
+ "a",
149
+ "button",
150
+ "input",
151
+ "select",
152
+ "textarea",
153
+ "summary",
154
+ "label",
155
+ "[role=button]",
156
+ "[role=link]",
157
+ "[role=tab]",
158
+ "[role=menuitem]",
159
+ "[role=switch]",
160
+ "[role=checkbox]",
161
+ "[role=combobox]",
162
+ "[contenteditable]",
163
+ "[tabindex]:not([tabindex='-1'])"
164
+ ].join(",")}{-webkit-app-region:no-drag}`,
165
+ `[data-slot="sidebar-inner"]{padding-top:calc(${CONTROLS_HEIGHT_PX}px - 0.5rem)}`
166
+ ].join("");
167
+ var HIDDEN_TITLE_BAR_PRELOAD = `(() => {
168
+ const mark = () => {
169
+ const root = document.documentElement
170
+ if (!root) return false
171
+ root.dataset.sixbDesktop = "inset"
172
+ root.style.setProperty("--sixb-desktop-controls-height", "${CONTROLS_HEIGHT_PX}px")
173
+ return true
174
+ }
175
+ if (!mark()) {
176
+ const observer = new MutationObserver(() => {
177
+ if (mark()) observer.disconnect()
178
+ })
179
+ observer.observe(document, { childList: true })
180
+ }
181
+
182
+ const addHandle = () => {
183
+ if (!document.body || document.getElementById("sixb-desktop-drag")) return
184
+ const handle = document.createElement("div")
185
+ handle.id = "sixb-desktop-drag"
186
+ handle.setAttribute("aria-hidden", "true")
187
+ // First in the body, not last: the draggable area is built in document order, so a control
188
+ // is only cut out of it if it comes after the handle.
189
+ document.body.prepend(handle)
190
+ }
191
+ if (document.readyState === "loading") {
192
+ document.addEventListener("DOMContentLoaded", addHandle, { once: true })
193
+ } else {
194
+ addHandle()
195
+ }
196
+ })()
197
+ `;
198
+
199
+ // src/generate.ts
200
+ var SHELL_SOURCE_DIR = fileURLToPath(new URL("../src/shell", import.meta.url));
201
+ function desktopDir(projectRoot, part) {
202
+ return join(projectRoot, ".sixb", "desktop", part);
203
+ }
204
+ async function writeDesktopApp(options) {
205
+ const identity = await readAppIdentity(options.appUrl);
206
+ const version = await readProjectVersion(options.projectRoot);
207
+ const dir = desktopDir(options.projectRoot, "app");
208
+ await rm(dir, { recursive: true, force: true });
209
+ await mkdir(dir, { recursive: true });
210
+ const config = {
211
+ name: identity.name,
212
+ appUrl: new URL(options.appUrl).href,
213
+ apiOrigin: new URL(options.apiOrigin).origin
214
+ };
215
+ const manifest = {
216
+ name: "sixb-desktop-app",
217
+ productName: identity.name,
218
+ version,
219
+ private: true,
220
+ main: "main.js"
221
+ };
222
+ const icon = identity.icon ? await writeIcon(dir, identity.icon) : null;
223
+ const template = await readFile(join(SHELL_SOURCE_DIR, "index.html"), "utf-8");
224
+ await Promise.all([
225
+ writeFile(join(dir, "package.json"), `${JSON.stringify(manifest, null, 2)}
226
+ `),
227
+ writeFile(join(dir, "shell.json"), `${JSON.stringify(config, null, 2)}
228
+ `),
229
+ writeFile(join(dir, "preload.js"), HIDDEN_TITLE_BAR_PRELOAD),
230
+ writeFile(join(dir, "index.html"), renderShellPage(template, { ...config, icon: !!icon }))
231
+ ]);
232
+ await run([
233
+ process.execPath,
234
+ "build",
235
+ join(SHELL_SOURCE_DIR, "main.ts"),
236
+ "--target=node",
237
+ "--format=cjs",
238
+ "--external=electron",
239
+ `--outfile=${join(dir, "main.js")}`
240
+ ], "Could not compile the desktop shell.");
241
+ return { dir, name: identity.name, version, icon };
242
+ }
243
+ function renderShellPage(template, page) {
244
+ const initial = Array.from(page.name.trim())[0]?.toUpperCase() ?? "";
245
+ const markup = {
246
+ name: escapeHtml(page.name),
247
+ host: escapeHtml(new URL(page.appUrl).host),
248
+ appUrl: escapeHtml(page.appUrl),
249
+ mark: page.icon ? '<img class="mark" src="icon.png" alt="" />' : `<div class="mark" aria-hidden="true">${escapeHtml(initial)}</div>`
250
+ };
251
+ return template.replaceAll(/\{\{(\w+)\}\}/g, (_match, key) => markup[key] ?? "");
252
+ }
253
+ async function writeIcon(dir, png) {
254
+ const icon = join(dir, "icon.png");
255
+ if (process.platform !== "darwin") {
256
+ await writeFile(icon, png);
257
+ return icon;
258
+ }
259
+ const source = join(dir, "..", "icon-source.png");
260
+ await writeFile(source, png);
261
+ await drawMacIcon(source, icon);
262
+ return icon;
263
+ }
264
+ async function readProjectVersion(projectRoot) {
265
+ try {
266
+ const manifest = JSON.parse(await readFile(join(projectRoot, "package.json"), "utf-8"));
267
+ const version = manifest?.version;
268
+ return typeof version === "string" ? version : "0.0.0";
269
+ } catch {
270
+ return "0.0.0";
271
+ }
272
+ }
273
+ function escapeHtml(value) {
274
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
275
+ }
276
+
277
+ // src/index.ts
278
+ var packageRequire = createRequire(import.meta.url);
279
+ var PACKAGER_SCRIPT = fileURLToPath2(new URL("../src/packager.mjs", import.meta.url));
280
+ async function openDesktopApp(options) {
281
+ const app = await writeDesktopApp(options);
282
+ const log = join2(dirname(app.dir), "electron.log");
283
+ const electron = Bun.spawn([electronBinary(), app.dir], {
284
+ stdio: ["ignore", "ignore", Bun.file(log)]
285
+ });
286
+ let closing = false;
287
+ const exited = electron.exited.then((code) => {
288
+ if (code !== 0 && !closing) {
289
+ console.error(`[SixbDesktop] The desktop window quit with exit code ${code}. See ${log}.`);
290
+ }
291
+ return code;
292
+ });
293
+ return {
294
+ exited,
295
+ close: () => {
296
+ closing = true;
297
+ electron.kill();
298
+ }
299
+ };
300
+ }
301
+ async function buildDesktopApp(options) {
302
+ const app = await writeDesktopApp(options);
303
+ const out = desktopDir(options.projectRoot, "dist");
304
+ await rm2(out, { recursive: true, force: true });
305
+ const mac = process.platform === "darwin";
306
+ const notarize = mac ? appleNotarization() : null;
307
+ const packaged = await packageApp({
308
+ dir: app.dir,
309
+ name: app.name,
310
+ out,
311
+ appBundleId: bundleIdentifier(options.appUrl),
312
+ appVersion: app.version,
313
+ electronVersion: packageRequire("electron/package.json").version,
314
+ ...mac && app.icon ? { icon: await macIcon(app.icon) } : {},
315
+ ...notarize ? { osxSign: {}, osxNotarize: notarize } : {}
316
+ });
317
+ if (!mac)
318
+ return { path: packaged, signed: false };
319
+ const name = (await readdir(packaged)).find((entry) => entry.endsWith(".app"));
320
+ if (!name)
321
+ throw new Error(`[SixbDesktop] The packaged app is missing from ${packaged}.`);
322
+ const bundle = join2(packaged, name);
323
+ if (!notarize) {
324
+ await run(["codesign", "--force", "--deep", "--sign", "-", bundle], "Could not sign the app.");
325
+ }
326
+ const zip = `${packaged}.zip`;
327
+ await run(["ditto", "-c", "-k", "--keepParent", bundle, zip], "Could not zip the app.");
328
+ return { path: zip, signed: notarize !== null };
329
+ }
330
+ function appleNotarization() {
331
+ const { APPLE_API_KEY, APPLE_API_KEY_ID, APPLE_API_ISSUER } = process.env;
332
+ if (!APPLE_API_KEY && !APPLE_API_KEY_ID && !APPLE_API_ISSUER)
333
+ return null;
334
+ if (!APPLE_API_KEY || !APPLE_API_KEY_ID || !APPLE_API_ISSUER) {
335
+ throw new Error("[SixbDesktop] Set all of APPLE_API_KEY, APPLE_API_KEY_ID and APPLE_API_ISSUER to sign the " + "macOS app, or none of them to build it unsigned.");
336
+ }
337
+ return {
338
+ appleApiKey: APPLE_API_KEY,
339
+ appleApiKeyId: APPLE_API_KEY_ID,
340
+ appleApiIssuer: APPLE_API_ISSUER
341
+ };
342
+ }
343
+ async function macIcon(png) {
344
+ const desktop = dirname(dirname(png));
345
+ const iconset = join2(desktop, "icon.iconset");
346
+ await rm2(iconset, { recursive: true, force: true });
347
+ await mkdir2(iconset);
348
+ const sizes = [16, 32, 128, 256, 512].flatMap((points) => [
349
+ { name: `icon_${points}x${points}.png`, pixels: points },
350
+ { name: `icon_${points}x${points}@2x.png`, pixels: points * 2 }
351
+ ]);
352
+ await Promise.all(sizes.map(({ name, pixels }) => run(["sips", "-z", `${pixels}`, `${pixels}`, png, "--out", join2(iconset, name)], "Could not scale the icon.")));
353
+ const icns = join2(desktop, "icon.icns");
354
+ await run(["iconutil", "-c", "icns", iconset, "-o", icns], "Could not convert the icon.");
355
+ return icns;
356
+ }
357
+ async function packageApp(options) {
358
+ const packager = Bun.spawn([electronBinary(), PACKAGER_SCRIPT, JSON.stringify({ ...options, quiet: true })], {
359
+ stdio: ["ignore", "inherit", "inherit"],
360
+ env: { ...process.env, ELECTRON_RUN_AS_NODE: "1", ELECTRON_NO_ASAR: "1" }
361
+ });
362
+ if (await packager.exited !== 0) {
363
+ throw new Error("[SixbDesktop] Packaging failed. See the output above.");
364
+ }
365
+ const [written] = await readdir(options.out);
366
+ if (!written)
367
+ throw new Error(`[SixbDesktop] The packager wrote nothing to ${options.out}.`);
368
+ return join2(options.out, written);
369
+ }
370
+ function electronBinary() {
371
+ try {
372
+ return packageRequire("electron");
373
+ } catch {
374
+ const electronDir = dirname(packageRequire.resolve("electron/package.json"));
375
+ throw new Error("[SixbDesktop] Electron's binary is missing: its install script did not run. " + `Run \`bun run --cwd ${electronDir} postinstall\`, then try again.`);
376
+ }
377
+ }
378
+ export {
379
+ buildDesktopApp,
380
+ openDesktopApp
381
+ };
382
+
383
+ //# debugId=30FED6904B68AC4164756E2164756E21