diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 7e77495e9..e6fb82333 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -1,5 +1,6 @@ import * as Cause from "effect/Cause"; import * as Data from "effect/Data"; +import * as Duration from "effect/Duration"; import { randomUUIDv4 } from "@threadlines/shared/uuid"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -39,6 +40,20 @@ class DesktopDevelopmentBackendPortRequiredError extends Data.TaggedError( } } +// Port discovery runs before the backend manager exists, so a probe that a +// security product leaves hanging would wedge startup with no window and no +// recovery prompt. The deadline turns that into the visible fatal-startup +// error box instead. +const BACKEND_PORT_DISCOVERY_TIMEOUT = Duration.seconds(30); + +class DesktopBackendPortDiscoveryTimeoutError extends Data.TaggedError( + "DesktopBackendPortDiscoveryTimeoutError", +)<{}> { + override get message() { + return `Timed out selecting a local port for the Threadlines background service after ${Duration.toSeconds(BACKEND_PORT_DISCOVERY_TIMEOUT)}s. A firewall or security tool may be blocking local network probes.`; + } +} + const { logInfo: logBootstrapInfo, logWarning: logBootstrapWarning } = DesktopObservability.makeComponentLogger("desktop-bootstrap"); @@ -99,7 +114,12 @@ const bootstrap = Effect.gen(function* () { const backendPortSelection = yield* resolveDesktopBackendPort({ configuredPort: environment.configuredBackendPort, probeHosts, - }); + }).pipe( + Effect.timeoutOrElse({ + duration: BACKEND_PORT_DISCOVERY_TIMEOUT, + orElse: () => new DesktopBackendPortDiscoveryTimeoutError(), + }), + ); const backendPort = backendPortSelection.port; yield* logBootstrapInfo( backendPortSelection.selectedByScan diff --git a/apps/desktop/src/app/DesktopCrashReport.test.ts b/apps/desktop/src/app/DesktopCrashReport.test.ts new file mode 100644 index 000000000..ea27e2760 --- /dev/null +++ b/apps/desktop/src/app/DesktopCrashReport.test.ts @@ -0,0 +1,103 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + redactSecrets, + resolveTelemetryConsent, + scrubUserPaths, + truncateTail, +} from "./DesktopCrashReport.ts"; + +describe("scrubUserPaths", () => { + it("replaces the home directory in both separator styles and any casing", () => { + const text = + "Error: EACCES at C:\\Users\\wilfredo\\.threadlines\\userdata\\state.sqlite " + + "(also seen as c:/users/wilfredo/.threadlines/logs)"; + const scrubbed = scrubUserPaths(text, "C:\\Users\\wilfredo"); + assert.equal( + scrubbed, + "Error: EACCES at ~\\.threadlines\\userdata\\state.sqlite (also seen as ~/.threadlines/logs)", + ); + }); + + it("handles posix home directories and leaves unrelated text alone", () => { + const scrubbed = scrubUserPaths("failed at /Users/will/x; port 3773 busy", "/Users/will"); + assert.equal(scrubbed, "failed at ~/x; port 3773 busy"); + }); + + it("is a no-op for an empty home directory", () => { + assert.equal(scrubUserPaths("text", ""), "text"); + }); +}); + +describe("redactSecrets", () => { + it("cuts values after credential-shaped labels", () => { + assert.equal( + redactSecrets("Error: connect failed api_key=sk-live-123 token: abc.def password=hunter2"), + "Error: connect failed api_key=[redacted] token: [redacted] password=[redacted]", + ); + }); + + it("leaves ordinary error text alone", () => { + const text = "EADDRINUSE: address already in use 127.0.0.1:3773"; + assert.equal(redactSecrets(text), text); + }); +}); + +describe("truncateTail", () => { + it("keeps the end of oversized output, where the fatal error lands", () => { + assert.equal(truncateTail("abcdef", 4), "cdef"); + assert.equal(truncateTail("abc", 4), "abc"); + }); +}); + +describe("resolveTelemetryConsent", () => { + it("defaults to enabled without settings", () => { + assert.isTrue(resolveTelemetryConsent({ envOverride: undefined, rawSettingsJson: undefined })); + }); + + it("honors usageAnalyticsEnabled from settings", () => { + assert.isFalse( + resolveTelemetryConsent({ + envOverride: undefined, + rawSettingsJson: JSON.stringify({ usageAnalyticsEnabled: false }), + }), + ); + assert.isTrue( + resolveTelemetryConsent({ + envOverride: undefined, + rawSettingsJson: JSON.stringify({ usageAnalyticsEnabled: true }), + }), + ); + }); + + it("lets the env override win in both directions", () => { + assert.isFalse( + resolveTelemetryConsent({ + envOverride: "false", + rawSettingsJson: JSON.stringify({ usageAnalyticsEnabled: true }), + }), + ); + assert.isTrue( + resolveTelemetryConsent({ + envOverride: "true", + rawSettingsJson: JSON.stringify({ usageAnalyticsEnabled: false }), + }), + ); + }); + + it("reads JSONC settings the way the server does", () => { + assert.isFalse( + resolveTelemetryConsent({ + envOverride: undefined, + rawSettingsJson: `{ + // telemetry disabled by hand + "usageAnalyticsEnabled": false, + }`, + }), + ); + }); + + it("keeps the default when settings are unreadable", () => { + assert.isTrue(resolveTelemetryConsent({ envOverride: undefined, rawSettingsJson: "not json" })); + }); +}); diff --git a/apps/desktop/src/app/DesktopCrashReport.ts b/apps/desktop/src/app/DesktopCrashReport.ts new file mode 100644 index 000000000..1e52decf2 --- /dev/null +++ b/apps/desktop/src/app/DesktopCrashReport.ts @@ -0,0 +1,213 @@ +/** + * DesktopCrashReport - anonymous startup-failure telemetry for the desktop + * shell. + * + * The server owns regular usage analytics, but when the backend never boots + * there is no server to report anything — so the shell sends one sanitized + * event itself. It honors the same consent as the server (`settings.json` + * `usageAnalyticsEnabled`, `THREADLINES_TELEMETRY_ENABLED` override), reuses + * the same anonymous install id file, and never sends raw paths: everything + * under the user's home directory is scrubbed to `~` before leaving the + * machine. + */ + +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import * as Crypto from "node:crypto"; + +import { DEFAULT_SERVER_SETTINGS, ServerSettings } from "@threadlines/contracts"; +import { fromLenientJson } from "@threadlines/shared/schemaJson"; + +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +declare const __THREADLINES_BUNDLED_POSTHOG_KEY__: string | undefined; +declare const __THREADLINES_BUNDLED_POSTHOG_HOST__: string | undefined; + +const bundledPosthogKey = + typeof __THREADLINES_BUNDLED_POSTHOG_KEY__ === "string" + ? __THREADLINES_BUNDLED_POSTHOG_KEY__ + : ""; + +const bundledPosthogHost = + typeof __THREADLINES_BUNDLED_POSTHOG_HOST__ === "string" + ? __THREADLINES_BUNDLED_POSTHOG_HOST__ + : "https://us.i.posthog.com"; + +const ANONYMOUS_ID_FILE_NAME = "anonymous-id"; +const STDERR_TAIL_MAX_CHARS = 2_000; + +// Values following credential-shaped labels are cut before anything leaves +// the machine. Boot-phase stderr should never contain these, but a config +// echo or connection string in a crash must not end up in telemetry. +const SECRET_PATTERN = + /((?:key|token|secret|password|passwd|credential|bearer|authorization)[\w-]*\s*[=:]\s*)[^\s"']+/gi; + +export function redactSecrets(text: string): string { + return text.replace(SECRET_PATTERN, "$1[redacted]"); +} + +export interface DesktopStartupFailureReport { + readonly failureKind: "process-exit" | "readiness-timeout"; + readonly attempts: number; + readonly lastExitCode: Option.Option; + readonly lastReason: string; + readonly stderrTail: string; +} + +export interface DesktopCrashReportShape { + /** Best-effort: resolves void on success and on any failure alike. */ + readonly reportStartupFailure: (report: DesktopStartupFailureReport) => Effect.Effect; +} + +export class DesktopCrashReport extends Context.Service< + DesktopCrashReport, + DesktopCrashReportShape +>()("threadlines/desktop/CrashReport") {} + +/** + * Replaces every occurrence of the user's home directory (either separator + * style, any casing) with `~` so crash reports carry no usernames or absolute + * personal paths. + */ +export function scrubUserPaths(text: string, homeDirectory: string): string { + if (homeDirectory.length === 0) { + return text; + } + const variants = new Set([ + homeDirectory, + homeDirectory.replaceAll("\\", "/"), + homeDirectory.replaceAll("/", "\\"), + ]); + let scrubbed = text; + for (const variant of variants) { + const escaped = variant.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + scrubbed = scrubbed.replace(new RegExp(escaped, "gi"), "~"); + } + return scrubbed; +} + +/** Keeps the end of the captured stderr, where the fatal error lands. */ +export function truncateTail(text: string, maxChars: number = STDERR_TAIL_MAX_CHARS): string { + return text.length <= maxChars ? text : text.slice(text.length - maxChars); +} + +const ServerSettingsJson = fromLenientJson(ServerSettings); +const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson); + +/** + * Mirrors the server's telemetry consent: `THREADLINES_TELEMETRY_ENABLED` + * wins when set, otherwise `usageAnalyticsEnabled` from settings.json decoded + * with the same lenient JSONC parser the server uses, defaulting to enabled + * when the file is absent or undecodable. + */ +export function resolveTelemetryConsent(input: { + readonly envOverride: string | undefined; + readonly rawSettingsJson: string | undefined; +}): boolean { + const override = input.envOverride?.trim().toLowerCase(); + if (override === "false") return false; + if (override === "true") return true; + + if (input.rawSettingsJson === undefined) { + return DEFAULT_SERVER_SETTINGS.usageAnalyticsEnabled; + } + return Option.match(decodeServerSettingsJson(input.rawSettingsJson), { + onNone: () => DEFAULT_SERVER_SETTINGS.usageAnalyticsEnabled, + onSome: (settings) => settings.usageAnalyticsEnabled, + }); +} + +const hashIdentifier = (value: string): string => + Crypto.createHash("sha256").update(value).digest("hex"); + +const makeDesktopCrashReport = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const httpClient = yield* HttpClient.HttpClient; + const anonymousIdPath = environment.path.join(environment.stateDir, ANONYMOUS_ID_FILE_NAME); + + // Same file the server uses, so the crash report and later usage telemetry + // count as one install. Created here when the backend never got far enough + // to create it itself. Like the server, sending is skipped when the id + // cannot be persisted: an unpersisted id would make every report look like + // a fresh installation. + const getIdentifier = Effect.gen(function* () { + const existing = yield* fileSystem + .readFileString(anonymousIdPath) + .pipe(Effect.map(Option.some), Effect.orElseSucceed(Option.none)); + if (Option.isSome(existing)) { + return Option.some(hashIdentifier(existing.value)); + } + const generated = Crypto.randomUUID(); + const persisted = yield* fileSystem.writeFileString(anonymousIdPath, generated).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + return persisted ? Option.some(hashIdentifier(generated)) : Option.none(); + }); + + const reportStartupFailure: DesktopCrashReportShape["reportStartupFailure"] = (report) => + Effect.gen(function* () { + // Same env overrides the server's AnalyticsService honors. + const posthogKey = process.env.THREADLINES_POSTHOG_KEY?.trim() || bundledPosthogKey.trim(); + const posthogHost = process.env.THREADLINES_POSTHOG_HOST?.trim() || bundledPosthogHost; + if (!posthogKey) return; + + const rawSettingsJson = yield* fileSystem + .readFileString(environment.serverSettingsPath) + .pipe(Effect.map(Option.some), Effect.orElseSucceed(Option.none)); + const consented = resolveTelemetryConsent({ + envOverride: process.env.THREADLINES_TELEMETRY_ENABLED, + rawSettingsJson: Option.getOrUndefined(rawSettingsJson), + }); + if (!consented) return; + + const identifier = yield* getIdentifier; + if (Option.isNone(identifier)) return; + const scrub = (text: string) => + redactSecrets(scrubUserPaths(text, environment.homeDirectory)); + const payload = { + api_key: posthogKey, + batch: [ + { + event: "desktop.backend.startup_failed", + distinct_id: identifier.value, + properties: { + $process_person_profile: false, + platform: environment.platform, + arch: environment.processArch, + threadlinesVersion: environment.appVersion, + clientType: "desktop-app", + failureKind: report.failureKind, + attempts: report.attempts, + exitCode: Option.getOrNull(report.lastExitCode), + reason: scrub(report.lastReason), + stderrTail: scrub(truncateTail(report.stderrTail)), + }, + timestamp: new Date().toISOString(), + }, + ], + }; + + yield* HttpClientRequest.post(`${posthogHost}/batch/`).pipe( + HttpClientRequest.bodyJson(payload), + Effect.flatMap(httpClient.execute), + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.asVoid, + ); + }).pipe( + Effect.catch((error) => + Effect.logDebug("startup failure crash report not sent", { cause: error }), + ), + Effect.withSpan("desktop.crashReport.reportStartupFailure"), + ); + + return DesktopCrashReport.of({ reportStartupFailure }); +}); + +export const layer = Layer.effect(DesktopCrashReport, makeDesktopCrashReport); diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index b1094780c..e4f41400c 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -24,6 +24,7 @@ import * as DesktopBackendManager from "./DesktopBackendManager.ts"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopState from "../app/DesktopState.ts"; +import * as DesktopStartupFailurePrompt from "../window/DesktopStartupFailurePrompt.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; const decodeDesktopBackendBootstrap = Schema.decodeEffect( @@ -109,12 +110,14 @@ function makeManagerLayer(input: { readonly desktopState?: DesktopState.DesktopStateShape; readonly desktopWindow?: Partial; readonly config?: DesktopBackendManager.DesktopBackendStartConfig; + readonly startupFailurePrompt?: DesktopStartupFailurePrompt.DesktopStartupFailurePromptShape; + readonly entryExists?: boolean; }) { return DesktopBackendManager.layer.pipe( Layer.provide( Layer.mergeAll( FileSystem.layerNoop({ - exists: () => Effect.succeed(true), + exists: () => Effect.succeed(input.entryExists ?? true), }), Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, { resolve: Effect.succeed(input.config ?? baseConfig), @@ -129,6 +132,12 @@ function makeManagerLayer(input: { writeOutputChunk: () => Effect.void, ...input.backendOutputLog, } satisfies DesktopObservability.DesktopBackendOutputLogShape), + Layer.succeed( + DesktopStartupFailurePrompt.DesktopStartupFailurePrompt, + input.startupFailurePrompt ?? { + handle: () => Effect.die("unexpected startup failure prompt"), + }, + ), Layer.succeed(DesktopWindow.DesktopWindow, { createMain: Effect.die("unexpected createMain"), ensureMain: Effect.die("unexpected ensureMain"), @@ -441,45 +450,245 @@ describe("DesktopBackendManager", () => { }), ); - it.effect("restarts an unexpectedly exited backend with the Effect clock", () => + it.effect( + "restarts a backend that dies before readiness, then caps at three attempts with a prompt", + () => + Effect.gen(function* () { + const starts = yield* Queue.unbounded(); + const promptReports = + yield* Queue.unbounded(); + const promptAction = + yield* Ref.make("retry"); + let startCount = 0; + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.sync(() => { + startCount += 1; + return makeProcess({ + exitCode: Queue.offer(starts, startCount).pipe( + Effect.as(ChildProcessSpawner.ExitCode(1)), + ), + }); + }), + ), + ); + + const managerLayer = makeManagerLayer({ + spawnerLayer, + httpClientLayer: httpClientLayer(() => Effect.never), + startupFailurePrompt: { + handle: (report) => + Queue.offer(promptReports, report).pipe(Effect.andThen(Ref.get(promptAction))), + }, + }); + + yield* Effect.gen(function* () { + const manager = yield* DesktopBackendManager.DesktopBackendManager; + yield* manager.start; + + assert.equal(yield* Queue.take(starts), 1); + + yield* TestClock.adjust(Duration.millis(499)); + assert.equal(yield* Queue.size(starts), 0); + yield* TestClock.adjust(Duration.millis(1)); + assert.equal(yield* Queue.take(starts), 2); + + yield* TestClock.adjust(Duration.millis(999)); + assert.equal(yield* Queue.size(starts), 0); + yield* TestClock.adjust(Duration.millis(1)); + assert.equal(yield* Queue.take(starts), 3); + + // The third failed attempt surfaces the prompt instead of a fourth + // silent restart. + const firstReport = yield* Queue.take(promptReports); + assert.equal(firstReport.failureKind, "process-exit"); + assert.equal(firstReport.attempts, 3); + assert.deepEqual(firstReport.lastExitCode, Option.some(1)); + + // "Try Again" resets the failure budget: a full new round of three. + assert.equal(yield* Queue.take(starts), 4); + yield* Ref.set(promptAction, "quit"); + yield* TestClock.adjust(Duration.millis(500)); + assert.equal(yield* Queue.take(starts), 5); + yield* TestClock.adjust(Duration.seconds(1)); + assert.equal(yield* Queue.take(starts), 6); + + const secondReport = yield* Queue.take(promptReports); + assert.equal(secondReport.attempts, 3); + + // "Quit" leaves the manager idle: no further spawns on any delay. + yield* TestClock.adjust(Duration.seconds(30)); + assert.equal(yield* Queue.size(starts), 0); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); + }), + ); + + it.effect("caps missing-entry retries with the same startup failure prompt", () => + Effect.gen(function* () { + const promptReports = + yield* Queue.unbounded(); + let spawnCount = 0; + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.sync(() => { + spawnCount += 1; + return makeProcess(); + }), + ), + ); + + const managerLayer = makeManagerLayer({ + spawnerLayer, + entryExists: false, + startupFailurePrompt: { + handle: (report) => Queue.offer(promptReports, report).pipe(Effect.as("quit" as const)), + }, + }); + + yield* Effect.gen(function* () { + const manager = yield* DesktopBackendManager.DesktopBackendManager; + yield* manager.start; + + yield* TestClock.adjust(Duration.millis(500)); + yield* TestClock.adjust(Duration.seconds(1)); + + const report = yield* Queue.take(promptReports); + assert.equal(report.attempts, 3); + assert.include(report.lastReason, "missing server entry"); + assert.equal(spawnCount, 0); + + yield* TestClock.adjust(Duration.seconds(30)); + assert.equal(yield* Queue.size(promptReports), 0); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); + }), + ); + + it.effect( + "kills and reports a backend that never answers readiness before the first window", + () => + Effect.gen(function* () { + const starts = yield* Queue.unbounded(); + const promptReports = + yield* Queue.unbounded(); + let startCount = 0; + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + startCount += 1; + yield* Queue.offer(starts, startCount); + const scope = yield* Scope.Scope; + const closed = yield* Deferred.make(); + const close = Deferred.succeed(closed, void 0).pipe(Effect.asVoid); + yield* Scope.addFinalizer(scope, close); + return makeProcess({ + stderr: Stream.make(new TextEncoder().encode("EADDRINUSE: port already bound\n")), + exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(143))), + kill: () => close, + }); + }), + ), + ); + + const managerLayer = makeManagerLayer({ + spawnerLayer, + httpClientLayer: httpClientLayer(() => Effect.never), + startupFailurePrompt: { + handle: (report) => Queue.offer(promptReports, report).pipe(Effect.as("quit" as const)), + }, + }); + + yield* Effect.gen(function* () { + const manager = yield* DesktopBackendManager.DesktopBackendManager; + yield* manager.start; + assert.equal(yield* Queue.take(starts), 1); + + // The 60s readiness budget elapses without a single healthy answer. + yield* TestClock.adjust(Duration.minutes(1)); + + const report = yield* Queue.take(promptReports); + assert.equal(report.failureKind, "readiness-timeout"); + assert.equal(report.attempts, 1); + assert.include(report.lastReason, "Timed out"); + assert.include(report.stderrTail, "EADDRINUSE"); + + // The unresponsive process was killed and nothing respawns. + yield* TestClock.adjust(Duration.seconds(30)); + assert.equal(yield* Queue.size(starts), 0); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); + }), + ); + + it.effect("keeps restarting without a prompt once the backend has been ready", () => Effect.gen(function* () { const starts = yield* Queue.unbounded(); + const exitFirstRun = yield* Deferred.make(); let startCount = 0; + let requestCount = 0; const spawnerLayer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make(() => - Effect.sync(() => { + Effect.gen(function* () { startCount += 1; + yield* Queue.offer(starts, startCount); + if (startCount === 1) { + return makeProcess({ + exitCode: Deferred.await(exitFirstRun).pipe( + Effect.as(ChildProcessSpawner.ExitCode(1)), + ), + }); + } + const scope = yield* Scope.Scope; + const closed = yield* Deferred.make(); + yield* Scope.addFinalizer(scope, Deferred.succeed(closed, void 0).pipe(Effect.asVoid)); return makeProcess({ - exitCode: Queue.offer(starts, startCount).pipe( - Effect.as(ChildProcessSpawner.ExitCode(1)), - ), + exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), }); }), ), ); + // First run answers healthy; every later run never answers, which after + // readiness must stay a logged warning, not a kill or a prompt. const managerLayer = makeManagerLayer({ spawnerLayer, - httpClientLayer: httpClientLayer(() => Effect.never), + httpClientLayer: httpClientLayer((request) => + Effect.suspend(() => { + requestCount += 1; + return requestCount === 1 + ? Effect.succeed(responseForRequest(request, 200)) + : Effect.never; + }), + ), }); yield* Effect.gen(function* () { const manager = yield* DesktopBackendManager.DesktopBackendManager; yield* manager.start; - assert.equal(yield* Queue.take(starts), 1); - yield* TestClock.adjust(Duration.millis(499)); - assert.equal(yield* Queue.size(starts), 0); - yield* TestClock.adjust(Duration.millis(1)); + // Wait for readiness, then crash the first run. + yield* Effect.gen(function* () { + while (!(yield* manager.snapshot).ready) { + yield* Effect.yieldNow; + } + }); + yield* Deferred.succeed(exitFirstRun, void 0); + + yield* TestClock.adjust(Duration.millis(500)); assert.equal(yield* Queue.take(starts), 2); - yield* TestClock.adjust(Duration.millis(999)); - assert.equal(yield* Queue.size(starts), 0); - yield* TestClock.adjust(Duration.millis(1)); - assert.equal(yield* Queue.take(starts), 3); + // The second run never becomes ready; the 60s readiness timeout must + // leave it running instead of surfacing a startup failure. + yield* TestClock.adjust(Duration.minutes(2)); + const snapshot = yield* manager.snapshot; + assert.equal(Option.isSome(snapshot.activePid), true); }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); }), ); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index de5d8c873..01dece253 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -27,12 +27,23 @@ import { } from "@threadlines/contracts"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; +import * as DesktopCrashReport from "../app/DesktopCrashReport.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopState from "../app/DesktopState.ts"; +import * as DesktopStartupFailurePrompt from "../window/DesktopStartupFailurePrompt.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; const INITIAL_RESTART_DELAY = Duration.millis(500); const MAX_RESTART_DELAY = Duration.seconds(10); +// Failed spawns before the first successful readiness stop looping and become +// a visible startup-failure prompt. Once the backend has been ready this app +// session, restarts stay unbounded: the window exists, so a crash there is +// not an invisible wedge. +const MAX_STARTUP_ATTEMPTS = 3; +// Cap on the retained stderr needed for a useful crash report. +const STDERR_TAIL_MAX_CHARS = 8_192; +// How long a finished process's output drains may lag its exit. +const DRAIN_FLUSH_TIMEOUT = Duration.seconds(1); const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1); const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100); const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1); @@ -136,20 +147,26 @@ interface ActiveBackendRun { interface BackendManagerState { readonly desiredRunning: boolean; readonly ready: boolean; + /** True once any run reached readiness in this app session. */ + readonly everReady: boolean; readonly config: Option.Option; readonly active: Option.Option; readonly restartAttempt: number; readonly restartFiber: Option.Option>; + /** Startup-failure dialog in flight; interrupted by stop(). */ + readonly promptFiber: Option.Option>; readonly nextRunId: number; } const initialState: BackendManagerState = { desiredRunning: false, ready: false, + everReady: false, config: Option.none(), active: Option.none(), restartAttempt: 0, restartFiber: Option.none(), + promptFiber: Option.none(), nextRunId: 1, }; @@ -256,7 +273,11 @@ const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBac const runBackendProcess = Effect.fn("runBackendProcess")(function* ( options: RunBackendProcessOptions, -): Effect.fn.Return { +): Effect.fn.Return< + { readonly exit: BackendProcessExit; readonly flushOutput: Effect.Effect }, + BackendProcessError, + BackendProcessRunRequirements +> { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const bootstrapJson = yield* encodeBootstrapJson(options.bootstrap).pipe( Effect.mapError((cause) => new BackendProcessBootstrapEncodeError({ cause })), @@ -290,9 +311,12 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( .pipe(Effect.mapError((cause) => new BackendProcessSpawnError({ cause }))); yield* options.onStarted?.(handle.pid) ?? Effect.void; + const drainFibers: Array> = []; if (options.captureOutput) { - yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped); - yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped); + drainFibers.push( + yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped), + yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped), + ); } yield* waitForHttpReady( options.httpBaseUrl, @@ -304,7 +328,14 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( Effect.forkScoped, ); - return describeProcessExit(yield* Effect.result(handle.exitCode)); + const exit = describeProcessExit(yield* Effect.result(handle.exitCode)); + // Joining the drains here would delay finalization (and make a dead run + // look active to start()), so the caller decides when to flush: bounded, + // and only where the buffered tail is actually read. + const flushOutput = Effect.forEach(drainFibers, (fiber) => + Fiber.await(fiber).pipe(Effect.timeoutOption(DRAIN_FLUSH_TIMEOUT), Effect.asVoid), + ).pipe(Effect.asVoid); + return { exit, flushOutput }; }); const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(function* () { @@ -314,6 +345,7 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio const backendOutputLog = yield* DesktopObservability.DesktopBackendOutputLog; const desktopState = yield* DesktopState.DesktopState; const desktopWindow = yield* DesktopWindow.DesktopWindow; + const startupFailurePrompt = yield* DesktopStartupFailurePrompt.DesktopStartupFailurePrompt; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const state = yield* Ref.make(initialState); @@ -373,11 +405,35 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio })); if (!entryExists) { - yield* scheduleRestart(`missing server entry at ${config.entryPath}`); + const reason = `missing server entry at ${config.entryPath}`; + const latest = yield* Ref.get(state); + // Same cap as a crashing spawn: a broken install must not retry + // invisibly forever either. + if (!latest.everReady && latest.restartAttempt >= MAX_STARTUP_ATTEMPTS - 1) { + yield* Ref.update(state, (current) => ({ + ...current, + desiredRunning: false, + })); + yield* triggerStartupFailure({ + failureKind: "process-exit", + attempts: latest.restartAttempt + 1, + lastExitCode: Option.none(), + lastReason: reason, + stderrTail: "", + }); + return; + } + yield* scheduleRestart(reason); return; } const runScope = yield* Scope.make("sequential"); + // Per-run crash-report inputs: the stderr tail carries the fatal + // error when the process dies, the timeout marker reroutes a + // killed-for-unresponsiveness exit away from the restart path. + const stderrTailRef = yield* Ref.make(""); + const stderrDecoder = new TextDecoder(); + const readinessTimeoutRef = yield* Ref.make(Option.none()); const runId = yield* Ref.modify(state, (latest) => [ latest.nextRunId, { @@ -394,6 +450,8 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio const finalizeRun = Effect.fn("desktop.backendManager.finalizeRun")(function* ( reason: string, + exitCode: Option.Option, + flushOutput: Effect.Effect, ) { yield* mutex.withPermits(1)( Effect.gen(function* () { @@ -447,9 +505,38 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio yield* Ref.set(desktopState.backendReady, false); } - if (isCurrentRun && nextState.desiredRunning) { + if (!isCurrentRun || !nextState.desiredRunning) { + return; + } + + const readinessTimeout = yield* Ref.get(readinessTimeoutRef); + const startupFailed = + !nextState.everReady && + (Option.isSome(readinessTimeout) || + nextState.restartAttempt >= MAX_STARTUP_ATTEMPTS - 1); + if (!startupFailed) { yield* scheduleRestart(reason); + return; } + + // Stop trying; the prompt fiber owns what happens next. + yield* Ref.update(state, (latest) => ({ + ...latest, + desiredRunning: false, + })); + // Let buffered output land in the tail before reading it. + yield* flushOutput; + const stderrTail = yield* Ref.get(stderrTailRef); + yield* triggerStartupFailure({ + failureKind: Option.isSome(readinessTimeout) ? "readiness-timeout" : "process-exit", + attempts: nextState.restartAttempt + 1, + lastExitCode: exitCode, + lastReason: Option.match(readinessTimeout, { + onNone: () => reason, + onSome: (timeout) => timeout.message, + }), + stderrTail, + }); }), ); }); @@ -479,6 +566,7 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio ...latest, restartAttempt: 0, ready: true, + everReady: true, }, ] as const; }); @@ -491,22 +579,53 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio Effect.catch((error) => logBackendManagerError("failed to open main window after backend readiness", { message: error.message, - }), + }).pipe( + // No window appeared, so a later backend failure is still an + // invisible wedge: keep the startup failure cap armed. + Effect.andThen(Ref.update(state, (latest) => ({ ...latest, everReady: false }))), + ), ), ); }), onReadinessFailure: (error) => - logBackendManagerWarning("backend readiness check failed during bootstrap", { - error: error.message, + Effect.gen(function* () { + yield* logBackendManagerWarning("backend readiness check failed during bootstrap", { + error: error.message, + }); + const latest = yield* Ref.get(state); + if (latest.everReady) { + return; + } + // Before the first readiness there is no window: a process that + // is alive but unresponsive would sit invisible forever. Mark + // the run and kill it so finalizeRun surfaces the failure + // instead of scheduling a restart. + yield* Ref.set(readinessTimeoutRef, Option.some(error)); + const run = Option.getOrUndefined(latest.active); + if (run?.id === runId) { + yield* Effect.forkIn(closeRun(run), parentScope); + } }), - onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), + onOutput: (streamName, chunk) => + streamName === "stderr" + ? backendOutputLog.writeOutputChunk(streamName, chunk).pipe( + Effect.andThen( + Ref.update(stderrTailRef, (tail) => { + const appended = tail + stderrDecoder.decode(chunk, { stream: true }); + return appended.length <= STDERR_TAIL_MAX_CHARS + ? appended + : appended.slice(appended.length - STDERR_TAIL_MAX_CHARS); + }), + ), + ) + : backendOutputLog.writeOutputChunk(streamName, chunk), }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(HttpClient.HttpClient, httpClient), Scope.provide(runScope), Effect.matchEffect({ - onFailure: (error) => finalizeRun(error.message), - onSuccess: (exit) => finalizeRun(exit.reason), + onFailure: (error) => finalizeRun(error.message, Option.none(), Effect.void), + onSuccess: ({ exit, flushOutput }) => finalizeRun(exit.reason, exit.code, flushOutput), }), Effect.ensuring(Scope.close(runScope, Exit.void).pipe(Effect.ignore)), ); @@ -580,15 +699,65 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio }); }); + // Forked so the (possibly minutes-long) dialog never blocks the manager + // mutex. "quit" is handled inside the prompt; "retry" resets the failure + // budget and starts over. The fiber is tracked in state so stop() can + // cancel a pending dialog's consequences. + const triggerStartupFailure = Effect.fn("desktop.backendManager.triggerStartupFailure")( + function* (report: DesktopCrashReport.DesktopStartupFailureReport) { + const promptFiber = yield* Effect.forkIn( + Effect.gen(function* () { + yield* logBackendManagerError("backend failed to start; showing startup failure prompt", { + failureKind: report.failureKind, + attempts: report.attempts, + reason: report.lastReason, + }); + const action = yield* startupFailurePrompt.handle(report); + if (action !== "retry") { + return; + } + // Someone may have started the backend while the dialog was open; + // retry must not fight them. + const shouldStart = yield* Ref.modify(state, (latest) => + latest.desiredRunning + ? ([false, latest] as const) + : ([true, { ...latest, restartAttempt: 0 }] as const), + ); + if (shouldStart) { + yield* start; + } + }).pipe( + Effect.catchCause((cause) => + logBackendManagerError("startup failure prompt failed", { + cause: Cause.pretty(cause), + }), + ), + Effect.ensuring( + Ref.update(state, (latest) => ({ + ...latest, + promptFiber: Option.none>(), + })), + ), + ), + parentScope, + ); + yield* Ref.update(state, (latest) => ({ + ...latest, + promptFiber: Option.some(promptFiber), + })); + }, + ); + const stop = Effect.fn("desktop.backendManager.stop")(function* (options?: { readonly timeout?: Duration.Duration; }) { - const { active, restartFiber } = yield* mutex.withPermits(1)( + const { active, restartFiber, promptFiber } = yield* mutex.withPermits(1)( Effect.gen(function* () { const result = yield* Ref.modify(state, (latest) => [ { active: latest.active, restartFiber: latest.restartFiber, + promptFiber: latest.promptFiber, }, { ...latest, @@ -596,6 +765,7 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio ready: false, active: Option.none(), restartFiber: Option.none>(), + promptFiber: Option.none>(), }, ]); yield* Ref.set(desktopState.backendReady, false); @@ -607,6 +777,12 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio onNone: () => Effect.void, onSome: (fiber) => Fiber.interrupt(fiber).pipe(Effect.asVoid), }); + // A stale startup-failure dialog must not resurrect the backend after a + // stop; the OS dialog itself stays visible but its choice goes nowhere. + yield* Option.match(promptFiber, { + onNone: () => Effect.void, + onSome: (fiber) => Fiber.interrupt(fiber).pipe(Effect.asVoid), + }); yield* Option.match(active, { onNone: () => Effect.void, onSome: (run) => closeRun(run, options), diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 934b6b736..c1fe39320 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -23,6 +23,7 @@ export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { export interface ElectronShellShape { readonly openExternal: (rawUrl: unknown) => Effect.Effect; + readonly openPath: (fileSystemPath: string) => Effect.Effect; readonly openScreenClip: () => Effect.Effect; readonly copyText: (text: string) => Effect.Effect; } @@ -43,6 +44,14 @@ const make = ElectronShell.of({ ), ), }), + // Electron resolves openPath with an error string on failure, "" on success. + openPath: (fileSystemPath) => + Effect.promise(() => + Electron.shell.openPath(fileSystemPath).then( + (openError) => openError === "", + () => false, + ), + ), openScreenClip: () => Effect.promise(() => Electron.shell.openExternal(WINDOWS_SCREEN_CLIP_URI).then( diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 41213884d..f9f645bea 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -31,6 +31,7 @@ import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; import * as ElectronWindow from "./electron/ElectronWindow.ts"; import * as DesktopApp from "./app/DesktopApp.ts"; import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; +import * as DesktopCrashReport from "./app/DesktopCrashReport.ts"; import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; import * as DesktopAssets from "./app/DesktopAssets.ts"; import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; @@ -55,6 +56,7 @@ import * as DesktopRelay from "./relay/DesktopRelay.ts"; import * as DesktopState from "./app/DesktopState.ts"; import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; import * as DesktopWindow from "./window/DesktopWindow.ts"; +import * as DesktopStartupFailurePrompt from "./window/DesktopStartupFailurePrompt.ts"; import * as DesktopStatusIndicator from "./window/DesktopStatusIndicator.ts"; import { readDesktopUserDataConfigFromEnv, @@ -147,7 +149,7 @@ async function exitAfterSecondaryInstanceHandoff(): Promise { // Safe before "ready": showErrorBox is the one dialog Electron allows early. Electron.dialog.showErrorBox( "Threadlines is already running", - "Another Threadlines process is already running on this computer but is not responding. Quit Threadlines from Task Manager (Windows) or Activity Monitor (Mac) and try again.", + "Another Threadlines process is already running on this computer but is not responding. It may be showing a startup error dialog. Check the taskbar and Alt+Tab first. Otherwise quit Threadlines from Task Manager (use the Details tab on Windows) or Activity Monitor (Mac) and try again.", ); Electron.app.exit(1); } @@ -253,6 +255,8 @@ const desktopServerExposureLayer = DesktopServerExposure.layer.pipe( const desktopWindowLayer = DesktopWindow.layer.pipe(Layer.provideMerge(desktopServerExposureLayer)); const desktopBackendLayer = DesktopBackendManager.layer.pipe( + Layer.provideMerge(DesktopStartupFailurePrompt.layer), + Layer.provideMerge(DesktopCrashReport.layer), Layer.provideMerge(DesktopAppIdentity.layer), Layer.provideMerge(DesktopBackendConfiguration.layer), Layer.provideMerge(desktopWindowLayer), diff --git a/apps/desktop/src/screenCapture/DesktopScreenCapture.test.ts b/apps/desktop/src/screenCapture/DesktopScreenCapture.test.ts index 0844727f1..f96652858 100644 --- a/apps/desktop/src/screenCapture/DesktopScreenCapture.test.ts +++ b/apps/desktop/src/screenCapture/DesktopScreenCapture.test.ts @@ -117,6 +117,7 @@ function screenCaptureLayer(input: { }) { const shellLayer = Layer.succeed(ElectronShell.ElectronShell, { openExternal: input.openExternal ?? (() => Effect.succeed(true)), + openPath: () => Effect.succeed(true), openScreenClip: input.openScreenClip ?? (() => Effect.succeed(true)), copyText: () => Effect.void, } satisfies ElectronShell.ElectronShellShape); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index ea28c8edb..8f623aa1a 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -98,6 +98,7 @@ const makeElectronShellLayer = (openedExternalUrl: Deferred.Deferred) => typeof rawUrl === "string" ? Deferred.succeed(openedExternalUrl, rawUrl).pipe(Effect.as(true)) : Effect.succeed(false), + openPath: () => Effect.die("unexpected openPath"), openScreenClip: () => Effect.die("unexpected openScreenClip"), copyText: () => Effect.die("unexpected copyText"), } satisfies ElectronShell.ElectronShellShape); diff --git a/apps/desktop/src/window/DesktopStartupFailurePrompt.ts b/apps/desktop/src/window/DesktopStartupFailurePrompt.ts new file mode 100644 index 000000000..2f3960e4b --- /dev/null +++ b/apps/desktop/src/window/DesktopStartupFailurePrompt.ts @@ -0,0 +1,146 @@ +/** + * DesktopStartupFailurePrompt - the visible end of a failed startup. + * + * When the backend cannot start, the desktop shell has no window to show, so + * without this dialog the app sits in Task Manager doing nothing the user can + * see. The prompt offers the three exits a stuck user needs: try again, look + * at the logs, or quit cleanly so the single-instance lock is released. The + * anonymous crash report is sent concurrently (bounded, best effort), and any + * defect in the dialog itself falls back to a plain error box plus quit — + * this path must never fail back into invisibility. + */ + +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; + +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronDialog from "../electron/ElectronDialog.ts"; +import * as ElectronShell from "../electron/ElectronShell.ts"; +import * as DesktopCrashReport from "../app/DesktopCrashReport.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; + +const CRASH_REPORT_TIMEOUT = Duration.seconds(3); +const DETAIL_REASON_MAX_CHARS = 300; + +const TRY_AGAIN_BUTTON_INDEX = 0; +const OPEN_LOGS_BUTTON_INDEX = 1; +const QUIT_BUTTON_INDEX = 2; + +export type DesktopStartupFailureAction = "retry" | "quit"; + +export interface DesktopStartupFailurePromptShape { + /** + * Reports the failure, shows the dialog until the user picks a way out, and + * quits the app itself when they choose to. "retry" asks the caller to + * reset its failure budget and start the backend again. Never fails. + */ + readonly handle: ( + report: DesktopCrashReport.DesktopStartupFailureReport, + ) => Effect.Effect; +} + +export class DesktopStartupFailurePrompt extends Context.Service< + DesktopStartupFailurePrompt, + DesktopStartupFailurePromptShape +>()("threadlines/desktop/StartupFailurePrompt") {} + +export function describeStartupFailure(input: { + readonly displayName: string; + readonly report: DesktopCrashReport.DesktopStartupFailureReport; + readonly logDir: string; +}): { readonly message: string; readonly detail: string } { + const { displayName, report, logDir } = input; + const attemptsText = report.attempts === 1 ? "1 attempt" : `${report.attempts} attempts`; + const kindText = + report.failureKind === "readiness-timeout" + ? `The ${displayName} background service started but never responded.` + : `The ${displayName} background service stopped unexpectedly while starting (${attemptsText}).`; + const reason = report.lastReason.trim(); + const reasonText = + reason.length === 0 + ? "" + : `\n\nLast error: ${ + reason.length <= DETAIL_REASON_MAX_CHARS + ? reason + : `${reason.slice(0, DETAIL_REASON_MAX_CHARS)}…` + }`; + return { + message: `${displayName} couldn't start`, + detail: `${kindText} You can try again, or open the logs folder to see what happened.${reasonText}\n\nLogs: ${logDir}`, + }; +} + +const makeDesktopStartupFailurePrompt = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const electronApp = yield* ElectronApp.ElectronApp; + const electronDialog = yield* ElectronDialog.ElectronDialog; + const electronShell = yield* ElectronShell.ElectronShell; + const crashReport = yield* DesktopCrashReport.DesktopCrashReport; + + const askUser = (report: DesktopCrashReport.DesktopStartupFailureReport) => + Effect.gen(function* () { + const { message, detail } = describeStartupFailure({ + displayName: environment.displayName, + report, + logDir: environment.logDir, + }); + + while (true) { + const result = yield* electronDialog.showMessageBox({ + type: "error", + title: message, + message, + detail, + buttons: ["Try Again", "Open Logs Folder", "Quit"], + defaultId: TRY_AGAIN_BUTTON_INDEX, + cancelId: QUIT_BUTTON_INDEX, + noLink: true, + }); + + if (result.response === OPEN_LOGS_BUTTON_INDEX) { + yield* electronShell.openPath(environment.logDir); + continue; + } + return result.response === TRY_AGAIN_BUTTON_INDEX ? ("retry" as const) : ("quit" as const); + } + }); + + const handle: DesktopStartupFailurePromptShape["handle"] = Effect.fn( + "desktop.startupFailurePrompt.handle", + )(function* (report) { + // Concurrent with the dialog: the user should never wait on telemetry. + const reportFiber = yield* Effect.forkChild( + crashReport + .reportStartupFailure(report) + .pipe(Effect.timeoutOption(CRASH_REPORT_TIMEOUT), Effect.asVoid), + ); + + const action = yield* askUser(report).pipe( + Effect.catchCause(() => + // The rich dialog itself failed; a plain error box and a clean quit + // beat an invisible process holding the single-instance lock. + electronDialog + .showErrorBox( + `${environment.displayName} couldn't start`, + `The background service failed to start and the recovery dialog could not be shown. See logs: ${environment.logDir}`, + ) + .pipe(Effect.as("quit" as const)), + ), + ); + + yield* Fiber.await(reportFiber); + if (action === "quit") { + yield* electronApp.quit; + } + return action; + }); + + return DesktopStartupFailurePrompt.of({ handle }); +}); + +export const layer = Layer.effect(DesktopStartupFailurePrompt, makeDesktopStartupFailurePrompt); + +export type { DesktopStartupFailureReport } from "../app/DesktopCrashReport.ts"; diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 40cedf62c..16ebbe522 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -164,6 +164,7 @@ const electronGlobalShortcutLayer = Layer.succeed(ElectronGlobalShortcut.Electro const electronShellLayer = Layer.succeed(ElectronShell.ElectronShell, { openExternal: () => Effect.succeed(true), + openPath: () => Effect.succeed(true), openScreenClip: () => Effect.succeed(true), copyText: () => Effect.void, } satisfies ElectronShell.ElectronShellShape); @@ -834,6 +835,7 @@ describe("DesktopWindow", () => { platform: "win32", electronShell: { openExternal: () => Effect.succeed(true), + openPath: () => Effect.succeed(true), openScreenClip, copyText: () => Effect.void, }, @@ -884,6 +886,7 @@ describe("DesktopWindow", () => { }, electronShell: { openExternal: () => Effect.succeed(true), + openPath: () => Effect.succeed(true), openScreenClip, copyText: () => Effect.void, }, @@ -924,6 +927,7 @@ describe("DesktopWindow", () => { platform: "win32", electronShell: { openExternal: () => Effect.succeed(true), + openPath: () => Effect.succeed(true), openScreenClip, copyText: () => Effect.void, }, @@ -952,6 +956,7 @@ describe("DesktopWindow", () => { platform: "win32", electronShell: { openExternal: () => Effect.succeed(true), + openPath: () => Effect.succeed(true), openScreenClip, copyText: () => Effect.void, }, diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 1e4eb82d9..5e245f55a 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -1,5 +1,23 @@ import { defineConfig } from "vite-plus"; +// The desktop shell sends its own startup-failure telemetry (the backend is +// not running to send anything), so it bakes in the same PostHog key the +// server build does. Keep in sync with resolveBundledTelemetryConfig in +// apps/server/vite.config.ts; importing it here would breach the tsconfig +// project boundary. +function resolveBundledTelemetryConfig(env: NodeJS.ProcessEnv = process.env): { + readonly posthogKey: string; + readonly posthogHost: string; +} { + const telemetryEnabled = env.THREADLINES_TELEMETRY_ENABLED?.trim().toLowerCase() !== "false"; + return { + posthogKey: telemetryEnabled ? (env.THREADLINES_POSTHOG_KEY?.trim() ?? "") : "", + posthogHost: env.THREADLINES_POSTHOG_HOST?.trim() || "https://us.i.posthog.com", + }; +} + +const bundledTelemetryConfig = resolveBundledTelemetryConfig(); + const shared = { format: "cjs" as const, outDir: "dist-electron", @@ -14,6 +32,10 @@ export default defineConfig({ ...shared, entry: ["src/main.ts"], clean: true, + define: { + __THREADLINES_BUNDLED_POSTHOG_KEY__: JSON.stringify(bundledTelemetryConfig.posthogKey), + __THREADLINES_BUNDLED_POSTHOG_HOST__: JSON.stringify(bundledTelemetryConfig.posthogHost), + }, deps: { alwaysBundle: (id) => id.startsWith("@threadlines/"), },