1import * as Cause from "effect/Cause";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import * as Ref from "effect/Ref";
6import * as Schema from "effect/Schema";
7
8import * as NetService from "@t3tools/shared/Net";
9import * as Crypto from "effect/Crypto";
10import * as ElectronApp from "../electron/ElectronApp.ts";
11import * as ElectronDialog from "../electron/ElectronDialog.ts";
12import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
13import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
14import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts";
15import * as DesktopAppActivation from "./DesktopAppActivation.ts";
16import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
17import * as DesktopHostedAuth from "./DesktopHostedAuth.ts";
18import * as DesktopClerk from "./DesktopClerk.ts";
19import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";
20import * as DesktopWindow from "../window/DesktopWindow.ts";
21import * as PreviewPasskeys from "../preview/Passkeys.ts";
22import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
23import * as DesktopEnvironment from "./DesktopEnvironment.ts";
24import * as DesktopLegacyLocalStorage from "./DesktopLegacyLocalStorage.ts";
25import * as DesktopLifecycle from "./DesktopLifecycle.ts";
26import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts";
27import * as DesktopObservability from "./DesktopObservability.ts";
28import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts";
29import * as DesktopShutdown from "./DesktopShutdown.ts";
30import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";
31import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
32import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts";
33import * as DesktopState from "./DesktopState.ts";
34import * as DesktopRemoteUpdates from "../updates/DesktopRemoteUpdates.ts";
35import * as DesktopUpdates from "../updates/DesktopUpdates.ts";
36import * as DesktopLocalUi from "../updates/DesktopLocalUi.ts";
37import * as DesktopSnapShot from "../snapShot/DesktopSnapShot.ts";
38import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts";
39import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts";
40import * as HostProcess from "@t3tools/shared/HostProcess";
41
42const DEFAULT_DESKTOP_BACKEND_PORT = 3773;
43const MAX_TCP_PORT = 65_535;
44const DESKTOP_BACKEND_PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::"] as const;
45
46const makeDesktopRunId = Crypto.Crypto.pipe(
47 Effect.flatMap((crypto) => crypto.randomUUIDv4()),
48 Effect.map((value) => value.replaceAll("-", "").slice(0, 12)),
49);
50
51export class DesktopBackendPortUnavailableError extends Schema.TaggedError<DesktopBackendPortUnavailableError>()(
52 "DesktopBackendPortUnavailableError",
53 {
54 startPort: Schema.Int,
55 maxPort: Schema.Int,
56 hosts: Schema.Array(Schema.String),
57 },
58) {
59 override get message(): string {
60 return `No desktop backend port is available on hosts ${this.hosts.join(", ")} between ${this.startPort} and ${this.maxPort}.`;
61 }
62}
63
64export class DesktopDevelopmentBackendPortRequiredError extends Schema.TaggedError<DesktopDevelopmentBackendPortRequiredError>()(
65 "DesktopDevelopmentBackendPortRequiredError",
66 {},
67) {
68 override get message(): string {
69 return "T3CODE_PORT is required in desktop development.";
70 }
71}
72
73const { logInfo: logBootstrapInfo, logWarning: logBootstrapWarning } =
74 DesktopObservability.makeComponentLogger("desktop-bootstrap");
75
76const { logInfo: logStartupInfo, logError: logStartupError } =
77 DesktopObservability.makeComponentLogger("desktop-startup");
78
79const resolveDesktopBackendPort = Effect.fn("resolveDesktopBackendPort")(function* (
80 configuredPort: Option.Option<number>,
81) {
82 if (Option.isSome(configuredPort)) {
83 return {
84 port: configuredPort.value,
85 selectedByScan: false,
86 } as const;
87 }
88
89 const net = yield* NetService.NetService;
90 for (let port = DEFAULT_DESKTOP_BACKEND_PORT; port <= MAX_TCP_PORT; port += 1) {
91 let availableOnEveryHost = true;
92
93 for (const host of DESKTOP_BACKEND_PORT_PROBE_HOSTS) {
94 if (!(yield* net.canListenOnHost(port, host))) {
95 availableOnEveryHost = false;
96 break;
97 }
98 }
99
100 if (availableOnEveryHost) {
101 return {
102 port,
103 selectedByScan: true,
104 } as const;
105 }
106 }
107
108 return yield* new DesktopBackendPortUnavailableError({
109 startPort: DEFAULT_DESKTOP_BACKEND_PORT,
110 maxPort: MAX_TCP_PORT,
111 hosts: DESKTOP_BACKEND_PORT_PROBE_HOSTS,
112 });
113});
114
115const handleFatalStartupError = Effect.fn("desktop.startup.handleFatalStartupError")(function* (
116 stage: string,
117 error: unknown,
118): Effect.fn.Return<
119 void,
120 never,
121 | DesktopShutdown.DesktopShutdown
122 | DesktopState.DesktopState
123 | ElectronApp.ElectronApp
124 | ElectronDialog.ElectronDialog
125> {
126 const shutdown = yield* DesktopShutdown.DesktopShutdown;
127 const state = yield* DesktopState.DesktopState;
128 const electronApp = yield* ElectronApp.ElectronApp;
129 const electronDialog = yield* ElectronDialog.ElectronDialog;
130 const message = error instanceof Error ? error.message : String(error);
131 const detail =
132 error instanceof Error && typeof error.stack === "string" ? `\n${error.stack}` : "";
133 yield* logStartupError("fatal startup error", {
134 stage,
135 message,
136 ...(detail.length > 0 ? { detail } : {}),
137 });
138 const wasQuitting = yield* Ref.getAndSet(state.quitting, true);
139 if (!wasQuitting) {
140 yield* electronDialog.showErrorBox(
141 "The Knife failed to start",
142 `Stage: ${stage}\n${message}${detail}`,
143 );
144 }
145 yield* shutdown.request;
146 yield* electronApp.quit;
147});
148
149const fatalStartupCause = <E>(stage: string, cause: Cause.Cause<E>) =>
150 handleFatalStartupError(stage, Cause.pretty(cause)).pipe(Effect.andThen(Effect.failCause(cause)));
151
152export const stopAllPoolInstances = Effect.fn("desktop.app.stopAllPoolInstances")(
153 function* (): Effect.fn.Return<void, never, DesktopBackendPool.DesktopBackendPool> {
154 // Stop every backend in the pool with a timeout to guarantee the quit
155 // path makes progress even if a backend hangs during teardown.
156 const pool = yield* DesktopBackendPool.DesktopBackendPool;
157 const instances = yield* pool.list;
158 yield* Effect.forEach(
159 instances,
160 (instance) => instance.stop({ timeout: Duration.seconds(5) }),
161 { concurrency: "unbounded" },
162 );
163 },
164);
165
166const bootstrap = Effect.gen(function* () {
167 const state = yield* DesktopState.DesktopState;
168 const environment = yield* DesktopEnvironment.DesktopEnvironment;
169 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
170 const desktopWindow = yield* DesktopWindow.DesktopWindow;
171 const snapShot = yield* DesktopSnapShot.DesktopSnapShot;
172 const appActivation = yield* DesktopAppActivation.DesktopAppActivation;
173 yield* logBootstrapInfo("bootstrap start");
174
175 const settings = yield* desktopSettings.get;
176 // The renderer loads independently of the chat server.
177 const electronProtocol = yield* ElectronProtocol.ElectronProtocol;
178 yield* electronProtocol.registerDesktopProtocol({
179 scheme: ElectronProtocol.getDesktopScheme(environment.isDevelopment),
180 ...(environment.isDevelopment
181 ? { targetOrigin: Option.getOrThrow(environment.devServerUrl) }
182 : { assetDirectories: (yield* DesktopLocalUi.DesktopLocalUi).assetDirectories }),
183 clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname,
184 });
185 yield* installDesktopIpcHandlers();
186 yield* logBootstrapInfo("bootstrap ipc handlers registered");
187 // Before any window: the preload merges these items before the app reads storage.
188 yield* (yield* DesktopLegacyLocalStorage.DesktopLegacyLocalStorage).load(
189 yield* (yield* DesktopAppIdentity.DesktopAppIdentity).resolveUserDataPath,
190 );
191
192 yield* snapShot.initialize;
193
194 if (!settings.localEnvironmentEnabled) {
195 yield* logBootstrapInfo("bootstrap skipping local environment (disabled in settings)");
196 if (!(yield* Ref.get(state.quitting))) {
197 yield* desktopWindow.createMainIfBackendReady;
198 }
199 return;
200 }
201
202 const pool = yield* DesktopBackendPool.DesktopBackendPool;
203 const primaryBackend = yield* pool.primary;
204 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
205 const wslBackend = yield* DesktopWslBackend.DesktopWslBackend;
206
207 if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) {
208 return yield* new DesktopDevelopmentBackendPortRequiredError();
209 }
210
211 const backendPortSelection = yield* resolveDesktopBackendPort(environment.configuredBackendPort);
212 const backendPort = backendPortSelection.port;
213 yield* logBootstrapInfo(
214 backendPortSelection.selectedByScan
215 ? "selected backend port via sequential scan"
216 : "using configured backend port",
217 {
218 port: backendPort,
219 ...(backendPortSelection.selectedByScan ? { startPort: DEFAULT_DESKTOP_BACKEND_PORT } : {}),
220 },
221 );
222
223 if (settings.serverExposureMode !== environment.defaultDesktopSettings.serverExposureMode) {
224 yield* logBootstrapInfo("bootstrap restoring persisted server exposure mode", {
225 mode: settings.serverExposureMode,
226 });
227 }
228 const serverExposureState = yield* serverExposure.configureFromSettings({ port: backendPort });
229 const backendConfig = yield* serverExposure.backendConfig;
230 yield* logBootstrapInfo("bootstrap resolved backend endpoint", {
231 baseUrl: backendConfig.httpBaseUrl.href,
232 });
233 if (serverExposureState.endpointUrl) {
234 yield* logBootstrapInfo("bootstrap enabled network access", {
235 endpointUrl: serverExposureState.endpointUrl,
236 });
237 } else if (
238 settings.serverExposureMode === "network-accessible" &&
239 serverExposureState.mode === "local-only"
240 ) {
241 yield* logBootstrapWarning(
242 "bootstrap fell back to local-only because no advertised network host was available",
243 );
244 }
245
246 if (!(yield* Ref.get(state.quitting))) {
247 // The main window waits for the primary backend. In wsl-only mode that is
248 // the WSL backend, which can be slow to cold-boot — show a "Connecting to
249 // WSL" splash immediately so the app feels responsive instead of presenting
250 // no window until WSL is ready. (Dual mode opens fast off the Windows
251 // primary, so no splash there.)
252 if (settings.wslOnly === true && settings.wslBackendEnabled === true) {
253 yield* desktopWindow.showConnectingSplash;
254 }
255 yield* primaryBackend.start;
256 yield* logBootstrapInfo("bootstrap backend start requested");
257 yield* appActivation.start.pipe(
258 Effect.tap(() => logBootstrapInfo("desktop app control socket ready")),
259 Effect.catch((error) => logStartupError("desktop app control socket unavailable", { error })),
260 );
261 // Bring up the WSL backend if the user previously enabled it. The
262 // primary is already starting; reconcile fires off the WSL register
263 // in parallel rather than blocking primary readiness on a possibly
264 // slow first wsl.exe spawn.
265 yield* Effect.forkScoped(wslBackend.reconcile);
266 }
267}).pipe(Effect.withSpan("desktop.bootstrap"));
268
269const startup = Effect.gen(function* () {
270 const appIdentity = yield* DesktopAppIdentity.DesktopAppIdentity;
271 const applicationMenu = yield* DesktopApplicationMenu.DesktopApplicationMenu;
272 const electronApp = yield* ElectronApp.ElectronApp;
273 const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
274 const linuxUrlHandler = yield* DesktopLinuxUrlHandler.DesktopLinuxUrlHandler;
275 const clerk = yield* DesktopClerk.DesktopClerk;
276 const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment;
277 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
278 const preReadyElectronOptions = yield* DesktopPreReadyPlatform.DesktopPreReadyElectronOptions;
279 const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage;
280 const updates = yield* DesktopUpdates.DesktopUpdates;
281 const environment = yield* DesktopEnvironment.DesktopEnvironment;
282 const previewPasskeys = yield* PreviewPasskeys.PreviewPasskeys;
283
284 yield* shellEnvironment.installIntoProcess;
285 const hasCommandLinePasswordStore =
286 preReadyElectronOptions.linuxPasswordStoreCommandLine !== null;
287 const linuxElectronOptions =
288 environment.platform === "linux" && !hasCommandLinePasswordStore
289 ? DesktopPreReadyPlatform.resolveEarlyLinuxElectronOptionsFromProcess(
290 yield* HostProcess.HomeDirectory,
291 )
292 : preReadyElectronOptions.linux;
293 if (linuxElectronOptions !== null && !hasCommandLinePasswordStore) {
294 if (
295 linuxElectronOptions.passwordStore !== null ||
296 preReadyElectronOptions.linux?.passwordStore !== null
297 ) {
298 yield* electronApp.removeCommandLineSwitch("password-store");
299 }
300 if (linuxElectronOptions.passwordStore !== null) {
301 yield* electronApp.appendCommandLineSwitch(
302 "password-store",
303 linuxElectronOptions.passwordStore,
304 );
305 }
306 }
307 const userDataPath = yield* appIdentity.resolveUserDataPath;
308 yield* electronApp.setPath("userData", userDataPath);
309 yield* logStartupInfo("runtime logging configured", { logDir: environment.logDir });
310 yield* desktopSettings.load;
311
312 if (linuxElectronOptions !== null) {
313 yield* logStartupInfo("linux password store configured", {
314 passwordStore: hasCommandLinePasswordStore
315 ? "command-line"
316 : (linuxElectronOptions.passwordStore ?? "electron-default"),
317 xdgCurrentDesktop: process.env.XDG_CURRENT_DESKTOP ?? null,
318 xdgSessionDesktop: process.env.XDG_SESSION_DESKTOP ?? null,
319 });
320 }
321
322 yield* appIdentity.configure;
323 yield* lifecycle.register;
324 yield* (yield* DesktopHostedAuth.DesktopHostedAuth).configure;
325 yield* clerk.configure;
326
327 yield* electronApp.whenReady.pipe(
328 Effect.withSpan("desktop.electron.whenReady"),
329 Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)),
330 );
331 yield* logStartupInfo("app ready");
332 if (environment.platform === "linux") {
333 const selectedBackend = yield* safeStorage.selectedStorageBackend;
334 yield* logStartupInfo("safe storage ready", {
335 backend: Option.getOrElse(selectedBackend, () => "unknown"),
336 });
337 }
338 yield* appIdentity.configure;
339 yield* previewPasskeys.configure;
340 yield* applicationMenu.configure;
341 yield* updates.configure;
342 yield* DesktopRemoteUpdates.listen;
343 yield* linuxUrlHandler.register;
344 yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause)));
345}).pipe(Effect.withSpan("desktop.startup"));
346
347const scopedProgram = Effect.scoped(
348 Effect.gen(function* () {
349 const runId = yield* makeDesktopRunId;
350 yield* Effect.annotateLogsScoped({ scope: "desktop", runId });
351 yield* Effect.annotateCurrentSpan({ scope: "desktop", runId });
352
353 const shutdown = yield* DesktopShutdown.DesktopShutdown;
354 const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory;
355
356 yield* Effect.addFinalizer(() =>
357 // Stop every backend in the pool, not just the primary. The
358 // electronApp.quit() path can race ahead of the layer-scope
359 // cascade, so leaving the WSL instance for its parent scope
360 // finalizer means it gets hard-killed by the OS instead of
361 // receiving SIGTERM + grace.
362 stopAllPoolInstances().pipe(
363 Effect.ensuring(rendererHistory.shutdown),
364 Effect.ensuring(shutdown.markComplete),
365 ),
366 );
367
368 yield* startup;
369 yield* shutdown.awaitRequest;
370 }),
371);
372
373export const program = scopedProgram.pipe(Effect.withSpan("desktop.app"));