Skip to content
Source
Sign inSign up

bootError.ts

395 lines13.6 KB

1import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts";
2​
3const FALLBACK_TRIAGE_COMMAND = "npx t3 triage";
4const DOWNLOAD_URL = "https://t3.codes/download";
5const ISSUE_URL = "https://github.com/pingdotgg/t3code/issues/new";
6const LAST_FAILURE_KEY = "t3code:boot-failed-at";
7const REPEAT_FAILURE_MS = 5 * 60_000;
8const MAX_CAUSES = 3;
9const MAX_ERROR_CHARS = 6_000;
10const COPY_BLOCKED = "Copy was blocked. The text is selected so you can copy it manually.";
11const UPDATE_DOWNLOAD_FAILED = "The update download failed. Try Download update again.";
12const UPDATE_INSTALL_FAILED = "The update install failed. Try Restart to update again.";
13​
14/** The Windows launcher is a `.cmd` run from PowerShell, which needs `&` to run a quoted path. */
15function launcherCommand(launcherPath: string) {
16 if (launcherPath.endsWith(".cmd")) {
17 return /^[\w:\\.-]+$/.test(launcherPath)
18 ? launcherPath
19 : `& '${launcherPath.replace(/['\u2018\u2019\u201A\u201B]/g, "$&$&")}'`;
20 }
21 return /^[\w@%+=:,./-]+$/.test(launcherPath)
22 ? launcherPath
23 : `'${launcherPath.replaceAll("'", `'"'"'`)}'`;
25​
26/** Desktop can run its bundled launcher without Node; everything else falls back to npx. */
27export async function resolveTriageCommand(bridge: DesktopBridge | undefined) {
28 try {
29 const state = await bridge?.cliCommand.getState();
30 // Windows reports onPath from its PATH entry alone; only an existing launcher proves it runs.
31 if (state?.launcherPath) {
32 return state.onPath ? "t3 triage" : `${launcherCommand(state.launcherPath)} triage`;
33 }
34 } catch (error) {
35 console.warn("Could not read the desktop CLI state.", error);
36 }
37 return FALLBACK_TRIAGE_COMMAND;
39​
40/** The update button only appears when the desktop updater has a newer build to offer. */
41export function updateActionFor(state: DesktopUpdateState | null | undefined) {
42 switch (state?.status) {
43 case "available":
44 return { kind: "download" } as const;
45 case "downloading":
46 return { kind: "downloading", percent: Math.round(state.downloadPercent ?? 0) } as const;
47 case "downloaded":
48 return { kind: "install" } as const;
49 default:
50 return null;
51 }
53​
54function safeString(value: unknown) {
55 try {
56 return String(value);
57 } catch {
58 return "";
59 }
61​
62function describeOne(value: unknown) {
63 if (value instanceof Error) {
64 const header = value.message ? `${value.name}: ${value.message}` : value.name;
65 // V8 stacks start with the header; Safari and Firefox stacks do not.
66 const stack = typeof value.stack === "string" ? value.stack : "";
67 return stack === header || stack.startsWith(`${header}\n`)
68 ? stack
69 : [header, stack].filter(Boolean).join("\n");
70 }
71 if (typeof value === "string") return value;
72 try {
73 return JSON.stringify(value) ?? safeString(value);
74 } catch {
75 return safeString(value);
76 }
78​
79/** Hosts can name private machines, and pairing tokens live in query strings and hashes. */
80function redact(text: string) {
81 return text
82 .replace(/\b[a-z][\w+.-]*:\/\/[^\s/?#'"()]+/gi, "")
83 .replace(/[?#][^\s'"()]*=[^\s'"()]*?((?::\d+){1,2})?(?=[\s'"()]|$)/g, "$1");
85​
86export interface BootErrorEnvironment {
87 readonly version: string;
88 readonly channel: string;
89 readonly surface: "desktop" | "web";
90 readonly platform: string;
91 readonly userAgent: string;
92 readonly time: string;
94​
95/** Plain text that is shown and copied as-is, so users see exactly what they share. */
96export function buildErrorReport(error: unknown, environment: BootErrorEnvironment) {
97 const parts: Array<string> = [];
98 const seen = new Set<unknown>();
99 let current = error;
100 while (current != null && !seen.has(current) && parts.length <= MAX_CAUSES) {
101 seen.add(current);
102 const text = describeOne(current);
103 parts.push(parts.length === 0 ? text : `Caused by: ${text}`);
104 current = current instanceof Error ? current.cause : undefined;
105 }
106 let details = redact(parts.join("\n") || "(no error message)");
107 if (details.length > MAX_ERROR_CHARS) {
108 details = `${details.slice(0, MAX_ERROR_CHARS)}\n[truncated]`;
109 }
110 return [
111 details,
112 "",
113 `Version: ${environment.version}`,
114 `Channel: ${environment.channel}`,
115 `Surface: ${environment.surface}`,
116 `Platform: ${environment.platform}`,
117 `User agent: ${environment.userAgent}`,
118 `Time: ${environment.time}`,
119 ].join("\n");
121​
122function clientPlatform(bridge: DesktopBridge | undefined) {
123 try {
124 return bridge?.getClientPlatform() ?? navigator.platform;
125 } catch {
126 return navigator.platform;
127 }
129​
130function readEnvironment(bridge: DesktopBridge | undefined): BootErrorEnvironment {
131 const version = import.meta.env.APP_VERSION;
132 return {
133 version,
134 channel:
135 import.meta.env.VITE_RELEASE_CHANNEL || /-(nightly|preview)\./.exec(version)?.[1] || "stable",
136 surface: bridge ? "desktop" : "web",
137 platform: clientPlatform(bridge),
138 userAgent: navigator.userAgent,
139 time: new Date().toISOString(),
140 };
142​
143/** Opens More options by default when startup already failed in this tab a moment ago. */
144function isRepeatFailure(now: number) {
145 try {
146 const previous = Number(sessionStorage.getItem(LAST_FAILURE_KEY));
147 sessionStorage.setItem(LAST_FAILURE_KEY, String(now));
148 return now - previous < REPEAT_FAILURE_MS;
149 } catch {
150 return false;
151 }
153​
154const icon = (paths: string) =>
155 `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
156const COPY_ICON = icon(
157 '<rect width="14" height="14" x="8" y="8" rx="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
158);
159const CHECK_ICON = icon('<path d="M20 6 9 17l-5-5"/>');
160​
161function element<K extends keyof HTMLElementTagNameMap>(
162 tag: K,
163 props: Partial<HTMLElementTagNameMap[K]> = {},
164 children: ReadonlyArray<Node | string> = [],
165) {
166 const node = Object.assign(document.createElement(tag), props);
167 node.append(...children);
168 return node;
170​
171function selectContents(target: HTMLElement) {
172 const range = document.createRange();
173 range.selectNodeContents(target);
174 const selection = window.getSelection();
175 selection?.removeAllRanges();
176 selection?.addRange(range);
178​
179function copySelection() {
180 try {
181 return document.execCommand("copy");
182 } catch {
183 return false;
184 }
186​
187/** Plain http has no Clipboard API, but the legacy copy command still works on a selection. */
188function copyText(target: HTMLElement, onCopied: () => void, onBlocked: (hint: string) => void) {
189 const fallback = () => {
190 selectContents(target);
191 if (copySelection()) return onCopied();
192 onBlocked(
193 /Mac|iPhone|iPad/.test(navigator.platform) ? "Press ⌘C to copy" : "Press Ctrl+C to copy",
194 );
195 };
196 const clipboard = window.isSecureContext ? navigator.clipboard : undefined;
197 if (typeof clipboard?.writeText !== "function") return fallback();
198 clipboard.writeText(target.textContent ?? "").then(onCopied, fallback);
200​
201function mountUpdateButton(
202 bridge: DesktopBridge,
203 button: HTMLButtonElement,
204 note: HTMLParagraphElement,
205 announce: (text: string) => void,
206) {
207 let action: ReturnType<typeof updateActionFor> = null;
208 const render = (state: DesktopUpdateState) => {
209 action = updateActionFor(state);
210 button.hidden = action === null;
211 // aria-disabled keeps keyboard focus on the button while the download runs.
212 button.setAttribute("aria-disabled", String(action?.kind === "downloading"));
213 if (action?.kind === "download") button.textContent = "Download update";
214 if (action?.kind === "downloading")
215 button.textContent = `Downloading update (${action.percent}%)`;
216 if (action?.kind === "install") button.textContent = "Restart to update";
217 const failure = !action
218 ? ""
219 : state.errorContext === "download"
220 ? UPDATE_DOWNLOAD_FAILED
221 : state.errorContext === "install"
222 ? UPDATE_INSTALL_FAILED
223 : "";
224 if (failure === note.textContent) return;
225 note.textContent = failure;
226 note.hidden = !failure;
227 if (failure) announce(failure);
228 };
229 button.addEventListener("click", () => {
230 const run =
231 action?.kind === "install"
232 ? bridge.installUpdate()
233 : action?.kind === "download"
234 ? bridge.downloadUpdate()
235 : undefined;
236 run?.then(
237 (result) => render(result.state),
238 (error: unknown) => console.warn("Could not update T3 Code.", error),
239 );
240 });
241 // A pushed state is newer than the initial read, so the read must not overwrite it.
242 let pushed = false;
243 try {
244 bridge.onUpdateState((state) => {
245 pushed = true;
246 render(state);
247 });
248 bridge.getUpdateState().then(
249 (state) => pushed || render(state),
250 (error: unknown) => console.warn("Could not read the desktop update state.", error),
251 );
252 } catch (error) {
253 console.warn("Could not read the desktop update state.", error);
254 }
256​
257export function showBootError(error: unknown) {
258 console.error("T3 Code failed to start.", error);
259 const bootShell = document.getElementById("boot-shell");
260 if (!bootShell) return;
261​
262 const bridge = window.desktopBridge;
263 const environment = readEnvironment(bridge);
264 const offline = !bridge && navigator.onLine === false;
265​
266 const status = element("span", { className: "boot-error-sr" });
267 status.setAttribute("aria-live", "polite");
268 // Clearing first makes screen readers announce a repeated message.
269 let announcement: ReturnType<typeof setTimeout> | undefined;
270 const announce = (text: string) => {
271 clearTimeout(announcement);
272 status.textContent = "";
273 announcement = setTimeout(() => (status.textContent = text), 100);
274 };
275​
276 const message = element("div", {}, [
277 element("h1", { textContent: "T3 Code failed to start" }),
278 element("p", {
279 textContent: offline
280 ? "This browser appears to be offline. Check the connection, then reload."
281 : "We are not sure why. Try a reload first.",
282 }),
283 ]);
284 message.setAttribute("role", "alert");
285​
286 const reload = element("button", {
287 id: "boot-error-reload",
288 type: "button",
289 textContent: "Reload",
290 });
291 reload.addEventListener("click", () => window.location.reload());
292 const update = element("button", { id: "boot-error-update", type: "button", hidden: true });
293 const updateNote = element("p", { id: "boot-error-update-note", hidden: true });
294 if (bridge) mountUpdateButton(bridge, update, updateNote, announce);
295​
296 const command = element("code", { textContent: FALLBACK_TRIAGE_COMMAND });
297 const copyCommand = element("button", {
298 className: "boot-error-icon",
299 type: "button",
300 title: "Copy command",
301 innerHTML: COPY_ICON,
302 });
303 copyCommand.setAttribute("aria-label", "Copy command");
304 copyCommand.addEventListener("click", () =>
305 copyText(
306 command,
307 () => {
308 copyCommand.innerHTML = CHECK_ICON;
309 copyCommand.title = "Copied";
310 copyCommand.setAttribute("aria-label", "Copied");
311 announce("Copied to clipboard.");
312 },
313 (hint) => {
314 copyCommand.textContent = hint;
315 copyCommand.title = hint;
316 copyCommand.setAttribute("aria-label", hint);
317 announce(COPY_BLOCKED);
318 },
319 ),
320 );
321 void resolveTriageCommand(bridge).then((value) => (command.textContent = value));
322​
323 let report = "(no error message)";
324 try {
325 report = buildErrorReport(error, environment);
326 } catch (reportError) {
327 console.warn("Could not describe the startup error.", reportError);
328 }
329 const details = element("pre", { id: "boot-error-details", tabIndex: 0, textContent: report });
330 const copyDetails = element("button", { type: "button", textContent: "Copy error details" });
331 copyDetails.addEventListener("click", () =>
332 copyText(
333 details,
334 () => {
335 copyDetails.textContent = "Copied";
336 announce("Copied to clipboard.");
337 },
338 (hint) => {
339 copyDetails.textContent = hint;
340 announce(COPY_BLOCKED);
341 },
342 ),
343 );
344​
345 const issue = new URL(ISSUE_URL);
346 issue.searchParams.set("template", "bug_report.yml");
347 issue.searchParams.set("title", "T3 Code failed to start");
348 issue.searchParams.set("area", "apps/web");
349 issue.searchParams.set("version", `${environment.version} (${environment.channel})`);
350 issue.searchParams.set("environment", `${environment.surface}, ${environment.platform}`);
351 const link = (href: string, textContent: string) =>
352 element("a", { href, textContent, target: "_blank", rel: "noreferrer" });
353 const nightly = environment.channel === "nightly";
354​
355 const more = element("details", { open: isRepeatFailure(Date.now()) }, [
356 element("summary", { textContent: "More options if it keeps failing" }),
357 ...(bridge
358 ? [
359 link(
360 nightly ? `${DOWNLOAD_URL}?channel=nightly` : DOWNLOAD_URL,
361 nightly
362 ? "Download and reinstall the latest nightly build"
363 : "Download and reinstall T3 Code",
364 ),
365 ]
366 : []),
367 element("div", {}, [
368 element("p", {
369 textContent:
370 "Run this on the computer that runs T3 Code. Your agent can check the local logs and help you file a bug report.",
371 }),
372 element("div", { id: "boot-error-command" }, [command, copyCommand]),
373 ]),
374 element("div", {}, [
375 details,
376 element("p", {
377 textContent: "Details may include local file paths and names. Check before sharing.",
378 }),
379 element("div", { id: "boot-error-report" }, [
380 copyDetails,
381 link(issue.href, "File a bug report on GitHub"),
382 ]),
383 ]),
384 ]);
385​
386 bootShell.replaceChildren(
387 element("div", { id: "boot-error" }, [
388 message,
389 element("div", { id: "boot-error-actions" }, [reload, update]),
390 updateNote,
391 more,
392 status,
393 ]),
394 );