|
| 1 | +import * as Cause from "effect/Cause"; |
| 2 | +import * as Data from "effect/Data"; |
| 3 | +import * as Effect from "effect/Effect"; |
| 4 | +import * as Option from "effect/Option"; |
| 5 | +import * as Random from "effect/Random"; |
| 6 | +import * as Ref from "effect/Ref"; |
| 7 | + |
| 8 | +import * as NetService from "@t3tools/shared/Net"; |
| 9 | +import * as ElectronApp from "../electron/ElectronApp.ts"; |
| 10 | +import * as ElectronDialog from "../electron/ElectronDialog.ts"; |
| 11 | +import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; |
| 12 | +import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; |
| 13 | +import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; |
| 14 | +import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; |
| 15 | +import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; |
| 16 | +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; |
| 17 | +import * as DesktopLifecycle from "./DesktopLifecycle.ts"; |
| 18 | +import * as DesktopObservability from "./DesktopObservability.ts"; |
| 19 | +import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; |
| 20 | +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 21 | +import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; |
| 22 | +import * as DesktopState from "./DesktopState.ts"; |
| 23 | +import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; |
| 24 | + |
| 25 | +const DEFAULT_DESKTOP_BACKEND_PORT = 3773; |
| 26 | +const MAX_TCP_PORT = 65_535; |
| 27 | +const DESKTOP_BACKEND_PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::"] as const; |
| 28 | + |
| 29 | +const makeDesktopRunId = Random.nextUUIDv4.pipe( |
| 30 | + Effect.map((value) => value.replaceAll("-", "").slice(0, 12)), |
| 31 | +); |
| 32 | + |
| 33 | +class DesktopBackendPortUnavailableError extends Data.TaggedError( |
| 34 | + "DesktopBackendPortUnavailableError", |
| 35 | +)<{ |
| 36 | + readonly startPort: number; |
| 37 | + readonly maxPort: number; |
| 38 | + readonly hosts: readonly string[]; |
| 39 | +}> { |
| 40 | + override get message() { |
| 41 | + return `No desktop backend port is available on hosts ${this.hosts.join(", ")} between ${this.startPort} and ${this.maxPort}.`; |
| 42 | + } |
| 43 | +} |
| 44 | + |
| 45 | +class DesktopDevelopmentBackendPortRequiredError extends Data.TaggedError( |
| 46 | + "DesktopDevelopmentBackendPortRequiredError", |
| 47 | +)<{}> { |
| 48 | + override get message() { |
| 49 | + return "T3CODE_PORT is required in desktop development."; |
| 50 | + } |
| 51 | +} |
| 52 | + |
| 53 | +const { logInfo: logBootstrapInfo, logWarning: logBootstrapWarning } = |
| 54 | + DesktopObservability.makeComponentLogger("desktop-bootstrap"); |
| 55 | + |
| 56 | +const { logInfo: logStartupInfo, logError: logStartupError } = |
| 57 | + DesktopObservability.makeComponentLogger("desktop-startup"); |
| 58 | + |
| 59 | +const resolveDesktopBackendPort = Effect.fn("resolveDesktopBackendPort")(function* ( |
| 60 | + configuredPort: Option.Option<number>, |
| 61 | +) { |
| 62 | + if (Option.isSome(configuredPort)) { |
| 63 | + return { |
| 64 | + port: configuredPort.value, |
| 65 | + selectedByScan: false, |
| 66 | + } as const; |
| 67 | + } |
| 68 | + |
| 69 | + const net = yield* NetService.NetService; |
| 70 | + for (let port = DEFAULT_DESKTOP_BACKEND_PORT; port <= MAX_TCP_PORT; port += 1) { |
| 71 | + let availableOnEveryHost = true; |
| 72 | + |
| 73 | + for (const host of DESKTOP_BACKEND_PORT_PROBE_HOSTS) { |
| 74 | + if (!(yield* net.canListenOnHost(port, host))) { |
| 75 | + availableOnEveryHost = false; |
| 76 | + break; |
| 77 | + } |
| 78 | + } |
| 79 | + |
| 80 | + if (availableOnEveryHost) { |
| 81 | + return { |
| 82 | + port, |
| 83 | + selectedByScan: true, |
| 84 | + } as const; |
| 85 | + } |
| 86 | + } |
| 87 | + |
| 88 | + return yield* new DesktopBackendPortUnavailableError({ |
| 89 | + startPort: DEFAULT_DESKTOP_BACKEND_PORT, |
| 90 | + maxPort: MAX_TCP_PORT, |
| 91 | + hosts: DESKTOP_BACKEND_PORT_PROBE_HOSTS, |
| 92 | + }); |
| 93 | +}); |
| 94 | + |
| 95 | +const handleFatalStartupError = Effect.fn("desktop.startup.handleFatalStartupError")(function* ( |
| 96 | + stage: string, |
| 97 | + error: unknown, |
| 98 | +): Effect.fn.Return< |
| 99 | + void, |
| 100 | + never, |
| 101 | + | DesktopLifecycle.DesktopShutdown |
| 102 | + | DesktopState.DesktopState |
| 103 | + | ElectronApp.ElectronApp |
| 104 | + | ElectronDialog.ElectronDialog |
| 105 | +> { |
| 106 | + const shutdown = yield* DesktopLifecycle.DesktopShutdown; |
| 107 | + const state = yield* DesktopState.DesktopState; |
| 108 | + const electronApp = yield* ElectronApp.ElectronApp; |
| 109 | + const electronDialog = yield* ElectronDialog.ElectronDialog; |
| 110 | + const message = error instanceof Error ? error.message : String(error); |
| 111 | + const detail = |
| 112 | + error instanceof Error && typeof error.stack === "string" ? `\n${error.stack}` : ""; |
| 113 | + yield* logStartupError("fatal startup error", { |
| 114 | + stage, |
| 115 | + message, |
| 116 | + ...(detail.length > 0 ? { detail } : {}), |
| 117 | + }); |
| 118 | + const wasQuitting = yield* Ref.getAndSet(state.quitting, true); |
| 119 | + if (!wasQuitting) { |
| 120 | + yield* electronDialog.showErrorBox( |
| 121 | + "T3 Code failed to start", |
| 122 | + `Stage: ${stage}\n${message}${detail}`, |
| 123 | + ); |
| 124 | + } |
| 125 | + yield* shutdown.request; |
| 126 | + yield* electronApp.quit; |
| 127 | +}); |
| 128 | + |
| 129 | +const fatalStartupCause = <E>(stage: string, cause: Cause.Cause<E>) => |
| 130 | + handleFatalStartupError(stage, Cause.pretty(cause)).pipe(Effect.andThen(Effect.failCause(cause))); |
| 131 | + |
| 132 | +const bootstrap = Effect.gen(function* () { |
| 133 | + const backendManager = yield* DesktopBackendManager.DesktopBackendManager; |
| 134 | + const state = yield* DesktopState.DesktopState; |
| 135 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 136 | + const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; |
| 137 | + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; |
| 138 | + yield* logBootstrapInfo("bootstrap start"); |
| 139 | + |
| 140 | + if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) { |
| 141 | + return yield* new DesktopDevelopmentBackendPortRequiredError(); |
| 142 | + } |
| 143 | + |
| 144 | + const backendPortSelection = yield* resolveDesktopBackendPort(environment.configuredBackendPort); |
| 145 | + const backendPort = backendPortSelection.port; |
| 146 | + yield* logBootstrapInfo( |
| 147 | + backendPortSelection.selectedByScan |
| 148 | + ? "selected backend port via sequential scan" |
| 149 | + : "using configured backend port", |
| 150 | + { |
| 151 | + port: backendPort, |
| 152 | + ...(backendPortSelection.selectedByScan ? { startPort: DEFAULT_DESKTOP_BACKEND_PORT } : {}), |
| 153 | + }, |
| 154 | + ); |
| 155 | + |
| 156 | + const settings = yield* desktopSettings.get; |
| 157 | + if (settings.serverExposureMode !== environment.defaultDesktopSettings.serverExposureMode) { |
| 158 | + yield* logBootstrapInfo("bootstrap restoring persisted server exposure mode", { |
| 159 | + mode: settings.serverExposureMode, |
| 160 | + }); |
| 161 | + } |
| 162 | + const serverExposureState = yield* serverExposure.configureFromSettings({ port: backendPort }); |
| 163 | + const backendConfig = yield* serverExposure.backendConfig; |
| 164 | + yield* logBootstrapInfo("bootstrap resolved backend endpoint", { |
| 165 | + baseUrl: backendConfig.httpBaseUrl.href, |
| 166 | + }); |
| 167 | + if (serverExposureState.endpointUrl) { |
| 168 | + yield* logBootstrapInfo("bootstrap enabled network access", { |
| 169 | + endpointUrl: serverExposureState.endpointUrl, |
| 170 | + }); |
| 171 | + } else if (settings.serverExposureMode === "network-accessible") { |
| 172 | + yield* logBootstrapWarning( |
| 173 | + "bootstrap fell back to local-only because no advertised network host was available", |
| 174 | + ); |
| 175 | + } |
| 176 | + |
| 177 | + yield* installDesktopIpcHandlers; |
| 178 | + yield* logBootstrapInfo("bootstrap ipc handlers registered"); |
| 179 | + |
| 180 | + if (!(yield* Ref.get(state.quitting))) { |
| 181 | + yield* backendManager.start; |
| 182 | + yield* logBootstrapInfo("bootstrap backend start requested"); |
| 183 | + } |
| 184 | +}).pipe(Effect.withSpan("desktop.bootstrap")); |
| 185 | + |
| 186 | +const startup = Effect.gen(function* () { |
| 187 | + const appIdentity = yield* DesktopAppIdentity.DesktopAppIdentity; |
| 188 | + const applicationMenu = yield* DesktopApplicationMenu.DesktopApplicationMenu; |
| 189 | + const electronApp = yield* ElectronApp.ElectronApp; |
| 190 | + const electronProtocol = yield* ElectronProtocol.ElectronProtocol; |
| 191 | + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; |
| 192 | + const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; |
| 193 | + const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; |
| 194 | + const updates = yield* DesktopUpdates.DesktopUpdates; |
| 195 | + const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 196 | + |
| 197 | + yield* shellEnvironment.installIntoProcess; |
| 198 | + const userDataPath = yield* appIdentity.resolveUserDataPath; |
| 199 | + yield* electronApp.setPath("userData", userDataPath); |
| 200 | + yield* logStartupInfo("runtime logging configured", { logDir: environment.logDir }); |
| 201 | + yield* desktopSettings.load; |
| 202 | + |
| 203 | + if (environment.platform === "linux") { |
| 204 | + yield* electronApp.appendCommandLineSwitch("class", environment.linuxWmClass); |
| 205 | + } |
| 206 | + |
| 207 | + yield* appIdentity.configure; |
| 208 | + yield* lifecycle.register; |
| 209 | + |
| 210 | + yield* electronApp.whenReady.pipe( |
| 211 | + Effect.withSpan("desktop.electron.whenReady"), |
| 212 | + Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)), |
| 213 | + ); |
| 214 | + yield* logStartupInfo("app ready"); |
| 215 | + yield* appIdentity.configure; |
| 216 | + yield* applicationMenu.configure; |
| 217 | + yield* electronProtocol.registerDesktopFileProtocol; |
| 218 | + yield* updates.configure; |
| 219 | + yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); |
| 220 | +}).pipe(Effect.withSpan("desktop.startup")); |
| 221 | + |
| 222 | +const scopedProgram = Effect.scoped( |
| 223 | + Effect.gen(function* () { |
| 224 | + const runId = yield* makeDesktopRunId; |
| 225 | + yield* Effect.annotateLogsScoped({ scope: "desktop", runId }); |
| 226 | + yield* Effect.annotateCurrentSpan({ scope: "desktop", runId }); |
| 227 | + |
| 228 | + const shutdown = yield* DesktopLifecycle.DesktopShutdown; |
| 229 | + const backendManager = yield* DesktopBackendManager.DesktopBackendManager; |
| 230 | + |
| 231 | + yield* Effect.addFinalizer(() => |
| 232 | + backendManager.stop().pipe(Effect.ensuring(shutdown.markComplete)), |
| 233 | + ); |
| 234 | + |
| 235 | + yield* startup; |
| 236 | + yield* shutdown.awaitRequest; |
| 237 | + }), |
| 238 | +); |
| 239 | + |
| 240 | +export const program = scopedProgram.pipe(Effect.withSpan("desktop.app")); |
0 commit comments