@wtfalch/desktop 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +257 -2
- package/package.json +38 -4
- package/src/access.mjs +90 -0
- package/src/check-origin.mjs +42 -0
- package/src/company.mjs +51 -0
- package/src/config.mjs +96 -0
- package/src/deep-link.mjs +69 -0
- package/src/index.mjs +15 -0
- package/src/lifecycle.mjs +20 -0
- package/src/login.mjs +215 -0
- package/src/menu.mjs +28 -0
- package/src/resolve.mjs +105 -0
- package/src/security.mjs +96 -0
- package/src/shell.mjs +471 -0
- package/src/strategies/cookie-handoff.mjs +79 -0
- package/src/updater.mjs +60 -0
- package/src/welcome-dir.mjs +5 -0
- package/src/window-state.mjs +39 -0
- package/welcome/fonts/OFL-bricolage-grotesque.txt +93 -0
- package/welcome/fonts/OFL-figtree.txt +93 -0
- package/welcome/fonts/bricolage-grotesque-latin-wght-normal.woff2 +0 -0
- package/welcome/fonts/figtree-latin-wght-normal.woff2 +0 -0
- package/welcome/sticker.svg +1 -0
- package/welcome/welcome-preload.cjs +10 -0
- package/welcome/welcome.css +354 -0
- package/welcome/welcome.html +56 -0
- package/welcome/welcome.js +176 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 William Tallis Falch
|
|
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,258 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @wtfalch/desktop
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The shared Electron shell for Boule, Archon and Agora. An app keeps
|
|
4
|
+
its own packaging (electron-builder config, icons, signing) and calls one entry
|
|
5
|
+
point, `startShell(config)`. The shell finds the app's address, signs the person
|
|
6
|
+
in through the system browser, shows the app in a locked window, and updates
|
|
7
|
+
itself.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
pnpm install
|
|
11
|
+
pnpm test # node --test test/*.test.mjs
|
|
12
|
+
pnpm test:electron # real Electron against a local fixture app
|
|
13
|
+
pnpm lint
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Wiring an app
|
|
17
|
+
|
|
18
|
+
The app's `desktop/package.json` depends on `@wtfalch/desktop` (exact version)
|
|
19
|
+
and `electron`, and electron-builder packs it from `node_modules`. The app's
|
|
20
|
+
`main.mjs`:
|
|
21
|
+
|
|
22
|
+
```js
|
|
23
|
+
import { cookieHandoff, startShell } from '@wtfalch/desktop';
|
|
24
|
+
|
|
25
|
+
await startShell({
|
|
26
|
+
appName: 'Boule', dataDirName: 'boule', partitionPrefix: 'persist:boule-',
|
|
27
|
+
origin: { kind: 'discover', wellKnownPath: '/.well-known/boule.json', hostPrefix: 'boule', sharedHost: { domain: 'oikos.host' } },
|
|
28
|
+
landingPath: '/dashboard', accessProbePath: null, feedUrl: null, updateChannel: null,
|
|
29
|
+
devOriginEnv: 'BOULE_URL', devFeedEnv: 'BOULE_UPDATE_FEED_URL',
|
|
30
|
+
permissions: { notifications: false, media: false }, navigationLinks: null,
|
|
31
|
+
signIn: cookieHandoff({ scheme: 'boule' }),
|
|
32
|
+
});
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
A fixed-origin app (Archon, behind Cloudflare Access) differs in three values:
|
|
36
|
+
`origin: { kind: 'fixed', origin: 'https://console.example.com' }`,
|
|
37
|
+
`accessProbePath: '/api/health'` and its own names. The app's `check` script is
|
|
38
|
+
`await runCheck(config, process.argv[2])` (exported), which walks the start of
|
|
39
|
+
the sign-in against a deployed origin and names what is wrong.
|
|
40
|
+
|
|
41
|
+
The app's `package.json` sets `"main": "main.mjs"`, the electron-builder
|
|
42
|
+
`protocols` (scheme and bundle id are fixed once the first build ships) and
|
|
43
|
+
`files` including `node_modules/@wtfalch/desktop/**`. `welcomeDir` is the
|
|
44
|
+
absolute path of the welcome assets, for a packaging test that checks they ship.
|
|
45
|
+
|
|
46
|
+
## The config
|
|
47
|
+
|
|
48
|
+
Every value is required. The package has no defaults, and `validateConfig`
|
|
49
|
+
refuses a missing or malformed one by name (`src/config.mjs`).
|
|
50
|
+
|
|
51
|
+
| Key | Type | Meaning |
|
|
52
|
+
| --- | --- | --- |
|
|
53
|
+
| `appName` | string | Window title, menu label, welcome page text, `app.setName`. |
|
|
54
|
+
| `dataDirName` | string | Folder under `appData` holding `company.json` and `window-state.json`. |
|
|
55
|
+
| `partitionPrefix` | string, starts `persist:` | The company's partition is this plus the hex of its origin. |
|
|
56
|
+
| `origin` | `OriginConfig` | How the address is found, below. |
|
|
57
|
+
| `landingPath` | string, starts `/` | Loaded after sign-in and on every launch. |
|
|
58
|
+
| `accessProbePath` | string or null | Behind Cloudflare Access: a path the app answers without a redirect (Archon: `/api/health`). The shell probes it to tell whether Access let the person in, and passes Access in the window first (below). `null`: not behind Access. |
|
|
59
|
+
| `feedUrl` | https string or null | Update feed set in code. Null uses the `app-update.yml` electron-builder bakes in. |
|
|
60
|
+
| `updateChannel` | string or null | electron-updater channel (`<channel>-mac.yml` on the same feed). Never assigns a falsy channel; `allowDowngrade` is always false. |
|
|
61
|
+
| `devOriginEnv` | string | Env var that overrides the origin. Unpackaged runs only. |
|
|
62
|
+
| `devFeedEnv` | string | Env var that overrides the feed. Unpackaged runs only. |
|
|
63
|
+
| `permissions` | `{ notifications, media }` | Granted to the company origin only; everything else is denied. `media` is camera and microphone. |
|
|
64
|
+
| `navigationLinks` | `{ pathPrefix }` or null | `<scheme>://<path>` and same-origin https links under `pathPrefix` open that page (queued when they arrive before the window). Null: such links are ignored. |
|
|
65
|
+
| `signIn` | `SignInStrategy` | How sign-in works, below. |
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
type OriginConfig =
|
|
69
|
+
| { kind: 'discover'; wellKnownPath: string; hostPrefix: string;
|
|
70
|
+
sharedHost: { domain: string } | null;
|
|
71
|
+
validate?: (json: unknown, origin: string) => { name: string } | undefined }
|
|
72
|
+
| { kind: 'fixed'; origin: string };
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Origin: discover or fixed
|
|
76
|
+
|
|
77
|
+
`discover` is Boule's email discovery. The email's domain gives
|
|
78
|
+
`https://<hostPrefix>.<domain>`; an email on `<slug>.<sharedHost.domain>` gives
|
|
79
|
+
`https://<slug>-<hostPrefix>.<sharedHost.domain>`. The shell fetches
|
|
80
|
+
`<origin><wellKnownPath>` with `redirect: 'error'`, a timeout and a 16 KB cap.
|
|
81
|
+
By default it must be JSON `{ "name": "<1 to 100 characters>" }`; an app with a
|
|
82
|
+
richer file passes `validate` (it gets the parsed JSON and the origin, returns
|
|
83
|
+
`{ name }` or undefined, and may add fields). On "not found" the page asks for
|
|
84
|
+
the address by hand.
|
|
85
|
+
|
|
86
|
+
`fixed` has no email screen and no discovery: the app signs in straight away
|
|
87
|
+
against that one origin, and the company name is `appName`. `devOriginEnv`
|
|
88
|
+
may point an unpackaged run at a local origin.
|
|
89
|
+
|
|
90
|
+
Origins are https only. Plain http is accepted for `localhost` and `127.0.0.1`
|
|
91
|
+
in an unpackaged run. `company.json` stores `{ origin, name }`;
|
|
92
|
+
a corrupt file means no company.
|
|
93
|
+
|
|
94
|
+
### Origin behind Cloudflare Access
|
|
95
|
+
|
|
96
|
+
Cloudflare Access answers a request without a `CF_Authorization` cookie with a
|
|
97
|
+
redirect to `*.cloudflareaccess.com`. The cookie lives in the company partition,
|
|
98
|
+
so:
|
|
99
|
+
|
|
100
|
+
1. Every request the shell makes to the origin after the window exists (redeem,
|
|
101
|
+
logout, the Access probe) goes through that partition's `session.fetch` with
|
|
102
|
+
`credentials: 'include'` (`partitionFetcher`). Global `fetch` is never used
|
|
103
|
+
for them; `DesktopLogin` has no default fetcher.
|
|
104
|
+
2. With `accessProbePath: '/api/health'`, before the system-browser sign-in starts (and
|
|
105
|
+
before a stored company loads) the shell asks the origin through the
|
|
106
|
+
partition with `redirect: 'manual'`, deciding from status and `Location`
|
|
107
|
+
only (Electron's `session.fetch` reports a wrong `response.url`, so it is never
|
|
108
|
+
read). A non-3xx answer, or a 3xx back to the origin itself, is a pass; a 3xx
|
|
109
|
+
to any other host (Access, or an identity provider) is not. A network failure
|
|
110
|
+
is not an Access answer: the window shows the "Could not reach" screen with
|
|
111
|
+
Try again. When Access wants a login, the window shows the origin so the person passes
|
|
112
|
+
Access there, and the shell polls every 1.5 s, up to 10 minutes, until a
|
|
113
|
+
request is answered by the origin itself. Then the handoff starts. Cancel
|
|
114
|
+
works during this wait. While it lasts the window may follow https redirects
|
|
115
|
+
anywhere (the identity provider); popups stay blocked.
|
|
116
|
+
3. `redirect: 'error'` for the redeem is unchanged. The redeem is sent with
|
|
117
|
+
`credentials: 'include'` so `CF_Authorization` goes along; the server is
|
|
118
|
+
trusted for any `Set-Cookie` on its redeem answer. Cancelling the Access wait
|
|
119
|
+
clears the partition's storage and cache.
|
|
120
|
+
|
|
121
|
+
Signing out clears the partition, `CF_Authorization` included, so the next
|
|
122
|
+
sign-in passes Access again.
|
|
123
|
+
|
|
124
|
+
## Sign-in strategies
|
|
125
|
+
|
|
126
|
+
`config.signIn` is the one place an app's sign-in lives. The package ships
|
|
127
|
+
`cookieHandoff({ scheme })`. Any object of this shape works:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
interface SignInStrategy {
|
|
131
|
+
scheme: string; // URL scheme registered with the OS
|
|
132
|
+
createLogin(ctx: Context): Login; // one per sign-in attempt
|
|
133
|
+
logout(ctx: Context): Promise<void>; // best effort; the shell clears the partition afterwards
|
|
134
|
+
check?(origin: string): Promise<{ what: string; fix: string }[]>; // for runCheck
|
|
135
|
+
preload?: string; // absolute path, preload for the company page
|
|
136
|
+
handlers?: Record<string, (ctx: Context, ...args) => unknown>; // ipcMain channels, company page top frame only
|
|
137
|
+
}
|
|
138
|
+
interface Context {
|
|
139
|
+
origin: string;
|
|
140
|
+
partition: Electron.Session;
|
|
141
|
+
fetcher: (url, init) => Promise<Response>; // the partition's session.fetch
|
|
142
|
+
openBrowser: (url: string) => Promise<void>;
|
|
143
|
+
userDataDir: string; // the app's data folder
|
|
144
|
+
safeStorage: Electron.SafeStorage;
|
|
145
|
+
}
|
|
146
|
+
interface Login {
|
|
147
|
+
start(opts: { email?: string; selectAccount: boolean }): Promise<void>; // resolves when signed in; rejects if cancelled, expired or refused
|
|
148
|
+
cancel(): void;
|
|
149
|
+
readonly active: boolean;
|
|
150
|
+
readonly code?: string; // pairing code the welcome page shows, if any
|
|
151
|
+
handleDeepLink(url: string): boolean; // true if the link was ours and consumed
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The shell owns the window, the partition (clearing it, the crash-safe
|
|
156
|
+
`pendingClear` marker), the company record and the welcome screen. The strategy
|
|
157
|
+
owns what is sent to the server and where a credential is kept.
|
|
158
|
+
|
|
159
|
+
### Mapping Agora's desktop-auth onto it
|
|
160
|
+
|
|
161
|
+
Agora (`apps/desktop`, `client.mjs`, `vault.mjs`) signs in with a bearer token and
|
|
162
|
+
a vault instead of cookies. It plugs in without changes to the package:
|
|
163
|
+
|
|
164
|
+
| Strategy part | Agora's code |
|
|
165
|
+
| --- | --- |
|
|
166
|
+
| `scheme` | `'agora'` |
|
|
167
|
+
| `createLogin(ctx)` | builds `Vault(join(ctx.userDataDir, 'session-<hex origin>'), ctx.safeStorage)` and `DesktopSession(ctx.origin, ctx.fetcher, ctx.partition, vault)`, calls `held.restore()`, and returns an adapter over `DesktopLogin(ctx.origin, held, ctx.openBrowser)`: `start` calls `login.start()`, `cancel` calls `login.cancel()`, `active` is true while `start` runs, `handleDeepLink` is `() => false` (Agora polls `redeem`; no link ends its sign-in), no `code`. |
|
|
168
|
+
| `logout(ctx)` | `held.restore(); await held.logout()` (revokes the token, clears the vault). |
|
|
169
|
+
| `check(origin)` | the body of Agora's `check-origin.mjs`, returning its problem list. |
|
|
170
|
+
| `preload`, `handlers` | `preload.cjs` and the `agora:session`, `agora:sign-in`, `agora:sign-out`, `agora:version`, `agora:check-for-updates` channels (the page drives sign-in through `window.wtfalchDesktop`). The badge channel is app-specific and goes here too. |
|
|
171
|
+
| `config.origin.validate` | Agora's `parseConfig` plus the issuer and mail-host trust checks. |
|
|
172
|
+
| `config.permissions` | `{ notifications: true, media: true }` once the open camera and microphone work lands. |
|
|
173
|
+
| `config.navigationLinks` | `{ pathPrefix: '/h' }` |
|
|
174
|
+
|
|
175
|
+
Not covered, and left for Agora's migration: its separate welcome window with a
|
|
176
|
+
confirm step and `restartAs` relaunch (the package's welcome is in-window and
|
|
177
|
+
needs no relaunch), the dock-icon picker, and `app.setBadgeCount`. These need
|
|
178
|
+
either an app-supplied hook or an Agora-side wrapper, and are not generic enough
|
|
179
|
+
to decide here. `handlers` must be given a way to start a sign-in from the page
|
|
180
|
+
before Agora's flow fits exactly; `ctx` does not carry one yet.
|
|
181
|
+
|
|
182
|
+
## The handoff contract (cookie handoff: what the server provides)
|
|
183
|
+
|
|
184
|
+
1. The shell makes a 32-byte random verifier (base64url, 43 characters) and opens
|
|
185
|
+
`<origin>/auth/desktop/start?challenge=<base64url sha256(verifier)>` in the
|
|
186
|
+
system browser, with `&login_hint=<email>` when an email is known, and
|
|
187
|
+
`&prompt=select_account` for the first sign-in after a sign-out.
|
|
188
|
+
2. The person signs in there and confirms. The browser ends on
|
|
189
|
+
`<scheme>://auth/done?id=<uuid v4>`.
|
|
190
|
+
3. The shell accepts only that exact scheme, host and path with a uuid v4 `id`,
|
|
191
|
+
and only while a sign-in is pending. Cancelling, or five minutes passing,
|
|
192
|
+
forgets the verifier; a later link does nothing.
|
|
193
|
+
4. The shell sends `POST <origin>/auth/desktop/redeem` with JSON
|
|
194
|
+
`{ "id": "<id>", "verifier": "<verifier>" }` and `redirect: 'error'`, through
|
|
195
|
+
the partition's `session.fetch`.
|
|
196
|
+
5. Success is `200` with JSON
|
|
197
|
+
`{ "cookies": [ { "name", "value", "path", "httpOnly", "secure", "sameSite", "maxAge" } ] }`.
|
|
198
|
+
At most 4 cookies. Every cookie is checked: the name matches
|
|
199
|
+
`/^(__Host-)?[A-Za-z0-9_]+$/`, the value is at most 4096 bytes without control
|
|
200
|
+
characters; on an https origin the name starts with `__Host-`, `secure` is true
|
|
201
|
+
and `path` is `/`. Nothing is installed unless every cookie passes.
|
|
202
|
+
6. The cookies go into the company's partition and the window loads
|
|
203
|
+
`<origin><landingPath>`. The page needs no IPC: the session is a cookie.
|
|
204
|
+
|
|
205
|
+
The pairing code (shown in the app and on the server's confirm page) is the first
|
|
206
|
+
6 Crockford base32 characters of `sha256("boule-desktop-pair:" + challenge)`,
|
|
207
|
+
shown as `ABC-DEF`. The `boule-desktop-pair:` label is fixed for every app so one
|
|
208
|
+
server implementation serves all of them.
|
|
209
|
+
|
|
210
|
+
Sign out (native menu) does a best-effort `POST <origin>/auth/logout` through the
|
|
211
|
+
partition with an `Origin` header and `redirect: 'manual'` (the 303 to the issuer is
|
|
212
|
+
never followed), clears the partition's storage and cache, forgets the company and
|
|
213
|
+
shows the welcome screen. "Cancel sign-in" is in the same menu.
|
|
214
|
+
|
|
215
|
+
A link the server does not recognise (404, 403, 409 `not_approved`) is not ours,
|
|
216
|
+
since any app can open a `<scheme>://` link: the sign-in keeps waiting with the same
|
|
217
|
+
verifier. 409 `consumed`, 410 and 401 end it ("Sign-in expired. Start again.");
|
|
218
|
+
429, 5xx and network errors retry the same link. At most 5 redeems per sign-in,
|
|
219
|
+
one at a time, at least 1.1 seconds apart.
|
|
220
|
+
|
|
221
|
+
If the server sends the company window to its own `/auth/` pages, the session has
|
|
222
|
+
ended: the shell signs out and shows "Your session ended. Sign in again." If the
|
|
223
|
+
company cannot be reached the shell keeps the company and its session and offers
|
|
224
|
+
"Try again". Nothing is downloaded, and every frame of the company page is held to
|
|
225
|
+
the same origin rule as the page.
|
|
226
|
+
|
|
227
|
+
A packaged app has no environment overrides and accepts only https origins. A
|
|
228
|
+
packaged app has no DevTools and no reload.
|
|
229
|
+
|
|
230
|
+
## Behaving like a Mac app
|
|
231
|
+
|
|
232
|
+
Closing the window hides it; the app stays in the dock. Cmd+Q really quits and
|
|
233
|
+
the dock icon shows the window again. Window position and size persist in
|
|
234
|
+
`window-state.json`, clamped to a connected display. Navigation in the company
|
|
235
|
+
window is locked to its origin, and `/auth/` and `/callback` are never loaded
|
|
236
|
+
there (case, percent-encoding, repeated and back slashes are normalised first).
|
|
237
|
+
|
|
238
|
+
## The welcome page
|
|
239
|
+
|
|
240
|
+
A local page (`welcome/welcome.html`, strict CSP, no remote content, no inline
|
|
241
|
+
script) in its own in-memory session. Its preload exposes `window.shellWelcome`:
|
|
242
|
+
`config` (appName, fixed, host, hostPrefix), `resolveEmail` and `resolveAddress`.
|
|
243
|
+
Main validates the input and accepts calls only from that window's own top frame.
|
|
244
|
+
For a fixed origin the page starts at "Sign in to <appName>" and its button asks
|
|
245
|
+
main to start the sign-in.
|
|
246
|
+
|
|
247
|
+
## Updating itself
|
|
248
|
+
|
|
249
|
+
electron-updater's generic provider: a static host serving `latest-mac.yml` (or
|
|
250
|
+
`<channel>-mac.yml`) and the zip it names. A packaged app checks once after its
|
|
251
|
+
window first shows; a failed check never blocks startup and is logged. Checksums
|
|
252
|
+
are never turned off.
|
|
253
|
+
|
|
254
|
+
## Releasing the package
|
|
255
|
+
|
|
256
|
+
`.github/workflows/release.yml` publishes on a `v*` tag over OIDC with no stored
|
|
257
|
+
token, as the estate's package template does. The first version is published by
|
|
258
|
+
hand; set the npm trusted publisher for this repo before the first tag.
|
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/desktop",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"description": "The shared Electron shell for wtfalch apps: sign-in handoff, welcome screen, locked company window, auto-update.",
|
|
7
|
+
"packageManager": "pnpm@10.0.0",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=22.0.0"
|
|
10
|
+
},
|
|
11
|
+
"files": ["LICENSE", "README.md", "src", "welcome"],
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./src/index.mjs",
|
|
14
|
+
"./welcome": "./welcome/welcome.html",
|
|
15
|
+
"./package.json": "./package.json"
|
|
16
|
+
},
|
|
17
|
+
"sideEffects": false,
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --test test/*.test.mjs",
|
|
23
|
+
"test:electron": "node test/electron/run.mjs",
|
|
24
|
+
"lint": "biome check .",
|
|
25
|
+
"check": "pnpm lint && pnpm test"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"electron-updater": "6.8.9"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"electron": ">=44"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@biomejs/biome": "1.9.4",
|
|
35
|
+
"electron": "44.4.5"
|
|
36
|
+
},
|
|
37
|
+
"pnpm": {
|
|
38
|
+
"onlyBuiltDependencies": ["electron"]
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/access.mjs
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Cloudflare Access support. An origin behind Access answers a request with no
|
|
2
|
+
// CF_Authorization cookie by redirecting to <team>.cloudflareaccess.com. The
|
|
3
|
+
// cookie lives in the company partition, so every request the shell makes to
|
|
4
|
+
// the origin goes through that partition's session.fetch, and the person
|
|
5
|
+
// passes Access in the app window before the system-browser sign-in starts.
|
|
6
|
+
// Everything here is pure or takes its effects as arguments.
|
|
7
|
+
|
|
8
|
+
export const isAccessHost = (hostname) =>
|
|
9
|
+
hostname === 'cloudflareaccess.com' || hostname.endsWith('.cloudflareaccess.com');
|
|
10
|
+
|
|
11
|
+
const hostOf = (value, base) => {
|
|
12
|
+
try {
|
|
13
|
+
return new URL(value, base).hostname;
|
|
14
|
+
} catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Decides from status and Location only. Electron's session.fetch returns an
|
|
20
|
+
// incorrect `.url` (electron.d.ts), so `response.url` is never read. With
|
|
21
|
+
// `redirect: 'manual'` the shell sees the first answer itself: a non-3xx answer
|
|
22
|
+
// is the origin talking, so Access let the request through. A 3xx whose Location
|
|
23
|
+
// resolves to the origin itself is the app's own redirect (also past). A 3xx to
|
|
24
|
+
// any other host is Access or an identity provider (instant auth): not yet.
|
|
25
|
+
export function passedAccess(response, origin) {
|
|
26
|
+
if (!(response.status >= 300 && response.status < 400)) return true;
|
|
27
|
+
const location = response.headers?.get?.('location');
|
|
28
|
+
if (!location) return false;
|
|
29
|
+
try {
|
|
30
|
+
return new URL(location, `${origin}/`).origin === origin;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// While the person logs in through Access, the window follows its redirects,
|
|
37
|
+
// including to the identity provider, so any https page is allowed, plus the
|
|
38
|
+
// origin itself (http only for a local development origin). Nothing else.
|
|
39
|
+
export function accessNavigation(value, origin) {
|
|
40
|
+
try {
|
|
41
|
+
const url = new URL(value);
|
|
42
|
+
if (url.username || url.password) return false;
|
|
43
|
+
return url.origin === origin || url.protocol === 'https:';
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// One request through the company partition's session. `credentials: 'include'`
|
|
50
|
+
// makes the partition's cookies (CF_Authorization, the app session) go along.
|
|
51
|
+
export const partitionFetcher = (partition) => (url, init) =>
|
|
52
|
+
partition.fetch(url, { ...init, credentials: 'include' });
|
|
53
|
+
|
|
54
|
+
// Asks the origin, through the partition, whether Access lets this person in:
|
|
55
|
+
// 'passed', 'access' (still being sent to log in) or 'offline' (the request
|
|
56
|
+
// itself failed, so Access cannot be judged).
|
|
57
|
+
export async function probeAccess(fetcher, origin, path) {
|
|
58
|
+
let response;
|
|
59
|
+
try {
|
|
60
|
+
response = await fetcher(`${origin}${path}`, {
|
|
61
|
+
method: 'GET',
|
|
62
|
+
redirect: 'manual',
|
|
63
|
+
signal: AbortSignal.timeout(8000),
|
|
64
|
+
});
|
|
65
|
+
} catch {
|
|
66
|
+
return 'offline';
|
|
67
|
+
}
|
|
68
|
+
const passed = passedAccess(response, origin);
|
|
69
|
+
await response.body?.cancel().catch(() => {});
|
|
70
|
+
return passed ? 'passed' : 'access';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Polls `probe` until it says 'passed'. Rejects when cancelled or out of time.
|
|
74
|
+
// `sleep` and `now` are injected so tests need no clock.
|
|
75
|
+
export async function waitForAccess({
|
|
76
|
+
probe,
|
|
77
|
+
isCancelled,
|
|
78
|
+
timeoutMs = 10 * 60 * 1000,
|
|
79
|
+
intervalMs = 1500,
|
|
80
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
81
|
+
now = Date.now,
|
|
82
|
+
}) {
|
|
83
|
+
const end = now() + timeoutMs;
|
|
84
|
+
for (;;) {
|
|
85
|
+
if (isCancelled()) throw new Error('Access cancelled');
|
|
86
|
+
if ((await probe()) === 'passed') return;
|
|
87
|
+
if (now() >= end) throw new Error('Access timed out');
|
|
88
|
+
await sleep(intervalMs);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { validateConfig } from './config.mjs';
|
|
2
|
+
// Preflight for one deployed origin. An app's check script calls
|
|
3
|
+
// runCheck(config, process.argv[2]). It signs nobody in.
|
|
4
|
+
import { discover, originForAddress } from './resolve.mjs';
|
|
5
|
+
|
|
6
|
+
export async function runCheck(config, typed) {
|
|
7
|
+
validateConfig(config);
|
|
8
|
+
const fixed = config.origin.kind === 'fixed';
|
|
9
|
+
const input =
|
|
10
|
+
typed ?? process.env[config.devOriginEnv] ?? (fixed ? config.origin.origin : undefined);
|
|
11
|
+
if (!input) {
|
|
12
|
+
console.error(`Usage: <check script> <${config.appName} address>`);
|
|
13
|
+
return 2;
|
|
14
|
+
}
|
|
15
|
+
const origin = originForAddress(input, { allowLocal: true });
|
|
16
|
+
const problems = [];
|
|
17
|
+
if (!fixed) {
|
|
18
|
+
const { wellKnownPath, validate } = config.origin;
|
|
19
|
+
try {
|
|
20
|
+
await discover(origin, fetch, { wellKnownPath, validate, allowLocal: true });
|
|
21
|
+
} catch {
|
|
22
|
+
problems.push({
|
|
23
|
+
what: `${origin}${wellKnownPath} is missing or malformed`,
|
|
24
|
+
fix: `Deploy a ${config.appName} release that serves it.`,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (!problems.length) {
|
|
29
|
+
if (config.accessProbePath !== null)
|
|
30
|
+
console.log(
|
|
31
|
+
'Note: this origin is behind Cloudflare Access; the check cannot see past it without a service token.',
|
|
32
|
+
);
|
|
33
|
+
if (typeof config.signIn.check === 'function')
|
|
34
|
+
problems.push(...(await config.signIn.check(origin)));
|
|
35
|
+
}
|
|
36
|
+
if (!problems.length) {
|
|
37
|
+
console.log(`${origin} is ready for the desktop shell.`);
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
for (const { what, fix } of problems) console.error(`${what}\n ${fix}`);
|
|
41
|
+
return 1;
|
|
42
|
+
}
|
package/src/company.mjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Main process only. The one company (or fixed origin) this app holds, as JSON in userData.
|
|
2
|
+
// A missing, corrupt or invalid file means no company. While a partition may
|
|
3
|
+
// hold a session that is not (or no longer) a stored company's, the file holds
|
|
4
|
+
// { pendingClear: <origin> } instead, so a crash cannot leave that session behind.
|
|
5
|
+
import { readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { originForAddress, validName } from './resolve.mjs';
|
|
7
|
+
|
|
8
|
+
const originOk = (value, options) => {
|
|
9
|
+
try {
|
|
10
|
+
return typeof value === 'string' && originForAddress(value, options) === value;
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
const read = (path) => {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
18
|
+
} catch {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
const write = (path, value) => writeFileSync(path, JSON.stringify(value), { mode: 0o600 });
|
|
23
|
+
|
|
24
|
+
export function loadCompany(path, options) {
|
|
25
|
+
const value = read(path);
|
|
26
|
+
if (!value || !originOk(value.origin, options) || !validName(value.name)) return undefined;
|
|
27
|
+
return {
|
|
28
|
+
origin: value.origin,
|
|
29
|
+
name: value.name,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function saveCompany(path, company, options) {
|
|
34
|
+
if (!originOk(company?.origin, options) || !validName(company.name))
|
|
35
|
+
throw new Error('Invalid company');
|
|
36
|
+
write(path, {
|
|
37
|
+
origin: company.origin,
|
|
38
|
+
name: company.name,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const markPendingClear = (path, origin) => write(path, { pendingClear: origin });
|
|
43
|
+
|
|
44
|
+
export function loadPendingClear(path, options) {
|
|
45
|
+
const origin = read(path)?.pendingClear;
|
|
46
|
+
return originOk(origin, options) ? origin : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function forgetCompany(path) {
|
|
50
|
+
rmSync(path, { force: true });
|
|
51
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// The one config an app passes to startShell(). Every value is required: the
|
|
2
|
+
// package holds no defaults, so an app's identity is visible in its own main.mjs.
|
|
3
|
+
//
|
|
4
|
+
// @typedef {{ kind: 'discover', wellKnownPath: string, hostPrefix: string,
|
|
5
|
+
// sharedHost: { domain: string } | null,
|
|
6
|
+
// validate?: (json: unknown, origin: string) => { name: string } | undefined }
|
|
7
|
+
// | { kind: 'fixed', origin: string }} OriginConfig
|
|
8
|
+
//
|
|
9
|
+
// @typedef {object} ShellConfig
|
|
10
|
+
// @property {string} appName Window title, menu label, welcome page text.
|
|
11
|
+
// @property {string} dataDirName Folder under appData for company.json and window-state.json.
|
|
12
|
+
// @property {string} partitionPrefix e.g. 'persist:boule-'; the origin's hex is appended.
|
|
13
|
+
// @property {OriginConfig} origin
|
|
14
|
+
// @property {string} landingPath e.g. '/dashboard'; loaded after sign-in.
|
|
15
|
+
// @property {string|null} accessProbePath Behind Cloudflare Access: a path the app answers without a redirect
|
|
16
|
+
// (e.g. '/api/health'), probed to tell whether Access let the person in. null: not behind Access.
|
|
17
|
+
// @property {string|null} feedUrl https feed baked into code, or null to use app-update.yml.
|
|
18
|
+
// @property {string|null} updateChannel electron-updater channel, or null.
|
|
19
|
+
// @property {string} devOriginEnv Unpackaged-only origin override, e.g. 'BOULE_URL'.
|
|
20
|
+
// @property {string} devFeedEnv Unpackaged-only feed override, e.g. 'BOULE_UPDATE_FEED_URL'.
|
|
21
|
+
// @property {{ notifications: boolean, media: boolean }} permissions Granted to the company origin only.
|
|
22
|
+
// @property {{ pathPrefix: string } | null} navigationLinks <scheme>://<path> links that open a page.
|
|
23
|
+
// @property {SignInStrategy} signIn See README.md, "Sign-in strategies".
|
|
24
|
+
import { originForAddress } from './resolve.mjs';
|
|
25
|
+
|
|
26
|
+
const text = (value, name) => {
|
|
27
|
+
if (typeof value !== 'string' || !value.trim())
|
|
28
|
+
throw new Error(`config.${name} must be a non-empty string`);
|
|
29
|
+
};
|
|
30
|
+
const flag = (value, name) => {
|
|
31
|
+
if (typeof value !== 'boolean') throw new Error(`config.${name} must be a boolean`);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function validateConfig(config) {
|
|
35
|
+
if (!config || typeof config !== 'object') throw new Error('startShell needs a config object');
|
|
36
|
+
for (const key of [
|
|
37
|
+
'appName',
|
|
38
|
+
'dataDirName',
|
|
39
|
+
'partitionPrefix',
|
|
40
|
+
'landingPath',
|
|
41
|
+
'devOriginEnv',
|
|
42
|
+
'devFeedEnv',
|
|
43
|
+
])
|
|
44
|
+
text(config[key], key);
|
|
45
|
+
if (!/^[a-z0-9-]+$/i.test(config.dataDirName))
|
|
46
|
+
throw new Error('config.dataDirName must be a plain folder name');
|
|
47
|
+
if (!config.partitionPrefix.startsWith('persist:'))
|
|
48
|
+
throw new Error("config.partitionPrefix must start with 'persist:'");
|
|
49
|
+
if (!config.landingPath.startsWith('/'))
|
|
50
|
+
throw new Error("config.landingPath must start with '/'");
|
|
51
|
+
if (config.accessProbePath !== null && !/^\/[^/\\]/.test(String(config.accessProbePath)))
|
|
52
|
+
throw new Error("config.accessProbePath must be null or a path starting with '/'");
|
|
53
|
+
for (const key of ['feedUrl', 'updateChannel'])
|
|
54
|
+
if (config[key] !== null && typeof config[key] !== 'string')
|
|
55
|
+
throw new Error(`config.${key} must be a string or null`);
|
|
56
|
+
if (config.feedUrl !== null && !config.feedUrl.startsWith('https://'))
|
|
57
|
+
throw new Error('config.feedUrl must be https');
|
|
58
|
+
const p = config.permissions;
|
|
59
|
+
if (!p || typeof p.notifications !== 'boolean' || typeof p.media !== 'boolean')
|
|
60
|
+
throw new Error('config.permissions needs boolean notifications and media');
|
|
61
|
+
const nav = config.navigationLinks;
|
|
62
|
+
if (
|
|
63
|
+
nav !== null &&
|
|
64
|
+
(!nav || typeof nav.pathPrefix !== 'string' || !nav.pathPrefix.startsWith('/'))
|
|
65
|
+
)
|
|
66
|
+
throw new Error('config.navigationLinks must be null or { pathPrefix: "/..." }');
|
|
67
|
+
const signIn = config.signIn;
|
|
68
|
+
if (
|
|
69
|
+
!signIn ||
|
|
70
|
+
typeof signIn.createLogin !== 'function' ||
|
|
71
|
+
typeof signIn.logout !== 'function' ||
|
|
72
|
+
typeof signIn.scheme !== 'string'
|
|
73
|
+
)
|
|
74
|
+
throw new Error('config.signIn must be a sign-in strategy: { scheme, createLogin, logout }');
|
|
75
|
+
const origin = config.origin;
|
|
76
|
+
if (origin?.kind === 'fixed') {
|
|
77
|
+
let normal;
|
|
78
|
+
try {
|
|
79
|
+
normal = originForAddress(origin.origin);
|
|
80
|
+
} catch {
|
|
81
|
+
normal = undefined;
|
|
82
|
+
}
|
|
83
|
+
if (normal !== origin.origin)
|
|
84
|
+
throw new Error('config.origin.origin must be a plain https origin');
|
|
85
|
+
} else if (origin?.kind === 'discover') {
|
|
86
|
+
text(origin.wellKnownPath, 'origin.wellKnownPath');
|
|
87
|
+
if (!origin.wellKnownPath.startsWith('/'))
|
|
88
|
+
throw new Error("config.origin.wellKnownPath must start with '/'");
|
|
89
|
+
text(origin.hostPrefix, 'origin.hostPrefix');
|
|
90
|
+
if (origin.sharedHost !== null && typeof origin.sharedHost?.domain !== 'string')
|
|
91
|
+
throw new Error('config.origin.sharedHost must be null or { domain }');
|
|
92
|
+
if (origin.validate !== undefined && typeof origin.validate !== 'function')
|
|
93
|
+
throw new Error('config.origin.validate must be a function');
|
|
94
|
+
} else throw new Error("config.origin.kind must be 'discover' or 'fixed'");
|
|
95
|
+
return config;
|
|
96
|
+
}
|