Skip to content
Source
Sign inSign up
Open History to see the last commit

DesktopStartupRecovery.ts

376 lines15.2 KB

1import type { DesktopUpdateState } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Option from "effect/Option";
5import * as Ref from "effect/Ref";
6import type * as Scope from "effect/Scope";
7import * as Stream from "effect/Stream";
8import * as SubscriptionRef from "effect/SubscriptionRef";
9import type * as Electron from "electron";
10​
11import * as ElectronApp from "../electron/ElectronApp.ts";
12import * as ElectronShell from "../electron/ElectronShell.ts";
13import * as ElectronTheme from "../electron/ElectronTheme.ts";
14import * as ElectronWindow from "../electron/ElectronWindow.ts";
15import * as DesktopUpdates from "../updates/DesktopUpdates.ts";
16import type { BackendStartupFailure } from "../backend/DesktopBackendManager.ts";
17import * as DesktopEnvironment from "./DesktopEnvironment.ts";
18import * as DesktopObservability from "./DesktopObservability.ts";
19import * as DesktopState from "./DesktopState.ts";
20​
21const RELEASES_URL = "https://github.com/pingdotgg/t3code/releases";
22const LAST_STARTED_VERSION_FILE = "desktop-last-started-version";
23// Buttons are plain links to this host; will-navigate cancels them and runs the action.
24const ACTION_ORIGIN = "https://startup-recovery.invalid";
25​
26const { logInfo, logWarning } = DesktopObservability.makeComponentLogger(
27 "desktop-startup-recovery",
28);
29​
30export type StartupRecoveryOffer =
31 | { readonly _tag: "Checking" }
32 | { readonly _tag: "Update"; readonly version: string }
33 | { readonly _tag: "Downloading"; readonly version: string; readonly percent: number | null }
34 | { readonly _tag: "Reinstall"; readonly version: string }
35 | { readonly _tag: "Releases" }
36 | { readonly _tag: "ManagedPolicy" };
37​
38const STARTUP_RECOVERY_ACTIONS = ["update", "reinstall", "releases", "copy", "quit"] as const;
39type StartupRecoveryAction = (typeof STARTUP_RECOVERY_ACTIONS)[number];
40​
41/** Prefer a newer build, then the last build that started on this machine, then the releases page. */
42export function resolveStartupRecoveryOffer(
43 update: DesktopUpdateState,
44 lastStartedVersion: string | null,
45 managedPolicy = false,
46): StartupRecoveryOffer {
47 if (update.status === "downloading" && update.availableVersion !== null) {
48 return {
49 _tag: "Downloading",
50 version: update.availableVersion,
51 percent: update.downloadPercent,
52 };
53 }
54 const updateVersion =
55 update.downloadedVersion ?? (update.status === "available" ? update.availableVersion : null);
56 // A version that already failed to apply here would loop through install and relaunch.
57 if (updateVersion !== null && updateVersion !== update.notAppliedVersion) {
58 return { _tag: "Update", version: updateVersion };
59 }
60 // A newer release can know a key this one refuses; an older one or the releases page cannot help.
61 if (managedPolicy) return { _tag: "ManagedPolicy" };
62 if (update.enabled && (update.status === "idle" || update.status === "checking")) {
63 return { _tag: "Checking" };
64 }
65 if (lastStartedVersion !== null && lastStartedVersion !== update.currentVersion) {
66 return { _tag: "Reinstall", version: lastStartedVersion };
67 }
68 return { _tag: "Releases" };
70​
71const escapeHtml = (value: string) =>
72 value.replace(
73 /[&<>"']/g,
74 (char) =>
75 ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char] ?? char,
76 );
77​
78const actionLink = (action: StartupRecoveryAction, label: string, primary = false) =>
79 `<a class="button${primary ? " primary" : ""}" href="${ACTION_ORIGIN}/${action}">${escapeHtml(label)}</a>`;
80​
81function describeOffer(offer: StartupRecoveryOffer): { text: string; action: string } {
82 switch (offer._tag) {
83 case "Checking":
84 return { text: "Checking for a newer version…", action: "" };
85 case "Update":
86 return {
87 text: `Version ${offer.version} is available. It can contain a fix.`,
88 action: actionLink("update", `Update to ${offer.version}`, true),
89 };
90 case "Downloading":
91 return {
92 text: `Downloading ${offer.version}${offer.percent === null ? "" : ` (${Math.floor(offer.percent / 10) * 10}%)`}. T3 Code restarts when the download is done.`,
93 action: "",
94 };
95 case "Reinstall":
96 return {
97 text: `Version ${offer.version} started correctly on this computer. Download it and install it to go back.`,
98 action: actionLink("reinstall", `Download ${offer.version}`, true),
99 };
100 case "Releases":
101 return {
102 text: "Get a different version from the releases page.",
103 action: actionLink("releases", "Open releases", true),
104 };
105 case "ManagedPolicy":
106 return {
107 text: "Your organization's managed policy stopped T3 Code from starting. Send the details below to your IT administrator.",
108 action: "",
109 };
110 }
112​
113function buildStartupRecoveryHtml(input: {
114 readonly appVersion: string;
115 readonly failure: BackendStartupFailure;
116 readonly offer: StartupRecoveryOffer;
117 readonly logDir: string;
118}): string {
119 const offer = describeOffer(input.offer);
120 const output = input.failure.output.trim();
121 return `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'"><title>T3 Code could not start</title><style>
122:root{color-scheme:light dark;--bg:#fff;--fg:#111;--muted:#666;--line:#ddd;--code:#f4f4f4}
123@media (prefers-color-scheme:dark){:root{--bg:#000;--fg:#fff;--muted:#999;--line:#333;--code:#111}}
124html,body{margin:0;height:100%;background:var(--bg);color:var(--fg)}
125body{font:13px/1.5 system-ui,-apple-system,'Segoe UI',sans-serif;padding:28px;box-sizing:border-box;display:flex;flex-direction:column;gap:14px}
126h1{font-size:18px;margin:0}p{margin:0}.muted{color:var(--muted)}
127.actions{display:flex;gap:8px;flex-wrap:wrap}
128.button{color:var(--fg);text-decoration:none;border:1px solid var(--line);border-radius:6px;padding:6px 12px}
129.button.primary{background:var(--fg);color:var(--bg);border-color:var(--fg)}
130pre{flex:1;min-height:60px;margin:0;overflow:auto;background:var(--code);border:1px solid var(--line);border-radius:6px;padding:10px;font:11px/1.4 ui-monospace,Menlo,Consolas,monospace;white-space:pre-wrap;word-break:break-word}
131</style></head><body>
132<h1>T3 Code could not start</h1>
133<p>${escapeHtml(input.failure.reason)}</p>
134<p>${escapeHtml(offer.text)}</p>
135<div class="actions">${offer.action}${actionLink("copy", "Copy details")}${actionLink("quit", "Quit")}</div>
136<pre>${escapeHtml(output.length > 0 ? output : "The server wrote no output.")}</pre>
137<p class="muted">Version ${escapeHtml(input.appVersion)}. T3 Code tries again in the background. Logs: ${escapeHtml(input.logDir)}</p>
138</body></html>`;
140​
141/** Shows a self-contained error window while the primary backend fails to start, so a broken release still opens. */
142export const listen: Effect.Effect<
143 void,
144 never,
145 | DesktopState.DesktopState
146 | DesktopUpdates.DesktopUpdates
147 | DesktopEnvironment.DesktopEnvironment
148 | ElectronApp.ElectronApp
149 | ElectronShell.ElectronShell
150 | ElectronTheme.ElectronTheme
151 | ElectronWindow.ElectronWindow
152 | FileSystem.FileSystem
153 | Scope.Scope
154> = Effect.gen(function* () {
155 const { primaryBackendStartup, quitting } = yield* DesktopState.DesktopState;
156 const updates = yield* DesktopUpdates.DesktopUpdates;
157 const environment = yield* DesktopEnvironment.DesktopEnvironment;
158 const electronApp = yield* ElectronApp.ElectronApp;
159 const electronShell = yield* ElectronShell.ElectronShell;
160 const electronTheme = yield* ElectronTheme.ElectronTheme;
161 const electronWindow = yield* ElectronWindow.ElectronWindow;
162 const fileSystem = yield* FileSystem.FileSystem;
163 const runFork = Effect.runForkWith(yield* Effect.context<never>());
164​
165 const lastStartedVersionPath = environment.path.join(
166 environment.stateDir,
167 LAST_STARTED_VERSION_FILE,
168 );
169 const lastStartedVersion = yield* fileSystem.readFileString(lastStartedVersionPath).pipe(
170 Effect.map((text) => text.trim() || null),
171 Effect.orElseSucceed(() => null),
172 );
173​
174 const windowRef = yield* Ref.make(Option.none<Electron.BrowserWindow>());
175 // The failure that opened the window; later restarts do not reload its details.
176 const failureRef = yield* Ref.make(Option.none<BackendStartupFailure>());
177 const renderedHtmlRef = yield* Ref.make("");
178 const checkedRef = yield* Ref.make(false);
179 let backendHasStarted = false;
180 const { latest, changes: updateChanges } = yield* updates.subscribe;
181 const updateStateRef = yield* Ref.make(latest);
182​
183 const offer = Effect.all([Ref.get(updateStateRef), Ref.get(failureRef)]).pipe(
184 Effect.map(([state, failure]) =>
185 resolveStartupRecoveryOffer(
186 state,
187 lastStartedVersion,
188 Option.exists(failure, ({ managedPolicy }) => managedPolicy),
189 ),
190 ),
191 );
192​
193 const render = Effect.gen(function* () {
194 const window = yield* Ref.get(windowRef);
195 const failure = yield* Ref.get(failureRef);
196 if (Option.isNone(window) || Option.isNone(failure) || window.value.isDestroyed()) return;
197 const html = buildStartupRecoveryHtml({
198 appVersion: environment.appVersion,
199 failure: failure.value,
200 offer: yield* offer,
201 logDir: environment.logDir,
202 });
203 if ((yield* Ref.getAndSet(renderedHtmlRef, html)) === html) return;
204 void window.value.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
205 });
206​
207 const runAction = Effect.fn("desktop.startupRecovery.runAction")(function* (
208 action: StartupRecoveryAction,
209 ) {
210 yield* logInfo("startup recovery action", { action });
211 const current = yield* offer;
212 switch (action) {
213 case "update": {
214 if (current._tag !== "Update") return;
215 const state = yield* updates.getState;
216 if (state.downloadedVersion === null) {
217 const download = yield* updates.download;
218 if (!download.completed) return;
219 }
220 // Waits for a running background check; plain install would refuse.
221 yield* updates.installPrepared(current.version);
222 return;
223 }
224 case "reinstall":
225 if (current._tag === "Reinstall") {
226 yield* electronShell.openExternal(`${RELEASES_URL}/tag/v${current.version}`);
227 }
228 return;
229 case "releases":
230 yield* electronShell.openExternal(RELEASES_URL);
231 return;
232 case "copy": {
233 const failure = yield* Ref.get(failureRef);
234 if (Option.isNone(failure)) return;
235 yield* electronShell.copyText(
236 `T3 Code ${environment.appVersion} (${environment.platform} ${environment.processArch}) could not start.\n${failure.value.reason}\n\n${failure.value.output}`,
237 );
238 return;
239 }
240 case "quit":
241 yield* electronApp.quit;
242 return;
243 }
244 });
245​
246 const show = Effect.fn("desktop.startupRecovery.show")(
247 function* () {
248 if (yield* Ref.get(quitting)) return;
249 // A visible boot shell still needs recovery if the backend has never started.
250 const main = yield* electronWindow.main;
251 if (
252 backendHasStarted &&
253 Option.isSome(main) &&
254 !main.value.isDestroyed() &&
255 main.value.isVisible()
256 )
257 return;
258 const existing = yield* Ref.get(windowRef);
259 if (Option.isSome(existing) && !existing.value.isDestroyed()) {
260 yield* electronWindow.reveal(existing.value);
261 return;
262 }
263 const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors;
264 const window = yield* electronWindow.create(
265 {
266 width: 640,
267 height: 480,
268 minWidth: 420,
269 minHeight: 320,
270 show: false,
271 title: "T3 Code could not start",
272 autoHideMenuBar: true,
273 backgroundColor: shouldUseDarkColors ? "#000000" : "#ffffff",
274 webPreferences: {
275 contextIsolation: true,
276 nodeIntegration: false,
277 sandbox: true,
278 javascript: false,
279 },
280 },
281 { auxiliary: true },
282 );
283 window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
284 window.webContents.on("will-navigate", (event, url) => {
285 event.preventDefault();
286 const action = STARTUP_RECOVERY_ACTIONS.find((name) => url === `${ACTION_ORIGIN}/${name}`);
287 if (action !== undefined) runFork(runAction(action));
288 });
289 window.once("ready-to-show", () => {
290 if (!window.isDestroyed()) window.show();
291 });
292 window.once("closed", () => {
293 runFork(
294 Ref.set(windowRef, Option.none()).pipe(Effect.andThen(Ref.set(renderedHtmlRef, ""))),
295 );
296 });
297 yield* Ref.set(windowRef, Option.some(window));
298 yield* render;
299 yield* logInfo("startup recovery window shown");
300 },
301 Effect.catch((error) =>
302 logWarning("could not show startup recovery", { errorTag: error._tag }),
303 ),
304 );
305​
306 const close = Effect.gen(function* () {
307 const window = yield* Ref.getAndSet(windowRef, Option.none());
308 if (Option.isSome(window) && !window.value.isDestroyed()) window.value.close();
309 });
310​
311 const closeWhenMainReady = Effect.gen(function* () {
312 if ((yield* SubscriptionRef.get(primaryBackendStartup))._tag !== "Ready") return;
313 const main = yield* electronWindow.main;
314 if (Option.isSome(main) && !main.value.isDestroyed() && main.value.isVisible()) yield* close;
315 });
316​
317 yield* electronApp.on(
318 "browser-window-created",
319 (_event: Electron.Event, window: Electron.BrowserWindow) => {
320 window.on("show", () => runFork(closeWhenMainReady));
321 },
322 );
323​
324 const rememberStartedVersion = Effect.gen(function* () {
325 if (lastStartedVersion === environment.appVersion) return;
326 yield* fileSystem.writeFileString(lastStartedVersionPath, environment.appVersion);
327 }).pipe(
328 Effect.catch((error) =>
329 logWarning("could not record the started version", { errorTag: error._tag }),
330 ),
331 );
332​
333 yield* updateChanges.pipe(
334 Stream.runForEach((state) => Ref.set(updateStateRef, state).pipe(Effect.andThen(render))),
335 Effect.forkScoped,
336 );
337​
338 yield* SubscriptionRef.changes(primaryBackendStartup).pipe(
339 Stream.runForEach(
340 Effect.fn("desktop.startupRecovery.onStartupChange")(function* (startup) {
341 if (startup._tag === "Ready") {
342 backendHasStarted = true;
343 yield* Ref.set(failureRef, Option.none());
344 yield* closeWhenMainReady;
345 yield* rememberStartedVersion;
346 return;
347 }
348 if (startup._tag !== "Failing") return;
349 if (Option.isSome(yield* Ref.get(failureRef))) return;
350 const { _tag, ...failure } = startup;
351 yield* Ref.set(failureRef, Option.some(failure));
352 // The backend manager already logs each failure's reason.
353 yield* logWarning("primary backend failing to start");
354 yield* show();
355 if (!(yield* Ref.getAndSet(checkedRef, true))) {
356 yield* updates.check("startup-recovery").pipe(Effect.forkScoped);
357 }
358 }),
359 ),
360 Effect.catchCause((cause) =>
361 Effect.logWarning("startup recovery stopped", cause).pipe(
362 Effect.annotateLogs({ component: "desktop-startup-recovery" }),
363 ),
364 ),
365 Effect.forkScoped,
366 );
367​
368 // macOS keeps running with no windows; the Dock icon must bring the error back.
369 yield* electronApp.on("activate", () => {
370 runFork(
371 Ref.get(failureRef).pipe(
372 Effect.flatMap((failure) => (Option.isSome(failure) ? show() : Effect.void)),
373 ),
374 );
375 });
376});