@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/src/shell.mjs
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
// Main process only; loaded by startShell() so that importing the package
|
|
5
|
+
// outside Electron (tests, check scripts) never imports 'electron'.
|
|
6
|
+
import {
|
|
7
|
+
BrowserWindow,
|
|
8
|
+
Menu,
|
|
9
|
+
WebContentsView,
|
|
10
|
+
app,
|
|
11
|
+
dialog,
|
|
12
|
+
ipcMain,
|
|
13
|
+
safeStorage,
|
|
14
|
+
screen,
|
|
15
|
+
session,
|
|
16
|
+
shell,
|
|
17
|
+
} from 'electron';
|
|
18
|
+
import updaterPkg from 'electron-updater';
|
|
19
|
+
import { accessNavigation, partitionFetcher, probeAccess, waitForAccess } from './access.mjs';
|
|
20
|
+
import {
|
|
21
|
+
forgetCompany,
|
|
22
|
+
loadCompany,
|
|
23
|
+
loadPendingClear,
|
|
24
|
+
markPendingClear,
|
|
25
|
+
saveCompany,
|
|
26
|
+
} from './company.mjs';
|
|
27
|
+
import { validateConfig } from './config.mjs';
|
|
28
|
+
import { findDeepLinkArg, initialDeepLink, resolveDeepLink } from './deep-link.mjs';
|
|
29
|
+
import { wireCloseBehavior } from './lifecycle.mjs';
|
|
30
|
+
import { menuTemplate } from './menu.mjs';
|
|
31
|
+
import { discover, originForAddress, originForEmail } from './resolve.mjs';
|
|
32
|
+
import {
|
|
33
|
+
allowedNavigation,
|
|
34
|
+
permissionCheckHandler,
|
|
35
|
+
permissionRequestHandler,
|
|
36
|
+
secureContents,
|
|
37
|
+
trustedSender,
|
|
38
|
+
trustedWelcomeSender,
|
|
39
|
+
} from './security.mjs';
|
|
40
|
+
import { configureUpdater, installUpdaterRecovery } from './updater.mjs';
|
|
41
|
+
import { welcomeDir } from './welcome-dir.mjs';
|
|
42
|
+
import { clampBounds, readWindowState, writeWindowState } from './window-state.mjs';
|
|
43
|
+
|
|
44
|
+
const { autoUpdater } = updaterPkg;
|
|
45
|
+
|
|
46
|
+
export function run(config) {
|
|
47
|
+
validateConfig(config);
|
|
48
|
+
const { appName, signIn } = config;
|
|
49
|
+
const fixed = config.origin.kind === 'fixed';
|
|
50
|
+
const welcomeFile = join(welcomeDir, 'welcome.html');
|
|
51
|
+
const welcomeUrl = pathToFileURL(welcomeFile).href;
|
|
52
|
+
app.setName(appName);
|
|
53
|
+
// A packaged build gets its scheme from Info.plist; registering an unpackaged
|
|
54
|
+
// Electron binary would hand <scheme>:// links to a dev checkout.
|
|
55
|
+
if (app.isPackaged) app.setAsDefaultProtocolClient(signIn.scheme);
|
|
56
|
+
const profile = join(app.getPath('appData'), config.dataDirName);
|
|
57
|
+
mkdirSync(profile, { recursive: true, mode: 0o700 });
|
|
58
|
+
app.setPath('userData', profile);
|
|
59
|
+
|
|
60
|
+
// Plain-http localhost origins and the dev environment overrides exist for
|
|
61
|
+
// development and tests; a packaged app has none of them.
|
|
62
|
+
const local = { allowLocal: !app.isPackaged };
|
|
63
|
+
const devEnv = (name) => (app.isPackaged ? undefined : process.env[name] || undefined);
|
|
64
|
+
const fixedOrigin = fixed
|
|
65
|
+
? devEnv(config.devOriginEnv)
|
|
66
|
+
? originForAddress(devEnv(config.devOriginEnv), local)
|
|
67
|
+
: config.origin.origin
|
|
68
|
+
: undefined;
|
|
69
|
+
const navOptions = config.navigationLinks && {
|
|
70
|
+
scheme: signIn.scheme,
|
|
71
|
+
pathPrefix: config.navigationLinks.pathPrefix,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
let login;
|
|
75
|
+
let showWindow = () => {};
|
|
76
|
+
let routeLink;
|
|
77
|
+
// macOS can hand us a link before the app is ready. A sign-in link finds
|
|
78
|
+
// nothing waiting and is ignored; a page link is queued for the window.
|
|
79
|
+
const queuedLinks = [];
|
|
80
|
+
app.on('open-url', (event, url) => {
|
|
81
|
+
event.preventDefault();
|
|
82
|
+
if (login?.handleDeepLink(url)) showWindow();
|
|
83
|
+
else if (navOptions) {
|
|
84
|
+
if (routeLink) routeLink(url);
|
|
85
|
+
else queuedLinks.push(url);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const locked = new WeakSet();
|
|
90
|
+
const lockSession = (partition, origin) => {
|
|
91
|
+
if (locked.has(partition)) return partition;
|
|
92
|
+
locked.add(partition);
|
|
93
|
+
if (origin) {
|
|
94
|
+
partition.setPermissionRequestHandler(permissionRequestHandler(origin, config.permissions));
|
|
95
|
+
partition.setPermissionCheckHandler(permissionCheckHandler(origin, config.permissions));
|
|
96
|
+
} else {
|
|
97
|
+
partition.setPermissionRequestHandler((_contents, _permission, callback) => callback(false));
|
|
98
|
+
partition.setPermissionCheckHandler(() => false);
|
|
99
|
+
}
|
|
100
|
+
partition.on('will-download', (_event, item) => item.cancel());
|
|
101
|
+
return partition;
|
|
102
|
+
};
|
|
103
|
+
const companyPartition = (origin) =>
|
|
104
|
+
lockSession(
|
|
105
|
+
session.fromPartition(`${config.partitionPrefix}${Buffer.from(origin).toString('hex')}`),
|
|
106
|
+
origin,
|
|
107
|
+
);
|
|
108
|
+
async function clearPartition(partition) {
|
|
109
|
+
await partition.clearStorageData();
|
|
110
|
+
await partition.clearCache();
|
|
111
|
+
await partition.cookies.flushStore();
|
|
112
|
+
}
|
|
113
|
+
const context = (origin) => {
|
|
114
|
+
const partition = companyPartition(origin);
|
|
115
|
+
// Every request to the company origin goes through its partition's session.
|
|
116
|
+
return {
|
|
117
|
+
origin,
|
|
118
|
+
partition,
|
|
119
|
+
fetcher: partitionFetcher(partition),
|
|
120
|
+
userDataDir: profile,
|
|
121
|
+
safeStorage,
|
|
122
|
+
openBrowser: (url) => shell.openExternal(url),
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
if (!app.requestSingleInstanceLock()) app.quit();
|
|
127
|
+
else
|
|
128
|
+
void app
|
|
129
|
+
.whenReady()
|
|
130
|
+
.then(async () => {
|
|
131
|
+
const companyFile = join(profile, 'company.json');
|
|
132
|
+
const stateFile = join(profile, 'window-state.json');
|
|
133
|
+
// A session left behind by a crash during sign-in or sign-out is cleared first.
|
|
134
|
+
const leftover = loadPendingClear(companyFile, local);
|
|
135
|
+
if (leftover) {
|
|
136
|
+
await clearPartition(companyPartition(leftover)).catch(() => {});
|
|
137
|
+
forgetCompany(companyFile);
|
|
138
|
+
}
|
|
139
|
+
let company = loadCompany(companyFile, local);
|
|
140
|
+
const savedState = readWindowState(stateFile);
|
|
141
|
+
const window = new BrowserWindow({
|
|
142
|
+
...clampBounds(savedState?.bounds, screen.getAllDisplays()),
|
|
143
|
+
show: false,
|
|
144
|
+
});
|
|
145
|
+
if (savedState?.maximized) window.maximize();
|
|
146
|
+
showWindow = () => {
|
|
147
|
+
window.show();
|
|
148
|
+
window.focus();
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// The window holds one view at a time: the welcome screen, in its own
|
|
152
|
+
// in-memory session, the Access login, or the company's page, in the
|
|
153
|
+
// company's partition.
|
|
154
|
+
let view;
|
|
155
|
+
const fit = () => {
|
|
156
|
+
const [width, height] = window.getContentSize();
|
|
157
|
+
view?.setBounds({ x: 0, y: 0, width, height });
|
|
158
|
+
};
|
|
159
|
+
const setView = (next) => {
|
|
160
|
+
if (view) {
|
|
161
|
+
window.contentView.removeChildView(view);
|
|
162
|
+
view.webContents.close();
|
|
163
|
+
}
|
|
164
|
+
view = next;
|
|
165
|
+
window.contentView.addChildView(view);
|
|
166
|
+
fit();
|
|
167
|
+
};
|
|
168
|
+
window.on('resize', fit);
|
|
169
|
+
const preferences = {
|
|
170
|
+
nodeIntegration: false,
|
|
171
|
+
contextIsolation: true,
|
|
172
|
+
sandbox: true,
|
|
173
|
+
webSecurity: true,
|
|
174
|
+
devTools: !app.isPackaged,
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
let userCancelled = false;
|
|
178
|
+
let gate; // the Access login in progress: { cancelled }
|
|
179
|
+
const cancelSignIn = () => {
|
|
180
|
+
userCancelled = true;
|
|
181
|
+
if (gate) gate.cancelled = true;
|
|
182
|
+
login?.cancel();
|
|
183
|
+
};
|
|
184
|
+
const welcomeConfig = JSON.stringify({
|
|
185
|
+
appName,
|
|
186
|
+
fixed,
|
|
187
|
+
host: fixed ? new URL(fixedOrigin).host : undefined,
|
|
188
|
+
hostPrefix: config.origin.hostPrefix,
|
|
189
|
+
});
|
|
190
|
+
// notice: 'failed', 'ended', 'offline:<name>' or 'waiting:<code>', shown by the page from its fragment.
|
|
191
|
+
const showWelcome = async (notice) => {
|
|
192
|
+
const next = new WebContentsView({
|
|
193
|
+
webPreferences: {
|
|
194
|
+
...preferences,
|
|
195
|
+
session: lockSession(session.fromPartition('shell-welcome')),
|
|
196
|
+
preload: join(welcomeDir, 'welcome-preload.cjs'),
|
|
197
|
+
additionalArguments: [`--shell-welcome=${encodeURIComponent(welcomeConfig)}`],
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
secureContents(next.webContents, (url) => url.split('#')[0] === welcomeUrl);
|
|
201
|
+
// The page's buttons change the fragment; that is its whole API.
|
|
202
|
+
next.webContents.on('did-navigate-in-page', (_event, url, isMainFrame) => {
|
|
203
|
+
if (!isMainFrame) return;
|
|
204
|
+
const hash = new URL(url).hash;
|
|
205
|
+
if (hash.startsWith('#cancel')) cancelSignIn();
|
|
206
|
+
else if (hash.startsWith('#retry') && company) void showCompany(company);
|
|
207
|
+
else if (hash.startsWith('#retry') && fixed) void signInFixed().catch(() => {});
|
|
208
|
+
else if (hash.startsWith('#signin') && fixed) void signInFixed().catch(() => {});
|
|
209
|
+
});
|
|
210
|
+
setView(next);
|
|
211
|
+
await next.webContents.loadFile(welcomeFile, notice ? { hash: notice } : undefined);
|
|
212
|
+
};
|
|
213
|
+
// The server sent the company window to its own sign-in page: the session is gone.
|
|
214
|
+
let ending = false;
|
|
215
|
+
const endSession = () => {
|
|
216
|
+
if (ending) return;
|
|
217
|
+
ending = true;
|
|
218
|
+
void signOut('ended')
|
|
219
|
+
.catch(() => {})
|
|
220
|
+
.finally(() => {
|
|
221
|
+
ending = false;
|
|
222
|
+
});
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// Behind Cloudflare Access. Resolves 'passed', 'offline' (the origin could
|
|
226
|
+
// not be reached), 'cancelled' or 'failed'. When Access wants a login, the
|
|
227
|
+
// window shows the origin so the person passes it there, and the shell
|
|
228
|
+
// polls through the partition until the origin answers for itself.
|
|
229
|
+
const passAccess = async (found) => {
|
|
230
|
+
if (config.accessProbePath === null) return 'passed';
|
|
231
|
+
const { partition, fetcher } = context(found.origin);
|
|
232
|
+
const probe = () => probeAccess(fetcher, found.origin, config.accessProbePath);
|
|
233
|
+
// Set before the first await so a second request cannot open a second gate.
|
|
234
|
+
const mine = { cancelled: false };
|
|
235
|
+
gate = mine;
|
|
236
|
+
try {
|
|
237
|
+
const first = await probe();
|
|
238
|
+
if (first !== 'access') return first;
|
|
239
|
+
const next = new WebContentsView({
|
|
240
|
+
webPreferences: { ...preferences, session: partition },
|
|
241
|
+
});
|
|
242
|
+
secureContents(next.webContents, (url) => accessNavigation(url, found.origin));
|
|
243
|
+
setView(next);
|
|
244
|
+
next.webContents.loadURL(found.origin).catch(() => {});
|
|
245
|
+
try {
|
|
246
|
+
await waitForAccess({ probe, isCancelled: () => mine.cancelled });
|
|
247
|
+
return 'passed';
|
|
248
|
+
} catch {
|
|
249
|
+
// Pages visited while gating must leave nothing behind.
|
|
250
|
+
if (mine.cancelled) await clearPartition(partition).catch(() => {});
|
|
251
|
+
return mine.cancelled ? 'cancelled' : 'failed';
|
|
252
|
+
}
|
|
253
|
+
} finally {
|
|
254
|
+
if (gate === mine) gate = undefined;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
// What to show when Access was not passed.
|
|
258
|
+
const showGateFailure = async (found, result) => {
|
|
259
|
+
if (result === 'offline') await showWelcome(`offline:${encodeURIComponent(found.name)}`);
|
|
260
|
+
else await showWelcome(result === 'cancelled' ? undefined : 'failed');
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const showCompany = async (found) => {
|
|
264
|
+
const access = await passAccess(found);
|
|
265
|
+
if (access !== 'passed') {
|
|
266
|
+
await showGateFailure(found, access);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const next = new WebContentsView({
|
|
270
|
+
webPreferences: {
|
|
271
|
+
...preferences,
|
|
272
|
+
session: companyPartition(found.origin),
|
|
273
|
+
...(signIn.preload ? { preload: signIn.preload } : {}),
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
secureContents(
|
|
277
|
+
next.webContents,
|
|
278
|
+
(url) => allowedNavigation(url, found.origin),
|
|
279
|
+
(url) => {
|
|
280
|
+
if (view === next && new URL(url).origin === found.origin) endSession();
|
|
281
|
+
},
|
|
282
|
+
);
|
|
283
|
+
setView(next);
|
|
284
|
+
try {
|
|
285
|
+
await next.webContents.loadURL(`${found.origin}${config.landingPath}`);
|
|
286
|
+
} catch (error) {
|
|
287
|
+
// A blocked redirect is the session ending, handled above. Anything else is
|
|
288
|
+
// the network: keep the company and the session, and offer to try again.
|
|
289
|
+
if (view === next && !ending && !/ERR_ABORTED/.test(error.message))
|
|
290
|
+
await showWelcome(`offline:${encodeURIComponent(found.name)}`);
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
let signedOut = false; // the next sign-in shows the issuer's account chooser
|
|
295
|
+
const startSignIn = (found, email) => {
|
|
296
|
+
const ctx = context(found.origin);
|
|
297
|
+
const mine = signIn.createLogin(ctx);
|
|
298
|
+
login = mine;
|
|
299
|
+
const selectAccount = signedOut;
|
|
300
|
+
userCancelled = false;
|
|
301
|
+
signedOut = false;
|
|
302
|
+
// Cookies may land in the partition before the company is saved.
|
|
303
|
+
markPendingClear(companyFile, found.origin);
|
|
304
|
+
mine
|
|
305
|
+
.start({ email, selectAccount })
|
|
306
|
+
.then(async () => {
|
|
307
|
+
company = found;
|
|
308
|
+
saveCompany(companyFile, company, local);
|
|
309
|
+
await showCompany(found);
|
|
310
|
+
})
|
|
311
|
+
.catch(async () => {
|
|
312
|
+
// A cancelled sign-in may have been replaced by a newer one; leave that alone.
|
|
313
|
+
if (login !== mine) return;
|
|
314
|
+
await clearPartition(ctx.partition).catch(() => {});
|
|
315
|
+
forgetCompany(companyFile);
|
|
316
|
+
await showWelcome(userCancelled ? undefined : 'failed');
|
|
317
|
+
})
|
|
318
|
+
.catch(() => {});
|
|
319
|
+
};
|
|
320
|
+
// Fixed origin: no email screen. Pass Access if needed, open the browser,
|
|
321
|
+
// and show the pairing code.
|
|
322
|
+
const signInFixed = async () => {
|
|
323
|
+
if (login?.active || gate) return;
|
|
324
|
+
userCancelled = false;
|
|
325
|
+
const found = { origin: fixedOrigin, name: appName };
|
|
326
|
+
const access = await passAccess(found);
|
|
327
|
+
if (access !== 'passed') {
|
|
328
|
+
await showGateFailure(found, access);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (login?.active) return;
|
|
332
|
+
startSignIn(found, undefined);
|
|
333
|
+
await showWelcome(login.code ? `waiting:${login.code}` : undefined);
|
|
334
|
+
};
|
|
335
|
+
// Without `start` it only checks that the app is there.
|
|
336
|
+
const resolveAndSignIn = async (origin, email, start = true) => {
|
|
337
|
+
if (login?.active) throw new Error('Sign-in already active');
|
|
338
|
+
let found;
|
|
339
|
+
const { wellKnownPath, validate } = config.origin;
|
|
340
|
+
try {
|
|
341
|
+
found = await discover(origin, fetch, { wellKnownPath, validate, ...local });
|
|
342
|
+
} catch {
|
|
343
|
+
return { notFound: true };
|
|
344
|
+
}
|
|
345
|
+
const host = new URL(found.origin).host;
|
|
346
|
+
if (!start) return { name: found.name, host };
|
|
347
|
+
if (login?.active) throw new Error('Sign-in already active');
|
|
348
|
+
startSignIn(found, email);
|
|
349
|
+
return { name: found.name, host, code: login.code };
|
|
350
|
+
};
|
|
351
|
+
if (!fixed) {
|
|
352
|
+
const welcomeCall = (channel, runCall) =>
|
|
353
|
+
ipcMain.handle(channel, (event, input, start) => {
|
|
354
|
+
if (!view || !trustedWelcomeSender(event, view.webContents, welcomeUrl))
|
|
355
|
+
throw new Error('Untrusted request');
|
|
356
|
+
if (typeof input !== 'string' || input.length > 320) throw new Error('Invalid input');
|
|
357
|
+
if (typeof start !== 'boolean') throw new Error('Invalid input');
|
|
358
|
+
return runCall(input.trim(), start);
|
|
359
|
+
});
|
|
360
|
+
welcomeCall('shell:resolve-email', (email, start) =>
|
|
361
|
+
resolveAndSignIn(originForEmail(email, config.origin), email.toLowerCase(), start),
|
|
362
|
+
);
|
|
363
|
+
welcomeCall('shell:resolve-address', (address, start) =>
|
|
364
|
+
resolveAndSignIn(originForAddress(address, local), undefined, start),
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
// A strategy's own page bridge: `signIn.handlers` maps a channel to
|
|
368
|
+
// (context, ...args). Each is reachable only from the company page's top frame.
|
|
369
|
+
for (const [channel, handler] of Object.entries(signIn.handlers ?? {}))
|
|
370
|
+
ipcMain.handle(channel, (event, ...args) => {
|
|
371
|
+
const origin = company?.origin ?? fixedOrigin;
|
|
372
|
+
if (!view || !origin || !trustedSender(event, view.webContents, origin))
|
|
373
|
+
throw new Error('Untrusted request');
|
|
374
|
+
return handler(context(origin), ...args);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const signOut = async (notice) => {
|
|
378
|
+
cancelSignIn();
|
|
379
|
+
const leaving = company;
|
|
380
|
+
company = undefined;
|
|
381
|
+
signedOut = true;
|
|
382
|
+
if (leaving) {
|
|
383
|
+
const ctx = context(leaving.origin);
|
|
384
|
+
// Best effort (skipped when the server already ended the session), whatever the answer.
|
|
385
|
+
if (notice !== 'ended') await signIn.logout(ctx).catch(() => {});
|
|
386
|
+
// If we die before the partition is clear, the next start finishes the job.
|
|
387
|
+
markPendingClear(companyFile, leaving.origin);
|
|
388
|
+
// Destroy the document before clearing its storage; an old document cannot write it back.
|
|
389
|
+
await showWelcome(notice);
|
|
390
|
+
await clearPartition(ctx.partition);
|
|
391
|
+
} else await showWelcome(notice);
|
|
392
|
+
forgetCompany(companyFile);
|
|
393
|
+
};
|
|
394
|
+
Menu.setApplicationMenu(
|
|
395
|
+
Menu.buildFromTemplate(
|
|
396
|
+
menuTemplate({
|
|
397
|
+
appName,
|
|
398
|
+
packaged: app.isPackaged,
|
|
399
|
+
onCancel: cancelSignIn,
|
|
400
|
+
onSignOut: () => {
|
|
401
|
+
void signOut().catch(() =>
|
|
402
|
+
dialog.showErrorBox(
|
|
403
|
+
'Sign-out',
|
|
404
|
+
'Sign-out could not be completed. Quit the app and try again.',
|
|
405
|
+
),
|
|
406
|
+
);
|
|
407
|
+
},
|
|
408
|
+
}),
|
|
409
|
+
),
|
|
410
|
+
);
|
|
411
|
+
app.on('second-instance', (_event, argv) => {
|
|
412
|
+
showWindow();
|
|
413
|
+
for (const arg of argv) login?.handleDeepLink(arg);
|
|
414
|
+
const target = navOptions && company && findDeepLinkArg(argv, company.origin, navOptions);
|
|
415
|
+
if (target) void view?.webContents.loadURL(target).catch(() => {});
|
|
416
|
+
});
|
|
417
|
+
app.on('before-quit', () => login?.cancel());
|
|
418
|
+
wireCloseBehavior(app, window);
|
|
419
|
+
window.on('close', () =>
|
|
420
|
+
writeWindowState(stateFile, {
|
|
421
|
+
bounds: window.isMaximized() ? window.getNormalBounds() : window.getBounds(),
|
|
422
|
+
maximized: window.isMaximized(),
|
|
423
|
+
}),
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
if (navOptions)
|
|
427
|
+
routeLink = (url) => {
|
|
428
|
+
const target = company && resolveDeepLink(url, company.origin, navOptions);
|
|
429
|
+
if (!target) return;
|
|
430
|
+
showWindow();
|
|
431
|
+
void view?.webContents.loadURL(target).catch(() => {});
|
|
432
|
+
};
|
|
433
|
+
if (company) {
|
|
434
|
+
await showCompany(company);
|
|
435
|
+
// A page link that arrived before the window existed or as a launch argument.
|
|
436
|
+
const link =
|
|
437
|
+
navOptions && initialDeepLink(queuedLinks, process.argv, company.origin, navOptions);
|
|
438
|
+
queuedLinks.length = 0;
|
|
439
|
+
if (link) void view?.webContents.loadURL(link).catch(() => {});
|
|
440
|
+
} else if (fixed) await signInFixed().catch(() => showWelcome('failed'));
|
|
441
|
+
else {
|
|
442
|
+
await showWelcome();
|
|
443
|
+
// Development only: go straight to one origin instead of asking for an email.
|
|
444
|
+
const dev = devEnv(config.devOriginEnv);
|
|
445
|
+
if (dev) await resolveAndSignIn(originForAddress(dev, local)).catch(() => {});
|
|
446
|
+
}
|
|
447
|
+
window.show();
|
|
448
|
+
// A failed or skipped check must never block startup or show a dialog.
|
|
449
|
+
// Unpackaged, the dev feed variable points the updater at a local static
|
|
450
|
+
// server. Packaged, the feed is config.feedUrl, or the app-update.yml
|
|
451
|
+
// electron-builder baked in when that is null; nothing in the environment
|
|
452
|
+
// can change it (Agora's packaged env override is not carried over).
|
|
453
|
+
installUpdaterRecovery(autoUpdater, {
|
|
454
|
+
onError: (error) =>
|
|
455
|
+
console.error(`${appName} update check failed:`, error?.message ?? error),
|
|
456
|
+
});
|
|
457
|
+
const devFeed = devEnv(config.devFeedEnv);
|
|
458
|
+
const feedUrl = devFeed ?? config.feedUrl ?? undefined;
|
|
459
|
+
const channel = config.updateChannel ?? undefined;
|
|
460
|
+
if (feedUrl)
|
|
461
|
+
configureUpdater(autoUpdater, feedUrl, {
|
|
462
|
+
forceDevUpdateConfig: !app.isPackaged,
|
|
463
|
+
channel,
|
|
464
|
+
});
|
|
465
|
+
if (app.isPackaged || devFeed) void autoUpdater.checkForUpdates().catch(() => {});
|
|
466
|
+
})
|
|
467
|
+
.catch(() => {
|
|
468
|
+
dialog.showErrorBox(`${appName} could not start`, 'Check your connection, then try again.');
|
|
469
|
+
app.quit();
|
|
470
|
+
});
|
|
471
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// The sign-in strategy for apps whose server speaks the cookie handoff
|
|
2
|
+
// (README.md, "The handoff contract"): Boule and Archon. Agora's bearer-token
|
|
3
|
+
// strategy lives in Agora and has the same shape.
|
|
4
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
5
|
+
import { DesktopLogin } from '../login.mjs';
|
|
6
|
+
|
|
7
|
+
// Preflight for one deployed origin. Walks the start of the handoff, as far as
|
|
8
|
+
// the issuer's account chooser, and names the one piece of deployment
|
|
9
|
+
// configuration that is wrong. It signs nobody in and needs no credential.
|
|
10
|
+
// A sign-in it starts is never redeemed and expires on the server in minutes.
|
|
11
|
+
async function check(origin, { fetcher = fetch } = {}) {
|
|
12
|
+
const problems = [];
|
|
13
|
+
const fail = (what, fix) => {
|
|
14
|
+
problems.push({ what, fix });
|
|
15
|
+
throw new Error(what);
|
|
16
|
+
};
|
|
17
|
+
const get = (url, init) =>
|
|
18
|
+
fetcher(url, { redirect: 'manual', signal: AbortSignal.timeout(15000), ...init });
|
|
19
|
+
try {
|
|
20
|
+
const challenge = createHash('sha256').update(randomBytes(32)).digest('base64url');
|
|
21
|
+
const started = await get(`${origin}/auth/desktop/start?challenge=${challenge}`);
|
|
22
|
+
const authorize = started.headers.get('location');
|
|
23
|
+
if (![302, 303].includes(started.status) || !authorize)
|
|
24
|
+
fail(
|
|
25
|
+
`${origin}/auth/desktop/start answered ${started.status}`,
|
|
26
|
+
'Deploy a release with the desktop sign-in routes and read its logs.',
|
|
27
|
+
);
|
|
28
|
+
// The issuer refuses an unregistered redirect_uri with 400 rather than a
|
|
29
|
+
// login page, so this is the only check that catches a missing registration.
|
|
30
|
+
const callback = new URL(authorize).searchParams.get('redirect_uri');
|
|
31
|
+
if (callback !== `${origin}/auth/desktop/callback`)
|
|
32
|
+
fail(
|
|
33
|
+
`The issuer request asks to return to ${callback}`,
|
|
34
|
+
`Expected ${origin}/auth/desktop/callback.`,
|
|
35
|
+
);
|
|
36
|
+
const chooser = await get(authorize);
|
|
37
|
+
if (chooser.status !== 302)
|
|
38
|
+
fail(
|
|
39
|
+
`The issuer answered ${chooser.status} for ${callback}`,
|
|
40
|
+
`Register ${callback} as a redirect URI on issuer application ${new URL(authorize).searchParams.get('client_id')}.`,
|
|
41
|
+
);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (!problems.length) problems.push({ what: error.message, fix: 'Unexpected failure.' });
|
|
44
|
+
}
|
|
45
|
+
return problems;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function cookieHandoff({ scheme }) {
|
|
49
|
+
if (typeof scheme !== 'string' || !/^[a-z][a-z0-9+.-]*$/.test(scheme))
|
|
50
|
+
throw new Error('cookieHandoff needs a url scheme');
|
|
51
|
+
return {
|
|
52
|
+
scheme,
|
|
53
|
+
createLogin: ({ origin, partition, fetcher, openBrowser }) => {
|
|
54
|
+
const login = new DesktopLogin({ scheme, origin, partition, fetcher, openBrowser });
|
|
55
|
+
// DesktopLogin takes (email, selectAccount); the strategy interface takes an object.
|
|
56
|
+
return {
|
|
57
|
+
start: ({ email, selectAccount }) => login.start(email, selectAccount),
|
|
58
|
+
cancel: () => login.cancel(),
|
|
59
|
+
handleDeepLink: (value) => login.handleDeepLink(value),
|
|
60
|
+
get active() {
|
|
61
|
+
return login.active;
|
|
62
|
+
},
|
|
63
|
+
get code() {
|
|
64
|
+
return login.code;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
// Best effort: the server answers 303 to the issuer, which is never followed.
|
|
69
|
+
logout: async ({ origin, fetcher }) => {
|
|
70
|
+
await fetcher(`${origin}/auth/logout`, {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
redirect: 'manual',
|
|
73
|
+
headers: { origin },
|
|
74
|
+
signal: AbortSignal.timeout(10000),
|
|
75
|
+
}).catch(() => {});
|
|
76
|
+
},
|
|
77
|
+
check,
|
|
78
|
+
};
|
|
79
|
+
}
|
package/src/updater.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Main process only. Wraps electron-updater's generic-provider feed so the
|
|
2
|
+
// app can update itself from a static file host: a public Cloudflare R2
|
|
3
|
+
// bucket in production, a local static server in tests. A no-op when no
|
|
4
|
+
// feed URL is configured, since the dev feed variable stays unset until the
|
|
5
|
+
// user's R2 bucket exists (see README.md).
|
|
6
|
+
//
|
|
7
|
+
// `channel` is electron-updater's own channel mechanism (`autoUpdater.channel`),
|
|
8
|
+
// not a second feed URL: on macOS it picks `<channel>-mac.yml` instead of
|
|
9
|
+
// `latest-mac.yml` off the same bucket, so a beta build never touches the
|
|
10
|
+
// file stable installs already read. Unset, the channel is never assigned
|
|
11
|
+
// and the provider falls back to its own default ("latest"). electron-updater
|
|
12
|
+
// refuses to reset `channel` back to a falsy value once a build has set it
|
|
13
|
+
// to a real one (`ERR_UPDATER_INVALID_CHANNEL`), so this never assigns a
|
|
14
|
+
// falsy channel -- a caller that wants the default channel just omits the
|
|
15
|
+
// option, on every call, for the lifetime of the process.
|
|
16
|
+
//
|
|
17
|
+
// electron-updater's own `channel` setter also flips `allowDowngrade` to
|
|
18
|
+
// `true` unconditionally (AppUpdater.js), so a channel build could install
|
|
19
|
+
// a release with a *lower* version number than the one already running.
|
|
20
|
+
// No channel this app ships today should do that silently, so
|
|
21
|
+
// `allowDowngrade` is always assigned here too, after `channel`, from this
|
|
22
|
+
// function's own option -- overriding electron-updater's automatic flip
|
|
23
|
+
// rather than inheriting it. See README.md, "Channel separation".
|
|
24
|
+
export function configureUpdater(
|
|
25
|
+
autoUpdater,
|
|
26
|
+
feedUrl,
|
|
27
|
+
{ forceDevUpdateConfig = false, channel, allowDowngrade = false } = {},
|
|
28
|
+
) {
|
|
29
|
+
if (!feedUrl) return false;
|
|
30
|
+
autoUpdater.forceDevUpdateConfig = forceDevUpdateConfig;
|
|
31
|
+
if (channel) autoUpdater.channel = channel;
|
|
32
|
+
autoUpdater.allowDowngrade = allowDowngrade;
|
|
33
|
+
autoUpdater.setFeedURL({ provider: 'generic', url: feedUrl });
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Main process only. Gives a failed check or download somewhere to go other
|
|
38
|
+
// than silently vanishing. electron-updater's AppUpdater is an EventEmitter
|
|
39
|
+
// and reports every check/download failure on 'error' in addition to
|
|
40
|
+
// rejecting the promise the caller already awaits -- main.mjs's startup
|
|
41
|
+
// check already discards that rejection (`.catch(() => {})`, so a failed or
|
|
42
|
+
// skipped check never blocks startup or shows a dialog), which left 'error'
|
|
43
|
+
// as the only place left to observe why a check failed. electron-updater
|
|
44
|
+
// itself resets its own in-flight-check/download state on failure
|
|
45
|
+
// (AppUpdater.js nulls both `checkForUpdatesPromise` and `downloadPromise`
|
|
46
|
+
// once their promise settles), so recovery -- a later check or download
|
|
47
|
+
// succeeding right after an earlier one failed -- is already a property of
|
|
48
|
+
// the library; this only adds a place to see that a failure happened.
|
|
49
|
+
// Idempotent per autoUpdater instance, so calling it again (a re-check, a
|
|
50
|
+
// dev reload) never attaches a second listener and never double-reports
|
|
51
|
+
// the same failure.
|
|
52
|
+
const recoveryInstalled = new WeakSet();
|
|
53
|
+
export function installUpdaterRecovery(autoUpdater, { onError } = {}) {
|
|
54
|
+
if (recoveryInstalled.has(autoUpdater)) return false;
|
|
55
|
+
recoveryInstalled.add(autoUpdater);
|
|
56
|
+
autoUpdater.on('error', (error, message) => {
|
|
57
|
+
onError?.(error, message);
|
|
58
|
+
});
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
|
|
3
|
+
// The welcome page, its preload, styles, sticker and fonts. electron-builder
|
|
4
|
+
// packs it with the package; this is where the shell loads it from.
|
|
5
|
+
export const welcomeDir = fileURLToPath(new URL('../welcome/', import.meta.url)).replace(/\/$/, '');
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Main process only. Persists window bounds and maximized state as small
|
|
2
|
+
// JSON in userData, and clamps stored bounds to whatever displays are
|
|
3
|
+
// currently connected so a disconnected monitor never strands the window.
|
|
4
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_BOUNDS = { width: 1200, height: 800 };
|
|
7
|
+
// A stored position only counts as visible when a meaningful slice of it
|
|
8
|
+
// (not just a stray pixel) still lands on a connected display.
|
|
9
|
+
const MIN_VISIBLE_WIDTH = 100;
|
|
10
|
+
const MIN_VISIBLE_HEIGHT = 50;
|
|
11
|
+
|
|
12
|
+
export function clampBounds(bounds, displays, fallback = DEFAULT_BOUNDS) {
|
|
13
|
+
if (!bounds || typeof bounds !== 'object') return fallback;
|
|
14
|
+
const { x, y, width, height } = bounds;
|
|
15
|
+
if (![x, y, width, height].every(Number.isFinite) || width < 200 || height < 200) return fallback;
|
|
16
|
+
const visible = (displays ?? []).some(({ bounds: d }) => {
|
|
17
|
+
const overlapX = Math.min(x + width, d.x + d.width) - Math.max(x, d.x);
|
|
18
|
+
const overlapY = Math.min(y + height, d.y + d.height) - Math.max(y, d.y);
|
|
19
|
+
return overlapX >= MIN_VISIBLE_WIDTH && overlapY >= MIN_VISIBLE_HEIGHT;
|
|
20
|
+
});
|
|
21
|
+
return visible ? { x, y, width, height } : fallback;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function readWindowState(path) {
|
|
25
|
+
try {
|
|
26
|
+
const state = JSON.parse(readFileSync(path, 'utf8'));
|
|
27
|
+
return state && typeof state === 'object' ? state : undefined;
|
|
28
|
+
} catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function writeWindowState(path, state) {
|
|
34
|
+
try {
|
|
35
|
+
writeFileSync(path, JSON.stringify(state));
|
|
36
|
+
} catch {
|
|
37
|
+
/* Best-effort; a stale window position is not worth crashing over. */
|
|
38
|
+
}
|
|
39
|
+
}
|