@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 +42 -8
- package/package.json +1 -1
- package/src/access.mjs +34 -0
- package/src/check-origin.mjs +4 -0
- package/src/config.mjs +8 -2
- package/src/index.mjs +1 -0
- package/src/lifecycle.mjs +22 -0
- package/src/resolve.mjs +26 -0
- package/src/shell.mjs +33 -10
- package/src/strategies/no-sign-in.mjs +17 -0
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
|
|
101
|
-
|
|
102
|
-
`credentials: 'include'` (`partitionFetcher`).
|
|
103
|
-
|
|
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 (
|
|
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 })
|
|
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
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).
|
package/src/check-origin.mjs
CHANGED
|
@@ -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 }
|
|
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
|
|
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 {
|
|
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(
|
|
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
|
|
232
|
-
const
|
|
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
|
+
}
|