@sixb/desktop 0.0.0-stage → 0.2.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.
@@ -0,0 +1,39 @@
1
+ /** What the shell was built to open. Written into the app by `writeDesktopApp`. */
2
+ export interface ShellConfig {
3
+ /** Shown in the title bar, and to the user approving this window's sign-in. */
4
+ readonly name: string;
5
+ /** The app, such as `https://app.example.com/`. */
6
+ readonly appUrl: string;
7
+ readonly apiOrigin: string;
8
+ }
9
+ export interface NavigationState {
10
+ /** Approval pages already opened, so a waiting page that reloads opens its browser tab once. */
11
+ readonly openedCodes: Set<string>;
12
+ lastSignIn: {
13
+ readonly url: string;
14
+ readonly at: number;
15
+ } | null;
16
+ }
17
+ export type NavigationStep =
18
+ /** Send the window somewhere else instead. */
19
+ {
20
+ readonly kind: "load";
21
+ readonly url: string;
22
+ }
23
+ /** Hand a URL to the system browser. */
24
+ | {
25
+ readonly kind: "open-external";
26
+ readonly url: string;
27
+ };
28
+ export declare function createNavigationState(): NavigationState;
29
+ /**
30
+ * Decides what the shell does about a navigation of its window.
31
+ *
32
+ * The window is a browser the user cannot finish a sign-in in: a magic link opens in their own
33
+ * browser, and identity providers turn embedded ones away. So the app's ordinary sign-in redirect
34
+ * becomes a request to be approved from the system browser, and the approval page opens there.
35
+ */
36
+ export declare function nextNavigationStep(config: ShellConfig, state: NavigationState, navigatedTo: string, now?: number): NavigationStep | null;
37
+ /** Only web addresses leave for the system browser; a page cannot make the shell launch programs. */
38
+ export declare function isWebUrl(value: string): boolean;
39
+ //# sourceMappingURL=navigation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"navigation.d.ts","sourceRoot":"","sources":["../../src/shell/navigation.ts"],"names":[],"mappings":"AAAA,mFAAmF;AACnF,MAAM,WAAW,WAAW;IAC1B,+EAA+E;IAC/E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,mDAAmD;IACnD,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;CAC3B;AAED,MAAM,WAAW,eAAe;IAC9B,gGAAgG;IAChG,QAAQ,CAAC,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;IACjC,UAAU,EAAE;QAAE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAA;CACjE;AAED,MAAM,MAAM,cAAc;AACxB,8CAA8C;AAC5C;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;CAAE;AACjD,wCAAwC;GACtC;IAAE,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAA;AAM5D,wBAAgB,qBAAqB,IAAI,eAAe,CAEvD;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,WAAW,EACnB,KAAK,EAAE,eAAe,EACtB,WAAW,EAAE,MAAM,EACnB,GAAG,GAAE,MAAmB,GACvB,cAAc,GAAG,IAAI,CAgCvB;AAED,qGAAqG;AACrG,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAO/C"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=preload.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"preload.d.ts","sourceRoot":"","sources":["../../src/shell/preload.ts"],"names":[],"mappings":""}
@@ -0,0 +1,48 @@
1
+ import type { ShellConfig } from "./navigation";
2
+ /**
3
+ * Whether the window itself may show `url`: only the app and the API that serves its sign-in
4
+ * pages. Anything else a page links to opens in the user's browser instead.
5
+ */
6
+ export declare function mayNavigate(config: ShellConfig, url: string): boolean;
7
+ /**
8
+ * Whether `url` is on the app's own origin. Camera, location and the like are the app's to ask for,
9
+ * and not a frame it embeds from elsewhere.
10
+ */
11
+ export declare function isAppUrl(config: ShellConfig, url: string): boolean;
12
+ /**
13
+ * What the window paints before a page has: the loading page's own background, so the window
14
+ * opens, and passes from that page to the app, without a flash of white.
15
+ */
16
+ export declare const WINDOW_BACKGROUND: {
17
+ readonly light: "#fafafa";
18
+ readonly dark: "#0a0a0a";
19
+ };
20
+ /**
21
+ * What the window's page runs with. The page is remote: it gets no Node and a world apart from the
22
+ * preload's, on every platform, and reaches the shell only through what the preload exposes.
23
+ */
24
+ export declare function pageWebPreferences(preload: string): {
25
+ sandbox: boolean;
26
+ contextIsolation: boolean;
27
+ nodeIntegration: boolean;
28
+ preload: string;
29
+ };
30
+ /** Only macOS draws window controls over the page; elsewhere the title bar stays. */
31
+ export declare function hasHiddenTitleBar(platform: NodeJS.Platform): boolean;
32
+ /** The band at the top of a window without a title bar where macOS draws the window controls. */
33
+ export declare const CONTROLS_HEIGHT_PX = 36;
34
+ /**
35
+ * Styles for a window without a title bar, inserted into every page it shows.
36
+ *
37
+ * The page reaches the top edge, under the window controls, so two things a title bar did have
38
+ * to come from the page: somewhere to grab the window, and room for the controls.
39
+ * - `#sixb-desktop-drag` (added by the preload) is the drag handle: a strip across the top, above
40
+ * the page. It ignores the pointer, and every control is cut out of the draggable area, so
41
+ * buttons and links under the strip still take their clicks and the space between them moves
42
+ * the window. Something clickable that is none of the things listed here needs
43
+ * `-webkit-app-region: no-drag` of its own.
44
+ * - The shared sidebar is moved down so most apps need nothing. Other layouts can use
45
+ * `html[data-sixb-desktop]` and `--sixb-desktop-controls-height`, which the preload sets.
46
+ */
47
+ export declare const HIDDEN_TITLE_BAR_CSS: string;
48
+ //# sourceMappingURL=window.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"window.d.ts","sourceRoot":"","sources":["../../src/shell/window.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAE/C;;;GAGG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAOrE;AAED;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,MAAM,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAMlE;AAED;;;GAGG;AACH,eAAO,MAAM,iBAAiB;;;CAAiD,CAAA;AAE/E;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM;;;;;EAEjD;AAED,qFAAqF;AACrF,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,CAAC,QAAQ,GAAG,OAAO,CAEpE;AAED,iGAAiG;AACjG,eAAO,MAAM,kBAAkB,KAAK,CAAA;AAEpC;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,oBAAoB,QAsBvB,CAAA"}
@@ -0,0 +1,5 @@
1
+ // Written by scripts/build-package.ts, and published on purpose.
2
+ // Bun applies the nearest tsconfig paths map to every import it resolves, node_modules
3
+ // included. An empty map here keeps a consumer's aliases from sending a module in this
4
+ // directory back into a second copy of its own source.
5
+ { "compilerOptions": { "paths": {} } }
package/package.json CHANGED
@@ -1,6 +1,59 @@
1
1
  {
2
2
  "name": "@sixb/desktop",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Ship a Sixb project's app as a desktop app",
5
+ "keywords": [
6
+ "sixb",
7
+ "bun",
8
+ "desktop",
9
+ "electron"
10
+ ],
11
+ "homepage": "https://sixb.ai",
12
+ "bugs": {
13
+ "url": "https://github.com/sixb-ai/sixb/issues"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/sixb-ai/sixb.git",
18
+ "directory": "packages/desktop"
19
+ },
20
+ "author": "Sixb contributors",
21
+ "type": "module",
22
+ "main": "./dist/index.js",
23
+ "types": "./dist/index.d.ts",
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "bun": "./src/index.ts",
28
+ "import": "./dist/index.js",
29
+ "default": "./dist/index.js"
30
+ }
31
+ },
32
+ "scripts": {
33
+ "build": "bun ../../scripts/build-package.ts",
34
+ "typecheck": "tsc --noEmit",
35
+ "test": "bun test",
36
+ "prepack": "bun run build"
37
+ },
38
+ "dependencies": {
39
+ "@electron/packager": "^20.3.0",
40
+ "electron": "^44.9.0"
41
+ },
42
+ "devDependencies": {
43
+ "@types/bun": "latest",
44
+ "typescript": "^5.7.0"
45
+ },
46
+ "license": "MIT",
47
+ "engines": {
48
+ "bun": ">=1.4.2"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public"
52
+ },
53
+ "files": [
54
+ "dist",
55
+ "src",
56
+ "README.md",
57
+ "LICENSE"
58
+ ]
59
+ }
@@ -0,0 +1,133 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises"
2
+ import { join } from "node:path"
3
+ import { fileURLToPath } from "node:url"
4
+ import { readAppIdentity } from "./identity"
5
+ import { drawMacIcon } from "./mac-icon"
6
+ import { run } from "./run"
7
+ import type { ShellConfig } from "./shell/navigation"
8
+
9
+ export interface DesktopAppOptions {
10
+ /** The Sixb project. The desktop app is generated under its `.sixb/desktop`. */
11
+ readonly projectRoot: string
12
+ /** Where the app is served. The window opens it; the desktop app takes its name and icon from it. */
13
+ readonly appUrl: string
14
+ /** The project's API. Its sign-in pages are where the window is approved. */
15
+ readonly apiOrigin: string
16
+ }
17
+
18
+ /** The Electron app `writeDesktopApp` generated, and what it is called. */
19
+ export interface GeneratedApp {
20
+ readonly dir: string
21
+ readonly name: string
22
+ readonly version: string
23
+ /** The app's icon in `dir`, when it declares one. */
24
+ readonly icon: string | null
25
+ }
26
+
27
+ // Read from the package's `src`, which ships, whether this module runs from `src` or `dist`.
28
+ const SHELL_SOURCE_DIR = fileURLToPath(new URL("../src/shell", import.meta.url))
29
+
30
+ /** Where a project's desktop app is generated (`app`) and packaged (`dist`). */
31
+ export function desktopDir(projectRoot: string, part: "app" | "dist"): string {
32
+ return join(projectRoot, ".sixb", "desktop", part)
33
+ }
34
+
35
+ /**
36
+ * Writes the Electron app that opens `appUrl` into the project's `.sixb/desktop/app`. The directory
37
+ * is build-owned, like the rest of `.sixb`: it is cleared on every call.
38
+ */
39
+ export async function writeDesktopApp(options: DesktopAppOptions): Promise<GeneratedApp> {
40
+ const identity = await readAppIdentity(options.appUrl)
41
+ const version = await readProjectVersion(options.projectRoot)
42
+ const dir = desktopDir(options.projectRoot, "app")
43
+ await rm(dir, { recursive: true, force: true })
44
+ await mkdir(dir, { recursive: true })
45
+
46
+ const config: ShellConfig = {
47
+ name: identity.name,
48
+ appUrl: new URL(options.appUrl).href,
49
+ apiOrigin: new URL(options.apiOrigin).origin,
50
+ }
51
+ const manifest = {
52
+ name: "sixb-desktop-app",
53
+ // Names the folder the window's cookies are kept in, so each app has its own sign-in.
54
+ productName: identity.name,
55
+ version,
56
+ private: true,
57
+ main: "main.js",
58
+ }
59
+ const icon = identity.icon ? await writeIcon(dir, identity.icon) : null
60
+ const template = await readFile(join(SHELL_SOURCE_DIR, "index.html"), "utf-8")
61
+ await Promise.all([
62
+ writeFile(join(dir, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`),
63
+ writeFile(join(dir, "shell.json"), `${JSON.stringify(config, null, 2)}\n`),
64
+ writeFile(join(dir, "index.html"), renderShellPage(template, { ...config, icon: !!icon })),
65
+ ])
66
+
67
+ // Electron loads plain JavaScript. A child process, because an in-process `Bun.build()` that
68
+ // wedges takes its caller down with it.
69
+ await run(
70
+ [
71
+ process.execPath,
72
+ "build",
73
+ join(SHELL_SOURCE_DIR, "main.ts"),
74
+ join(SHELL_SOURCE_DIR, "preload.ts"),
75
+ "--target=node",
76
+ "--format=cjs",
77
+ "--external=electron",
78
+ `--outdir=${dir}`,
79
+ ],
80
+ "Could not compile the desktop shell."
81
+ )
82
+ return { dir, name: identity.name, version, icon }
83
+ }
84
+
85
+ /** The page the window shows while the app loads, and when it cannot reach the app. */
86
+ export function renderShellPage(
87
+ template: string,
88
+ page: { readonly name: string; readonly appUrl: string; readonly icon: boolean }
89
+ ): string {
90
+ const initial = Array.from(page.name.trim())[0]?.toUpperCase() ?? ""
91
+ const markup: Record<string, string> = {
92
+ name: escapeHtml(page.name),
93
+ host: escapeHtml(new URL(page.appUrl).host),
94
+ appUrl: escapeHtml(page.appUrl),
95
+ // The app's icon, or the first letter of its name standing in for one.
96
+ mark: page.icon
97
+ ? '<img class="mark" src="icon.png" alt="" />'
98
+ : `<div class="mark" aria-hidden="true">${escapeHtml(initial)}</div>`,
99
+ }
100
+ return template.replaceAll(/\{\{(\w+)\}\}/g, (_match, key: string) => markup[key] ?? "")
101
+ }
102
+
103
+ /** Writes the app's icon into `dir` as `icon.png`; on macOS, drawn the way macOS draws app icons. */
104
+ async function writeIcon(dir: string, png: Uint8Array): Promise<string> {
105
+ const icon = join(dir, "icon.png")
106
+ if (process.platform !== "darwin") {
107
+ await writeFile(icon, png)
108
+ return icon
109
+ }
110
+ // The original goes beside the app, not in it: everything in the app's directory ships.
111
+ const source = join(dir, "..", "icon-source.png")
112
+ await writeFile(source, png)
113
+ await drawMacIcon(source, icon)
114
+ return icon
115
+ }
116
+
117
+ async function readProjectVersion(projectRoot: string): Promise<string> {
118
+ try {
119
+ const manifest: unknown = JSON.parse(await readFile(join(projectRoot, "package.json"), "utf-8"))
120
+ const version = (manifest as { readonly version?: unknown } | null)?.version
121
+ return typeof version === "string" ? version : "0.0.0"
122
+ } catch {
123
+ return "0.0.0"
124
+ }
125
+ }
126
+
127
+ function escapeHtml(value: string): string {
128
+ return value
129
+ .replaceAll("&", "&amp;")
130
+ .replaceAll("<", "&lt;")
131
+ .replaceAll(">", "&gt;")
132
+ .replaceAll('"', "&quot;")
133
+ }
@@ -0,0 +1,83 @@
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
+ /**
13
+ * Reads the app's web app manifest, the one a browser installs the app from. The desktop app is
14
+ * the same app installed another way, so it takes the same name and icon and has no settings of
15
+ * its own.
16
+ */
17
+ export async function readAppIdentity(appUrl: string): Promise<AppIdentity> {
18
+ const manifestUrl = new URL("/app.webmanifest", appUrl)
19
+ const manifest: unknown = await (await fetchFromApp(manifestUrl)).json()
20
+ const { name, icons } = (typeof manifest === "object" && manifest !== null ? manifest : {}) as {
21
+ readonly name?: unknown
22
+ readonly icons?: unknown
23
+ }
24
+
25
+ const iconUrl = desktopIcon(icons, manifestUrl)
26
+ return {
27
+ name: typeof name === "string" && name.trim() ? name.trim() : "Sixb",
28
+ icon: iconUrl ? new Uint8Array(await (await fetchFromApp(iconUrl)).arrayBuffer()) : null,
29
+ }
30
+ }
31
+
32
+ /**
33
+ * The app's id on the desktop, from the address it opens: `https://orders.acme.com` becomes
34
+ * `com.acme.orders`. Each deployment gets its own, so a staging build installs beside production.
35
+ */
36
+ export function bundleIdentifier(appUrl: string): string {
37
+ const labels = new URL(appUrl).hostname
38
+ .split(".")
39
+ .reverse()
40
+ .map((label) => label.toLowerCase().replaceAll(/[^a-z0-9-]/g, "-"))
41
+ .filter(Boolean)
42
+ return (labels.length > 1 ? labels : ["local", ...labels]).join(".")
43
+ }
44
+
45
+ // A maskable icon is drawn to its edges for a platform to cut its own shape from, which is what
46
+ // macOS does with it. Any other icon may carry its own shape and margins.
47
+ function desktopIcon(icons: unknown, manifestUrl: URL): URL | null {
48
+ let best: { readonly url: URL; readonly rank: readonly [number, number] } | null = null
49
+ for (const icon of Array.isArray(icons) ? icons : []) {
50
+ if (typeof icon !== "object" || icon === null) continue
51
+ const { src, sizes, type, purpose } = icon as Record<string, unknown>
52
+ if (typeof src !== "string") continue
53
+ const url = new URL(src, manifestUrl)
54
+ if (type === undefined ? !url.pathname.endsWith(".png") : type !== "image/png") continue
55
+
56
+ const maskable = typeof purpose === "string" && purpose.split(/\s+/).includes("maskable")
57
+ const size = Math.max(
58
+ 0,
59
+ ...(typeof sizes === "string" ? sizes.split(/\s+/) : []).map(
60
+ (entry) => Number.parseInt(entry, 10) || 0
61
+ )
62
+ )
63
+ const rank = [maskable ? 1 : 0, size] as const
64
+ if (!best || rank[0] > best.rank[0] || (rank[0] === best.rank[0] && rank[1] > best.rank[1])) {
65
+ best = { url, rank }
66
+ }
67
+ }
68
+ return best?.url ?? null
69
+ }
70
+
71
+ async function fetchFromApp(url: URL): Promise<Response> {
72
+ let response: Response
73
+ try {
74
+ response = await fetch(url)
75
+ } catch (error) {
76
+ throw new Error(
77
+ `[SixbDesktop] Could not reach ${url} (${error instanceof Error ? error.message : error}). ` +
78
+ "The desktop app takes its name and icon from the app, so the app must be running there."
79
+ )
80
+ }
81
+ if (!response.ok) throw new Error(`[SixbDesktop] ${url} answered ${response.status}.`)
82
+ return response
83
+ }
package/src/index.ts ADDED
@@ -0,0 +1,177 @@
1
+ import { mkdir, readdir, rm } from "node:fs/promises"
2
+ import { createRequire } from "node:module"
3
+ import { dirname, join } from "node:path"
4
+ import { fileURLToPath } from "node:url"
5
+ import { type DesktopAppOptions, desktopDir, writeDesktopApp } from "./generate"
6
+ import { bundleIdentifier } from "./identity"
7
+ import { run } from "./run"
8
+
9
+ export type { DesktopAppOptions } from "./generate"
10
+
11
+ export interface DesktopWindow {
12
+ /** Resolves with Electron's exit code once the user quits it. */
13
+ readonly exited: Promise<number>
14
+ close(): void
15
+ }
16
+
17
+ export interface DesktopBuild {
18
+ /** What to hand to users: a zip of the app on macOS, the app's folder elsewhere. */
19
+ readonly path: string
20
+ /** Whether it was signed and notarized, and so opens without a warning. */
21
+ readonly signed: boolean
22
+ }
23
+
24
+ const packageRequire = createRequire(import.meta.url)
25
+ // From the package's `src`, like the shell: see `SHELL_SOURCE_DIR`.
26
+ const PACKAGER_SCRIPT = fileURLToPath(new URL("../src/packager.mjs", import.meta.url))
27
+
28
+ /** Opens the app in a desktop window. `sixb dev --desktop` calls this. */
29
+ export async function openDesktopApp(options: DesktopAppOptions): Promise<DesktopWindow> {
30
+ const app = await writeDesktopApp(options)
31
+ // Chromium writes plenty to stderr when all is well, so it goes to a file, named if the window
32
+ // quits on its own with an error.
33
+ const log = join(dirname(app.dir), "electron.log")
34
+ const electron = Bun.spawn([electronBinary(), app.dir], {
35
+ stdio: ["ignore", "ignore", Bun.file(log)],
36
+ })
37
+ let closing = false
38
+ const exited = electron.exited.then((code) => {
39
+ if (code !== 0 && !closing) {
40
+ console.error(`[SixbDesktop] The desktop window quit with exit code ${code}. See ${log}.`)
41
+ }
42
+ return code
43
+ })
44
+ return {
45
+ exited,
46
+ close: () => {
47
+ closing = true
48
+ electron.kill()
49
+ },
50
+ }
51
+ }
52
+
53
+ /** Packages the app for this machine's platform. `sixb desktop build` calls this. */
54
+ export async function buildDesktopApp(options: DesktopAppOptions): Promise<DesktopBuild> {
55
+ const app = await writeDesktopApp(options)
56
+ const out = desktopDir(options.projectRoot, "dist")
57
+ await rm(out, { recursive: true, force: true })
58
+
59
+ const mac = process.platform === "darwin"
60
+ const notarize = mac ? appleNotarization() : null
61
+ const packaged = await packageApp({
62
+ dir: app.dir,
63
+ name: app.name,
64
+ out,
65
+ appBundleId: bundleIdentifier(options.appUrl),
66
+ appVersion: app.version,
67
+ electronVersion: (packageRequire("electron/package.json") as { readonly version: string })
68
+ .version,
69
+ ...(mac && app.icon ? { icon: await macIcon(app.icon) } : {}),
70
+ // Signs with the Developer ID certificate in the keychain, which notarization requires.
71
+ ...(notarize ? { osxSign: {}, osxNotarize: notarize } : {}),
72
+ })
73
+ if (!mac) return { path: packaged, signed: false }
74
+
75
+ const name = (await readdir(packaged)).find((entry) => entry.endsWith(".app"))
76
+ if (!name) throw new Error(`[SixbDesktop] The packaged app is missing from ${packaged}.`)
77
+ const bundle = join(packaged, name)
78
+ // Packaging breaks the ad-hoc signature Electron ships with. macOS calls a downloaded app with a
79
+ // broken signature damaged, and offers no way to open it; signed ad hoc, it can be allowed.
80
+ if (!notarize) {
81
+ await run(["codesign", "--force", "--deep", "--sign", "-", bundle], "Could not sign the app.")
82
+ }
83
+ // `ditto`, not `zip`: it keeps the bundle's symlinks, and with them its signature.
84
+ const zip = `${packaged}.zip`
85
+ await run(["ditto", "-c", "-k", "--keepParent", bundle, zip], "Could not zip the app.")
86
+ return { path: zip, signed: notarize !== null }
87
+ }
88
+
89
+ /**
90
+ * An App Store Connect API key, which notarizes the app so macOS opens it without a warning.
91
+ * Without one, the app is built unsigned.
92
+ */
93
+ function appleNotarization(): {
94
+ readonly appleApiKey: string
95
+ readonly appleApiKeyId: string
96
+ readonly appleApiIssuer: string
97
+ } | null {
98
+ const { APPLE_API_KEY, APPLE_API_KEY_ID, APPLE_API_ISSUER } = process.env
99
+ if (!APPLE_API_KEY && !APPLE_API_KEY_ID && !APPLE_API_ISSUER) return null
100
+ if (!APPLE_API_KEY || !APPLE_API_KEY_ID || !APPLE_API_ISSUER) {
101
+ throw new Error(
102
+ "[SixbDesktop] Set all of APPLE_API_KEY, APPLE_API_KEY_ID and APPLE_API_ISSUER to sign the " +
103
+ "macOS app, or none of them to build it unsigned."
104
+ )
105
+ }
106
+ return {
107
+ appleApiKey: APPLE_API_KEY,
108
+ appleApiKeyId: APPLE_API_KEY_ID,
109
+ appleApiIssuer: APPLE_API_ISSUER,
110
+ }
111
+ }
112
+
113
+ /**
114
+ * The packager wants `.icns`: every size macOS shows, scaled from the 1024-pixel icon, in one file.
115
+ * `sips` and `iconutil` ship with every Mac. Written beside the app, not in it: everything in the
116
+ * app's directory ships inside the bundle.
117
+ */
118
+ async function macIcon(png: string): Promise<string> {
119
+ const desktop = dirname(dirname(png))
120
+ const iconset = join(desktop, "icon.iconset")
121
+ await rm(iconset, { recursive: true, force: true })
122
+ await mkdir(iconset)
123
+ const sizes = [16, 32, 128, 256, 512].flatMap((points) => [
124
+ { name: `icon_${points}x${points}.png`, pixels: points },
125
+ { name: `icon_${points}x${points}@2x.png`, pixels: points * 2 },
126
+ ])
127
+ await Promise.all(
128
+ sizes.map(({ name, pixels }) =>
129
+ run(
130
+ ["sips", "-z", `${pixels}`, `${pixels}`, png, "--out", join(iconset, name)],
131
+ "Could not scale the icon."
132
+ )
133
+ )
134
+ )
135
+ const icns = join(desktop, "icon.icns")
136
+ await run(["iconutil", "-c", "icns", iconset, "-o", icns], "Could not convert the icon.")
137
+ return icns
138
+ }
139
+
140
+ /**
141
+ * Runs `@electron/packager` into `options.out`, which starts empty, and resolves with the one
142
+ * directory it wrote there. Under Electron's own binary, which doubles as the Node the packager was
143
+ * written for: under Bun, the packager fails whenever it verifies a download (its checksum
144
+ * dependency does not load).
145
+ */
146
+ async function packageApp(
147
+ options: { readonly out: string } & Record<string, unknown>
148
+ ): Promise<string> {
149
+ const packager = Bun.spawn(
150
+ [electronBinary(), PACKAGER_SCRIPT, JSON.stringify({ ...options, quiet: true })],
151
+ {
152
+ stdio: ["ignore", "inherit", "inherit"],
153
+ // Without the second, Electron's file system reads the `app.asar` the packager writes as a
154
+ // directory.
155
+ env: { ...process.env, ELECTRON_RUN_AS_NODE: "1", ELECTRON_NO_ASAR: "1" },
156
+ }
157
+ )
158
+ if ((await packager.exited) !== 0) {
159
+ throw new Error("[SixbDesktop] Packaging failed. See the output above.")
160
+ }
161
+ const [written] = await readdir(options.out)
162
+ if (!written) throw new Error(`[SixbDesktop] The packager wrote nothing to ${options.out}.`)
163
+ return join(options.out, written)
164
+ }
165
+
166
+ // Outside Electron, `require("electron")` is the path of the binary its install script fetched.
167
+ function electronBinary(): string {
168
+ try {
169
+ return packageRequire("electron") as string
170
+ } catch {
171
+ const electronDir = dirname(packageRequire.resolve("electron/package.json"))
172
+ throw new Error(
173
+ "[SixbDesktop] Electron's binary is missing: its install script did not run. " +
174
+ `Run \`bun run --cwd ${electronDir} postinstall\`, then try again.`
175
+ )
176
+ }
177
+ }
@@ -0,0 +1,81 @@
1
+ import { run } from "./run"
2
+
3
+ /**
4
+ * Draws `png` the way macOS draws app icons, into `out`: on Apple's grid, a rounded square of 824
5
+ * pixels with continuous corners in a canvas of 1024, over the soft shadow Apple's own icons have.
6
+ * An icon drawn to its edges otherwise stands larger than every other icon in the Dock.
7
+ */
8
+ export async function drawMacIcon(png: string, out: string): Promise<void> {
9
+ await run(
10
+ ["osascript", "-l", "JavaScript", "-e", MAC_ICON_SCRIPT, png, out],
11
+ "Could not draw the macOS icon."
12
+ )
13
+ }
14
+
15
+ // AppKit, through the JavaScript `osascript` runs, which every Mac has. The corner is the curve
16
+ // UIKit rounds continuous corners with; with this radius it matches the outline of Apple's Notes
17
+ // icon to a few pixels, and the shadow matches its shadow.
18
+ const MAC_ICON_SCRIPT = `
19
+ ObjC.import("AppKit")
20
+ ObjC.import("CoreGraphics")
21
+
22
+ function run([input, output]) {
23
+ const canvas = 1024
24
+ const body = 824
25
+ const radius = 185.4
26
+ const min = (canvas - body) / 2
27
+ const max = min + body
28
+
29
+ // One corner, as offsets in radii from the edge it leaves and the edge it joins.
30
+ const corner = [
31
+ [[1.52866483, 0]],
32
+ [[0.66993427, 0.065496], [1.08849323, 0], [0.86840689, 0.02065986]],
33
+ [[0.63149399, 0.074911]],
34
+ [[0.074911, 0.63149399], [0.37282392, 0.16906933], [0.16906933, 0.37282392]],
35
+ [[0.065496, 0.66993427]],
36
+ [[0, 1.52866483], [0.02065986, 0.86840689], [0, 1.08849323]],
37
+ ]
38
+ const corners = [
39
+ ([u, v]) => $.NSMakePoint(max - u * radius, max - v * radius),
40
+ ([u, v]) => $.NSMakePoint(max - v * radius, min + u * radius),
41
+ ([u, v]) => $.NSMakePoint(min + u * radius, min + v * radius),
42
+ ([u, v]) => $.NSMakePoint(min + v * radius, max - u * radius),
43
+ ]
44
+ const shape = $.NSBezierPath.bezierPath
45
+ shape.moveToPoint(corners[3]([0, 1.52866483]))
46
+ for (const at of corners) {
47
+ for (const [to, c1, c2] of corner) {
48
+ if (c1) shape.curveToPointControlPoint1ControlPoint2(at(to), at(c1), at(c2))
49
+ else shape.lineToPoint(at(to))
50
+ }
51
+ }
52
+ shape.closePath
53
+
54
+ const image = $.NSImage.alloc.initWithContentsOfFile(input)
55
+ if (image.isNil()) throw new Error("Cannot read " + input)
56
+ const bitmap = $.NSBitmapImageRep.alloc
57
+ .initWithBitmapDataPlanesPixelsWidePixelsHighBitsPerSampleSamplesPerPixelHasAlphaIsPlanarColorSpaceNameBytesPerRowBitsPerPixel(
58
+ null, canvas, canvas, 8, 4, true, false, $.NSDeviceRGBColorSpace, 0, 0
59
+ )
60
+ const context = $.NSGraphicsContext.graphicsContextWithBitmapImageRep(bitmap)
61
+ $.NSGraphicsContext.setCurrentContext(context)
62
+ context.setImageInterpolation($.NSImageInterpolationHigh)
63
+
64
+ const shadow = $.NSShadow.alloc.init
65
+ shadow.setShadowOffset($.NSMakeSize(0, -8))
66
+ shadow.setShadowBlurRadius(30)
67
+ shadow.setShadowColor($.NSColor.colorWithWhiteAlpha(0, 0.22))
68
+ shadow.set
69
+ // A layer, so the shadow falls from the clipped icon and not from the clip.
70
+ $.CGContextBeginTransparencyLayer(context.CGContext, null)
71
+ shape.addClip
72
+ image.drawInRectFromRectOperationFraction(
73
+ $.NSMakeRect(min, min, body, body), $.NSZeroRect, $.NSCompositingOperationSourceOver, 1
74
+ )
75
+ $.CGContextEndTransparencyLayer(context.CGContext)
76
+ context.flushGraphics
77
+
78
+ const png = bitmap.representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $.NSDictionary.dictionary)
79
+ if (!png.writeToFileAtomically(output, true)) throw new Error("Cannot write " + output)
80
+ }
81
+ `
@@ -0,0 +1,5 @@
1
+ // Runs `@electron/packager` with the options `buildDesktopApp` passes as JSON. Started under
2
+ // Electron's own Node, not Bun: see `packageApp`.
3
+ import { packager } from "@electron/packager"
4
+
5
+ await packager(JSON.parse(process.argv[2]))
package/src/run.ts ADDED
@@ -0,0 +1,11 @@
1
+ /** Runs a command to completion, quietly unless it fails: then it throws `failure` and its errors. */
2
+ export async function run(command: readonly string[], failure: string): Promise<void> {
3
+ const child = Bun.spawn([...command], {
4
+ stdio: ["ignore", "ignore", "pipe"],
5
+ timeout: 120_000,
6
+ })
7
+ const [exitCode, errors] = await Promise.all([child.exited, new Response(child.stderr).text()])
8
+ if (exitCode !== 0) {
9
+ throw new Error(`[SixbDesktop] ${failure}${errors.trim() ? `\n${errors.trim()}` : ""}`)
10
+ }
11
+ }