@wtfalch/desktop 0.1.2 → 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,6 +92,36 @@ 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
@@ -127,7 +158,7 @@ sign-in passes Access again.
127
158
  ## Sign-in strategies
128
159
 
129
160
  `config.signIn` is the one place an app's sign-in lives. The package ships
130
- `cookieHandoff({ scheme })`. Any object of this shape works:
161
+ `cookieHandoff({ scheme })` and `noSignIn({ scheme })` (for a `local` origin). Any object of this shape works:
131
162
 
132
163
  ```ts
133
164
  interface SignInStrategy {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/desktop",
3
- "version": "0.1.2",
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.",
@@ -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
@@ -33,9 +33,9 @@ import {
33
33
  } from './company.mjs';
34
34
  import { validateConfig } from './config.mjs';
35
35
  import { findDeepLinkArg, initialDeepLink, resolveDeepLink } from './deep-link.mjs';
36
- import { wireCloseBehavior } from './lifecycle.mjs';
36
+ import { stopOnQuit, wireCloseBehavior } from './lifecycle.mjs';
37
37
  import { menuTemplate } from './menu.mjs';
38
- import { discover, originForAddress, originForEmail } from './resolve.mjs';
38
+ import { discover, localOrigin, originForAddress, originForEmail } from './resolve.mjs';
39
39
  import {
40
40
  allowedNavigation,
41
41
  permissionCheckHandler,
@@ -54,6 +54,7 @@ export function run(config) {
54
54
  validateConfig(config);
55
55
  const { appName, signIn } = config;
56
56
  const fixed = config.origin.kind === 'fixed';
57
+ const isLocal = config.origin.kind === 'local';
57
58
  const welcomeFile = join(welcomeDir, 'welcome.html');
58
59
  const welcomeUrl = pathToFileURL(welcomeFile).href;
59
60
  app.setName(appName);
@@ -109,7 +110,9 @@ export function run(config) {
109
110
  };
110
111
  const companyPartition = (origin) =>
111
112
  lockSession(
112
- session.fromPartition(`${config.partitionPrefix}${Buffer.from(origin).toString('hex')}`),
113
+ session.fromPartition(
114
+ `${config.partitionPrefix}${isLocal ? 'local' : Buffer.from(origin).toString('hex')}`,
115
+ ),
113
116
  origin,
114
117
  );
115
118
  async function clearPartition(partition) {
@@ -138,12 +141,12 @@ export function run(config) {
138
141
  const companyFile = join(profile, 'company.json');
139
142
  const stateFile = join(profile, 'window-state.json');
140
143
  // A session left behind by a crash during sign-in or sign-out is cleared first.
141
- const leftover = loadPendingClear(companyFile, local);
144
+ const leftover = isLocal ? undefined : loadPendingClear(companyFile, local);
142
145
  if (leftover) {
143
146
  await clearPartition(companyPartition(leftover)).catch(() => {});
144
147
  forgetCompany(companyFile);
145
148
  }
146
- let company = loadCompany(companyFile, local);
149
+ let company = isLocal ? undefined : loadCompany(companyFile, local);
147
150
  const savedState = readWindowState(stateFile);
148
151
  const window = new BrowserWindow({
149
152
  ...clampBounds(savedState?.bounds, screen.getAllDisplays()),
@@ -220,7 +223,7 @@ export function run(config) {
220
223
  // The server sent the company window to its own sign-in page: the session is gone.
221
224
  let ending = false;
222
225
  const endSession = () => {
223
- if (ending) return;
226
+ if (ending || isLocal) return;
224
227
  ending = true;
225
228
  void signOut('ended')
226
229
  .catch(() => {})
@@ -271,7 +274,7 @@ export function run(config) {
271
274
  };
272
275
 
273
276
  const showCompany = async (found) => {
274
- const access = await passAccess(found);
277
+ const access = isLocal ? 'passed' : await passAccess(found);
275
278
  if (access !== 'passed') {
276
279
  await showGateFailure(found, access);
277
280
  return;
@@ -385,6 +388,7 @@ export function run(config) {
385
388
  });
386
389
 
387
390
  const signOut = async (notice) => {
391
+ if (isLocal) return;
388
392
  cancelSignIn();
389
393
  const leaving = company;
390
394
  company = undefined;
@@ -425,6 +429,7 @@ export function run(config) {
425
429
  if (target) void view?.webContents.loadURL(target).catch(() => {});
426
430
  });
427
431
  app.on('before-quit', () => login?.cancel());
432
+ if (isLocal && config.origin.stop) stopOnQuit(app, config.origin.stop);
428
433
  wireCloseBehavior(app, window);
429
434
  window.on('close', () =>
430
435
  writeWindowState(stateFile, {
@@ -447,6 +452,14 @@ export function run(config) {
447
452
  navOptions && initialDeepLink(queuedLinks, process.argv, company.origin, navOptions);
448
453
  queuedLinks.length = 0;
449
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);
450
463
  } else if (fixed) await signInFixed().catch(() => showWelcome('failed'));
451
464
  else {
452
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
+ }