@wtfalch/desktop 0.1.1 → 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.
package/README.md CHANGED
@@ -69,7 +69,8 @@ type OriginConfig =
69
69
  | { kind: 'discover'; wellKnownPath: string; hostPrefix: string;
70
70
  sharedHost: { domain: string } | null;
71
71
  validate?: (json: unknown, origin: string) => { name: string } | undefined }
72
- | { kind: 'fixed'; origin: string };
72
+ | { kind: 'fixed'; origin: string }
73
+ | { kind: 'local'; start(ctx: { session: Electron.Session }): Promise<string>; stop?(): Promise<void> };
73
74
  ```
74
75
 
75
76
  ### Origin: discover or fixed
@@ -91,21 +92,54 @@ Origins are https only. Plain http is accepted for `localhost` and `127.0.0.1`
91
92
  in an unpackaged run. `company.json` stores `{ origin, name }`;
92
93
  a corrupt file means no company.
93
94
 
95
+ ### Local origin
96
+
97
+ `local` is for an app that runs its own server on the person's machine. The
98
+ shell calls `start({ session })` once the app is ready. `session` is the
99
+ window's own partition, so the app can set cookies on it before the first page
100
+ loads. `start` resolves to the server's origin: `http://127.0.0.1:<port>` or
101
+ `http://localhost:<port>`, with a port and no path, query or credentials;
102
+ anything else throws and the app does not start. `stop()` is awaited once when
103
+ the app quits.
104
+
105
+ There is no discovery, welcome screen, Access probe or `check()`; the window
106
+ loads `<origin><landingPath>` and is locked to that origin like any other. The
107
+ partition name is `partitionPrefix` plus `local`, since the port changes on
108
+ every start. Use it with `noSignIn({ scheme })`: a strategy whose login never
109
+ starts and whose logout does nothing. Sign out and Cancel sign-in do nothing
110
+ for a local origin. `fixed` still refuses http.
111
+
112
+ ```js
113
+ origin: {
114
+ kind: 'local',
115
+ start: async ({ session }) => {
116
+ const port = await startServer();
117
+ await session.cookies.set({ url: `http://127.0.0.1:${port}`, name: 'reel', value: token });
118
+ return `http://127.0.0.1:${port}`;
119
+ },
120
+ stop: async () => stopServer(),
121
+ },
122
+ signIn: noSignIn({ scheme: 'reel' }),
123
+ ```
124
+
94
125
  ### Origin behind Cloudflare Access
95
126
 
96
127
  Cloudflare Access answers a request without a `CF_Authorization` cookie with a
97
128
  redirect to `*.cloudflareaccess.com`. The cookie lives in the company partition,
98
129
  so:
99
130
 
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.
131
+ 1. Every request the shell makes to the origin after the window exists goes
132
+ through that partition. The redeem and logout use its `session.fetch` with
133
+ `credentials: 'include'` (`partitionFetcher`). The Access probe uses
134
+ `net.request` on the same session with its cookies (`manualRedirectFetcher`),
135
+ because `session.fetch` rejects a `redirect: 'manual'` request that meets a
136
+ 3xx with "Redirect was cancelled" (Electron 44.4.5), which would read Access's
137
+ own 302 as offline. Global `fetch` is never used for them; `DesktopLogin` has
138
+ no default fetcher.
104
139
  2. With `accessProbePath: '/api/health'`, before the system-browser sign-in starts (and
105
140
  before a stored company loads) the shell asks the origin through the
106
141
  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
142
+ only (the `redirect` event names the target before anything is followed). A non-3xx answer, or a 3xx back to the origin itself, is a pass; a 3xx
109
143
  to any other host (Access, or an identity provider) is not. A network failure
110
144
  is not an Access answer: the window shows the "Could not reach" screen with
111
145
  Try again. When Access wants a login, the window shows the origin so the person passes
@@ -124,7 +158,7 @@ sign-in passes Access again.
124
158
  ## Sign-in strategies
125
159
 
126
160
  `config.signIn` is the one place an app's sign-in lives. The package ships
127
- `cookieHandoff({ scheme })`. Any object of this shape works:
161
+ `cookieHandoff({ scheme })` and `noSignIn({ scheme })` (for a `local` origin). Any object of this shape works:
128
162
 
129
163
  ```ts
130
164
  interface SignInStrategy {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/desktop",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "The shared Electron shell for wtfalch apps: sign-in handoff, welcome screen, locked company window, auto-update.",
package/src/access.mjs CHANGED
@@ -51,6 +51,40 @@ export function accessNavigation(value, origin) {
51
51
  export const partitionFetcher = (partition) => (url, init) =>
52
52
  partition.fetch(url, { ...init, credentials: 'include' });
53
53
 
54
+ // The probe's fetcher. Electron's session.fetch rejects a `redirect: 'manual'`
55
+ // request that meets a 3xx with "Redirect was cancelled" (Electron 44.4.5), so
56
+ // Access's own 302 would read as offline. net.request on the same partition
57
+ // session names the target in its 'redirect' event instead, before anything is
58
+ // followed. `request` is net.request bound to the partition, injected so this
59
+ // module stays plain Node.
60
+ export const manualRedirectFetcher =
61
+ (request) =>
62
+ (url, init = {}) =>
63
+ new Promise((resolve, reject) => {
64
+ const req = request({
65
+ url,
66
+ method: init.method ?? 'GET',
67
+ redirect: 'manual',
68
+ useSessionCookies: true,
69
+ });
70
+ const answer = (status, location) => {
71
+ req.abort();
72
+ resolve({ status, headers: new Headers(location ? { location } : {}) });
73
+ };
74
+ req.on('redirect', (status, _method, location) => answer(status, location));
75
+ req.on('response', (response) => answer(response.statusCode));
76
+ req.on('error', reject);
77
+ init.signal?.addEventListener(
78
+ 'abort',
79
+ () => {
80
+ req.abort();
81
+ reject(init.signal.reason);
82
+ },
83
+ { once: true },
84
+ );
85
+ req.end();
86
+ });
87
+
54
88
  // Asks the origin, through the partition, whether Access lets this person in:
55
89
  // 'passed', 'access' (still being sent to log in) or 'offline' (the request
56
90
  // itself failed, so Access cannot be judged).
@@ -5,6 +5,10 @@ import { discover, originForAddress } from './resolve.mjs';
5
5
 
6
6
  export async function runCheck(config, typed) {
7
7
  validateConfig(config);
8
+ if (config.origin.kind === 'local') {
9
+ console.log('A local origin is started by the app itself; there is nothing to check.');
10
+ return 0;
11
+ }
8
12
  const fixed = config.origin.kind === 'fixed';
9
13
  const input =
10
14
  typed ?? process.env[config.devOriginEnv] ?? (fixed ? config.origin.origin : undefined);
package/src/config.mjs CHANGED
@@ -4,7 +4,8 @@
4
4
  // @typedef {{ kind: 'discover', wellKnownPath: string, hostPrefix: string,
5
5
  // sharedHost: { domain: string } | null,
6
6
  // validate?: (json: unknown, origin: string) => { name: string } | undefined }
7
- // | { kind: 'fixed', origin: string }} OriginConfig
7
+ // | { kind: 'fixed', origin: string }
8
+ // | { kind: 'local', start: ({ session }) => Promise<string>, stop?: () => Promise<void> }} OriginConfig
8
9
  //
9
10
  // @typedef {object} ShellConfig
10
11
  // @property {string} appName Window title, menu label, welcome page text.
@@ -91,6 +92,11 @@ export function validateConfig(config) {
91
92
  throw new Error('config.origin.sharedHost must be null or { domain }');
92
93
  if (origin.validate !== undefined && typeof origin.validate !== 'function')
93
94
  throw new Error('config.origin.validate must be a function');
94
- } else throw new Error("config.origin.kind must be 'discover' or 'fixed'");
95
+ } else if (origin?.kind === 'local') {
96
+ if (typeof origin.start !== 'function')
97
+ throw new Error('config.origin.start must be a function');
98
+ if (origin.stop !== undefined && typeof origin.stop !== 'function')
99
+ throw new Error('config.origin.stop must be a function');
100
+ } else throw new Error("config.origin.kind must be 'discover', 'fixed' or 'local'");
95
101
  return config;
96
102
  }
package/src/index.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  export { welcomeDir } from './welcome-dir.mjs';
3
3
  export { validateConfig } from './config.mjs';
4
4
  export { cookieHandoff } from './strategies/cookie-handoff.mjs';
5
+ export { noSignIn } from './strategies/no-sign-in.mjs';
5
6
  export { runCheck } from './check-origin.mjs';
6
7
  export { isAccessHost, passedAccess } from './access.mjs';
7
8
 
package/src/lifecycle.mjs CHANGED
@@ -18,3 +18,25 @@ export function wireCloseBehavior(app, window, platform = process.platform) {
18
18
  if (platform !== 'darwin') app.quit();
19
19
  });
20
20
  }
21
+
22
+ // Runs `stop` once when the app quits, and waits for it before the quit goes on.
23
+ // A stop that has not finished after `timeoutMs` is given up on, so a hung
24
+ // server cannot keep the app from quitting.
25
+ export function stopOnQuit(app, stop, timeoutMs = 5000) {
26
+ let stopped = false;
27
+ app.on('will-quit', (event) => {
28
+ if (stopped) return;
29
+ stopped = true;
30
+ event.preventDefault();
31
+ let timer;
32
+ const timeout = new Promise((resolve) => {
33
+ timer = setTimeout(resolve, timeoutMs);
34
+ });
35
+ Promise.race([Promise.resolve().then(stop), timeout])
36
+ .catch((error) => console.error('stop failed:', error?.message ?? error))
37
+ .finally(() => {
38
+ clearTimeout(timer);
39
+ app.quit();
40
+ });
41
+ });
42
+ }
package/src/resolve.mjs CHANGED
@@ -59,6 +59,32 @@ export function originForAddress(input, { allowLocal = false } = {}) {
59
59
  return `https://${url.host}`;
60
60
  }
61
61
 
62
+ // What an app's local server answered with: http, 127.0.0.1 or localhost, a
63
+ // port, nothing else. Returns the origin or throws.
64
+ export function localOrigin(value) {
65
+ let url;
66
+ try {
67
+ url = new URL(value);
68
+ } catch {
69
+ url = undefined;
70
+ }
71
+ if (
72
+ typeof value !== 'string' ||
73
+ !url ||
74
+ url.protocol !== 'http:' ||
75
+ !isLocal(url.hostname) ||
76
+ !url.port ||
77
+ url.username ||
78
+ url.password ||
79
+ url.pathname !== '/' ||
80
+ url.search ||
81
+ url.hash ||
82
+ value !== url.origin
83
+ )
84
+ throw new Error(`${value} is not a local origin.`);
85
+ return url.origin;
86
+ }
87
+
62
88
  export async function readCapped(response, max) {
63
89
  const reader = response.body?.getReader();
64
90
  if (!reader) throw new Error('Empty response');
package/src/shell.mjs CHANGED
@@ -4,6 +4,7 @@ import { pathToFileURL } from 'node:url';
4
4
  // Main process only; loaded by startShell() so that importing the package
5
5
  // outside Electron (tests, check scripts) never imports 'electron'.
6
6
  import {
7
+ net,
7
8
  BrowserWindow,
8
9
  Menu,
9
10
  WebContentsView,
@@ -16,7 +17,13 @@ import {
16
17
  shell,
17
18
  } from 'electron';
18
19
  import updaterPkg from 'electron-updater';
19
- import { accessNavigation, partitionFetcher, probeAccess, waitForAccess } from './access.mjs';
20
+ import {
21
+ accessNavigation,
22
+ manualRedirectFetcher,
23
+ partitionFetcher,
24
+ probeAccess,
25
+ waitForAccess,
26
+ } from './access.mjs';
20
27
  import {
21
28
  forgetCompany,
22
29
  loadCompany,
@@ -26,9 +33,9 @@ import {
26
33
  } from './company.mjs';
27
34
  import { validateConfig } from './config.mjs';
28
35
  import { findDeepLinkArg, initialDeepLink, resolveDeepLink } from './deep-link.mjs';
29
- import { wireCloseBehavior } from './lifecycle.mjs';
36
+ import { stopOnQuit, wireCloseBehavior } from './lifecycle.mjs';
30
37
  import { menuTemplate } from './menu.mjs';
31
- import { discover, originForAddress, originForEmail } from './resolve.mjs';
38
+ import { discover, localOrigin, originForAddress, originForEmail } from './resolve.mjs';
32
39
  import {
33
40
  allowedNavigation,
34
41
  permissionCheckHandler,
@@ -47,6 +54,7 @@ export function run(config) {
47
54
  validateConfig(config);
48
55
  const { appName, signIn } = config;
49
56
  const fixed = config.origin.kind === 'fixed';
57
+ const isLocal = config.origin.kind === 'local';
50
58
  const welcomeFile = join(welcomeDir, 'welcome.html');
51
59
  const welcomeUrl = pathToFileURL(welcomeFile).href;
52
60
  app.setName(appName);
@@ -102,7 +110,9 @@ export function run(config) {
102
110
  };
103
111
  const companyPartition = (origin) =>
104
112
  lockSession(
105
- session.fromPartition(`${config.partitionPrefix}${Buffer.from(origin).toString('hex')}`),
113
+ session.fromPartition(
114
+ `${config.partitionPrefix}${isLocal ? 'local' : Buffer.from(origin).toString('hex')}`,
115
+ ),
106
116
  origin,
107
117
  );
108
118
  async function clearPartition(partition) {
@@ -131,12 +141,12 @@ export function run(config) {
131
141
  const companyFile = join(profile, 'company.json');
132
142
  const stateFile = join(profile, 'window-state.json');
133
143
  // A session left behind by a crash during sign-in or sign-out is cleared first.
134
- const leftover = loadPendingClear(companyFile, local);
144
+ const leftover = isLocal ? undefined : loadPendingClear(companyFile, local);
135
145
  if (leftover) {
136
146
  await clearPartition(companyPartition(leftover)).catch(() => {});
137
147
  forgetCompany(companyFile);
138
148
  }
139
- let company = loadCompany(companyFile, local);
149
+ let company = isLocal ? undefined : loadCompany(companyFile, local);
140
150
  const savedState = readWindowState(stateFile);
141
151
  const window = new BrowserWindow({
142
152
  ...clampBounds(savedState?.bounds, screen.getAllDisplays()),
@@ -213,7 +223,7 @@ export function run(config) {
213
223
  // The server sent the company window to its own sign-in page: the session is gone.
214
224
  let ending = false;
215
225
  const endSession = () => {
216
- if (ending) return;
226
+ if (ending || isLocal) return;
217
227
  ending = true;
218
228
  void signOut('ended')
219
229
  .catch(() => {})
@@ -228,8 +238,11 @@ export function run(config) {
228
238
  // polls through the partition until the origin answers for itself.
229
239
  const passAccess = async (found) => {
230
240
  if (config.accessProbePath === null) return 'passed';
231
- const { partition, fetcher } = context(found.origin);
232
- const probe = () => probeAccess(fetcher, found.origin, config.accessProbePath);
241
+ const { partition } = context(found.origin);
242
+ const probeFetcher = manualRedirectFetcher((options) =>
243
+ net.request({ ...options, session: partition }),
244
+ );
245
+ const probe = () => probeAccess(probeFetcher, found.origin, config.accessProbePath);
233
246
  // Set before the first await so a second request cannot open a second gate.
234
247
  const mine = { cancelled: false };
235
248
  gate = mine;
@@ -261,7 +274,7 @@ export function run(config) {
261
274
  };
262
275
 
263
276
  const showCompany = async (found) => {
264
- const access = await passAccess(found);
277
+ const access = isLocal ? 'passed' : await passAccess(found);
265
278
  if (access !== 'passed') {
266
279
  await showGateFailure(found, access);
267
280
  return;
@@ -375,6 +388,7 @@ export function run(config) {
375
388
  });
376
389
 
377
390
  const signOut = async (notice) => {
391
+ if (isLocal) return;
378
392
  cancelSignIn();
379
393
  const leaving = company;
380
394
  company = undefined;
@@ -415,6 +429,7 @@ export function run(config) {
415
429
  if (target) void view?.webContents.loadURL(target).catch(() => {});
416
430
  });
417
431
  app.on('before-quit', () => login?.cancel());
432
+ if (isLocal && config.origin.stop) stopOnQuit(app, config.origin.stop);
418
433
  wireCloseBehavior(app, window);
419
434
  window.on('close', () =>
420
435
  writeWindowState(stateFile, {
@@ -437,6 +452,14 @@ export function run(config) {
437
452
  navOptions && initialDeepLink(queuedLinks, process.argv, company.origin, navOptions);
438
453
  queuedLinks.length = 0;
439
454
  if (link) void view?.webContents.loadURL(link).catch(() => {});
455
+ } else if (isLocal) {
456
+ // The person runs the server themselves: no discovery, Access or sign-in.
457
+ // The partition is one fixed name, since the port changes on every start.
458
+ const partition = session.fromPartition(`${config.partitionPrefix}local`);
459
+ const origin = localOrigin(await config.origin.start({ session: partition }));
460
+ lockSession(partition, origin);
461
+ company = { origin, name: appName };
462
+ await showCompany(company);
440
463
  } else if (fixed) await signInFixed().catch(() => showWelcome('failed'));
441
464
  else {
442
465
  await showWelcome();
@@ -0,0 +1,17 @@
1
+ // The sign-in strategy for an app with nobody to sign in: the person runs the
2
+ // server themselves. Meant for the `local` origin; nothing stops another kind
3
+ // from using it, and then no sign-in is ever started.
4
+ export function noSignIn({ scheme }) {
5
+ if (typeof scheme !== 'string' || !/^[a-z][a-z0-9+.-]*$/.test(scheme))
6
+ throw new Error('noSignIn needs a url scheme');
7
+ return {
8
+ scheme,
9
+ createLogin: () => ({
10
+ start: async () => {},
11
+ cancel: () => {},
12
+ handleDeepLink: () => false,
13
+ active: false,
14
+ }),
15
+ logout: async () => {},
16
+ };
17
+ }