@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 +33 -2
- package/package.json +1 -1
- 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 +20 -7
- 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,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 })
|
|
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
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
|
@@ -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(
|
|
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
|
+
}
|