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

DesktopApp.ts

437 lines17.6 KB

1import * as Cause from "effect/Cause";
2import * as Config from "effect/Config";
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 SecureRandom from "effect/crypto/SecureRandom";
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 DesktopClerk from "./DesktopClerk.ts";
18import * as DesktopCliCommand from "./DesktopCliCommand.ts";
19import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";
20import * as DesktopWindow from "../window/DesktopWindow.ts";
21import * as PreviewPasskeys from "../preview/Passkeys.ts";
22import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts";
23import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
24import * as DesktopHomeOwner from "../backend/DesktopHomeOwner.ts";
25import * as DesktopLocalEnvironmentAuth from "../backend/DesktopLocalEnvironmentAuth.ts";
26import * as DesktopEnvironment from "./DesktopEnvironment.ts";
27import * as DesktopLegacyLocalStorage from "./DesktopLegacyLocalStorage.ts";
28import * as DesktopLifecycle from "./DesktopLifecycle.ts";
29import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts";
30import * as DesktopObservability from "./DesktopObservability.ts";
31import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts";
32import * as DesktopShutdown from "./DesktopShutdown.ts";
33import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";
34import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
35import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts";
36import * as DesktopStartupRecovery from "./DesktopStartupRecovery.ts";
37import * as DesktopState from "./DesktopState.ts";
38import * as DesktopRemoteUpdates from "../updates/DesktopRemoteUpdates.ts";
39import * as DesktopUpdates from "../updates/DesktopUpdates.ts";
40import * as DesktopSnapShot from "../snapShot/DesktopSnapShot.ts";
41import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts";
42import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts";
43import * as HostProcess from "@t3tools/shared/HostProcess";
44​
45const DEFAULT_DESKTOP_BACKEND_PORT = 3773;
46const MAX_TCP_PORT = 65_535;
47const DESKTOP_BACKEND_PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::"] as const;
48​
49const LinuxDesktopSessionConfig = Config.all({
50 current: Config.String("XDG_CURRENT_DESKTOP").pipe(Config.option),
51 session: Config.String("XDG_SESSION_DESKTOP").pipe(Config.option),
52});
53​
54const makeDesktopRunId = SecureRandom.SecureRandom.pipe(
55 Effect.flatMap((random) => random.uuidV4()),
56 Effect.map((value) => value.replaceAll("-", "").slice(0, 12)),
57);
58​
59export class DesktopBackendPortUnavailableError extends Schema.TaggedError<DesktopBackendPortUnavailableError>()(
60 "DesktopBackendPortUnavailableError",
61 {
62 startPort: Schema.Int,
63 maxPort: Schema.Int,
64 hosts: Schema.Array(Schema.String),
65 },
66) {
67 override get message(): string {
68 return `No desktop backend port is available on hosts ${this.hosts.join(", ")} between ${this.startPort} and ${this.maxPort}.`;
69 }
71​
72export class DesktopDevelopmentBackendPortRequiredError extends Schema.TaggedError<DesktopDevelopmentBackendPortRequiredError>()(
73 "DesktopDevelopmentBackendPortRequiredError",
74 {},
75) {
76 override get message(): string {
77 return "T3CODE_PORT is required in desktop development.";
78 }
80​
81const { logInfo: logBootstrapInfo, logWarning: logBootstrapWarning } =
82 DesktopObservability.makeComponentLogger("desktop-bootstrap");
83​
84const { logInfo: logStartupInfo, logError: logStartupError } =
85 DesktopObservability.makeComponentLogger("desktop-startup");
86​
87const resolveDesktopBackendPort = Effect.fn("resolveDesktopBackendPort")(function* (
88 configuredPort: Option.Option<number>,
89) {
90 if (Option.isSome(configuredPort)) {
91 return {
92 port: configuredPort.value,
93 selectedByScan: false,
94 } as const;
95 }
96​
97 const net = yield* NetService.NetService;
98 for (let port = DEFAULT_DESKTOP_BACKEND_PORT; port <= MAX_TCP_PORT; port += 1) {
99 let availableOnEveryHost = true;
100​
101 for (const host of DESKTOP_BACKEND_PORT_PROBE_HOSTS) {
102 if (!(yield* net.canListenOnHost(port, host))) {
103 availableOnEveryHost = false;
104 break;
105 }
106 }
107​
108 if (availableOnEveryHost) {
109 return {
110 port,
111 selectedByScan: true,
112 } as const;
113 }
114 }
115​
116 return yield* new DesktopBackendPortUnavailableError({
117 startPort: DEFAULT_DESKTOP_BACKEND_PORT,
118 maxPort: MAX_TCP_PORT,
119 hosts: DESKTOP_BACKEND_PORT_PROBE_HOSTS,
120 });
121});
122​
123const handleFatalStartupError = Effect.fn("desktop.startup.handleFatalStartupError")(function* (
124 stage: string,
125 error: unknown,
126): Effect.fn.Return<
127 void,
128 never,
129 | DesktopShutdown.DesktopShutdown
130 | DesktopState.DesktopState
131 | ElectronApp.ElectronApp
132 | ElectronDialog.ElectronDialog
133> {
134 const shutdown = yield* DesktopShutdown.DesktopShutdown;
135 const state = yield* DesktopState.DesktopState;
136 const electronApp = yield* ElectronApp.ElectronApp;
137 const electronDialog = yield* ElectronDialog.ElectronDialog;
138 const message = error instanceof Error ? error.message : String(error);
139 const detail =
140 error instanceof Error && typeof error.stack === "string" ? `\n${error.stack}` : "";
141 yield* logStartupError("fatal startup error", {
142 stage,
143 message,
144 ...(detail.length > 0 ? { detail } : {}),
145 });
146 const wasQuitting = yield* Ref.getAndSet(state.quitting, true);
147 if (!wasQuitting) {
148 yield* electronDialog.showErrorBox(
149 "T3 Code failed to start",
150 `Stage: ${stage}\n${message}${detail}`,
151 );
152 }
153 yield* shutdown.request;
154 yield* electronApp.quit;
155});
156​
157const fatalStartupCause = <E>(stage: string, cause: Cause.Cause<E>) =>
158 handleFatalStartupError(stage, Cause.pretty(cause)).pipe(Effect.andThen(Effect.failCause(cause)));
159​
160const stopAllPoolInstances = Effect.fn("desktop.app.stopAllPoolInstances")(
161 function* (): Effect.fn.Return<void, never, DesktopBackendPool.DesktopBackendPool> {
162 const pool = yield* DesktopBackendPool.DesktopBackendPool;
163 const instances = yield* pool.list;
164 yield* Effect.forEach(
165 instances,
166 (instance) => instance.stop({ timeout: DesktopBackendManager.BACKEND_QUIT_STOP_TIMEOUT }),
167 { concurrency: "unbounded" },
168 );
169 },
170);
171​
172// Spawns the local backend as soon as the protocol, IPC, and legacy storage that every window needs exist.
173const startLocalBackend = Effect.gen(function* () {
174 const state = yield* DesktopState.DesktopState;
175 const environment = yield* DesktopEnvironment.DesktopEnvironment;
176 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
177 const desktopWindow = yield* DesktopWindow.DesktopWindow;
178 yield* logBootstrapInfo("bootstrap start");
179​
180 const settings = yield* desktopSettings.get;
181 const electronProtocol = yield* ElectronProtocol.ElectronProtocol;
182 yield* electronProtocol.registerDesktopProtocol({
183 scheme: ElectronProtocol.getDesktopScheme(environment.isDevelopment),
184 ...(environment.isDevelopment
185 ? { targetOrigin: Option.getOrThrow(environment.devServerUrl) }
186 : { assetDirectory: environment.clientAssetsDir }),
187 clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname,
188 });
189 yield* installDesktopIpcHandlers();
190 yield* logBootstrapInfo("bootstrap ipc handlers registered");
191 // Before any window: the preload merges these items before the app reads storage.
192 yield* (yield* DesktopLegacyLocalStorage.DesktopLegacyLocalStorage).load(
193 yield* (yield* DesktopAppIdentity.DesktopAppIdentity).resolveUserDataPath,
194 );
195​
196 if (!settings.localEnvironmentEnabled) {
197 yield* logBootstrapInfo("bootstrap skipping local environment (disabled in settings)");
198 return false;
199 }
200​
201 const primaryBackend = yield* (yield* DesktopBackendPool.DesktopBackendPool).primary;
202 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
203​
204 if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) {
205 return yield* new DesktopDevelopmentBackendPortRequiredError();
206 }
207​
208 // A backend started while another server holds this home's lock can only exit with code 78.
209 if (
210 !(settings.wslOnly && settings.wslBackendEnabled) &&
211 !(yield* DesktopHomeOwner.awaitHomeOwnershipRelease())
212 ) {
213 const owner = yield* DesktopHomeOwner.readLiveHomeOwner();
214 yield* logBootstrapWarning("bootstrap found another server owning the T3 home", {
215 owner: Option.getOrNull(owner),
216 });
217 yield* (yield* ElectronDialog.ElectronDialog).showErrorBox(
218 "This T3 home is unavailable",
219 DesktopHomeOwner.homeOwnedMessage(owner),
220 );
221 yield* (yield* ElectronApp.ElectronApp).quit;
222 return false;
223 }
224​
225 const backendPortSelection = yield* resolveDesktopBackendPort(environment.configuredBackendPort);
226 const backendPort = backendPortSelection.port;
227 yield* logBootstrapInfo(
228 backendPortSelection.selectedByScan
229 ? "selected backend port via sequential scan"
230 : "using configured backend port",
231 {
232 port: backendPort,
233 ...(backendPortSelection.selectedByScan ? { startPort: DEFAULT_DESKTOP_BACKEND_PORT } : {}),
234 },
235 );
236​
237 if (settings.serverExposureMode !== environment.defaultDesktopSettings.serverExposureMode) {
238 yield* logBootstrapInfo("bootstrap restoring persisted server exposure mode", {
239 mode: settings.serverExposureMode,
240 });
241 }
242 const serverExposureState = yield* serverExposure.configureFromSettings({ port: backendPort });
243 const backendConfig = yield* serverExposure.backendConfig;
244 yield* logBootstrapInfo("bootstrap resolved backend endpoint", {
245 baseUrl: backendConfig.httpBaseUrl.href,
246 });
247 if (serverExposureState.endpointUrl) {
248 yield* logBootstrapInfo("bootstrap enabled network access", {
249 endpointUrl: serverExposureState.endpointUrl,
250 });
251 } else if (
252 settings.serverExposureMode === "network-accessible" &&
253 serverExposureState.mode === "local-only"
254 ) {
255 yield* logBootstrapWarning(
256 "bootstrap fell back to local-only because no advertised network host was available",
257 );
258 }
259​
260 if (yield* Ref.get(state.quitting)) {
261 return false;
262 }
263 if (settings.wslOnly === true && settings.wslBackendEnabled === true) {
264 yield* desktopWindow.showConnectingSplash;
265 }
266 yield* primaryBackend.start;
267 yield* logBootstrapInfo("bootstrap backend start requested");
268 // The renderer asks for this token first; minting it at readiness saves a round trip.
269 yield* Effect.forkScoped(
270 (yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth).getBearerToken.pipe(
271 Effect.ignore,
272 ),
273 );
274 return true;
275}).pipe(Effect.withSpan("desktop.startLocalBackend"));
276​
277const bootstrap = Effect.fn("desktop.bootstrap")(function* (backendStarted: boolean) {
278 const state = yield* DesktopState.DesktopState;
279 const environment = yield* DesktopEnvironment.DesktopEnvironment;
280 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
281 const desktopWindow = yield* DesktopWindow.DesktopWindow;
282 const snapShot = yield* DesktopSnapShot.DesktopSnapShot;
283 const appActivation = yield* DesktopAppActivation.DesktopAppActivation;
284 const settings = yield* desktopSettings.get;
285 if (settings.localEnvironmentEnabled && (!backendStarted || (yield* Ref.get(state.quitting)))) {
286 return;
287 }
288​
289 yield* snapShot.initialize;
290 // Only the user's own login shell PATH shows every t3 a new terminal runs; a fresh install has
291 // no cached one. Without it, the command is left to Settings.
292 const installCli = (yield* DesktopCliCommand.DesktopCliCommand).installOnFirstLaunch;
293 yield* Effect.forkScoped(
294 (yield* DesktopShellEnvironment.DesktopShellEnvironment).awaitUserShellDiscovery.pipe(
295 Effect.flatMap((found) =>
296 found || environment.platform === "win32"
297 ? installCli
298 : logBootstrapInfo("skipped installing the t3 command without the shell PATH"),
299 ),
300 ),
301 );
302​
303 if (!settings.localEnvironmentEnabled) {
304 if (!(yield* Ref.get(state.quitting))) {
305 yield* desktopWindow.createMainIfBackendReady;
306 }
307 return;
308 }
309​
310 const primaryBackend = yield* (yield* DesktopBackendPool.DesktopBackendPool).primary;
311 // WSL-only fallback can change the primary endpoint before readiness.
312 const config = yield* primaryBackend.currentConfig;
313 if (
314 !environment.isDevelopment &&
315 !settings.wslOnly &&
316 Option.isSome(config) &&
317 Option.isNone(config.value.preflightFailure)
318 ) {
319 yield* desktopWindow.ensureMain;
320 }
321 yield* appActivation.start.pipe(
322 Effect.tap(() => logBootstrapInfo("desktop app control socket ready")),
323 Effect.catch((error) => logStartupError("desktop app control socket unavailable", { error })),
324 );
325 yield* Effect.forkScoped((yield* DesktopWslBackend.DesktopWslBackend).reconcile);
326});
327​
328const startup = Effect.gen(function* () {
329 const appIdentity = yield* DesktopAppIdentity.DesktopAppIdentity;
330 const applicationMenu = yield* DesktopApplicationMenu.DesktopApplicationMenu;
331 const electronApp = yield* ElectronApp.ElectronApp;
332 const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
333 const linuxUrlHandler = yield* DesktopLinuxUrlHandler.DesktopLinuxUrlHandler;
334 const clerk = yield* DesktopClerk.DesktopClerk;
335 const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment;
336 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
337 const preReadyElectronOptions = yield* DesktopPreReadyPlatform.DesktopPreReadyElectronOptions;
338 const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage;
339 const updates = yield* DesktopUpdates.DesktopUpdates;
340 const environment = yield* DesktopEnvironment.DesktopEnvironment;
341 const previewPasskeys = yield* PreviewPasskeys.PreviewPasskeys;
342​
343 yield* shellEnvironment.installIntoProcess;
344 const hasCommandLinePasswordStore =
345 preReadyElectronOptions.linuxPasswordStoreCommandLine !== null;
346 const linuxElectronOptions =
347 environment.platform === "linux" && !hasCommandLinePasswordStore
348 ? DesktopPreReadyPlatform.resolveEarlyLinuxElectronOptionsFromProcess(
349 yield* HostProcess.HomeDirectory,
350 )
351 : preReadyElectronOptions.linux;
352 if (linuxElectronOptions !== null && !hasCommandLinePasswordStore) {
353 if (
354 linuxElectronOptions.passwordStore !== null ||
355 preReadyElectronOptions.linux?.passwordStore !== null
356 ) {
357 yield* electronApp.removeCommandLineSwitch("password-store");
358 }
359 if (linuxElectronOptions.passwordStore !== null) {
360 yield* electronApp.appendCommandLineSwitch(
361 "password-store",
362 linuxElectronOptions.passwordStore,
363 );
364 }
365 }
366 const userDataPath = yield* appIdentity.resolveUserDataPath;
367 yield* electronApp.setPath("userData", userDataPath);
368 yield* logStartupInfo("runtime logging configured", { logDir: environment.logDir });
369 yield* desktopSettings.load;
370​
371 if (linuxElectronOptions !== null) {
372 const xdgDesktop = yield* LinuxDesktopSessionConfig;
373 yield* logStartupInfo("linux password store configured", {
374 passwordStore: hasCommandLinePasswordStore
375 ? "command-line"
376 : (linuxElectronOptions.passwordStore ?? "electron-default"),
377 xdgCurrentDesktop: Option.getOrNull(xdgDesktop.current),
378 xdgSessionDesktop: Option.getOrNull(xdgDesktop.session),
379 });
380 }
381​
382 yield* appIdentity.configure;
383 yield* lifecycle.register;
384 yield* clerk.configure;
385​
386 yield* electronApp.whenReady.pipe(
387 Effect.withSpan("desktop.electron.whenReady"),
388 Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)),
389 );
390 yield* logStartupInfo("app ready");
391 if (environment.platform === "linux") {
392 const selectedBackend = yield* safeStorage.selectedStorageBackend;
393 yield* logStartupInfo("safe storage ready", {
394 backend: Option.getOrElse(selectedBackend, () => "unknown"),
395 });
396 }
397 yield* appIdentity.configure;
398 // Sets canInstallUpdates, which the backend bootstrap payload reads.
399 yield* updates.configure;
400 yield* DesktopRemoteUpdates.listen;
401 yield* DesktopStartupRecovery.listen;
402 const backendStarted = yield* startLocalBackend.pipe(
403 Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause)),
404 );
405 yield* previewPasskeys.configure;
406 yield* applicationMenu.configure;
407 // Portal identity is prepared pre-ready; MIME helpers must not gate the backend or window.
408 yield* linuxUrlHandler.register.pipe(Effect.forkScoped);
409 yield* bootstrap(backendStarted).pipe(
410 Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause)),
411 );
412}).pipe(Effect.withSpan("desktop.startup"));
413​
414const scopedProgram = Effect.scoped(
415 Effect.gen(function* () {
416 const runId = yield* makeDesktopRunId;
417 yield* Effect.annotateLogsScoped({ scope: "desktop", runId });
418 yield* Effect.annotateCurrentSpan({ scope: "desktop", runId });
419​
420 const shutdown = yield* DesktopShutdown.DesktopShutdown;
421 const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory;
422​
423 yield* Effect.addFinalizer(() =>
424 // Quit can outrun layer finalizers; stop pooled backends here so they receive SIGTERM before OS teardown.
425 stopAllPoolInstances().pipe(
426 Effect.ensuring(rendererHistory.shutdown),
427 Effect.ensuring(shutdown.markComplete),
428 Effect.withSpan("desktop.app.shutdown"),
429 ),
430 );
431​
432 yield* startup;
433 yield* shutdown.awaitRequest;
434 }),
435);
436​
437export const program = scopedProgram.pipe(Effect.withSpan("desktop.app"));