@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 +21 -0
- package/README.md +81 -2
- package/dist/generate.d.ts +30 -0
- package/dist/generate.d.ts.map +1 -0
- package/dist/identity.d.ts +22 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +383 -0
- package/dist/index.js.map +15 -0
- package/dist/mac-icon.d.ts +7 -0
- package/dist/mac-icon.d.ts.map +1 -0
- package/dist/run.d.ts +3 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/shell/main.d.ts +2 -0
- package/dist/shell/main.d.ts.map +1 -0
- package/dist/shell/navigation.d.ts +39 -0
- package/dist/shell/navigation.d.ts.map +1 -0
- package/dist/shell/window.d.ts +44 -0
- package/dist/shell/window.d.ts.map +1 -0
- package/dist/tsconfig.json +5 -0
- package/package.json +57 -4
- package/src/generate.ts +134 -0
- package/src/identity.ts +83 -0
- package/src/index.ts +177 -0
- package/src/mac-icon.ts +81 -0
- package/src/packager.mjs +5 -0
- package/src/run.ts +11 -0
- package/src/shell/index.html +204 -0
- package/src/shell/main.ts +107 -0
- package/src/shell/navigation.ts +84 -0
- package/src/shell/window.ts +111 -0
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
|
+
}
|
package/src/mac-icon.ts
ADDED
|
@@ -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
|
+
`
|
package/src/packager.mjs
ADDED
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
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>{{name}}</title>
|
|
7
|
+
<style>
|
|
8
|
+
/* The colors mirror packages/ui/src/styles/globals.css, so the app replaces this page
|
|
9
|
+
without a change of tone. `WINDOW_BACKGROUND` in window.ts repeats --background. */
|
|
10
|
+
:root {
|
|
11
|
+
color-scheme: light dark;
|
|
12
|
+
--background: #fafafa;
|
|
13
|
+
--foreground: #101010;
|
|
14
|
+
--primary: #0a0a0a;
|
|
15
|
+
--primary-foreground: #ffffff;
|
|
16
|
+
--muted-foreground: #5f5f5f;
|
|
17
|
+
}
|
|
18
|
+
@media (prefers-color-scheme: dark) {
|
|
19
|
+
:root {
|
|
20
|
+
--background: #0a0a0a;
|
|
21
|
+
--foreground: #f4f4f4;
|
|
22
|
+
--primary: #f4f4f4;
|
|
23
|
+
--primary-foreground: #0a0a0a;
|
|
24
|
+
--muted-foreground: #8a8a8a;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
* {
|
|
28
|
+
box-sizing: border-box;
|
|
29
|
+
}
|
|
30
|
+
html,
|
|
31
|
+
body {
|
|
32
|
+
height: 100%;
|
|
33
|
+
}
|
|
34
|
+
body {
|
|
35
|
+
margin: 0;
|
|
36
|
+
/* Sits a little above dead-center. */
|
|
37
|
+
padding-bottom: 8vh;
|
|
38
|
+
display: grid;
|
|
39
|
+
place-items: center;
|
|
40
|
+
font-family: "SF Pro Text", "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif;
|
|
41
|
+
background: var(--background);
|
|
42
|
+
color: var(--foreground);
|
|
43
|
+
-webkit-font-smoothing: antialiased;
|
|
44
|
+
/* Nothing here is a document: the whole page moves the window, and no text selects. */
|
|
45
|
+
-webkit-app-region: drag;
|
|
46
|
+
user-select: none;
|
|
47
|
+
cursor: default;
|
|
48
|
+
}
|
|
49
|
+
main {
|
|
50
|
+
display: flex;
|
|
51
|
+
flex-direction: column;
|
|
52
|
+
align-items: center;
|
|
53
|
+
animation: enter 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
|
54
|
+
}
|
|
55
|
+
.mark {
|
|
56
|
+
display: grid;
|
|
57
|
+
place-items: center;
|
|
58
|
+
width: 3.5rem;
|
|
59
|
+
height: 3.5rem;
|
|
60
|
+
border-radius: 0.875rem;
|
|
61
|
+
background: var(--primary);
|
|
62
|
+
color: var(--primary-foreground);
|
|
63
|
+
font-size: 1.5rem;
|
|
64
|
+
font-weight: 600;
|
|
65
|
+
letter-spacing: -0.02em;
|
|
66
|
+
box-shadow:
|
|
67
|
+
0 1px 1px rgb(0 0 0 / 0.06),
|
|
68
|
+
0 12px 28px -12px rgb(0 0 0 / 0.35);
|
|
69
|
+
}
|
|
70
|
+
@media (prefers-color-scheme: dark) {
|
|
71
|
+
/* A shadow on black is only a smudge around the mark. */
|
|
72
|
+
.mark {
|
|
73
|
+
box-shadow: none;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/* The app's own icon brings its own shape. */
|
|
77
|
+
img.mark {
|
|
78
|
+
width: 4rem;
|
|
79
|
+
height: 4rem;
|
|
80
|
+
border-radius: 0;
|
|
81
|
+
background: none;
|
|
82
|
+
box-shadow: none;
|
|
83
|
+
}
|
|
84
|
+
h1 {
|
|
85
|
+
margin: 1.125rem 0 0;
|
|
86
|
+
font-size: 1.0625rem;
|
|
87
|
+
font-weight: 600;
|
|
88
|
+
letter-spacing: -0.01em;
|
|
89
|
+
}
|
|
90
|
+
/* One fixed height for both states, so the name does not move when one replaces the other. */
|
|
91
|
+
.status {
|
|
92
|
+
display: flex;
|
|
93
|
+
flex-direction: column;
|
|
94
|
+
align-items: center;
|
|
95
|
+
height: 4.5rem;
|
|
96
|
+
margin-top: 1.25rem;
|
|
97
|
+
}
|
|
98
|
+
p {
|
|
99
|
+
margin: 0;
|
|
100
|
+
font-size: 0.8125rem;
|
|
101
|
+
color: var(--muted-foreground);
|
|
102
|
+
}
|
|
103
|
+
.progress {
|
|
104
|
+
position: relative;
|
|
105
|
+
width: 5rem;
|
|
106
|
+
height: 3px;
|
|
107
|
+
border-radius: 3px;
|
|
108
|
+
overflow: hidden;
|
|
109
|
+
background: color-mix(in srgb, var(--foreground) 10%, transparent);
|
|
110
|
+
}
|
|
111
|
+
.progress::after {
|
|
112
|
+
content: "";
|
|
113
|
+
position: absolute;
|
|
114
|
+
inset: 0;
|
|
115
|
+
width: 40%;
|
|
116
|
+
border-radius: inherit;
|
|
117
|
+
background: var(--foreground);
|
|
118
|
+
animation: slide 1.3s cubic-bezier(0.65, 0, 0.35, 1) infinite;
|
|
119
|
+
}
|
|
120
|
+
/* A quick load never gets as far as words; a slow one says what it is waiting for. */
|
|
121
|
+
#connecting p {
|
|
122
|
+
margin-top: 1rem;
|
|
123
|
+
opacity: 0;
|
|
124
|
+
animation: appear 0.4s ease 2.5s forwards;
|
|
125
|
+
}
|
|
126
|
+
#unreachable a {
|
|
127
|
+
display: inline-flex;
|
|
128
|
+
align-items: center;
|
|
129
|
+
height: 2rem;
|
|
130
|
+
margin-top: 0.875rem;
|
|
131
|
+
padding: 0 0.875rem;
|
|
132
|
+
border-radius: 0.375rem;
|
|
133
|
+
background: var(--primary);
|
|
134
|
+
color: var(--primary-foreground);
|
|
135
|
+
font-size: 0.8125rem;
|
|
136
|
+
font-weight: 500;
|
|
137
|
+
text-decoration: none;
|
|
138
|
+
cursor: pointer;
|
|
139
|
+
-webkit-app-region: no-drag;
|
|
140
|
+
}
|
|
141
|
+
#unreachable a:hover {
|
|
142
|
+
opacity: 0.9;
|
|
143
|
+
}
|
|
144
|
+
#unreachable a:focus-visible {
|
|
145
|
+
outline: 2px solid var(--primary);
|
|
146
|
+
outline-offset: 2px;
|
|
147
|
+
}
|
|
148
|
+
/* The shell reopens this page at #unreachable when the app's server does not answer. It is
|
|
149
|
+
the same page a moment later, so it does not make its entrance twice. */
|
|
150
|
+
#unreachable:not(:target),
|
|
151
|
+
:root:has(#unreachable:target) #connecting {
|
|
152
|
+
display: none;
|
|
153
|
+
}
|
|
154
|
+
:root:has(#unreachable:target) main {
|
|
155
|
+
animation: none;
|
|
156
|
+
}
|
|
157
|
+
@keyframes enter {
|
|
158
|
+
from {
|
|
159
|
+
opacity: 0;
|
|
160
|
+
transform: translateY(4px);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
@keyframes appear {
|
|
164
|
+
to {
|
|
165
|
+
opacity: 1;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
@keyframes slide {
|
|
169
|
+
from {
|
|
170
|
+
transform: translateX(-100%);
|
|
171
|
+
}
|
|
172
|
+
to {
|
|
173
|
+
transform: translateX(250%);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
@media (prefers-reduced-motion: reduce) {
|
|
177
|
+
main,
|
|
178
|
+
#connecting p {
|
|
179
|
+
animation: none;
|
|
180
|
+
opacity: 1;
|
|
181
|
+
}
|
|
182
|
+
.progress::after {
|
|
183
|
+
animation: none;
|
|
184
|
+
width: 100%;
|
|
185
|
+
opacity: 0.35;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
</style>
|
|
189
|
+
</head>
|
|
190
|
+
<body>
|
|
191
|
+
<main>
|
|
192
|
+
{{mark}}
|
|
193
|
+
<h1>{{name}}</h1>
|
|
194
|
+
<div class="status" id="connecting">
|
|
195
|
+
<div class="progress"></div>
|
|
196
|
+
<p role="status">Connecting to {{host}}…</p>
|
|
197
|
+
</div>
|
|
198
|
+
<div class="status" id="unreachable">
|
|
199
|
+
<p>Can’t reach {{host}}</p>
|
|
200
|
+
<a href="{{appUrl}}">Try again</a>
|
|
201
|
+
</div>
|
|
202
|
+
</main>
|
|
203
|
+
</body>
|
|
204
|
+
</html>
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// The desktop shell's main process. `writeDesktopApp` compiles this file into the generated
|
|
2
|
+
// Electron app; nothing imports it.
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { app, BrowserWindow, type Event, nativeTheme, session, shell } from "electron"
|
|
6
|
+
import { createNavigationState, isWebUrl, nextNavigationStep, type ShellConfig } from "./navigation"
|
|
7
|
+
import { HIDDEN_TITLE_BAR_CSS, isAppUrl, mayNavigate, WINDOW_BACKGROUND } from "./window"
|
|
8
|
+
|
|
9
|
+
// Chromium's code for a load that was replaced by another one, which is not a failure.
|
|
10
|
+
const ERR_ABORTED = -3
|
|
11
|
+
|
|
12
|
+
// Not `__dirname`: the bundler replaces it with this source file's directory at compile time,
|
|
13
|
+
// which is not where the compiled app lives.
|
|
14
|
+
const appDir = app.getAppPath()
|
|
15
|
+
const config = JSON.parse(readFileSync(join(appDir, "shell.json"), "utf-8")) as ShellConfig
|
|
16
|
+
const loadingPage = join(appDir, "index.html")
|
|
17
|
+
const icon = existsSync(join(appDir, "icon.png")) ? join(appDir, "icon.png") : undefined
|
|
18
|
+
// Only macOS draws window controls over the page; elsewhere the title bar stays.
|
|
19
|
+
const inset = process.platform === "darwin"
|
|
20
|
+
const state = createNavigationState()
|
|
21
|
+
|
|
22
|
+
app.setName(config.name)
|
|
23
|
+
|
|
24
|
+
function createWindow(): void {
|
|
25
|
+
const win = new BrowserWindow({
|
|
26
|
+
title: config.name,
|
|
27
|
+
width: 1280,
|
|
28
|
+
height: 832,
|
|
29
|
+
titleBarStyle: inset ? "hiddenInset" : "default",
|
|
30
|
+
icon,
|
|
31
|
+
// Opens once the loading page has painted, over a background of that page's color.
|
|
32
|
+
show: false,
|
|
33
|
+
backgroundColor: nativeTheme.shouldUseDarkColors
|
|
34
|
+
? WINDOW_BACKGROUND.dark
|
|
35
|
+
: 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
|
+
},
|
|
43
|
+
})
|
|
44
|
+
const contents = win.webContents
|
|
45
|
+
win.once("ready-to-show", () => win.show())
|
|
46
|
+
|
|
47
|
+
// A load that fails or is replaced also rejects; `did-fail-load` below is what answers it.
|
|
48
|
+
const load = (url: string) => void win.loadURL(url).catch(() => {})
|
|
49
|
+
|
|
50
|
+
function onNavigation(url: string, event?: Event): void {
|
|
51
|
+
const step = nextNavigationStep(config, state, url)
|
|
52
|
+
if (step?.kind === "load") {
|
|
53
|
+
event?.preventDefault()
|
|
54
|
+
load(step.url)
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
if (step?.kind === "open-external") void shell.openExternal(step.url)
|
|
58
|
+
if (event && !mayNavigate(config, url)) {
|
|
59
|
+
event.preventDefault()
|
|
60
|
+
if (isWebUrl(url)) void shell.openExternal(url)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
contents.on("will-navigate", (event, url) => onNavigation(url, event))
|
|
65
|
+
contents.on("will-redirect", (event, url) => onNavigation(url, event))
|
|
66
|
+
contents.on("did-navigate", (_event, url) => onNavigation(url))
|
|
67
|
+
|
|
68
|
+
// Links that ask for a new window belong in the user's browser, not in a second shell window.
|
|
69
|
+
contents.setWindowOpenHandler(({ url }) => {
|
|
70
|
+
if (isWebUrl(url)) void shell.openExternal(url)
|
|
71
|
+
return { action: "deny" }
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
if (inset) {
|
|
75
|
+
contents.on("dom-ready", () => void contents.insertCSS(HIDDEN_TITLE_BAR_CSS))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The loading page stays on screen until the app's first response replaces it. If the app
|
|
79
|
+
// cannot be reached, it comes back saying so.
|
|
80
|
+
contents.on("did-fail-load", (_event, errorCode, _description, _url, isMainFrame) => {
|
|
81
|
+
if (isMainFrame && errorCode !== ERR_ABORTED) {
|
|
82
|
+
void win.loadFile(loadingPage, { hash: "unreachable" })
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
void win.loadFile(loadingPage).then(() => load(config.appUrl))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
void app.whenReady().then(() => {
|
|
89
|
+
// A packaged app carries its icon in its bundle. Run from source, it would show Electron's.
|
|
90
|
+
if (icon && !app.isPackaged) app.dock?.setIcon(icon)
|
|
91
|
+
|
|
92
|
+
// Judged by the frame that asks, which may be another site's embedded in the app.
|
|
93
|
+
session.defaultSession.setPermissionRequestHandler(
|
|
94
|
+
(_contents, _permission, callback, details) => {
|
|
95
|
+
callback(isAppUrl(config, details.requestingUrl))
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
createWindow()
|
|
100
|
+
app.on("activate", () => {
|
|
101
|
+
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
app.on("window-all-closed", () => {
|
|
106
|
+
if (process.platform !== "darwin") app.quit()
|
|
107
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
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
|
+
|
|
10
|
+
export interface NavigationState {
|
|
11
|
+
/** Approval pages already opened, so a waiting page that reloads opens its browser tab once. */
|
|
12
|
+
readonly openedCodes: Set<string>
|
|
13
|
+
lastSignIn: { readonly url: string; readonly at: number } | null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type NavigationStep =
|
|
17
|
+
/** Send the window somewhere else instead. */
|
|
18
|
+
| { readonly kind: "load"; readonly url: string }
|
|
19
|
+
/** Hand a URL to the system browser. */
|
|
20
|
+
| { readonly kind: "open-external"; readonly url: string }
|
|
21
|
+
|
|
22
|
+
// One sign-in redirect can be reported more than once (as it starts, and again if the server
|
|
23
|
+
// redirects to it). Answering each report would start a second sign-in request and orphan the first.
|
|
24
|
+
const SIGN_IN_REPEAT_WINDOW_MS = 2_000
|
|
25
|
+
|
|
26
|
+
export function createNavigationState(): NavigationState {
|
|
27
|
+
return { openedCodes: new Set(), lastSignIn: null }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Decides what the shell does about a navigation of its window.
|
|
32
|
+
*
|
|
33
|
+
* The window is a browser the user cannot finish a sign-in in: a magic link opens in their own
|
|
34
|
+
* browser, and identity providers turn embedded ones away. So the app's ordinary sign-in redirect
|
|
35
|
+
* becomes a request to be approved from the system browser, and the approval page opens there.
|
|
36
|
+
*/
|
|
37
|
+
export function nextNavigationStep(
|
|
38
|
+
config: ShellConfig,
|
|
39
|
+
state: NavigationState,
|
|
40
|
+
navigatedTo: string,
|
|
41
|
+
now: number = Date.now()
|
|
42
|
+
): NavigationStep | null {
|
|
43
|
+
let url: URL
|
|
44
|
+
try {
|
|
45
|
+
url = new URL(navigatedTo)
|
|
46
|
+
} catch {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
if (url.origin !== new URL(config.apiOrigin).origin) return null
|
|
50
|
+
|
|
51
|
+
if (url.pathname === "/auth/sign-in") {
|
|
52
|
+
const last = state.lastSignIn
|
|
53
|
+
if (last && last.url === url.href && now - last.at < SIGN_IN_REPEAT_WINDOW_MS) return null
|
|
54
|
+
state.lastSignIn = { url: url.href, at: now }
|
|
55
|
+
|
|
56
|
+
const waiting = new URL("/auth/sign-in/device", url.origin)
|
|
57
|
+
const returnTo = url.searchParams.get("returnTo")
|
|
58
|
+
if (returnTo) waiting.searchParams.set("returnTo", returnTo)
|
|
59
|
+
waiting.searchParams.set("client", config.name)
|
|
60
|
+
return { kind: "load", url: waiting.href }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (url.pathname === "/auth/sign-in/device") {
|
|
64
|
+
const userCode = url.searchParams.get("user_code")
|
|
65
|
+
if (!userCode || state.openedCodes.has(userCode)) return null
|
|
66
|
+
state.openedCodes.add(userCode)
|
|
67
|
+
|
|
68
|
+
const approval = new URL("/auth/device", url.origin)
|
|
69
|
+
approval.searchParams.set("user_code", userCode)
|
|
70
|
+
return { kind: "open-external", url: approval.href }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Only web addresses leave for the system browser; a page cannot make the shell launch programs. */
|
|
77
|
+
export function isWebUrl(value: string): boolean {
|
|
78
|
+
try {
|
|
79
|
+
const { protocol } = new URL(value)
|
|
80
|
+
return protocol === "https:" || protocol === "http:"
|
|
81
|
+
} catch {
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
}
|