History

Merge origin/main into t3code/mobile-remote-connect

Julius Marminge committed 6 months ago

Commit b3574762 parents 87f7434 9d919d0216 files · +18385 −9857Browse files

Unified · Split

216 files
  1. .gitignore +2 −0
  2. .plans/19-remote-endpoints-hosted-static.md +0 −1
  3. apps/desktop/.gitignore +0 −2
  4. apps/desktop/package.json +3 −1
  5. apps/desktop/src/app/DesktopApp.ts +240 −0
  6. apps/desktop/src/app/DesktopAppIdentity.test.ts +176 −0
  7. apps/desktop/src/app/DesktopAppIdentity.ts +127 −0
  8. apps/desktop/src/app/DesktopAssets.ts +85 −0
  9. apps/desktop/src/app/DesktopConfig.ts +58 −0
  10. apps/desktop/src/app/DesktopEnvironment.test.ts +117 −0
  11. apps/desktop/src/app/DesktopEnvironment.ts +249 −0
  12. apps/desktop/src/app/DesktopLifecycle.ts +233 −0
  13. apps/desktop/src/app/DesktopObservability.test.ts +162 −0
  14. apps/desktop/src/app/DesktopObservability.ts +395 −0
  15. apps/desktop/src/app/DesktopState.ts +21 −0
  16. apps/desktop/src/appBranding.test.ts +0 −47
  17. apps/desktop/src/appBranding.ts +0 −28
  18. apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +195 −0
  19. apps/desktop/src/backend/DesktopBackendConfiguration.ts +170 −0
  20. apps/desktop/src/backend/DesktopBackendManager.test.ts +490 −0
  21. apps/desktop/src/backend/DesktopBackendManager.ts +596 −0
  22. apps/desktop/src/backend/DesktopServerExposure.test.ts +365 −0
  23. apps/desktop/src/backend/DesktopServerExposure.ts +548 −0
  24. apps/desktop/src/backend/tailscaleEndpointProvider.test.ts +142 −0
  25. apps/desktop/src/backend/tailscaleEndpointProvider.ts +138 −0
  26. apps/desktop/src/backendPort.test.ts +0 −109
  27. apps/desktop/src/backendPort.ts +0 −83
  28. apps/desktop/src/backendReadiness.test.ts +0 −114
  29. apps/desktop/src/backendReadiness.ts +0 −107
  30. apps/desktop/src/backendStartupReadiness.test.ts +0 −58
  31. apps/desktop/src/backendStartupReadiness.ts +0 −56
  32. apps/desktop/src/clientPersistence.test.ts +0 −249
  33. apps/desktop/src/clientPersistence.ts +0 −238
  34. apps/desktop/src/confirmDialog.test.ts +0 −57
  35. apps/desktop/src/confirmDialog.ts +0 −26
  36. apps/desktop/src/desktopSettings.test.ts +0 −230
  37. apps/desktop/src/desktopSettings.ts +0 −125
  38. apps/desktop/src/electron/ElectronApp.test.ts +109 −0
  39. apps/desktop/src/electron/ElectronApp.ts +118 −0
  40. apps/desktop/src/electron/ElectronDialog.test.ts +93 −0
  41. apps/desktop/src/electron/ElectronDialog.ts +84 −0
  42. apps/desktop/src/electron/ElectronMenu.test.ts +119 −0
  43. apps/desktop/src/electron/ElectronMenu.ts +181 −0
  44. apps/desktop/src/electron/ElectronProtocol.test.ts +105 −0
  45. apps/desktop/src/electron/ElectronProtocol.ts +272 −0
  46. apps/desktop/src/electron/ElectronSafeStorage.ts +70 −0
  47. apps/desktop/src/electron/ElectronShell.test.ts +59 −0
  48. apps/desktop/src/electron/ElectronShell.ts +50 −0
  49. apps/desktop/src/electron/ElectronTheme.test.ts +52 −0
  50. apps/desktop/src/electron/ElectronTheme.ts +40 −0
  51. apps/desktop/src/electron/ElectronUpdater.test.ts +79 −0
  52. apps/desktop/src/electron/ElectronUpdater.ts +139 −0
  53. apps/desktop/src/electron/ElectronWindow.test.ts +51 −0
  54. apps/desktop/src/electron/ElectronWindow.ts +135 −0
  55. apps/desktop/src/ipc/DesktopIpc.ts +219 −0
  56. apps/desktop/src/ipc/DesktopIpcHandlers.ts +84 −0
  57. apps/desktop/src/ipc/channels.ts +35 −0
  58. apps/desktop/src/ipc/methods/clientSettings.ts +28 −0
  59. apps/desktop/src/ipc/methods/savedEnvironments.ts +76 −0
  60. apps/desktop/src/ipc/methods/serverExposure.ts +69 −0
  61. apps/desktop/src/ipc/methods/sshEnvironment.ts +127 −0
  62. apps/desktop/src/ipc/methods/updates.ts +62 −0
  63. apps/desktop/src/ipc/methods/window.ts +135 −0
  64. apps/desktop/src/main.ts +138 −2287
  65. apps/desktop/src/preload.ts +54 −74
  66. apps/desktop/src/rotatingFileSink.test.ts +0 −74
  67. apps/desktop/src/runtimeArch.test.ts +0 −49
  68. apps/desktop/src/runtimeArch.ts +0 −39
  69. apps/desktop/src/serverExposure.test.ts +0 −258
  70. apps/desktop/src/serverExposure.ts +0 −188
  71. apps/desktop/src/serverListeningDetector.test.ts +0 −31
  72. apps/desktop/src/serverListeningDetector.ts +0 −56
  73. apps/desktop/src/settings/DesktopAppSettings.test.ts +284 −0
  74. apps/desktop/src/settings/DesktopAppSettings.ts +318 −0
  75. apps/desktop/src/settings/DesktopClientSettings.test.ts +185 −0
  76. apps/desktop/src/settings/DesktopClientSettings.ts +118 −0
  77. apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +344 −0
  78. apps/desktop/src/settings/DesktopSavedEnvironments.ts +390 −0
  79. apps/desktop/src/shell/DesktopShellEnvironment.test.ts +232 −0
  80. apps/desktop/src/shell/DesktopShellEnvironment.ts +356 −0
  81. apps/desktop/src/ssh/DesktopSshEnvironment.test.ts +118 −0
  82. apps/desktop/src/ssh/DesktopSshEnvironment.ts +150 −0
  83. apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts +144 −0
  84. apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts +332 −0
  85. apps/desktop/src/ssh/DesktopSshRemoteApi.test.ts +79 −0
  86. apps/desktop/src/ssh/DesktopSshRemoteApi.ts +124 −0
  87. apps/desktop/src/sshEnvironment.test.ts +0 −98
  88. apps/desktop/src/sshEnvironment.ts +0 −420
  89. apps/desktop/src/syncShellEnvironment.test.ts +0 −289
  90. apps/desktop/src/syncShellEnvironment.ts +0 −109
  91. apps/desktop/src/tailscaleEndpointProvider.test.ts +0 −122
  92. apps/desktop/src/tailscaleEndpointProvider.ts +0 −142
  93. apps/desktop/src/updateChannels.test.ts +0 −41
  94. apps/desktop/src/updateState.test.ts +0 −175
  95. apps/desktop/src/updateState.ts +0 −52
  96. apps/desktop/src/updates/DesktopUpdates.test.ts +295 −0
  97. apps/desktop/src/updates/DesktopUpdates.ts +664 −0
  98. apps/desktop/src/updates/updateChannels.ts +0 −7
  99. apps/desktop/src/updates/updateMachine.test.ts +0 −0
  100. apps/desktop/src/updates/updateMachine.ts +9 −1
  101. apps/desktop/src/window/DesktopApplicationMenu.test.ts +132 −0
  102. apps/desktop/src/window/DesktopApplicationMenu.ts +212 −0
  103. apps/desktop/src/window/DesktopWindow.test.ts +180 −0
  104. apps/desktop/src/window/DesktopWindow.ts +368 −0
  105. apps/desktop/src/windowReveal.test.ts +0 −74
  106. apps/desktop/src/windowReveal.ts +0 −28
  107. apps/desktop/tsconfig.json +13 −1
  108. apps/server/src/cli/config.test.ts +63 −44
  109. apps/server/src/cli/config.ts +3 −26
  110. apps/server/src/http.ts +1 −1
  111. apps/server/src/observability/Attributes.test.ts +1 −36
  112. apps/server/src/observability/Attributes.ts +0 −80
  113. apps/server/src/observability/Layers/Observability.ts +1 −2
  114. apps/server/src/observability/LocalFileTracer.test.ts +0 −117
  115. apps/server/src/observability/LocalFileTracer.ts +0 −112
  116. apps/server/src/observability/Services/BrowserTraceCollector.ts +1 −2
  117. apps/server/src/observability/TraceSink.test.ts +0 −152
  118. apps/server/src/observability/TraceSink.ts +0 −68
  119. apps/server/src/orchestration/decider.ts +11 −21
  120. apps/server/src/provider/Layers/CodexAdapter.test.ts +36 −0
  121. apps/server/src/provider/Layers/CodexAdapter.ts +1 −1
  122. apps/server/src/provider/Layers/CodexProvider.ts +2 −0
  123. apps/server/src/provider/Layers/CodexSessionRuntime.ts +6 −5
  124. apps/server/src/provider/Layers/CursorAdapter.test.ts +1 −0
  125. apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +9 −25
  126. apps/server/src/provider/Layers/ProviderRegistry.test.ts +20 −0
  127. apps/server/src/sourceControl/SourceControlRepositoryService.ts +15 −21
  128. apps/server/src/vcs/GitVcsDriverCore.ts +2 −2
  129. apps/web/package.json +1 −1
  130. apps/web/src/components/ChatMarkdown.tsx +18 −1
  131. apps/web/src/components/ChatView.tsx +2 −0
  132. apps/web/src/components/ComposerPromptEditor.tsx +1 −2
  133. apps/web/src/components/Sidebar.tsx +94 −7
  134. apps/web/src/components/chat/ChatComposer.tsx +1 −1
  135. apps/web/src/components/chat/MessagesTimeline.tsx +71 −18
  136. apps/web/src/components/chat/SkillInlineText.tsx +87 −0
  137. apps/web/src/components/composerInlineChip.ts +2 −0
  138. apps/web/src/components/settings/SettingsPanels.tsx +5 −0
  139. apps/web/src/components/ui/input.tsx +26 −15
  140. apps/web/src/components/ui/number-field.tsx +156 −0
  141. apps/web/src/localApi.test.ts +2 −0
  142. apps/web/tsconfig.json +1 −1
  143. apps/web/vite.config.ts +0 −2
  144. bun.lock +11 −5
  145. packages/client-runtime/src/gitStatusState.ts +1 −1
  146. packages/client-runtime/src/wsRpcClient.ts +2 −1
  147. packages/client-runtime/src/wsRpcProtocol.ts +4 −1
  148. packages/client-runtime/src/wsTransport.test.ts +1 −1
  149. packages/client-runtime/src/wsTransport.ts +8 −1
  150. packages/client-runtime/tsconfig.json +14 −0
  151. packages/contracts/src/auth.ts +1 −1
  152. packages/contracts/src/baseSchemas.ts +2 −1
  153. packages/contracts/src/desktopBootstrap.ts +18 −0
  154. packages/contracts/src/editor.ts +1 −1
  155. packages/contracts/src/environment.ts +2 −1
  156. packages/contracts/src/filesystem.ts +1 −1
  157. packages/contracts/src/git.test.ts +1 −1
  158. packages/contracts/src/git.ts +1 −1
  159. packages/contracts/src/index.ts +1 −0
  160. packages/contracts/src/ipc.ts +187 −23
  161. packages/contracts/src/keybindings.test.ts +2 −2
  162. packages/contracts/src/keybindings.ts +1 −1
  163. packages/contracts/src/model.ts +3 −1
  164. packages/contracts/src/orchestration.test.ts +2 −1
  165. packages/contracts/src/orchestration.ts +6 −1
  166. packages/contracts/src/project.ts +1 −1
  167. packages/contracts/src/provider.test.ts +1 −1
  168. packages/contracts/src/provider.ts +1 −1
  169. packages/contracts/src/providerInstance.test.ts +1 −1
  170. packages/contracts/src/providerInstance.ts +2 −1
  171. packages/contracts/src/providerRuntime.test.ts +1 −1
  172. packages/contracts/src/providerRuntime.ts +2 −1
  173. packages/contracts/src/remoteAccess.ts +1 −1
  174. packages/contracts/src/review.ts +1 −1
  175. packages/contracts/src/rpc.ts +1 −1
  176. packages/contracts/src/server.test.ts +1 −1
  177. packages/contracts/src/server.ts +2 −1
  178. packages/contracts/src/settings.test.ts +1 −1
  179. packages/contracts/src/settings.ts +15 −1
  180. packages/contracts/src/sourceControl.ts +1 −1
  181. packages/contracts/src/terminal.test.ts +1 −1
  182. packages/contracts/src/terminal.ts +1 −1
  183. packages/contracts/src/vcs.ts +1 −1
  184. packages/contracts/tsconfig.json +2 −2
  185. packages/effect-acp/scripts/generate.ts +6 −1
  186. packages/effect-acp/tsconfig.json +2 −2
  187. packages/effect-codex-app-server/scripts/generate.ts +41 −40
  188. packages/effect-codex-app-server/src/_generated/meta.gen.ts +72 −21
  189. packages/effect-codex-app-server/src/_generated/namespaces.gen.ts +28 −9
  190. packages/effect-codex-app-server/src/_generated/schema.gen.ts +4149 −2647
  191. packages/effect-codex-app-server/tsconfig.json +2 −2
  192. packages/shared/package.json +4 −0
  193. packages/shared/src/DrainableWorker.test.ts +2 −1
  194. packages/shared/src/DrainableWorker.ts +4 −2
  195. packages/shared/src/KeyedCoalescingWorker.test.ts +3 −2
  196. packages/shared/src/KeyedCoalescingWorker.ts +4 −2
  197. packages/shared/src/Net.test.ts +15 −15
  198. packages/shared/src/Net.ts +95 −88
  199. packages/shared/src/observability.test.ts +314 −0
  200. packages/shared/src/observability.ts +259 −7
  201. packages/shared/src/schemaJson.ts +9 −11
  202. packages/shared/src/serverSettings.ts +1 −1
  203. packages/shared/src/shell.ts +2 −2
  204. packages/shared/tsconfig.json +14 −0
  205. packages/ssh/src/auth.test.ts +4 −1
  206. packages/ssh/src/auth.ts +6 −1
  207. packages/ssh/src/command.test.ts +7 −1
  208. packages/ssh/src/command.ts +7 −1
  209. packages/ssh/src/config.test.ts +3 −1
  210. packages/ssh/src/config.ts +4 −1
  211. packages/ssh/src/errors.ts +1 −1
  212. packages/ssh/src/tunnel.test.ts +7 −1
  213. packages/ssh/src/tunnel.ts +20 −18
  214. packages/ssh/tsconfig.json +2 −1
  215. packages/tailscale/src/tailscale.test.ts +4 −1
  216. packages/tailscale/src/tailscale.ts +5 −1
.gitignore+2−0
@@ -23,3 +23,5 @@ __screenshots__/ · 22 unmodified lines
2323 .tanstack
2424 squashfs-root/
2525 .vercel
26+dist-electron/
27+.electron-runtime/
.plans/19-remote-endpoints-hosted-static.md+0−1
@@ -347,4 +347,3 @@ Each implementation PR should run: · 346 unmodified lines
347347 - `bun typecheck`
348348 - focused tests for changed backend/web behavior
349349 - backend tests for any server-side endpoint discovery or auth changes using `bun run test`, never `bun test`
350−
apps/desktop/.gitignore+0−2
@@ -1,2 +0,0 @@
1−dist-electron/
2−.electron-runtime/
apps/desktop/package.json+3−1
@@ -17,10 +17,12 @@ · 16 unmodified lines
1717 "dependencies": {
1818 "@effect/platform-node": "catalog:",
1919 "effect": "catalog:",
20− "electron": "40.9.3",
20+ "electron": "41.5.0",
2121 "electron-updater": "^6.6.2"
2222 },
2323 "devDependencies": {
24+ "@effect/language-service": "catalog:",
25+ "@effect/vitest": "catalog:",
2426 "@t3tools/client-runtime": "workspace:*",
2527 "@t3tools/contracts": "workspace:*",
2628 "@t3tools/shared": "workspace:*",
apps/desktop/src/app/DesktopApp.ts+240−0
@@ -0,0 +1,240 @@
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"));
apps/desktop/src/app/DesktopAppIdentity.test.ts+176−0
@@ -0,0 +1,176 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { assert, describe, it } from "@effect/vitest";
3+import * as Effect from "effect/Effect";
4+import * as FileSystem from "effect/FileSystem";
5+import * as Layer from "effect/Layer";
6+import * as Option from "effect/Option";
7+
8+import type * as Electron from "electron";
9+
10+import * as ElectronApp from "../electron/ElectronApp.ts";
11+import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
12+import * as DesktopAssets from "./DesktopAssets.ts";
13+import * as DesktopConfig from "./DesktopConfig.ts";
14+import * as DesktopEnvironment from "./DesktopEnvironment.ts";
15+
16+const defaultEnvironmentInput = {
17+ dirname: "/repo/apps/desktop/dist-electron",
18+ homeDirectory: "/Users/alice",
19+ platform: "darwin",
20+ processArch: "arm64",
21+ appVersion: "1.2.3",
22+ appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar",
23+ isPackaged: true,
24+ resourcesPath: "/Applications/T3 Code.app/Contents/Resources",
25+ runningUnderArm64Translation: false,
26+} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput;
27+
28+type TestEnvironmentInput = Partial<DesktopEnvironment.MakeDesktopEnvironmentInput> & {
29+ readonly env?: Record<string, string | undefined>;
30+};
31+
32+interface ElectronAppCalls {
33+ readonly setAboutPanelOptions: Array<Electron.AboutPanelOptionsOptions>;
34+ readonly setDockIcon: string[];
35+ readonly setName: string[];
36+}
37+
38+const makeElectronAppLayer = (calls: ElectronAppCalls) =>
39+ Layer.succeed(ElectronApp.ElectronApp, {
40+ metadata: Effect.die("unexpected metadata read"),
41+ name: Effect.succeed("T3 Code"),
42+ whenReady: Effect.void,
43+ quit: Effect.void,
44+ exit: () => Effect.void,
45+ relaunch: () => Effect.void,
46+ setPath: () => Effect.void,
47+ setName: (name) =>
48+ Effect.sync(() => {
49+ calls.setName.push(name);
50+ }),
51+ setAboutPanelOptions: (options) =>
52+ Effect.sync(() => {
53+ calls.setAboutPanelOptions.push(options);
54+ }),
55+ setAppUserModelId: () => Effect.void,
56+ setDesktopName: () => Effect.void,
57+ setDockIcon: (iconPath) =>
58+ Effect.sync(() => {
59+ calls.setDockIcon.push(iconPath);
60+ }),
61+ appendCommandLineSwitch: () => Effect.void,
62+ on: () => Effect.void,
63+ } satisfies ElectronApp.ElectronAppShape);
64+
65+const makeAssetsLayer = (png: Option.Option<string>) =>
66+ Layer.succeed(DesktopAssets.DesktopAssets, {
67+ iconPaths: Effect.succeed({
68+ ico: Option.none(),
69+ icns: Option.none(),
70+ png,
71+ }),
72+ resolveResourcePath: () => Effect.succeed(Option.none()),
73+ } satisfies DesktopAssets.DesktopAssetsShape);
74+
75+const makeEnvironmentLayer = (overrides: TestEnvironmentInput = {}) => {
76+ const { env, ...environmentOverrides } = overrides;
77+ return DesktopEnvironment.layer({
78+ ...defaultEnvironmentInput,
79+ ...environmentOverrides,
80+ }).pipe(
81+ Layer.provide(
82+ Layer.mergeAll(
83+ NodeServices.layer,
84+ DesktopConfig.layerTest({
85+ ...env,
86+ }),
87+ ),
88+ ),
89+ );
90+};
91+
92+const withIdentity = <A, E, R>(
93+ effect: Effect.Effect<
94+ A,
95+ E,
96+ | R
97+ | DesktopAppIdentity.DesktopAppIdentity
98+ | DesktopEnvironment.DesktopEnvironment
99+ | FileSystem.FileSystem
100+ >,
101+ input: {
102+ readonly calls?: ElectronAppCalls;
103+ readonly environment?: TestEnvironmentInput;
104+ readonly legacyPathExists?: boolean;
105+ readonly packageJson?: string;
106+ readonly pngIconPath?: Option.Option<string>;
107+ } = {},
108+) => {
109+ const calls: ElectronAppCalls = input.calls ?? {
110+ setAboutPanelOptions: [],
111+ setDockIcon: [],
112+ setName: [],
113+ };
114+
115+ return effect.pipe(
116+ Effect.provide(
117+ DesktopAppIdentity.layer.pipe(
118+ Layer.provideMerge(
119+ FileSystem.layerNoop({
120+ exists: (path) =>
121+ Effect.succeed(input.legacyPathExists === true && path.includes("T3 Code (Alpha)")),
122+ readFileString: () =>
123+ Effect.succeed(input.packageJson ?? '{"t3codeCommitHash":"abcdef1234567890"}'),
124+ }),
125+ ),
126+ Layer.provideMerge(makeAssetsLayer(input.pngIconPath ?? Option.none())),
127+ Layer.provideMerge(makeElectronAppLayer(calls)),
128+ Layer.provideMerge(makeEnvironmentLayer(input.environment)),
129+ ),
130+ ),
131+ );
132+};
133+
134+describe("DesktopAppIdentity", () => {
135+ it.effect("keeps using the legacy userData path when it already exists", () =>
136+ withIdentity(
137+ Effect.gen(function* () {
138+ const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
139+ const userDataPath = yield* identity.resolveUserDataPath;
140+
141+ assert.equal(userDataPath, "/Users/alice/Library/Application Support/T3 Code (Alpha)");
142+ }),
143+ { legacyPathExists: true },
144+ ),
145+ );
146+
147+ it.effect("configures app identity from the environment commit override", () => {
148+ const calls: ElectronAppCalls = {
149+ setAboutPanelOptions: [],
150+ setDockIcon: [],
151+ setName: [],
152+ };
153+
154+ return withIdentity(
155+ Effect.gen(function* () {
156+ const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
157+ yield* identity.configure;
158+
159+ assert.deepEqual(calls.setName, ["T3 Code (Alpha)"]);
160+ assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "T3 Code (Alpha)");
161+ assert.equal(calls.setAboutPanelOptions[0]?.applicationVersion, "1.2.3");
162+ assert.equal(calls.setAboutPanelOptions[0]?.version, "0123456789ab");
163+ assert.deepEqual(calls.setDockIcon, ["/icon.png"]);
164+ }),
165+ {
166+ calls,
167+ environment: {
168+ env: {
169+ T3CODE_COMMIT_HASH: "0123456789abcdef",
170+ },
171+ },
172+ pngIconPath: Option.some("/icon.png"),
173+ },
174+ );
175+ });
176+});
apps/desktop/src/app/DesktopAppIdentity.ts+127−0
@@ -0,0 +1,127 @@
1+import * as Context from "effect/Context";
2+import * as Effect from "effect/Effect";
3+import * as FileSystem from "effect/FileSystem";
4+import * as Layer from "effect/Layer";
5+import * as Option from "effect/Option";
6+import * as Ref from "effect/Ref";
7+import * as Schema from "effect/Schema";
8+
9+import * as ElectronApp from "../electron/ElectronApp.ts";
10+import * as DesktopAssets from "./DesktopAssets.ts";
11+import * as DesktopEnvironment from "./DesktopEnvironment.ts";
12+
13+const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i;
14+const COMMIT_HASH_DISPLAY_LENGTH = 12;
15+
16+const AppPackageMetadata = Schema.Struct({
17+ t3codeCommitHash: Schema.optional(Schema.String),
18+});
19+
20+export interface DesktopAppIdentityShape {
21+ readonly resolveUserDataPath: Effect.Effect<string>;
22+ readonly configure: Effect.Effect<void>;
23+}
24+
25+export class DesktopAppIdentity extends Context.Service<
26+ DesktopAppIdentity,
27+ DesktopAppIdentityShape
28+>()("t3/desktop/AppIdentity") {}
29+
30+const normalizeCommitHash = (value: string): Option.Option<string> => {
31+ const trimmed = value.trim();
32+ return COMMIT_HASH_PATTERN.test(trimmed)
33+ ? Option.some(trimmed.slice(0, COMMIT_HASH_DISPLAY_LENGTH).toLowerCase())
34+ : Option.none();
35+};
36+
37+const make = Effect.gen(function* () {
38+ const assets = yield* DesktopAssets.DesktopAssets;
39+ const electronApp = yield* ElectronApp.ElectronApp;
40+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
41+ const fileSystem = yield* FileSystem.FileSystem;
42+ const commitHashCache = yield* Ref.make<Option.Option<Option.Option<string>>>(Option.none());
43+
44+ const resolveEmbeddedCommitHash = Effect.gen(function* () {
45+ const packageJsonPath = environment.path.join(environment.appRoot, "package.json");
46+ const raw = yield* fileSystem.readFileString(packageJsonPath).pipe(Effect.option);
47+ return yield* Option.match(raw, {
48+ onNone: () => Effect.succeed(Option.none<string>()),
49+ onSome: (value) =>
50+ Schema.decodeEffect(Schema.fromJsonString(AppPackageMetadata))(value).pipe(
51+ Effect.map((parsed) =>
52+ Option.fromNullishOr(parsed.t3codeCommitHash).pipe(Option.flatMap(normalizeCommitHash)),
53+ ),
54+ Effect.catch(() => Effect.succeed(Option.none<string>())),
55+ ),
56+ });
57+ });
58+
59+ const resolveAboutCommitHash = Effect.gen(function* () {
60+ const cached = yield* Ref.get(commitHashCache);
61+ if (Option.isSome(cached)) {
62+ return cached.value;
63+ }
64+
65+ const override = Option.flatMap(environment.commitHashOverride, normalizeCommitHash);
66+ if (Option.isSome(override)) {
67+ yield* Ref.set(commitHashCache, Option.some(override));
68+ return override;
69+ }
70+
71+ if (!environment.isPackaged) {
72+ const empty = Option.none<string>();
73+ yield* Ref.set(commitHashCache, Option.some(empty));
74+ return empty;
75+ }
76+
77+ const commitHash = yield* resolveEmbeddedCommitHash;
78+ yield* Ref.set(commitHashCache, Option.some(commitHash));
79+ return commitHash;
80+ });
81+
82+ const resolveUserDataPath = Effect.gen(function* () {
83+ const legacyPath = environment.path.join(
84+ environment.appDataDirectory,
85+ environment.legacyUserDataDirName,
86+ );
87+ const legacyPathExists = yield* fileSystem
88+ .exists(legacyPath)
89+ .pipe(Effect.orElseSucceed(() => false));
90+ return legacyPathExists
91+ ? legacyPath
92+ : environment.path.join(environment.appDataDirectory, environment.userDataDirName);
93+ }).pipe(Effect.withSpan("desktop.appIdentity.resolveUserDataPath"));
94+
95+ const configure = Effect.gen(function* () {
96+ const commitHash = yield* resolveAboutCommitHash;
97+ yield* electronApp.setName(environment.displayName);
98+ yield* electronApp.setAboutPanelOptions({
99+ applicationName: environment.displayName,
100+ applicationVersion: environment.appVersion,
101+ version: Option.getOrElse(commitHash, () => "unknown"),
102+ });
103+
104+ if (environment.platform === "win32") {
105+ yield* electronApp.setAppUserModelId(environment.appUserModelId);
106+ }
107+
108+ if (environment.platform === "linux") {
109+ yield* electronApp.setDesktopName(environment.linuxDesktopEntryName);
110+ }
111+
112+ if (environment.platform === "darwin") {
113+ const iconPaths = yield* assets.iconPaths;
114+ yield* Option.match(iconPaths.png, {
115+ onNone: () => Effect.void,
116+ onSome: electronApp.setDockIcon,
117+ });
118+ }
119+ }).pipe(Effect.withSpan("desktop.appIdentity.configure"));
120+
121+ return DesktopAppIdentity.of({
122+ resolveUserDataPath,
123+ configure,
124+ });
125+});
126+
127+export const layer = Layer.effect(DesktopAppIdentity, make);
apps/desktop/src/app/DesktopAssets.ts+85−0
@@ -0,0 +1,85 @@
1+import * as Context from "effect/Context";
2+import * as Effect from "effect/Effect";
3+import * as FileSystem from "effect/FileSystem";
4+import * as Layer from "effect/Layer";
5+import * as Option from "effect/Option";
6+
7+import * as DesktopEnvironment from "./DesktopEnvironment.ts";
8+
9+export interface DesktopIconPaths {
10+ readonly ico: Option.Option<string>;
11+ readonly icns: Option.Option<string>;
12+ readonly png: Option.Option<string>;
13+}
14+
15+export interface DesktopAssetsShape {
16+ readonly iconPaths: Effect.Effect<DesktopIconPaths>;
17+ readonly resolveResourcePath: (fileName: string) => Effect.Effect<Option.Option<string>>;
18+}
19+
20+export class DesktopAssets extends Context.Service<DesktopAssets, DesktopAssetsShape>()(
21+ "t3/desktop/Assets",
22+) {}
23+
24+const resolveResourcePath = Effect.fn("desktop.assets.resolveResourcePath")(function* (
25+ fileName: string,
26+): Effect.fn.Return<
27+ Option.Option<string>,
28+ never,
29+ FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment
30+> {
31+ const fileSystem = yield* FileSystem.FileSystem;
32+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
33+ const candidates = environment.resolveResourcePathCandidates(fileName);
34+ for (const candidate of candidates) {
35+ const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false));
36+ if (exists) {
37+ return Option.some(candidate);
38+ }
39+ }
40+ return Option.none<string>();
41+});
42+
43+const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* (
44+ ext: keyof DesktopIconPaths,
45+): Effect.fn.Return<
46+ Option.Option<string>,
47+ never,
48+ FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment
49+> {
50+ const fileSystem = yield* FileSystem.FileSystem;
51+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
52+ if (environment.isDevelopment && process.platform === "darwin" && ext === "png") {
53+ const developmentDockIconPath = environment.developmentDockIconPath;
54+ const developmentDockIconExists = yield* fileSystem
55+ .exists(developmentDockIconPath)
56+ .pipe(Effect.orElseSucceed(() => false));
57+ if (developmentDockIconExists) {
58+ return Option.some(developmentDockIconPath);
59+ }
60+ }
61+
62+ return yield* resolveResourcePath(`icon.${ext}`);
63+});
64+
65+const make = Effect.gen(function* () {
66+ const context = yield* Effect.context<
67+ FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment
68+ >();
69+ const [ico, icns, png] = yield* Effect.all(
70+ [resolveIconPath("ico"), resolveIconPath("icns"), resolveIconPath("png")] as const,
71+ { concurrency: "unbounded" },
72+ );
73+ const iconPaths = { ico, icns, png } satisfies DesktopIconPaths;
74+
75+ return DesktopAssets.of({
76+ iconPaths: Effect.succeed(iconPaths),
77+ resolveResourcePath: Effect.fn("desktop.assets.resolveResourcePath.scoped")(
78+ function* (fileName) {
79+ return yield* resolveResourcePath(fileName).pipe(Effect.provide(context));
80+ },
81+ ),
82+ });
83+});
84+
85+export const layer = Layer.effect(DesktopAssets, make);
apps/desktop/src/app/DesktopConfig.ts+58−0
@@ -0,0 +1,58 @@
1+import * as Config from "effect/Config";
2+import * as ConfigProvider from "effect/ConfigProvider";
3+import * as Option from "effect/Option";
4+
5+const trimNonEmptyOption = (value: string): Option.Option<string> => {
6+ const trimmed = value.trim();
7+ return trimmed.length > 0 ? Option.some(trimmed) : Option.none();
8+};
9+
10+const trimmedString = (name: string) =>
11+ Config.string(name).pipe(Config.option, Config.map(Option.flatMap(trimNonEmptyOption)));
12+
13+const optionalBoolean = (name: string) =>
14+ Config.boolean(name).pipe(Config.option, Config.map(Option.getOrElse(() => false)));
15+
16+const commaSeparatedStrings = (name: string) =>
17+ trimmedString(name).pipe(
18+ Config.map(
19+ Option.match({
20+ onNone: () => [],
21+ onSome: (value) =>
22+ value
23+ .split(",")
24+ .map((entry) => entry.trim())
25+ .filter((entry) => entry.length > 0),
26+ }),
27+ ),
28+ );
29+
30+const compactEnv = (env: Readonly<Record<string, string | undefined>>): Record<string, string> =>
31+ Object.fromEntries(
32+ Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined),
33+ );
34+
35+export const DesktopConfig = Config.all({
36+ appDataDirectory: trimmedString("APPDATA"),
37+ xdgConfigHome: trimmedString("XDG_CONFIG_HOME"),
38+ t3Home: trimmedString("T3CODE_HOME"),
39+ devServerUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option),
40+ devRemoteT3ServerEntryPath: trimmedString("T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH"),
41+ configuredBackendPort: Config.port("T3CODE_PORT").pipe(Config.option),
42+ commitHashOverride: trimmedString("T3CODE_COMMIT_HASH"),
43+ desktopLanHostOverride: trimmedString("T3CODE_DESKTOP_LAN_HOST"),
44+ desktopHttpsEndpointUrls: commaSeparatedStrings("T3CODE_DESKTOP_HTTPS_ENDPOINTS"),
45+ otlpTracesUrl: trimmedString("T3CODE_OTLP_TRACES_URL"),
46+ otlpExportIntervalMs: Config.int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe(
47+ Config.withDefault(10_000),
48+ ),
49+ appImagePath: trimmedString("APPIMAGE"),
50+ disableAutoUpdate: optionalBoolean("T3CODE_DISABLE_AUTO_UPDATE"),
51+ mockUpdates: optionalBoolean("T3CODE_DESKTOP_MOCK_UPDATES"),
52+ mockUpdateServerPort: Config.port("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe(
53+ Config.withDefault(3000),
54+ ),
55+});
56+
57+export const layerTest = (env: Readonly<Record<string, string | undefined>>) =>
58+ ConfigProvider.layer(ConfigProvider.fromEnv({ env: compactEnv(env) }));
apps/desktop/src/app/DesktopEnvironment.test.ts+117−0
@@ -0,0 +1,117 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { assert, describe, it } from "@effect/vitest";
3+import * as Effect from "effect/Effect";
4+import * as Layer from "effect/Layer";
5+import * as Option from "effect/Option";
6+
7+import * as DesktopEnvironment from "./DesktopEnvironment.ts";
8+import * as DesktopConfig from "./DesktopConfig.ts";
9+
10+const defaultInput = {
11+ dirname: "/repo/apps/desktop/dist-electron",
12+ homeDirectory: "/Users/alice",
13+ platform: "darwin",
14+ processArch: "arm64",
15+ appVersion: "0.0.22",
16+ appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar",
17+ isPackaged: false,
18+ resourcesPath: "/Applications/T3 Code.app/Contents/Resources",
19+ runningUnderArm64Translation: false,
20+} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput;
21+
22+const makeEnvironmentLayer = (
23+ overrides: Partial<DesktopEnvironment.MakeDesktopEnvironmentInput> = {},
24+ env: Record<string, string | undefined> = {},
25+) =>
26+ DesktopEnvironment.layer({
27+ ...defaultInput,
28+ ...overrides,
29+ }).pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest(env))));
30+
31+const makeEnvironment = (
32+ overrides: Partial<DesktopEnvironment.MakeDesktopEnvironmentInput> = {},
33+ env: Record<string, string | undefined> = {},
34+) =>
35+ Effect.gen(function* () {
36+ return yield* DesktopEnvironment.DesktopEnvironment;
37+ }).pipe(Effect.provide(makeEnvironmentLayer(overrides, env)));
38+
39+describe("DesktopEnvironment", () => {
40+ it.effect("derives state paths and development identity inside Effect", () =>
41+ Effect.gen(function* () {
42+ const environment = yield* makeEnvironment(
43+ {},
44+ {
45+ T3CODE_HOME: " /tmp/t3 ",
46+ T3CODE_COMMIT_HASH: " 0123456789abcdef ",
47+ T3CODE_PORT: "4949",
48+ VITE_DEV_SERVER_URL: "http://localhost:5173",
49+ T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH: " /remote/server.mjs ",
50+ T3CODE_OTLP_TRACES_URL: " http://127.0.0.1:4318/v1/traces ",
51+ T3CODE_OTLP_EXPORT_INTERVAL_MS: "2500",
52+ },
53+ );
54+
55+ assert.equal(environment.isDevelopment, true);
56+ assert.equal(environment.appDataDirectory, "/Users/alice/Library/Application Support");
57+ assert.equal(environment.baseDir, "/tmp/t3");
58+ assert.equal(environment.stateDir, "/tmp/t3/dev");
59+ assert.equal(environment.desktopSettingsPath, "/tmp/t3/dev/desktop-settings.json");
60+ assert.equal(environment.clientSettingsPath, "/tmp/t3/dev/client-settings.json");
61+ assert.equal(environment.savedEnvironmentRegistryPath, "/tmp/t3/dev/saved-environments.json");
62+ assert.equal(environment.serverSettingsPath, "/tmp/t3/dev/settings.json");
63+ assert.equal(environment.logDir, "/tmp/t3/dev/logs");
64+ assert.equal(environment.rootDir, "/repo");
65+ assert.equal(environment.appRoot, "/repo");
66+ assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs");
67+ assert.equal(environment.backendCwd, "/repo");
68+ assert.equal(environment.appUserModelId, "com.t3tools.t3code.dev");
69+ assert.equal(environment.linuxWmClass, "t3code-dev");
70+ assert.deepEqual(
71+ Option.map(environment.devServerUrl, (url) => url.href),
72+ Option.some("http://localhost:5173/"),
73+ );
74+ assert.deepEqual(environment.devRemoteT3ServerEntryPath, Option.some("/remote/server.mjs"));
75+ assert.deepEqual(environment.configuredBackendPort, Option.some(4949));
76+ assert.deepEqual(environment.commitHashOverride, Option.some("0123456789abcdef"));
77+ assert.deepEqual(environment.otlpTracesUrl, Option.some("http://127.0.0.1:4318/v1/traces"));
78+ assert.equal(environment.otlpExportIntervalMs, 2500);
79+ }),
80+ );
81+
82+ it.effect("derives production state paths under userdata", () =>
83+ Effect.gen(function* () {
84+ const environment = yield* makeEnvironment(
85+ {},
86+ {
87+ T3CODE_HOME: "/tmp/t3",
88+ },
89+ );
90+
91+ assert.equal(environment.isDevelopment, false);
92+ assert.equal(environment.stateDir, "/tmp/t3/userdata");
93+ assert.equal(environment.logDir, "/tmp/t3/userdata/logs");
94+ assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json");
95+ }),
96+ );
97+
98+ it.effect("resolves picker defaults without nullish sentinels", () =>
99+ Effect.gen(function* () {
100+ const environment = yield* makeEnvironment();
101+
102+ assert.deepEqual(environment.resolvePickFolderDefaultPath(null), Option.none());
103+ assert.deepEqual(
104+ environment.resolvePickFolderDefaultPath({ initialPath: " " }),
105+ Option.none(),
106+ );
107+ assert.deepEqual(
108+ environment.resolvePickFolderDefaultPath({ initialPath: "~" }),
109+ Option.some("/Users/alice"),
110+ );
111+ assert.deepEqual(
112+ environment.resolvePickFolderDefaultPath({ initialPath: "~/project" }),
113+ Option.some("/Users/alice/project"),
114+ );
115+ }),
116+ );
117+});
apps/desktop/src/app/DesktopEnvironment.ts+249−0
@@ -0,0 +1,249 @@
1+import type {
2+ DesktopAppBranding,
3+ DesktopAppStageLabel,
4+ DesktopRuntimeArch,
5+ DesktopRuntimeInfo,
6+} from "@t3tools/contracts";
7+import * as Config from "effect/Config";
8+import * as Context from "effect/Context";
9+import * as Effect from "effect/Effect";
10+import * as Layer from "effect/Layer";
11+import * as Option from "effect/Option";
12+import * as Path from "effect/Path";
13+
14+import {
15+ type DesktopSettings,
16+ resolveDefaultDesktopSettings,
17+} from "../settings/DesktopAppSettings.ts";
18+import * as DesktopConfig from "./DesktopConfig.ts";
19+import { isNightlyDesktopVersion } from "../updates/updateChannels.ts";
20+
21+export interface MakeDesktopEnvironmentInput {
22+ readonly dirname: string;
23+ readonly homeDirectory: string;
24+ readonly platform: NodeJS.Platform;
25+ readonly processArch: string;
26+ readonly appVersion: string;
27+ readonly appPath: string;
28+ readonly isPackaged: boolean;
29+ readonly resourcesPath: string;
30+ readonly runningUnderArm64Translation: boolean;
31+}
32+
33+export interface DesktopEnvironmentShape {
34+ readonly path: Path.Path;
35+ readonly dirname: string;
36+ readonly platform: NodeJS.Platform;
37+ readonly processArch: string;
38+ readonly isPackaged: boolean;
39+ readonly isDevelopment: boolean;
40+ readonly appVersion: string;
41+ readonly appPath: string;
42+ readonly resourcesPath: string;
43+ readonly homeDirectory: string;
44+ readonly appDataDirectory: string;
45+ readonly baseDir: string;
46+ readonly stateDir: string;
47+ readonly desktopSettingsPath: string;
48+ readonly clientSettingsPath: string;
49+ readonly savedEnvironmentRegistryPath: string;
50+ readonly serverSettingsPath: string;
51+ readonly logDir: string;
52+ readonly rootDir: string;
53+ readonly appRoot: string;
54+ readonly backendEntryPath: string;
55+ readonly backendCwd: string;
56+ readonly preloadPath: string;
57+ readonly appUpdateYmlPath: string;
58+ readonly devServerUrl: Option.Option<URL>;
59+ readonly devRemoteT3ServerEntryPath: Option.Option<string>;
60+ readonly configuredBackendPort: Option.Option<number>;
61+ readonly commitHashOverride: Option.Option<string>;
62+ readonly otlpTracesUrl: Option.Option<string>;
63+ readonly otlpExportIntervalMs: number;
64+ readonly branding: DesktopAppBranding;
65+ readonly displayName: string;
66+ readonly appUserModelId: string;
67+ readonly linuxDesktopEntryName: string;
68+ readonly linuxWmClass: string;
69+ readonly userDataDirName: string;
70+ readonly legacyUserDataDirName: string;
71+ readonly defaultDesktopSettings: DesktopSettings;
72+ readonly runtimeInfo: DesktopRuntimeInfo;
73+ readonly resolvePickFolderDefaultPath: (rawOptions: unknown) => Option.Option<string>;
74+ readonly resolveResourcePathCandidates: (fileName: string) => readonly string[];
75+ readonly developmentDockIconPath: string;
76+}
77+
78+export class DesktopEnvironment extends Context.Service<
79+ DesktopEnvironment,
80+ DesktopEnvironmentShape
81+>()("t3/desktop/Environment") {}
82+
83+const APP_BASE_NAME = "T3 Code";
84+
85+function resolveDesktopAppStageLabel(input: {
86+ readonly isDevelopment: boolean;
87+ readonly appVersion: string;
88+}): DesktopAppStageLabel {
89+ if (input.isDevelopment) {
90+ return "Dev";
91+ }
92+
93+ return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : "Alpha";
94+}
95+
96+function resolveDesktopAppBranding(input: {
97+ readonly isDevelopment: boolean;
98+ readonly appVersion: string;
99+}): DesktopAppBranding {
100+ const stageLabel = resolveDesktopAppStageLabel(input);
101+ return {
102+ baseName: APP_BASE_NAME,
103+ stageLabel,
104+ displayName: `${APP_BASE_NAME} (${stageLabel})`,
105+ };
106+}
107+
108+function normalizeDesktopArch(arch: string): DesktopRuntimeArch {
109+ if (arch === "arm64") return "arm64";
110+ if (arch === "x64") return "x64";
111+ return "other";
112+}
113+
114+function resolveDesktopRuntimeInfo(input: {
115+ readonly platform: NodeJS.Platform;
116+ readonly processArch: string;
117+ readonly runningUnderArm64Translation: boolean;
118+}): DesktopRuntimeInfo {
119+ const appArch = normalizeDesktopArch(input.processArch);
120+
121+ if (input.platform !== "darwin") {
122+ return {
123+ hostArch: appArch,
124+ appArch,
125+ runningUnderArm64Translation: false,
126+ };
127+ }
128+
129+ const hostArch = appArch === "arm64" || input.runningUnderArm64Translation ? "arm64" : appArch;
130+
131+ return {
132+ hostArch,
133+ appArch,
134+ runningUnderArm64Translation: input.runningUnderArm64Translation,
135+ };
136+}
137+
138+const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* (
139+ input: MakeDesktopEnvironmentInput,
140+): Effect.fn.Return<DesktopEnvironmentShape, Config.ConfigError, Path.Path> {
141+ const path = yield* Path.Path;
142+ const config = yield* DesktopConfig.DesktopConfig;
143+ const homeDirectory = input.homeDirectory;
144+ const devServerUrl = config.devServerUrl;
145+ const isDevelopment = Option.isSome(devServerUrl);
146+ const appDataDirectory =
147+ input.platform === "win32"
148+ ? Option.getOrElse(config.appDataDirectory, () =>
149+ path.join(homeDirectory, "AppData", "Roaming"),
150+ )
151+ : input.platform === "darwin"
152+ ? path.join(homeDirectory, "Library", "Application Support")
153+ : Option.getOrElse(config.xdgConfigHome, () => path.join(homeDirectory, ".config"));
154+ const baseDir = Option.getOrElse(config.t3Home, () => path.join(homeDirectory, ".t3"));
155+ const rootDir = path.resolve(input.dirname, "../../..");
156+ const appRoot = input.isPackaged ? input.appPath : rootDir;
157+ const branding = resolveDesktopAppBranding({
158+ isDevelopment,
159+ appVersion: input.appVersion,
160+ });
161+ const displayName = branding.displayName;
162+ const stateDir = path.join(baseDir, isDevelopment ? "dev" : "userdata");
163+ const userDataDirName = isDevelopment ? "t3code-dev" : "t3code";
164+ const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
165+ const resourcesPath = input.resourcesPath;
166+
167+ return DesktopEnvironment.of({
168+ path,
169+ dirname: input.dirname,
170+ platform: input.platform,
171+ processArch: input.processArch,
172+ isPackaged: input.isPackaged,
173+ isDevelopment,
174+ appVersion: input.appVersion,
175+ appPath: input.appPath,
176+ resourcesPath,
177+ homeDirectory,
178+ appDataDirectory,
179+ baseDir,
180+ stateDir,
181+ desktopSettingsPath: path.join(stateDir, "desktop-settings.json"),
182+ clientSettingsPath: path.join(stateDir, "client-settings.json"),
183+ savedEnvironmentRegistryPath: path.join(stateDir, "saved-environments.json"),
184+ serverSettingsPath: path.join(stateDir, "settings.json"),
185+ logDir: path.join(stateDir, "logs"),
186+ rootDir,
187+ appRoot,
188+ backendEntryPath: path.join(appRoot, "apps/server/dist/bin.mjs"),
189+ backendCwd: input.isPackaged ? homeDirectory : appRoot,
190+ preloadPath: path.join(input.dirname, "preload.cjs"),
191+ appUpdateYmlPath: input.isPackaged
192+ ? path.join(resourcesPath, "app-update.yml")
193+ : path.join(input.appPath, "dev-app-update.yml"),
194+ devServerUrl,
195+ devRemoteT3ServerEntryPath: config.devRemoteT3ServerEntryPath,
196+ configuredBackendPort: config.configuredBackendPort,
197+ commitHashOverride: config.commitHashOverride,
198+ otlpTracesUrl: config.otlpTracesUrl,
199+ otlpExportIntervalMs: config.otlpExportIntervalMs,
200+ branding,
201+ displayName,
202+ appUserModelId: isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code",
203+ linuxDesktopEntryName: isDevelopment ? "t3code-dev.desktop" : "t3code.desktop",
204+ linuxWmClass: isDevelopment ? "t3code-dev" : "t3code",
205+ userDataDirName,
206+ legacyUserDataDirName,
207+ defaultDesktopSettings: resolveDefaultDesktopSettings(input.appVersion),
208+ runtimeInfo: resolveDesktopRuntimeInfo({
209+ platform: input.platform,
210+ processArch: input.processArch,
211+ runningUnderArm64Translation: input.runningUnderArm64Translation,
212+ }),
213+ resolvePickFolderDefaultPath: (rawOptions) => {
214+ if (typeof rawOptions !== "object" || rawOptions === null) {
215+ return Option.none();
216+ }
217+
218+ const { initialPath } = rawOptions as { initialPath?: unknown };
219+ if (typeof initialPath !== "string") {
220+ return Option.none();
221+ }
222+
223+ const trimmedPath = initialPath.trim();
224+ if (trimmedPath.length === 0) {
225+ return Option.none();
226+ }
227+
228+ if (trimmedPath === "~") {
229+ return Option.some(homeDirectory);
230+ }
231+
232+ if (trimmedPath.startsWith("~/") || trimmedPath.startsWith("~\\")) {
233+ return Option.some(path.join(homeDirectory, trimmedPath.slice(2)));
234+ }
235+
236+ return Option.some(path.resolve(trimmedPath));
237+ },
238+ resolveResourcePathCandidates: (fileName) => [
239+ path.join(input.dirname, "../resources", fileName),
240+ path.join(input.dirname, "../prod-resources", fileName),
241+ path.join(resourcesPath, "resources", fileName),
242+ path.join(resourcesPath, fileName),
243+ ],
244+ developmentDockIconPath: path.join(rootDir, "assets", "dev", "blueprint-macos-1024.png"),
245+ });
246+});
247+
248+export const layer = (input: MakeDesktopEnvironmentInput) =>
249+ Layer.effect(DesktopEnvironment, makeDesktopEnvironment(input));
apps/desktop/src/app/DesktopLifecycle.ts+233−0
@@ -0,0 +1,233 @@
1+import * as Cause from "effect/Cause";
2+import * as Context from "effect/Context";
3+import * as Effect from "effect/Effect";
4+import * as Deferred from "effect/Deferred";
5+import * as Layer from "effect/Layer";
6+import * as Ref from "effect/Ref";
7+import * as Scope from "effect/Scope";
8+
9+import type * as Electron from "electron";
10+
11+import * as DesktopEnvironment from "./DesktopEnvironment.ts";
12+import * as DesktopObservability from "./DesktopObservability.ts";
13+import * as ElectronApp from "../electron/ElectronApp.ts";
14+import * as ElectronTheme from "../electron/ElectronTheme.ts";
15+import * as DesktopState from "./DesktopState.ts";
16+import * as DesktopWindow from "../window/DesktopWindow.ts";
17+
18+export interface DesktopShutdownShape {
19+ readonly request: Effect.Effect<void>;
20+ readonly awaitRequest: Effect.Effect<void>;
21+ readonly markComplete: Effect.Effect<void>;
22+ readonly awaitComplete: Effect.Effect<void>;
23+ readonly isComplete: Effect.Effect<boolean>;
24+}
25+
26+export class DesktopShutdown extends Context.Service<DesktopShutdown, DesktopShutdownShape>()(
27+ "t3/desktop/Shutdown",
28+) {}
29+
30+const makeShutdown = Effect.gen(function* () {
31+ const requested = yield* Deferred.make<void>();
32+ const completed = yield* Deferred.make<void>();
33+ const completedRef = yield* Ref.make(false);
34+
35+ return DesktopShutdown.of({
36+ request: Deferred.succeed(requested, undefined).pipe(Effect.asVoid),
37+ awaitRequest: Deferred.await(requested),
38+ markComplete: Ref.set(completedRef, true).pipe(
39+ Effect.andThen(Deferred.succeed(completed, undefined)),
40+ Effect.asVoid,
41+ ),
42+ awaitComplete: Deferred.await(completed),
43+ isComplete: Ref.get(completedRef),
44+ });
45+});
46+
47+export const layerShutdown = Layer.effect(DesktopShutdown, makeShutdown);
48+
49+export type DesktopLifecycleRuntimeServices =
50+ | DesktopEnvironment.DesktopEnvironment
51+ | DesktopShutdown
52+ | DesktopState.DesktopState
53+ | DesktopWindow.DesktopWindow
54+ | ElectronApp.ElectronApp
55+ | ElectronTheme.ElectronTheme;
56+
57+export interface DesktopLifecycleShape {
58+ readonly relaunch: (
59+ reason: string,
60+ ) => Effect.Effect<void, never, DesktopLifecycleRuntimeServices>;
61+ readonly register: Effect.Effect<void, never, Scope.Scope | DesktopLifecycleRuntimeServices>;
62+}
63+
64+export class DesktopLifecycle extends Context.Service<DesktopLifecycle, DesktopLifecycleShape>()(
65+ "t3/desktop/Lifecycle",
66+) {}
67+
68+const { logInfo: logLifecycleInfo, logError: logLifecycleError } =
69+ DesktopObservability.makeComponentLogger("desktop-lifecycle");
70+
71+function addScopedListener<Args extends ReadonlyArray<unknown>>(
72+ target: unknown,
73+ eventName: string,
74+ listener: (...args: Args) => void,
75+): Effect.Effect<void, never, Scope.Scope> {
76+ const eventTarget = target as {
77+ on: (eventName: string, listener: (...args: Array<unknown>) => void) => unknown;
78+ removeListener: (eventName: string, listener: (...args: Array<unknown>) => void) => unknown;
79+ };
80+ const untypedListener = listener as unknown as (...args: Array<unknown>) => void;
81+ return Effect.acquireRelease(
82+ Effect.sync(() => {
83+ eventTarget.on(eventName, untypedListener);
84+ }),
85+ () =>
86+ Effect.sync(() => {
87+ eventTarget.removeListener(eventName, untypedListener);
88+ }),
89+ ).pipe(Effect.asVoid);
90+}
91+
92+const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdownAndWait")(
93+ function* (): Effect.fn.Return<void, never, DesktopShutdown> {
94+ const shutdown = yield* DesktopShutdown;
95+ yield* shutdown.request;
96+ yield* shutdown.awaitComplete;
97+ },
98+);
99+
100+function handleBeforeQuit(
101+ event: Electron.Event,
102+ runEffect: <A, E>(effect: Effect.Effect<A, E, DesktopLifecycleRuntimeServices>) => Promise<A>,
103+ allowQuit: () => boolean,
104+ markQuitAllowed: () => void,
105+): void {
106+ if (allowQuit()) {
107+ void runEffect(
108+ Effect.gen(function* () {
109+ const state = yield* DesktopState.DesktopState;
110+ yield* Ref.set(state.quitting, true);
111+ yield* logLifecycleInfo("before-quit received");
112+ }).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")),
113+ );
114+ return;
115+ }
116+
117+ event.preventDefault();
118+ void runEffect(
119+ Effect.gen(function* () {
120+ const state = yield* DesktopState.DesktopState;
121+ yield* Ref.set(state.quitting, true);
122+ yield* logLifecycleInfo("before-quit received");
123+ yield* requestDesktopShutdownAndWait();
124+ }).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")),
125+ ).finally(() => {
126+ markQuitAllowed();
127+ void runEffect(
128+ Effect.gen(function* () {
129+ const electronApp = yield* ElectronApp.ElectronApp;
130+ yield* electronApp.quit;
131+ }).pipe(Effect.withSpan("desktop.lifecycle.quitAfterShutdown")),
132+ );
133+ });
134+}
135+
136+function quitFromSignal(
137+ signal: "SIGINT" | "SIGTERM",
138+ runEffect: <A, E>(effect: Effect.Effect<A, E, DesktopLifecycleRuntimeServices>) => Promise<A>,
139+): void {
140+ void runEffect(
141+ Effect.gen(function* () {
142+ yield* Effect.annotateCurrentSpan({ signal });
143+ const electronApp = yield* ElectronApp.ElectronApp;
144+ const state = yield* DesktopState.DesktopState;
145+ const wasQuitting = yield* Ref.getAndSet(state.quitting, true);
146+ if (wasQuitting) return;
147+ yield* logLifecycleInfo("process signal received", { signal });
148+ yield* requestDesktopShutdownAndWait();
149+ yield* electronApp.quit;
150+ }).pipe(Effect.withSpan("desktop.lifecycle.processSignal")),
151+ );
152+}
153+
154+export const layer = Layer.succeed(
155+ DesktopLifecycle,
156+ DesktopLifecycle.of({
157+ relaunch: Effect.fn("desktop.lifecycle.relaunch")(function* (reason) {
158+ const electronApp = yield* ElectronApp.ElectronApp;
159+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
160+ const state = yield* DesktopState.DesktopState;
161+ yield* logLifecycleInfo("desktop relaunch requested", { reason });
162+ yield* Effect.gen(function* () {
163+ yield* Effect.yieldNow;
164+ yield* Ref.set(state.quitting, true);
165+ yield* requestDesktopShutdownAndWait();
166+ if (environment.isDevelopment) {
167+ yield* electronApp.exit(75);
168+ return;
169+ }
170+ yield* electronApp.relaunch({
171+ execPath: process.execPath,
172+ args: process.argv.slice(1),
173+ });
174+ yield* electronApp.exit(0);
175+ }).pipe(
176+ Effect.catchCause((cause) =>
177+ logLifecycleError("desktop relaunch failed", {
178+ cause: Cause.pretty(cause),
179+ }),
180+ ),
181+ Effect.forkDetach,
182+ Effect.asVoid,
183+ );
184+ }),
185+ register: Effect.gen(function* () {
186+ const desktopWindow = yield* DesktopWindow.DesktopWindow;
187+ const electronApp = yield* ElectronApp.ElectronApp;
188+ const electronTheme = yield* ElectronTheme.ElectronTheme;
189+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
190+ const context = yield* Effect.context<DesktopLifecycleRuntimeServices>();
191+ const runEffect = Effect.runPromiseWith(context);
192+ let quitAllowed = false;
193+ yield* electronTheme.onUpdated(() => {
194+ void runEffect(
195+ desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")),
196+ );
197+ });
198+ yield* electronApp.on("before-quit", (event: Electron.Event) => {
199+ handleBeforeQuit(
200+ event,
201+ runEffect,
202+ () => quitAllowed,
203+ () => {
204+ quitAllowed = true;
205+ },
206+ );
207+ });
208+ yield* electronApp.on("activate", () => {
209+ void runEffect(desktopWindow.activate.pipe(Effect.withSpan("desktop.lifecycle.activate")));
210+ });
211+ yield* electronApp.on("window-all-closed", () => {
212+ void runEffect(
213+ Effect.gen(function* () {
214+ const app = yield* ElectronApp.ElectronApp;
215+ const state = yield* DesktopState.DesktopState;
216+ if (environment.platform !== "darwin" && !(yield* Ref.get(state.quitting))) {
217+ yield* app.quit;
218+ }
219+ }).pipe(Effect.withSpan("desktop.lifecycle.windowAllClosed")),
220+ );
221+ });
222+
223+ if (environment.platform !== "win32") {
224+ yield* addScopedListener(process, "SIGINT", () => {
225+ quitFromSignal("SIGINT", runEffect);
226+ });
227+ yield* addScopedListener(process, "SIGTERM", () => {
228+ quitFromSignal("SIGTERM", runEffect);
229+ });
230+ }
231+ }).pipe(Effect.withSpan("desktop.lifecycle.register")),
232+ }),
233+);
apps/desktop/src/app/DesktopObservability.test.ts+162−0
@@ -0,0 +1,162 @@
1+import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
2+import * as NodeServices from "@effect/platform-node/NodeServices";
3+import { assert, describe, it } from "@effect/vitest";
4+import * as Effect from "effect/Effect";
5+import * as FileSystem from "effect/FileSystem";
6+import * as Layer from "effect/Layer";
7+import * as Schema from "effect/Schema";
8+
9+import * as DesktopConfig from "./DesktopConfig.ts";
10+import * as DesktopEnvironment from "./DesktopEnvironment.ts";
11+import * as DesktopObservability from "./DesktopObservability.ts";
12+
13+const DesktopBackendChildLogRecord = Schema.Struct({
14+ message: Schema.String,
15+ level: Schema.Literals(["INFO", "ERROR"]),
16+ timestamp: Schema.String,
17+ annotations: Schema.Record(Schema.String, Schema.Unknown),
18+ spans: Schema.Record(Schema.String, Schema.Unknown),
19+ fiberId: Schema.String,
20+});
21+
22+const decodeDesktopBackendChildLogRecord = Schema.decodeEffect(
23+ Schema.fromJsonString(DesktopBackendChildLogRecord),
24+);
25+
26+const TraceRecordLine = Schema.Struct({
27+ name: Schema.String,
28+ attributes: Schema.Record(Schema.String, Schema.Unknown),
29+ events: Schema.Array(
30+ Schema.Struct({
31+ name: Schema.String,
32+ attributes: Schema.Record(Schema.String, Schema.Unknown),
33+ }),
34+ ),
35+});
36+
37+const decodeTraceRecordLine = Schema.decodeUnknownSync(Schema.fromJsonString(TraceRecordLine));
38+
39+const environmentInput = (baseDir: string) =>
40+ ({
41+ dirname: "/repo/apps/desktop/dist-electron",
42+ homeDirectory: baseDir,
43+ platform: "darwin",
44+ processArch: "arm64",
45+ appVersion: "1.2.3",
46+ appPath: "/repo",
47+ isPackaged: false,
48+ resourcesPath: "/repo/resources",
49+ runningUnderArm64Translation: false,
50+ }) satisfies DesktopEnvironment.MakeDesktopEnvironmentInput;
51+
52+const makeEnvironmentLayer = (baseDir: string) =>
53+ DesktopEnvironment.layer(environmentInput(baseDir)).pipe(
54+ Layer.provide(
55+ Layer.mergeAll(
56+ NodeServices.layer,
57+ DesktopConfig.layerTest({
58+ T3CODE_HOME: baseDir,
59+ VITE_DEV_SERVER_URL: "http://127.0.0.1:5733",
60+ }),
61+ ),
62+ ),
63+ );
64+
65+describe("DesktopObservability", () => {
66+ it.effect("persists desktop Effect logs as span events in desktop.trace.ndjson", () =>
67+ Effect.gen(function* () {
68+ const fileSystem = yield* FileSystem.FileSystem;
69+ const baseDir = yield* fileSystem.makeTempDirectoryScoped({
70+ prefix: "t3-desktop-observability-test-",
71+ });
72+ const environmentLayer = makeEnvironmentLayer(baseDir);
73+ const tracePath = yield* Effect.gen(function* () {
74+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
75+ return environment.path.join(environment.logDir, "desktop.trace.ndjson");
76+ }).pipe(Effect.provide(environmentLayer));
77+ const logPath = yield* Effect.gen(function* () {
78+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
79+ return environment.path.join(environment.logDir, "desktop-main.log");
80+ }).pipe(Effect.provide(environmentLayer));
81+
82+ yield* Effect.scoped(
83+ Effect.gen(function* () {
84+ yield* Effect.annotateCurrentSpan({ "desktop.test": true });
85+ yield* Effect.logInfo("desktop trace event");
86+ }).pipe(
87+ Effect.withSpan("desktop-observability-test"),
88+ Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))),
89+ ),
90+ );
91+
92+ const records = (yield* fileSystem.readFileString(tracePath))
93+ .trim()
94+ .split("\n")
95+ .filter((line) => line.length > 0)
96+ .map((line) => decodeTraceRecordLine(line));
97+ const record = records.find((entry) => entry.name === "desktop-observability-test");
98+
99+ assert.notEqual(record, undefined);
100+ if (!record) {
101+ return;
102+ }
103+ assert.equal(record.attributes["desktop.test"], true);
104+ assert.equal(
105+ record.events.some((event) => event.name === "desktop trace event"),
106+ true,
107+ );
108+ assert.isFalse(yield* fileSystem.exists(logPath));
109+ }).pipe(
110+ Effect.scoped,
111+ Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)),
112+ ),
113+ );
114+
115+ it.effect("persists backend child output as structured JSON records in development", () =>
116+ Effect.gen(function* () {
117+ const fileSystem = yield* FileSystem.FileSystem;
118+ const baseDir = yield* fileSystem.makeTempDirectoryScoped({
119+ prefix: "t3-desktop-backend-output-log-test-",
120+ });
121+ const environmentLayer = makeEnvironmentLayer(baseDir);
122+ const logPath = yield* Effect.gen(function* () {
123+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
124+ return environment.path.join(environment.logDir, "server-child.log");
125+ }).pipe(Effect.provide(environmentLayer));
126+
127+ yield* Effect.gen(function* () {
128+ const outputLog = yield* DesktopObservability.DesktopBackendOutputLog;
129+ yield* outputLog.writeSessionBoundary({
130+ phase: "START",
131+ details: "pid=123 port=3773 cwd=/repo",
132+ });
133+ yield* outputLog.writeOutputChunk("stdout", new TextEncoder().encode("hello server\n"));
134+ }).pipe(
135+ Effect.annotateLogs({ runId: "test-run" }),
136+ Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))),
137+ );
138+
139+ const log = yield* fileSystem.readFileString(logPath);
140+ const lines = log.trimEnd().split("\n");
141+ const boundary = yield* decodeDesktopBackendChildLogRecord(lines[0] ?? "");
142+ const output = yield* decodeDesktopBackendChildLogRecord(lines[1] ?? "");
143+
144+ assert.equal(boundary.message, "backend child process session start");
145+ assert.equal(boundary.level, "INFO");
146+ assert.equal(boundary.annotations.component, "desktop-backend-child");
147+ assert.equal(boundary.annotations.runId, "test-run");
148+ assert.equal(boundary.annotations.phase, "START");
149+ assert.equal(boundary.annotations.details, "pid=123 port=3773 cwd=/repo");
150+
151+ assert.equal(output.message, "backend child process output");
152+ assert.equal(output.level, "INFO");
153+ assert.equal(output.annotations.component, "desktop-backend-child");
154+ assert.equal(output.annotations.runId, "test-run");
155+ assert.equal(output.annotations.stream, "stdout");
156+ assert.equal(output.annotations.text, "hello server\n");
157+ }).pipe(
158+ Effect.scoped,
159+ Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)),
160+ ),
161+ );
162+});
apps/desktop/src/app/DesktopObservability.ts+395−0
@@ -0,0 +1,395 @@
1+import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability";
2+import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
3+import * as Context from "effect/Context";
4+import * as Data from "effect/Data";
5+import * as DateTime from "effect/DateTime";
6+import * as Effect from "effect/Effect";
7+import * as FileSystem from "effect/FileSystem";
8+import * as Layer from "effect/Layer";
9+import * as Logger from "effect/Logger";
10+import * as Option from "effect/Option";
11+import * as Path from "effect/Path";
12+import * as PlatformError from "effect/PlatformError";
13+import * as References from "effect/References";
14+import * as Ref from "effect/Ref";
15+import * as Schema from "effect/Schema";
16+import * as Semaphore from "effect/Semaphore";
17+import * as Tracer from "effect/Tracer";
18+import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability";
19+
20+import * as DesktopEnvironment from "./DesktopEnvironment.ts";
21+
22+const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024;
23+const DESKTOP_LOG_FILE_MAX_FILES = 10;
24+const DESKTOP_BACKEND_CHILD_LOG_FIBER_ID = "#backend-child";
25+const DESKTOP_TRACE_BATCH_WINDOW_MS = 200;
26+
27+export interface RotatingLogFileWriter {
28+ readonly writeBytes: (chunk: Uint8Array) => Effect.Effect<void>;
29+ readonly writeText: (chunk: string) => Effect.Effect<void>;
30+}
31+
32+export interface DesktopBackendOutputLogShape {
33+ readonly writeSessionBoundary: (input: {
34+ readonly phase: "START" | "END";
35+ readonly details: string;
36+ }) => Effect.Effect<void>;
37+ readonly writeOutputChunk: (
38+ streamName: "stdout" | "stderr",
39+ chunk: Uint8Array,
40+ ) => Effect.Effect<void>;
41+}
42+
43+export class DesktopBackendOutputLog extends Context.Service<
44+ DesktopBackendOutputLog,
45+ DesktopBackendOutputLogShape
46+>()("t3/desktop/BackendOutputLog") {}
47+
48+const textEncoder = new TextEncoder();
49+const textDecoder = new TextDecoder();
50+
51+export type DesktopLogAnnotations = Record<string, unknown>;
52+
53+export interface DesktopComponentLogger {
54+ readonly annotate: <A, E, R>(
55+ effect: Effect.Effect<A, E, R>,
56+ annotations?: DesktopLogAnnotations,
57+ ) => Effect.Effect<A, E, R>;
58+ readonly logDebug: (message: string, annotations?: DesktopLogAnnotations) => Effect.Effect<void>;
59+ readonly logInfo: (message: string, annotations?: DesktopLogAnnotations) => Effect.Effect<void>;
60+ readonly logWarning: (
61+ message: string,
62+ annotations?: DesktopLogAnnotations,
63+ ) => Effect.Effect<void>;
64+ readonly logError: (message: string, annotations?: DesktopLogAnnotations) => Effect.Effect<void>;
65+}
66+
67+export function makeComponentLogger(component: string): DesktopComponentLogger {
68+ const annotate: DesktopComponentLogger["annotate"] = (effect, annotations) =>
69+ effect.pipe(
70+ Effect.annotateLogs({
71+ component,
72+ ...annotations,
73+ }),
74+ );
75+
76+ return {
77+ annotate,
78+ logDebug: (message, annotations) => annotate(Effect.logDebug(message), annotations),
79+ logInfo: (message, annotations) => annotate(Effect.logInfo(message), annotations),
80+ logWarning: (message, annotations) => annotate(Effect.logWarning(message), annotations),
81+ logError: (message, annotations) => annotate(Effect.logError(message), annotations),
82+ };
83+}
84+
85+class DesktopLogFileWriterConfigurationError extends Data.TaggedError(
86+ "DesktopLogFileWriterConfigurationError",
87+)<{
88+ readonly option: "maxBytes" | "maxFiles";
89+ readonly value: number;
90+}> {
91+ override get message() {
92+ return `${this.option} must be >= 1 (received ${this.value})`;
93+ }
94+}
95+
96+type DesktopLogFileWriterError =
97+ | DesktopLogFileWriterConfigurationError
98+ | PlatformError.PlatformError;
99+
100+const sanitizeLogValue = (value: string): string => value.replace(/\s+/g, " ").trim();
101+
102+const DesktopBackendChildLogRecord = Schema.Struct({
103+ message: Schema.String,
104+ level: Schema.Literals(["INFO", "ERROR"]),
105+ timestamp: Schema.String,
106+ annotations: Schema.Record(Schema.String, Schema.Unknown),
107+ spans: Schema.Record(Schema.String, Schema.Unknown),
108+ fiberId: Schema.String,
109+});
110+
111+const encodeDesktopBackendChildLogRecord = Schema.encodeEffect(
112+ Schema.fromJsonString(DesktopBackendChildLogRecord),
113+);
114+
115+const DesktopBackendOutputLogNoop: DesktopBackendOutputLogShape = {
116+ writeSessionBoundary: () => Effect.void,
117+ writeOutputChunk: () => Effect.void,
118+};
119+
120+const currentDesktopRunId = Effect.gen(function* () {
121+ const annotations = yield* References.CurrentLogAnnotations;
122+ const runId = annotations.runId;
123+ return typeof runId === "string" && runId.length > 0 ? runId : "unknown";
124+});
125+
126+const refreshFileSize = (
127+ fileSystem: FileSystem.FileSystem,
128+ filePath: string,
129+): Effect.Effect<number, never> =>
130+ fileSystem.stat(filePath).pipe(
131+ Effect.map((stat) => Number(stat.size)),
132+ Effect.orElseSucceed(() => 0),
133+ );
134+
135+const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(function* (input: {
136+ readonly filePath: string;
137+ readonly maxBytes?: number;
138+ readonly maxFiles?: number;
139+}): Effect.fn.Return<
140+ RotatingLogFileWriter,
141+ DesktopLogFileWriterError,
142+ FileSystem.FileSystem | Path.Path
143+> {
144+ const fileSystem = yield* FileSystem.FileSystem;
145+ const path = yield* Path.Path;
146+ const maxBytes = input.maxBytes ?? DESKTOP_LOG_FILE_MAX_BYTES;
147+ const maxFiles = input.maxFiles ?? DESKTOP_LOG_FILE_MAX_FILES;
148+ const directory = path.dirname(input.filePath);
149+ const baseName = path.basename(input.filePath);
150+
151+ if (maxBytes < 1) {
152+ return yield* new DesktopLogFileWriterConfigurationError({
153+ option: "maxBytes",
154+ value: maxBytes,
155+ });
156+ }
157+ if (maxFiles < 1) {
158+ return yield* new DesktopLogFileWriterConfigurationError({
159+ option: "maxFiles",
160+ value: maxFiles,
161+ });
162+ }
163+
164+ yield* fileSystem.makeDirectory(directory, { recursive: true });
165+
166+ const withSuffix = (index: number) => `${input.filePath}.${index}`;
167+ const currentSize = yield* Ref.make(yield* refreshFileSize(fileSystem, input.filePath));
168+ const mutex = yield* Semaphore.make(1);
169+
170+ const pruneOverflowBackups = Effect.gen(function* () {
171+ const entries = yield* fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed(() => []));
172+ for (const entry of entries) {
173+ if (!entry.startsWith(`${baseName}.`)) continue;
174+ const suffix = Number(entry.slice(baseName.length + 1));
175+ if (!Number.isInteger(suffix) || suffix <= maxFiles) continue;
176+ yield* fileSystem.remove(path.join(directory, entry), { force: true }).pipe(Effect.ignore);
177+ }
178+ });
179+
180+ const rotate = Effect.gen(function* () {
181+ yield* fileSystem.remove(withSuffix(maxFiles), { force: true }).pipe(Effect.ignore);
182+ for (let index = maxFiles - 1; index >= 1; index -= 1) {
183+ const source = withSuffix(index);
184+ const sourceExists = yield* fileSystem.exists(source).pipe(Effect.orElseSucceed(() => false));
185+ if (sourceExists) {
186+ yield* fileSystem.rename(source, withSuffix(index + 1));
187+ }
188+ }
189+ const currentExists = yield* fileSystem
190+ .exists(input.filePath)
191+ .pipe(Effect.orElseSucceed(() => false));
192+ if (currentExists) {
193+ yield* fileSystem.rename(input.filePath, withSuffix(1));
194+ }
195+ yield* Ref.set(currentSize, 0);
196+ }).pipe(
197+ Effect.catch(() =>
198+ refreshFileSize(fileSystem, input.filePath).pipe(
199+ Effect.flatMap((size) => Ref.set(currentSize, size)),
200+ ),
201+ ),
202+ );
203+
204+ const writeBytes = (chunk: Uint8Array): Effect.Effect<void> => {
205+ if (chunk.byteLength === 0) return Effect.void;
206+
207+ return mutex.withPermits(1)(
208+ Effect.gen(function* () {
209+ const beforeSize = yield* Ref.get(currentSize);
210+ if (beforeSize > 0 && beforeSize + chunk.byteLength > maxBytes) {
211+ yield* rotate;
212+ }
213+
214+ yield* fileSystem.writeFile(input.filePath, chunk, { flag: "a" });
215+ const afterSize = (yield* Ref.get(currentSize)) + chunk.byteLength;
216+ yield* Ref.set(currentSize, afterSize);
217+
218+ if (afterSize > maxBytes) {
219+ yield* rotate;
220+ }
221+ }).pipe(
222+ Effect.catch(() =>
223+ refreshFileSize(fileSystem, input.filePath).pipe(
224+ Effect.flatMap((size) => Ref.set(currentSize, size)),
225+ ),
226+ ),
227+ ),
228+ );
229+ };
230+
231+ yield* pruneOverflowBackups;
232+
233+ return {
234+ writeBytes,
235+ writeText: (chunk) => writeBytes(textEncoder.encode(chunk)),
236+ } satisfies RotatingLogFileWriter;
237+});
238+
239+const readPersistedOtlpTracesUrl: Effect.Effect<
240+ Option.Option<string>,
241+ never,
242+ FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment
243+> = Effect.gen(function* () {
244+ const fileSystem = yield* FileSystem.FileSystem;
245+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
246+ const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(Effect.option);
247+ if (Option.isNone(raw)) {
248+ return Option.none();
249+ }
250+
251+ const parsed = parsePersistedServerObservabilitySettings(raw.value);
252+ return Option.fromNullishOr(parsed.otlpTracesUrl);
253+});
254+
255+const resolveOtlpTracesUrl = Effect.gen(function* () {
256+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
257+ if (Option.isSome(environment.otlpTracesUrl)) {
258+ return environment.otlpTracesUrl;
259+ }
260+ return yield* readPersistedOtlpTracesUrl;
261+});
262+
263+const writeDevelopmentConsoleOutput = (
264+ streamName: "stdout" | "stderr",
265+ chunk: Uint8Array,
266+): Effect.Effect<void> =>
267+ Effect.sync(() => {
268+ const output = streamName === "stderr" ? process.stderr : process.stdout;
269+ output.write(chunk);
270+ }).pipe(Effect.ignore);
271+
272+const writeBackendChildLogRecord = Effect.fn("desktop.observability.writeBackendChildLogRecord")(
273+ function* (
274+ logFile: RotatingLogFileWriter,
275+ input: {
276+ readonly message: string;
277+ readonly level: "INFO" | "ERROR";
278+ readonly annotations: Record<string, unknown>;
279+ },
280+ ): Effect.fn.Return<void> {
281+ return yield* Effect.gen(function* () {
282+ const timestamp = DateTime.formatIso(yield* DateTime.now);
283+ const encoded = yield* encodeDesktopBackendChildLogRecord({
284+ message: input.message,
285+ level: input.level,
286+ timestamp,
287+ annotations: input.annotations,
288+ spans: {},
289+ fiberId: DESKTOP_BACKEND_CHILD_LOG_FIBER_ID,
290+ });
291+ yield* logFile.writeText(`${encoded}\n`);
292+ }).pipe(Effect.ignore({ log: true }));
293+ },
294+);
295+
296+const backendOutputLogLayer = Layer.effect(
297+ DesktopBackendOutputLog,
298+ Effect.gen(function* () {
299+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
300+
301+ const writer = yield* makeRotatingLogFileWriter({
302+ filePath: environment.path.join(environment.logDir, "server-child.log"),
303+ }).pipe(Effect.option);
304+
305+ return Option.match(writer, {
306+ onNone: () => DesktopBackendOutputLogNoop,
307+ onSome: (logFile) =>
308+ ({
309+ writeSessionBoundary: Effect.fn(
310+ "desktop.observability.backendOutput.writeSessionBoundary",
311+ )(function* ({ phase, details }) {
312+ const runId = yield* currentDesktopRunId;
313+ yield* writeBackendChildLogRecord(logFile, {
314+ message: `backend child process session ${phase.toLowerCase()}`,
315+ level: "INFO",
316+ annotations: {
317+ component: "desktop-backend-child",
318+ runId,
319+ phase,
320+ details: sanitizeLogValue(details),
321+ },
322+ });
323+ }),
324+ writeOutputChunk: Effect.fn("desktop.observability.backendOutput.writeOutputChunk")(
325+ function* (streamName, chunk) {
326+ if (environment.isDevelopment) {
327+ yield* writeDevelopmentConsoleOutput(streamName, chunk);
328+ }
329+ const runId = yield* currentDesktopRunId;
330+ yield* writeBackendChildLogRecord(logFile, {
331+ message: "backend child process output",
332+ level: streamName === "stderr" ? "ERROR" : "INFO",
333+ annotations: {
334+ component: "desktop-backend-child",
335+ runId,
336+ stream: streamName,
337+ text: textDecoder.decode(chunk),
338+ },
339+ });
340+ },
341+ ),
342+ }) satisfies DesktopBackendOutputLogShape,
343+ });
344+ }),
345+);
346+
347+const desktopLoggerLayer = Layer.mergeAll(
348+ Logger.layer([Logger.consolePretty(), Logger.tracerLogger], { mergeWithExisting: false }),
349+ Layer.succeed(References.MinimumLogLevel, "Info"),
350+);
351+
352+const tracerLayer = Layer.unwrap(
353+ Effect.gen(function* () {
354+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
355+ const otlpTracesUrl = yield* resolveOtlpTracesUrl;
356+ const tracePath = environment.path.join(environment.logDir, "desktop.trace.ndjson");
357+ const sink = yield* makeTraceSink({
358+ filePath: tracePath,
359+ maxBytes: DESKTOP_LOG_FILE_MAX_BYTES,
360+ maxFiles: DESKTOP_LOG_FILE_MAX_FILES,
361+ batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS,
362+ });
363+ const delegate = Option.isNone(otlpTracesUrl)
364+ ? undefined
365+ : yield* OtlpTracer.make({
366+ url: otlpTracesUrl.value,
367+ exportInterval: `${environment.otlpExportIntervalMs} millis`,
368+ resource: {
369+ serviceName: "desktop",
370+ attributes: {
371+ "service.runtime": "desktop",
372+ "service.mode": environment.isDevelopment ? "development" : "packaged",
373+ },
374+ },
375+ });
376+ const tracer = yield* makeLocalFileTracer({
377+ filePath: tracePath,
378+ maxBytes: DESKTOP_LOG_FILE_MAX_BYTES,
379+ maxFiles: DESKTOP_LOG_FILE_MAX_FILES,
380+ batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS,
381+ sink,
382+ ...(delegate ? { delegate } : {}),
383+ });
384+
385+ return Layer.succeed(Tracer.Tracer, tracer);
386+ }),
387+).pipe(Layer.provideMerge(OtlpSerialization.layerJson));
388+
389+export const layer = Layer.mergeAll(
390+ backendOutputLogLayer,
391+ desktopLoggerLayer,
392+ tracerLayer,
393+ Layer.succeed(Tracer.MinimumTraceLevel, "Info"),
394+ Layer.succeed(References.TracerTimingEnabled, true),
395+);
apps/desktop/src/app/DesktopState.ts+21−0
@@ -0,0 +1,21 @@
1+import * as Context from "effect/Context";
2+import * as Effect from "effect/Effect";
3+import * as Layer from "effect/Layer";
4+import * as Ref from "effect/Ref";
5+
6+export interface DesktopStateShape {
7+ readonly backendReady: Ref.Ref<boolean>;
8+ readonly quitting: Ref.Ref<boolean>;
9+}
10+
11+export class DesktopState extends Context.Service<DesktopState, DesktopStateShape>()(
12+ "t3/desktop/State",
13+) {}
14+
15+export const layer = Layer.effect(
16+ DesktopState,
17+ Effect.all({
18+ backendReady: Ref.make(false),
19+ quitting: Ref.make(false),
20+ }),
21+);
apps/desktop/src/appBranding.test.ts+0−47
@@ -1,47 +0,0 @@
1−import { describe, expect, it } from "vitest";
2−
3−import { resolveDesktopAppBranding, resolveDesktopAppStageLabel } from "./appBranding.ts";
4−
5−describe("resolveDesktopAppStageLabel", () => {
6− it("uses Dev in desktop development", () => {
7− expect(
8− resolveDesktopAppStageLabel({
9− isDevelopment: true,
10− appVersion: "0.0.17-nightly.20260414.1",
11− }),
12− ).toBe("Dev");
13− });
14−
15− it("uses Nightly for packaged nightly builds", () => {
16− expect(
17− resolveDesktopAppStageLabel({
18− isDevelopment: false,
19− appVersion: "0.0.17-nightly.20260414.1",
20− }),
21− ).toBe("Nightly");
22− });
23−
24− it("uses Alpha for packaged stable builds", () => {
25− expect(
26− resolveDesktopAppStageLabel({
27− isDevelopment: false,
28− appVersion: "0.0.17",
29− }),
30− ).toBe("Alpha");
31− });
32−});
33−
34−describe("resolveDesktopAppBranding", () => {
35− it("returns a complete desktop branding payload", () => {
36− expect(
37− resolveDesktopAppBranding({
38− isDevelopment: false,
39− appVersion: "0.0.17-nightly.20260414.1",
40− }),
41− ).toEqual({
42− baseName: "T3 Code",
43− stageLabel: "Nightly",
44− displayName: "T3 Code (Nightly)",
45− });
46− });
47−});
apps/desktop/src/appBranding.ts+0−28
@@ -1,28 +0,0 @@
1−import type { DesktopAppBranding, DesktopAppStageLabel } from "@t3tools/contracts";
2−
3−import { isNightlyDesktopVersion } from "./updateChannels.ts";
4−
5−const APP_BASE_NAME = "T3 Code";
6−
7−export function resolveDesktopAppStageLabel(input: {
8− readonly isDevelopment: boolean;
9− readonly appVersion: string;
10−}): DesktopAppStageLabel {
11− if (input.isDevelopment) {
12− return "Dev";
13− }
14−
15− return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : "Alpha";
16−}
17−
18−export function resolveDesktopAppBranding(input: {
19− readonly isDevelopment: boolean;
20− readonly appVersion: string;
21−}): DesktopAppBranding {
22− const stageLabel = resolveDesktopAppStageLabel(input);
23− return {
24− baseName: APP_BASE_NAME,
25− stageLabel,
26− displayName: `${APP_BASE_NAME} (${stageLabel})`,
27− };
28−}
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+195−0
@@ -0,0 +1,195 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { assert, describe, it } from "@effect/vitest";
3+import * as Effect from "effect/Effect";
4+import * as FileSystem from "effect/FileSystem";
5+import * as Layer from "effect/Layer";
6+import * as Schema from "effect/Schema";
7+
8+import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
9+import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
10+import * as DesktopConfig from "../app/DesktopConfig.ts";
11+import * as DesktopServerExposure from "./DesktopServerExposure.ts";
12+
13+const PersistedServerObservabilitySettingsDocument = Schema.Struct({
14+ observability: Schema.Struct({
15+ otlpTracesUrl: Schema.String,
16+ otlpMetricsUrl: Schema.String,
17+ }),
18+});
19+
20+const encodePersistedServerObservabilitySettingsDocument = Schema.encodeEffect(
21+ Schema.fromJsonString(PersistedServerObservabilitySettingsDocument),
22+);
23+
24+const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExposure, {
25+ getState: Effect.die("unexpected getState"),
26+ backendConfig: Effect.succeed({
27+ port: 4888,
28+ bindHost: "0.0.0.0",
29+ httpBaseUrl: new URL("http://127.0.0.1:4888"),
30+ tailscaleServeEnabled: true,
31+ tailscaleServePort: 8443,
32+ }),
33+ configureFromSettings: () => Effect.die("unexpected configureFromSettings"),
34+ setMode: () => Effect.die("unexpected setMode"),
35+ setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"),
36+ getAdvertisedEndpoints: Effect.succeed([]),
37+} satisfies DesktopServerExposure.DesktopServerExposureShape);
38+
39+function makeEnvironmentLayer(
40+ baseDir: string,
41+ options?: {
42+ readonly isPackaged?: boolean;
43+ readonly devServerUrl?: string;
44+ },
45+) {
46+ return DesktopEnvironment.layer({
47+ dirname: "/repo/apps/desktop/src",
48+ homeDirectory: baseDir,
49+ platform: "darwin",
50+ processArch: "x64",
51+ appVersion: "1.2.3",
52+ appPath: "/repo",
53+ isPackaged: options?.isPackaged ?? true,
54+ resourcesPath: "/missing/resources",
55+ runningUnderArm64Translation: false,
56+ }).pipe(
57+ Layer.provide(
58+ Layer.mergeAll(
59+ NodeServices.layer,
60+ DesktopConfig.layerTest({
61+ T3CODE_HOME: baseDir,
62+ T3CODE_PORT: "9999",
63+ T3CODE_MODE: "desktop",
64+ T3CODE_DESKTOP_LAN_HOST: "192.168.1.50",
65+ VITE_DEV_SERVER_URL: options?.devServerUrl,
66+ }),
67+ ),
68+ ),
69+ );
70+}
71+
72+const withHarness = <A, E, R>(
73+ effect: Effect.Effect<
74+ A,
75+ E,
76+ | R
77+ | DesktopEnvironment.DesktopEnvironment
78+ | FileSystem.FileSystem
79+ | DesktopBackendConfiguration.DesktopBackendConfiguration
80+ >,
81+) =>
82+ Effect.gen(function* () {
83+ const fileSystem = yield* FileSystem.FileSystem;
84+ const baseDir = yield* fileSystem.makeTempDirectoryScoped({
85+ prefix: "t3-desktop-backend-config-test-",
86+ });
87+
88+ return yield* effect.pipe(
89+ Effect.provide(
90+ DesktopBackendConfiguration.layer.pipe(
91+ Layer.provideMerge(serverExposureLayer),
92+ Layer.provideMerge(makeEnvironmentLayer(baseDir)),
93+ ),
94+ ),
95+ );
96+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer));
97+
98+describe("DesktopBackendConfiguration", () => {
99+ it.effect("resolves backend start config with a stable scoped bootstrap token", () =>
100+ withHarness(
101+ Effect.gen(function* () {
102+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
103+ const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
104+
105+ const first = yield* configuration.resolve;
106+ const second = yield* configuration.resolve;
107+
108+ assert.equal(first.executablePath, process.execPath);
109+ assert.equal(first.entryPath, environment.backendEntryPath);
110+ assert.equal(first.cwd, environment.backendCwd);
111+ assert.equal(first.captureOutput, true);
112+ assert.equal(first.env.ELECTRON_RUN_AS_NODE, "1");
113+ assert.isUndefined(first.env.T3CODE_PORT);
114+ assert.isUndefined(first.env.T3CODE_MODE);
115+ assert.isUndefined(first.env.T3CODE_DESKTOP_LAN_HOST);
116+
117+ assert.equal(first.bootstrap.mode, "desktop");
118+ assert.equal(first.bootstrap.noBrowser, true);
119+ assert.equal(first.bootstrap.port, 4888);
120+ assert.equal(first.bootstrap.host, "0.0.0.0");
121+ assert.equal(first.bootstrap.t3Home, environment.baseDir);
122+ assert.equal(first.bootstrap.tailscaleServeEnabled, true);
123+ assert.equal(first.bootstrap.tailscaleServePort, 8443);
124+ assert.match(first.bootstrap.desktopBootstrapToken, /^[0-9a-f]{48}$/i);
125+ assert.equal(second.bootstrap.desktopBootstrapToken, first.bootstrap.desktopBootstrapToken);
126+ }),
127+ ),
128+ );
129+
130+ it.effect("includes persisted backend observability endpoints when present", () =>
131+ withHarness(
132+ Effect.gen(function* () {
133+ const fileSystem = yield* FileSystem.FileSystem;
134+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
135+ const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
136+
137+ yield* fileSystem.makeDirectory(environment.path.dirname(environment.serverSettingsPath), {
138+ recursive: true,
139+ });
140+ yield* fileSystem.writeFileString(
141+ environment.serverSettingsPath,
142+ yield* encodePersistedServerObservabilitySettingsDocument({
143+ observability: {
144+ otlpTracesUrl: " http://127.0.0.1:4318/v1/traces ",
145+ otlpMetricsUrl: " http://127.0.0.1:4318/v1/metrics ",
146+ },
147+ }),
148+ );
149+
150+ const config = yield* configuration.resolve;
151+ assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces");
152+ assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics");
153+ }),
154+ ),
155+ );
156+
157+ it.effect("omits backend observability endpoints when settings are missing", () =>
158+ withHarness(
159+ Effect.gen(function* () {
160+ const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
161+ const config = yield* configuration.resolve;
162+
163+ assert.isUndefined(config.bootstrap.otlpTracesUrl);
164+ assert.isUndefined(config.bootstrap.otlpMetricsUrl);
165+ }),
166+ ),
167+ );
168+
169+ it.effect("captures backend output in development so child process logs can be persisted", () =>
170+ Effect.gen(function* () {
171+ const fileSystem = yield* FileSystem.FileSystem;
172+ const baseDir = yield* fileSystem.makeTempDirectoryScoped({
173+ prefix: "t3-desktop-backend-config-test-",
174+ });
175+
176+ yield* Effect.gen(function* () {
177+ const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
178+ const config = yield* configuration.resolve;
179+ assert.equal(config.captureOutput, true);
180+ }).pipe(
181+ Effect.provide(
182+ DesktopBackendConfiguration.layer.pipe(
183+ Layer.provideMerge(serverExposureLayer),
184+ Layer.provideMerge(
185+ makeEnvironmentLayer(baseDir, {
186+ isPackaged: false,
187+ devServerUrl: "http://127.0.0.1:5733",
188+ }),
189+ ),
190+ ),
191+ ),
192+ );
193+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
194+ );
195+});
apps/desktop/src/backend/DesktopBackendConfiguration.ts+170−0
@@ -0,0 +1,170 @@
1+import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
2+import * as Context from "effect/Context";
3+import * as Effect from "effect/Effect";
4+import * as FileSystem from "effect/FileSystem";
5+import * as Layer from "effect/Layer";
6+import * as Option from "effect/Option";
7+import * as Random from "effect/Random";
8+import * as Ref from "effect/Ref";
9+
10+import * as DesktopBackendManager from "./DesktopBackendManager.ts";
11+import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
12+import * as DesktopObservability from "../app/DesktopObservability.ts";
13+import * as DesktopServerExposure from "./DesktopServerExposure.ts";
14+
15+export interface DesktopBackendConfigurationShape {
16+ readonly resolve: Effect.Effect<DesktopBackendManager.DesktopBackendStartConfig>;
17+}
18+
19+export class DesktopBackendConfiguration extends Context.Service<
20+ DesktopBackendConfiguration,
21+ DesktopBackendConfigurationShape
22+>()("t3/desktop/BackendConfiguration") {}
23+
24+interface BackendObservabilitySettings {
25+ readonly otlpTracesUrl: Option.Option<string>;
26+ readonly otlpMetricsUrl: Option.Option<string>;
27+}
28+
29+const emptyBackendObservabilitySettings: BackendObservabilitySettings = {
30+ otlpTracesUrl: Option.none(),
31+ otlpMetricsUrl: Option.none(),
32+};
33+
34+const DESKTOP_BACKEND_ENV_NAMES = [
35+ "T3CODE_PORT",
36+ "T3CODE_MODE",
37+ "T3CODE_NO_BROWSER",
38+ "T3CODE_HOST",
39+ "T3CODE_DESKTOP_WS_URL",
40+ "T3CODE_DESKTOP_LAN_ACCESS",
41+ "T3CODE_DESKTOP_LAN_HOST",
42+ "T3CODE_DESKTOP_HTTPS_ENDPOINTS",
43+ "T3CODE_TAILSCALE_SERVE",
44+ "T3CODE_TAILSCALE_SERVE_PORT",
45+] as const;
46+
47+const backendChildEnvPatch = (): Record<string, string | undefined> =>
48+ Object.fromEntries(DESKTOP_BACKEND_ENV_NAMES.map((name) => [name, undefined]));
49+
50+const { logWarning: logBackendConfigurationWarning } = DesktopObservability.makeComponentLogger(
51+ "desktop-backend-configuration",
52+);
53+
54+const readPersistedBackendObservabilitySettings: Effect.Effect<
55+ BackendObservabilitySettings,
56+ never,
57+ FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment
58+> = Effect.gen(function* () {
59+ const fileSystem = yield* FileSystem.FileSystem;
60+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
61+ const exists = yield* fileSystem
62+ .exists(environment.serverSettingsPath)
63+ .pipe(Effect.orElseSucceed(() => false));
64+ if (!exists) {
65+ return emptyBackendObservabilitySettings;
66+ }
67+
68+ const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(Effect.option);
69+ if (Option.isNone(raw)) {
70+ yield* logBackendConfigurationWarning(
71+ "failed to read persisted backend observability settings",
72+ );
73+ return emptyBackendObservabilitySettings;
74+ }
75+
76+ const parsed = parsePersistedServerObservabilitySettings(raw.value);
77+ return {
78+ otlpTracesUrl: Option.fromNullishOr(parsed.otlpTracesUrl),
79+ otlpMetricsUrl: Option.fromNullishOr(parsed.otlpMetricsUrl),
80+ };
81+});
82+
83+const getOrCreateBootstrapToken = Effect.fn("desktop.backendConfiguration.bootstrapToken")(
84+ function* (tokenRef: Ref.Ref<Option.Option<string>>) {
85+ const existing = yield* Ref.get(tokenRef);
86+ if (Option.isSome(existing)) {
87+ return existing.value;
88+ }
89+
90+ let token = "";
91+ while (token.length < 48) {
92+ token += (yield* Random.nextUUIDv4).replace(/-/g, "");
93+ }
94+ token = token.slice(0, 48);
95+ yield* Ref.set(tokenRef, Option.some(token));
96+ return token;
97+ },
98+);
99+
100+const resolveBackendStartConfig = Effect.fn("desktop.backendConfiguration.resolveStartConfig")(
101+ function* (input: {
102+ readonly bootstrapToken: string;
103+ readonly observabilitySettings: BackendObservabilitySettings;
104+ }): Effect.fn.Return<
105+ DesktopBackendManager.DesktopBackendStartConfig,
106+ never,
107+ DesktopEnvironment.DesktopEnvironment | DesktopServerExposure.DesktopServerExposure
108+ > {
109+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
110+ const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
111+ const backendExposure = yield* serverExposure.backendConfig;
112+
113+ return {
114+ executablePath: process.execPath,
115+ entryPath: environment.backendEntryPath,
116+ cwd: environment.backendCwd,
117+ env: {
118+ ...backendChildEnvPatch(),
119+ ELECTRON_RUN_AS_NODE: "1",
120+ },
121+ bootstrap: {
122+ mode: "desktop",
123+ noBrowser: true,
124+ port: backendExposure.port,
125+ t3Home: environment.baseDir,
126+ host: backendExposure.bindHost,
127+ desktopBootstrapToken: input.bootstrapToken,
128+ tailscaleServeEnabled: backendExposure.tailscaleServeEnabled,
129+ tailscaleServePort: backendExposure.tailscaleServePort,
130+ ...Option.match(input.observabilitySettings.otlpTracesUrl, {
131+ onNone: () => ({}),
132+ onSome: (otlpTracesUrl) => ({ otlpTracesUrl }),
133+ }),
134+ ...Option.match(input.observabilitySettings.otlpMetricsUrl, {
135+ onNone: () => ({}),
136+ onSome: (otlpMetricsUrl) => ({ otlpMetricsUrl }),
137+ }),
138+ },
139+ httpBaseUrl: backendExposure.httpBaseUrl,
140+ captureOutput: true,
141+ };
142+ },
143+);
144+
145+export const layer = Layer.effect(
146+ DesktopBackendConfiguration,
147+ Effect.gen(function* () {
148+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
149+ const fileSystem = yield* FileSystem.FileSystem;
150+ const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
151+ const tokenRef = yield* Ref.make(Option.none<string>());
152+
153+ return DesktopBackendConfiguration.of({
154+ resolve: Effect.gen(function* () {
155+ const bootstrapToken = yield* getOrCreateBootstrapToken(tokenRef);
156+ const observabilitySettings = yield* readPersistedBackendObservabilitySettings.pipe(
157+ Effect.provideService(FileSystem.FileSystem, fileSystem),
158+ Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
159+ );
160+ return yield* resolveBackendStartConfig({
161+ bootstrapToken,
162+ observabilitySettings,
163+ }).pipe(
164+ Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
165+ Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure),
166+ );
167+ }).pipe(Effect.withSpan("desktop.backendConfiguration.resolve")),
168+ });
169+ }),
170+);
apps/desktop/src/backend/DesktopBackendManager.test.ts+490−0
@@ -0,0 +1,490 @@
1+import {
2+ DesktopBackendBootstrap,
3+ type DesktopBackendBootstrap as DesktopBackendBootstrapValue,
4+} from "@t3tools/contracts";
5+import { assert, describe, it } from "@effect/vitest";
6+import * as Deferred from "effect/Deferred";
7+import * as Duration from "effect/Duration";
8+import * as Effect from "effect/Effect";
9+import * as FileSystem from "effect/FileSystem";
10+import * as Layer from "effect/Layer";
11+import * as Option from "effect/Option";
12+import * as Queue from "effect/Queue";
13+import * as Ref from "effect/Ref";
14+import * as Schema from "effect/Schema";
15+import * as Sink from "effect/Sink";
16+import * as Scope from "effect/Scope";
17+import * as Stream from "effect/Stream";
18+import { TestClock } from "effect/testing";
19+import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
20+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
21+
22+import * as DesktopBackendManager from "./DesktopBackendManager.ts";
23+import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
24+import * as DesktopObservability from "../app/DesktopObservability.ts";
25+import * as DesktopState from "../app/DesktopState.ts";
26+import * as DesktopWindow from "../window/DesktopWindow.ts";
27+
28+const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = {
29+ executablePath: "/electron",
30+ entryPath: "/server/bin.mjs",
31+ cwd: "/server",
32+ env: { ELECTRON_RUN_AS_NODE: "1" },
33+ bootstrap: {
34+ mode: "desktop",
35+ noBrowser: true,
36+ port: 3773,
37+ t3Home: "/tmp/t3",
38+ host: "127.0.0.1",
39+ desktopBootstrapToken: "token",
40+ tailscaleServeEnabled: false,
41+ tailscaleServePort: 443,
42+ },
43+ httpBaseUrl: new URL("http://127.0.0.1:3773"),
44+ captureOutput: true,
45+};
46+
47+const configWithObservability: DesktopBackendBootstrapValue = {
48+ ...baseConfig.bootstrap,
49+ tailscaleServeEnabled: true,
50+ otlpTracesUrl: "http://127.0.0.1:4318/v1/traces",
51+};
52+
53+function makeProcess(options?: {
54+ readonly stdout?: Stream.Stream<Uint8Array>;
55+ readonly stderr?: Stream.Stream<Uint8Array>;
56+ readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode>;
57+ readonly kill?: ChildProcessSpawner.ChildProcessHandle["kill"];
58+}): ChildProcessSpawner.ChildProcessHandle {
59+ return ChildProcessSpawner.makeHandle({
60+ pid: ChildProcessSpawner.ProcessId(123),
61+ stdout: options?.stdout ?? Stream.empty,
62+ stderr: options?.stderr ?? Stream.empty,
63+ all: Stream.merge(options?.stdout ?? Stream.empty, options?.stderr ?? Stream.empty),
64+ exitCode: options?.exitCode ?? Effect.succeed(ChildProcessSpawner.ExitCode(0)),
65+ isRunning: Effect.succeed(false),
66+ kill: options?.kill ?? (() => Effect.void),
67+ stdin: Sink.drain,
68+ getInputFd: () => Sink.drain,
69+ getOutputFd: () => Stream.empty,
70+ unref: Effect.succeed(Effect.void),
71+ });
72+}
73+
74+function responseForRequest(
75+ request: HttpClientRequest.HttpClientRequest,
76+ status: number,
77+): HttpClientResponse.HttpClientResponse {
78+ return HttpClientResponse.fromWeb(request, new Response(null, { status }));
79+}
80+
81+function httpClientLayer(
82+ handler: (
83+ request: HttpClientRequest.HttpClientRequest,
84+ ) => Effect.Effect<HttpClientResponse.HttpClientResponse>,
85+) {
86+ return Layer.succeed(
87+ HttpClient.HttpClient,
88+ HttpClient.make((request) => handler(request)),
89+ );
90+}
91+
92+const healthyHttpClientLayer = httpClientLayer((request) =>
93+ Effect.succeed(responseForRequest(request, 200)),
94+);
95+
96+function decodeBootstrap(raw: string) {
97+ return Schema.decodeEffect(Schema.fromJsonString(DesktopBackendBootstrap))(raw);
98+}
99+
100+function makeManagerLayer(input: {
101+ readonly spawnerLayer: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>;
102+ readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
103+ readonly backendOutputLog?: Partial<DesktopObservability.DesktopBackendOutputLogShape>;
104+ readonly desktopState?: DesktopState.DesktopStateShape;
105+ readonly desktopWindow?: Partial<DesktopWindow.DesktopWindowShape>;
106+ readonly config?: DesktopBackendManager.DesktopBackendStartConfig;
107+}) {
108+ return DesktopBackendManager.layer.pipe(
109+ Layer.provide(
110+ Layer.mergeAll(
111+ FileSystem.layerNoop({
112+ exists: () => Effect.succeed(true),
113+ }),
114+ Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, {
115+ resolve: Effect.succeed(input.config ?? baseConfig),
116+ }),
117+ input.spawnerLayer,
118+ input.httpClientLayer ?? healthyHttpClientLayer,
119+ input.desktopState
120+ ? Layer.succeed(DesktopState.DesktopState, input.desktopState)
121+ : DesktopState.layer,
122+ Layer.succeed(DesktopObservability.DesktopBackendOutputLog, {
123+ writeSessionBoundary: () => Effect.void,
124+ writeOutputChunk: () => Effect.void,
125+ ...input.backendOutputLog,
126+ } satisfies DesktopObservability.DesktopBackendOutputLogShape),
127+ Layer.succeed(DesktopWindow.DesktopWindow, {
128+ createMain: Effect.die("unexpected createMain"),
129+ ensureMain: Effect.die("unexpected ensureMain"),
130+ revealOrCreateMain: Effect.die("unexpected revealOrCreateMain"),
131+ activate: Effect.void,
132+ createMainIfBackendReady: Effect.void,
133+ handleBackendReady: Effect.void,
134+ dispatchMenuAction: () => Effect.void,
135+ syncAppearance: Effect.void,
136+ ...input.desktopWindow,
137+ } satisfies DesktopWindow.DesktopWindowShape),
138+ ),
139+ ),
140+ );
141+}
142+
143+describe("DesktopBackendManager", () => {
144+ it.effect("spawns the backend with fd3 bootstrap JSON and reports HTTP readiness", () =>
145+ Effect.gen(function* () {
146+ let spawnedCommand: ChildProcess.Command | undefined;
147+ let bootstrapJson = "";
148+ let readyCount = 0;
149+ const ready = yield* Deferred.make<void>();
150+ const exited = yield* Queue.unbounded<void>();
151+
152+ const spawnerLayer = Layer.succeed(
153+ ChildProcessSpawner.ChildProcessSpawner,
154+ ChildProcessSpawner.make((command) =>
155+ Effect.gen(function* () {
156+ spawnedCommand = command;
157+ if (command._tag === "StandardCommand") {
158+ const fd3 = command.options.additionalFds?.fd3;
159+ if (fd3?.type === "input" && fd3.stream) {
160+ bootstrapJson = yield* fd3.stream.pipe(Stream.decodeText(), Stream.mkString);
161+ }
162+ }
163+
164+ return makeProcess({
165+ exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
166+ });
167+ }),
168+ ),
169+ );
170+
171+ const managerLayer = makeManagerLayer({
172+ config: {
173+ ...baseConfig,
174+ bootstrap: configWithObservability,
175+ },
176+ spawnerLayer,
177+ desktopWindow: {
178+ handleBackendReady: Effect.sync(() => {
179+ readyCount += 1;
180+ }).pipe(Effect.andThen(Deferred.succeed(ready, void 0))),
181+ },
182+ backendOutputLog: {
183+ writeSessionBoundary: ({ phase }) =>
184+ phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void,
185+ },
186+ });
187+
188+ yield* Effect.gen(function* () {
189+ const manager = yield* DesktopBackendManager.DesktopBackendManager;
190+ yield* manager.start;
191+ yield* Queue.take(exited);
192+
193+ assert.equal(readyCount, 1);
194+ assert.isDefined(spawnedCommand);
195+ if (spawnedCommand._tag !== "StandardCommand") {
196+ throw new Error("Expected backend to spawn a standard command.");
197+ }
198+
199+ assert.equal(spawnedCommand.command, "/electron");
200+ assert.deepEqual(spawnedCommand.args, ["/server/bin.mjs", "--bootstrap-fd", "3"]);
201+ assert.equal(spawnedCommand.options.cwd, "/server");
202+ assert.equal(spawnedCommand.options.extendEnv, true);
203+ assert.equal(spawnedCommand.options.stdout, "pipe");
204+ assert.equal(spawnedCommand.options.stderr, "pipe");
205+ assert.equal(spawnedCommand.options.killSignal, "SIGTERM");
206+ assert.isDefined(spawnedCommand.options.forceKillAfter);
207+ assert.equal(
208+ Duration.toMillis(Duration.fromInputUnsafe(spawnedCommand.options.forceKillAfter)),
209+ 2_000,
210+ );
211+
212+ assert.deepEqual(yield* decodeBootstrap(bootstrapJson), configWithObservability);
213+ }).pipe(Effect.provide(managerLayer));
214+ }),
215+ );
216+
217+ it.effect("retries HTTP readiness before reporting the backend ready", () =>
218+ Effect.gen(function* () {
219+ const requestUrls: Array<string> = [];
220+ const statuses = [503, 200];
221+ let readyCount = 0;
222+ const firstRequest = yield* Deferred.make<void>();
223+ const ready = yield* Deferred.make<void>();
224+ const exited = yield* Queue.unbounded<void>();
225+
226+ const spawnerLayer = Layer.succeed(
227+ ChildProcessSpawner.ChildProcessSpawner,
228+ ChildProcessSpawner.make(() =>
229+ Effect.succeed(
230+ makeProcess({
231+ exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
232+ }),
233+ ),
234+ ),
235+ );
236+
237+ const managerLayer = makeManagerLayer({
238+ spawnerLayer,
239+ httpClientLayer: httpClientLayer((request) =>
240+ Effect.gen(function* () {
241+ const status = statuses.shift();
242+ assert.isDefined(status);
243+ requestUrls.push(request.url);
244+ yield* Deferred.succeed(firstRequest, void 0);
245+ return responseForRequest(request, status);
246+ }),
247+ ),
248+ desktopWindow: {
249+ handleBackendReady: Effect.sync(() => {
250+ readyCount += 1;
251+ }).pipe(Effect.andThen(Deferred.succeed(ready, void 0))),
252+ },
253+ backendOutputLog: {
254+ writeSessionBoundary: ({ phase }) =>
255+ phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void,
256+ },
257+ });
258+
259+ yield* Effect.gen(function* () {
260+ const manager = yield* DesktopBackendManager.DesktopBackendManager;
261+ yield* manager.start;
262+ yield* Deferred.await(firstRequest);
263+
264+ assert.equal(readyCount, 0);
265+ assert.deepEqual(requestUrls, ["http://127.0.0.1:3773/.well-known/t3/environment"]);
266+
267+ yield* TestClock.adjust(Duration.millis(100));
268+ yield* Queue.take(exited);
269+
270+ assert.equal(readyCount, 1);
271+ assert.deepEqual(requestUrls, [
272+ "http://127.0.0.1:3773/.well-known/t3/environment",
273+ "http://127.0.0.1:3773/.well-known/t3/environment",
274+ ]);
275+ }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer)));
276+ }),
277+ );
278+
279+ it.effect("starts the configured backend and closes the scoped process on stop", () =>
280+ Effect.gen(function* () {
281+ let startCount = 0;
282+ let closedCount = 0;
283+ const closed = yield* Deferred.make<void>();
284+ const startedPids = yield* Queue.unbounded<number>();
285+ const ready = yield* Deferred.make<void>();
286+ const backendReady = yield* Ref.make(false);
287+ const quitting = yield* Ref.make(false);
288+
289+ const spawnerLayer = Layer.succeed(
290+ ChildProcessSpawner.ChildProcessSpawner,
291+ ChildProcessSpawner.make(() =>
292+ Effect.gen(function* () {
293+ const scope = yield* Scope.Scope;
294+ startCount += 1;
295+ yield* Queue.offer(startedPids, 123);
296+ const close = Effect.sync(() => {
297+ closedCount += 1;
298+ }).pipe(Effect.andThen(Deferred.succeed(closed, void 0)), Effect.asVoid);
299+
300+ yield* Scope.addFinalizer(scope, close);
301+
302+ return makeProcess({
303+ exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
304+ kill: () => close,
305+ });
306+ }),
307+ ),
308+ );
309+
310+ const managerLayer = makeManagerLayer({
311+ spawnerLayer,
312+ desktopState: {
313+ backendReady,
314+ quitting,
315+ },
316+ desktopWindow: {
317+ handleBackendReady: Deferred.succeed(ready, void 0).pipe(Effect.asVoid),
318+ },
319+ });
320+
321+ yield* Effect.gen(function* () {
322+ const manager = yield* DesktopBackendManager.DesktopBackendManager;
323+ assert.isTrue(Option.isNone(yield* manager.currentConfig));
324+
325+ yield* manager.start;
326+ assert.equal(yield* Queue.take(startedPids), 123);
327+ yield* Deferred.await(ready);
328+ assert.isTrue(yield* Ref.get(backendReady));
329+ assert.deepEqual(yield* manager.currentConfig, Option.some(baseConfig));
330+
331+ const runningSnapshot = yield* manager.snapshot;
332+ assert.equal(runningSnapshot.ready, true);
333+ assert.deepEqual(runningSnapshot.activePid, Option.some(123));
334+
335+ yield* manager.stop();
336+ assert.equal(startCount, 1);
337+ assert.equal(closedCount, 1);
338+
339+ const stoppedSnapshot = yield* manager.snapshot;
340+ assert.isFalse(yield* Ref.get(backendReady));
341+ assert.equal(stoppedSnapshot.desiredRunning, false);
342+ assert.equal(stoppedSnapshot.ready, false);
343+ assert.equal(Option.isNone(stoppedSnapshot.activePid), true);
344+ }).pipe(Effect.provide(managerLayer));
345+ }),
346+ );
347+
348+ it.effect("restarts an unexpectedly exited backend with the Effect clock", () =>
349+ Effect.gen(function* () {
350+ const starts = yield* Queue.unbounded<number>();
351+ let startCount = 0;
352+
353+ const spawnerLayer = Layer.succeed(
354+ ChildProcessSpawner.ChildProcessSpawner,
355+ ChildProcessSpawner.make(() =>
356+ Effect.sync(() => {
357+ startCount += 1;
358+ return makeProcess({
359+ exitCode: Queue.offer(starts, startCount).pipe(
360+ Effect.as(ChildProcessSpawner.ExitCode(1)),
361+ ),
362+ });
363+ }),
364+ ),
365+ );
366+
367+ const managerLayer = makeManagerLayer({
368+ spawnerLayer,
369+ httpClientLayer: httpClientLayer(() => Effect.never),
370+ });
371+
372+ yield* Effect.gen(function* () {
373+ const manager = yield* DesktopBackendManager.DesktopBackendManager;
374+ yield* manager.start;
375+
376+ assert.equal(yield* Queue.take(starts), 1);
377+
378+ yield* TestClock.adjust(Duration.millis(499));
379+ assert.equal(yield* Queue.size(starts), 0);
380+ yield* TestClock.adjust(Duration.millis(1));
381+ assert.equal(yield* Queue.take(starts), 2);
382+
383+ yield* TestClock.adjust(Duration.millis(999));
384+ assert.equal(yield* Queue.size(starts), 0);
385+ yield* TestClock.adjust(Duration.millis(1));
386+ assert.equal(yield* Queue.take(starts), 3);
387+ }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer)));
388+ }),
389+ );
390+
391+ it.effect("cancels a scheduled restart when start is requested manually", () =>
392+ Effect.gen(function* () {
393+ const starts = yield* Queue.unbounded<number>();
394+ const secondClosed = yield* Deferred.make<void>();
395+ let startCount = 0;
396+
397+ const spawnerLayer = Layer.succeed(
398+ ChildProcessSpawner.ChildProcessSpawner,
399+ ChildProcessSpawner.make(() =>
400+ Effect.gen(function* () {
401+ startCount += 1;
402+ yield* Queue.offer(starts, startCount);
403+
404+ if (startCount === 1) {
405+ return makeProcess({
406+ exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)),
407+ });
408+ }
409+
410+ const scope = yield* Scope.Scope;
411+ const close = Deferred.succeed(secondClosed, void 0).pipe(Effect.asVoid);
412+ yield* Scope.addFinalizer(scope, close);
413+ return makeProcess({
414+ exitCode: Deferred.await(secondClosed).pipe(
415+ Effect.as(ChildProcessSpawner.ExitCode(0)),
416+ ),
417+ kill: () => close,
418+ });
419+ }),
420+ ),
421+ );
422+
423+ const managerLayer = makeManagerLayer({
424+ spawnerLayer,
425+ httpClientLayer: httpClientLayer(() => Effect.never),
426+ });
427+
428+ yield* Effect.gen(function* () {
429+ const manager = yield* DesktopBackendManager.DesktopBackendManager;
430+ yield* manager.start;
431+
432+ assert.equal(yield* Queue.take(starts), 1);
433+
434+ yield* manager.start;
435+ assert.equal(yield* Queue.take(starts), 2);
436+
437+ yield* manager.stop();
438+ yield* TestClock.adjust(Duration.millis(500));
439+
440+ assert.equal(yield* Queue.size(starts), 0);
441+ }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer)));
442+ }),
443+ );
444+
445+ it.effect("does not restart after stop cancels a scheduled restart", () =>
446+ Effect.gen(function* () {
447+ const starts = yield* Queue.unbounded<number>();
448+ let startCount = 0;
449+
450+ const spawnerLayer = Layer.succeed(
451+ ChildProcessSpawner.ChildProcessSpawner,
452+ ChildProcessSpawner.make(() =>
453+ Effect.sync(() => {
454+ startCount += 1;
455+ return makeProcess({
456+ exitCode: Queue.offer(starts, startCount).pipe(
457+ Effect.as(ChildProcessSpawner.ExitCode(1)),
458+ ),
459+ });
460+ }),
461+ ),
462+ );
463+
464+ const managerLayer = makeManagerLayer({
465+ spawnerLayer,
466+ httpClientLayer: httpClientLayer(() => Effect.never),
467+ });
468+
469+ yield* Effect.gen(function* () {
470+ const manager = yield* DesktopBackendManager.DesktopBackendManager;
471+ yield* manager.start;
472+ assert.equal(yield* Queue.take(starts), 1);
473+
474+ let restartScheduled = false;
475+ while (!restartScheduled) {
476+ restartScheduled = (yield* manager.snapshot).restartScheduled;
477+ if (!restartScheduled) {
478+ yield* Effect.yieldNow;
479+ }
480+ }
481+
482+ yield* manager.stop();
483+ yield* TestClock.adjust(Duration.millis(500));
484+
485+ assert.equal(yield* Queue.size(starts), 0);
486+ assert.equal((yield* manager.snapshot).desiredRunning, false);
487+ }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer)));
488+ }),
489+ );
490+});
apps/desktop/src/backend/DesktopBackendManager.ts+596−0
@@ -0,0 +1,596 @@
1+import * as Cause from "effect/Cause";
2+import * as Context from "effect/Context";
3+import * as Data from "effect/Data";
4+import * as Duration from "effect/Duration";
5+import * as Effect from "effect/Effect";
6+import * as Exit from "effect/Exit";
7+import * as Fiber from "effect/Fiber";
8+import * as FileSystem from "effect/FileSystem";
9+import * as Layer from "effect/Layer";
10+import * as Option from "effect/Option";
11+import * as PlatformError from "effect/PlatformError";
12+import * as Ref from "effect/Ref";
13+import * as Result from "effect/Result";
14+import * as Schedule from "effect/Schedule";
15+import * as Schema from "effect/Schema";
16+import * as Semaphore from "effect/Semaphore";
17+import * as Scope from "effect/Scope";
18+import * as Stream from "effect/Stream";
19+import { HttpClient } from "effect/unstable/http";
20+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
21+
22+import {
23+ DesktopBackendBootstrap,
24+ type DesktopBackendBootstrap as DesktopBackendBootstrapValue,
25+} from "@t3tools/contracts";
26+
27+import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
28+import * as DesktopObservability from "../app/DesktopObservability.ts";
29+import * as DesktopState from "../app/DesktopState.ts";
30+import * as DesktopWindow from "../window/DesktopWindow.ts";
31+
32+const INITIAL_RESTART_DELAY = Duration.millis(500);
33+const MAX_RESTART_DELAY = Duration.seconds(10);
34+const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1);
35+const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100);
36+const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1);
37+const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(2);
38+const BACKEND_READINESS_PATH = "/.well-known/t3/environment";
39+
40+type BackendProcessLayerServices = ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient;
41+
42+type BackendProcessRunRequirements = BackendProcessLayerServices | Scope.Scope;
43+
44+export type BackendProcessOutputStream = "stdout" | "stderr";
45+
46+export interface DesktopBackendStartConfig {
47+ readonly executablePath: string;
48+ readonly entryPath: string;
49+ readonly cwd: string;
50+ readonly env: Record<string, string | undefined>;
51+ readonly bootstrap: DesktopBackendBootstrapValue;
52+ readonly httpBaseUrl: URL;
53+ readonly captureOutput: boolean;
54+}
55+
56+interface BackendProcessExit {
57+ readonly code: Option.Option<number>;
58+ readonly reason: string;
59+ readonly result: Result.Result<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>;
60+}
61+
62+export class BackendTimeoutError extends Data.TaggedError("BackendTimeoutError")<{
63+ readonly url: URL;
64+}> {
65+ override get message() {
66+ return `Timed out waiting for backend readiness at ${this.url.href}.`;
67+ }
68+}
69+
70+class BackendProcessBootstrapEncodeError extends Data.TaggedError(
71+ "BackendProcessBootstrapEncodeError",
72+)<{
73+ readonly cause: Schema.SchemaError;
74+}> {
75+ override get message() {
76+ return `Failed to encode desktop backend bootstrap payload: ${this.cause.message}`;
77+ }
78+}
79+
80+class BackendProcessSpawnError extends Data.TaggedError("BackendProcessSpawnError")<{
81+ readonly cause: PlatformError.PlatformError;
82+}> {
83+ override get message() {
84+ return `Failed to spawn desktop backend process: ${this.cause.message}`;
85+ }
86+}
87+
88+type BackendProcessError = BackendProcessBootstrapEncodeError | BackendProcessSpawnError;
89+
90+interface RunBackendProcessOptions extends DesktopBackendStartConfig {
91+ readonly readinessTimeout?: Duration.Duration;
92+ readonly onStarted?: (pid: number) => Effect.Effect<void>;
93+ readonly onReady?: () => Effect.Effect<void>;
94+ readonly onReadinessFailure?: (error: BackendTimeoutError) => Effect.Effect<void>;
95+ readonly onOutput?: (
96+ streamName: BackendProcessOutputStream,
97+ chunk: Uint8Array,
98+ ) => Effect.Effect<void>;
99+}
100+
101+export interface DesktopBackendSnapshot {
102+ readonly desiredRunning: boolean;
103+ readonly ready: boolean;
104+ readonly activePid: Option.Option<number>;
105+ readonly restartAttempt: number;
106+ readonly restartScheduled: boolean;
107+}
108+
109+export interface DesktopBackendManagerShape {
110+ readonly start: Effect.Effect<void>;
111+ readonly stop: (options?: { readonly timeout?: Duration.Duration }) => Effect.Effect<void>;
112+ readonly currentConfig: Effect.Effect<Option.Option<DesktopBackendStartConfig>>;
113+ readonly snapshot: Effect.Effect<DesktopBackendSnapshot>;
114+}
115+
116+export class DesktopBackendManager extends Context.Service<
117+ DesktopBackendManager,
118+ DesktopBackendManagerShape
119+>()("t3/desktop/BackendManager") {}
120+
121+const { logWarning: logBackendManagerWarning, logError: logBackendManagerError } =
122+ DesktopObservability.makeComponentLogger("desktop-backend-manager");
123+
124+interface ActiveBackendRun {
125+ readonly id: number;
126+ readonly scope: Scope.Closeable;
127+ readonly fiber: Option.Option<Fiber.Fiber<void, never>>;
128+ readonly pid: Option.Option<number>;
129+}
130+
131+interface BackendManagerState {
132+ readonly desiredRunning: boolean;
133+ readonly ready: boolean;
134+ readonly config: Option.Option<DesktopBackendStartConfig>;
135+ readonly active: Option.Option<ActiveBackendRun>;
136+ readonly restartAttempt: number;
137+ readonly restartFiber: Option.Option<Fiber.Fiber<void, never>>;
138+ readonly nextRunId: number;
139+}
140+
141+const initialState: BackendManagerState = {
142+ desiredRunning: false,
143+ ready: false,
144+ config: Option.none(),
145+ active: Option.none(),
146+ restartAttempt: 0,
147+ restartFiber: Option.none(),
148+ nextRunId: 1,
149+};
150+
151+const activePid = (active: Option.Option<ActiveBackendRun>): Option.Option<number> =>
152+ Option.flatMap(active, (run) => run.pid);
153+
154+const withActiveRun =
155+ (runId: number, f: (run: ActiveBackendRun) => ActiveBackendRun) =>
156+ (state: BackendManagerState): BackendManagerState => ({
157+ ...state,
158+ active: Option.map(state.active, (run) => (run.id === runId ? f(run) : run)),
159+ });
160+
161+const calculateRestartDelay = (attempt: number): Duration.Duration =>
162+ Duration.min(Duration.times(INITIAL_RESTART_DELAY, 2 ** attempt), MAX_RESTART_DELAY);
163+
164+const closeRun = (
165+ run: ActiveBackendRun,
166+ options?: { readonly timeout?: Duration.Duration },
167+): Effect.Effect<void> => {
168+ const waitForFiber = Option.match(run.fiber, {
169+ onNone: () => Effect.void,
170+ onSome: (fiber) => Fiber.await(fiber).pipe(Effect.asVoid),
171+ });
172+ const close = Scope.close(run.scope, Exit.void).pipe(Effect.andThen(waitForFiber));
173+
174+ return (
175+ options?.timeout ? close.pipe(Effect.timeoutOption(options.timeout), Effect.asVoid) : close
176+ ).pipe(Effect.ignore);
177+};
178+
179+const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(function* (
180+ baseUrl: URL,
181+ timeout: Duration.Duration,
182+): Effect.fn.Return<void, BackendTimeoutError, HttpClient.HttpClient> {
183+ const readinessUrl = new URL(BACKEND_READINESS_PATH, baseUrl);
184+ const client = (yield* HttpClient.HttpClient).pipe(
185+ HttpClient.filterStatusOk,
186+ HttpClient.transformResponse(Effect.timeout(DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT)),
187+ HttpClient.retry(Schedule.spaced(DEFAULT_BACKEND_READINESS_INTERVAL)),
188+ );
189+
190+ yield* client.get(readinessUrl).pipe(
191+ Effect.asVoid,
192+ Effect.timeout(timeout),
193+ Effect.mapError(() => new BackendTimeoutError({ url: readinessUrl })),
194+ );
195+});
196+
197+function describeProcessExit(
198+ result: Result.Result<ChildProcessSpawner.ExitCode, PlatformError.PlatformError>,
199+): BackendProcessExit {
200+ if (Result.isSuccess(result)) {
201+ return {
202+ code: Option.some(result.success),
203+ reason: `code=${result.success}`,
204+ result,
205+ };
206+ }
207+
208+ return {
209+ code: Option.none(),
210+ reason: result.failure.message,
211+ result,
212+ };
213+}
214+
215+function drainBackendOutput(
216+ streamName: BackendProcessOutputStream,
217+ stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>,
218+ onOutput: (streamName: BackendProcessOutputStream, chunk: Uint8Array) => Effect.Effect<void>,
219+): Effect.Effect<void> {
220+ return stream.pipe(
221+ Stream.runForEach((chunk) => onOutput(streamName, chunk)),
222+ Effect.ignore,
223+ );
224+}
225+
226+const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap));
227+
228+const runBackendProcess = Effect.fn("runBackendProcess")(function* (
229+ options: RunBackendProcessOptions,
230+): Effect.fn.Return<BackendProcessExit, BackendProcessError, BackendProcessRunRequirements> {
231+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
232+ const bootstrapJson = yield* encodeBootstrapJson(options.bootstrap).pipe(
233+ Effect.mapError((cause) => new BackendProcessBootstrapEncodeError({ cause })),
234+ );
235+ const onOutput = options.onOutput ?? (() => Effect.void);
236+ const command = ChildProcess.make(
237+ options.executablePath,
238+ [options.entryPath, "--bootstrap-fd", "3"],
239+ {
240+ cwd: options.cwd,
241+ env: options.env,
242+ extendEnv: true,
243+ // In Electron main, process.execPath points to the Electron binary.
244+ // Run the child in Node mode so this backend process does not become a GUI app instance.
245+ stdin: "ignore",
246+ stdout: options.captureOutput ? "pipe" : "inherit",
247+ stderr: options.captureOutput ? "pipe" : "inherit",
248+ killSignal: "SIGTERM",
249+ forceKillAfter: DEFAULT_BACKEND_TERMINATE_GRACE,
250+ additionalFds: {
251+ fd3: {
252+ type: "input",
253+ stream: Stream.encodeText(Stream.make(`${bootstrapJson}\n`)),
254+ },
255+ },
256+ },
257+ );
258+
259+ const handle = yield* spawner
260+ .spawn(command)
261+ .pipe(Effect.mapError((cause) => new BackendProcessSpawnError({ cause })));
262+
263+ yield* options.onStarted?.(handle.pid) ?? Effect.void;
264+ if (options.captureOutput) {
265+ yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped);
266+ yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped);
267+ }
268+ yield* waitForHttpReady(
269+ options.httpBaseUrl,
270+ options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT,
271+ ).pipe(
272+ Effect.tap(() => options.onReady?.() ?? Effect.void),
273+ Effect.catch((error) => options.onReadinessFailure?.(error) ?? Effect.void),
274+ Effect.forkScoped,
275+ );
276+
277+ return describeProcessExit(yield* Effect.result(handle.exitCode));
278+});
279+
280+const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(function* () {
281+ const parentScope = yield* Scope.Scope;
282+ const fileSystem = yield* FileSystem.FileSystem;
283+ const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
284+ const backendOutputLog = yield* DesktopObservability.DesktopBackendOutputLog;
285+ const desktopState = yield* DesktopState.DesktopState;
286+ const desktopWindow = yield* DesktopWindow.DesktopWindow;
287+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
288+ const httpClient = yield* HttpClient.HttpClient;
289+ const state = yield* Ref.make(initialState);
290+ const mutex = yield* Semaphore.make(1);
291+
292+ const updateActiveRun = (runId: number, f: (run: ActiveBackendRun) => ActiveBackendRun) =>
293+ Ref.update(state, withActiveRun(runId, f));
294+
295+ const snapshot = Ref.get(state).pipe(
296+ Effect.map(
297+ (current): DesktopBackendSnapshot => ({
298+ desiredRunning: current.desiredRunning,
299+ ready: current.ready,
300+ activePid: activePid(current.active),
301+ restartAttempt: current.restartAttempt,
302+ restartScheduled: Option.isSome(current.restartFiber),
303+ }),
304+ ),
305+ );
306+ const currentConfig = Ref.get(state).pipe(Effect.map((current) => current.config));
307+
308+ const cancelRestart = Effect.gen(function* () {
309+ const restartFiber = yield* Ref.modify(state, (current) => [
310+ current.restartFiber,
311+ {
312+ ...current,
313+ restartFiber: Option.none(),
314+ },
315+ ]);
316+
317+ yield* Option.match(restartFiber, {
318+ onNone: () => Effect.void,
319+ onSome: (fiber) => Fiber.interrupt(fiber).pipe(Effect.asVoid),
320+ });
321+ });
322+
323+ const start: Effect.Effect<void> = Effect.suspend(() =>
324+ mutex.withPermits(1)(
325+ Effect.gen(function* () {
326+ const current = yield* Ref.get(state);
327+ if (Option.isSome(current.active)) {
328+ return;
329+ }
330+
331+ yield* Ref.set(desktopState.backendReady, false);
332+ const config = yield* configuration.resolve;
333+ const entryExists = yield* fileSystem
334+ .exists(config.entryPath)
335+ .pipe(Effect.orElseSucceed(() => false));
336+
337+ yield* cancelRestart;
338+ yield* Ref.update(state, (latest) => ({
339+ ...latest,
340+ desiredRunning: true,
341+ ready: false,
342+ config: Option.some(config),
343+ }));
344+
345+ if (!entryExists) {
346+ yield* scheduleRestart(`missing server entry at ${config.entryPath}`);
347+ return;
348+ }
349+
350+ const runScope = yield* Scope.make("sequential");
351+ const runId = yield* Ref.modify(state, (latest) => [
352+ latest.nextRunId,
353+ {
354+ ...latest,
355+ active: Option.some({
356+ id: latest.nextRunId,
357+ scope: runScope,
358+ fiber: Option.none(),
359+ pid: Option.none(),
360+ } satisfies ActiveBackendRun),
361+ nextRunId: latest.nextRunId + 1,
362+ },
363+ ]);
364+
365+ const finalizeRun = Effect.fn("desktop.backendManager.finalizeRun")(function* (
366+ reason: string,
367+ ) {
368+ yield* mutex.withPermits(1)(
369+ Effect.gen(function* () {
370+ const { isCurrentRun, nextState, pid } = yield* Ref.modify(
371+ state,
372+ (
373+ latest,
374+ ): readonly [
375+ {
376+ readonly isCurrentRun: boolean;
377+ readonly nextState: BackendManagerState;
378+ readonly pid: Option.Option<number>;
379+ },
380+ BackendManagerState,
381+ ] => {
382+ const currentRun = Option.getOrUndefined(latest.active);
383+ if (currentRun?.id !== runId) {
384+ return [
385+ {
386+ isCurrentRun: false,
387+ nextState: latest,
388+ pid: Option.none<number>(),
389+ },
390+ latest,
391+ ] as const;
392+ }
393+
394+ const next = {
395+ ...latest,
396+ active: Option.none<ActiveBackendRun>(),
397+ ready: false,
398+ };
399+ return [
400+ {
401+ isCurrentRun: true,
402+ nextState: next,
403+ pid: currentRun.pid,
404+ },
405+ next,
406+ ] as const;
407+ },
408+ );
409+
410+ if (isCurrentRun) {
411+ if (Option.isSome(pid)) {
412+ yield* backendOutputLog.writeSessionBoundary({
413+ phase: "END",
414+ details: `pid=${pid.value} ${reason}`,
415+ });
416+ }
417+ yield* Ref.set(desktopState.backendReady, false);
418+ }
419+
420+ if (isCurrentRun && nextState.desiredRunning) {
421+ yield* scheduleRestart(reason);
422+ }
423+ }),
424+ );
425+ });
426+
427+ const program = runBackendProcess({
428+ ...config,
429+ onStarted: Effect.fn("desktop.backendManager.onStarted")(function* (pid) {
430+ yield* updateActiveRun(runId, (run) => ({
431+ ...run,
432+ pid: Option.some(pid),
433+ }));
434+ yield* backendOutputLog.writeSessionBoundary({
435+ phase: "START",
436+ details: `pid=${pid} port=${config.bootstrap.port} cwd=${config.cwd}`,
437+ });
438+ }),
439+ onReady: Effect.fn("desktop.backendManager.onReady")(function* () {
440+ const isCurrentRun = yield* Ref.modify(state, (latest) => {
441+ const activeRun = Option.getOrUndefined(latest.active);
442+ if (activeRun?.id !== runId) {
443+ return [false, latest] as const;
444+ }
445+
446+ return [
447+ true,
448+ {
449+ ...latest,
450+ restartAttempt: 0,
451+ ready: true,
452+ },
453+ ] as const;
454+ });
455+ if (!isCurrentRun) {
456+ return;
457+ }
458+
459+ yield* Ref.set(desktopState.backendReady, true);
460+ yield* desktopWindow.handleBackendReady.pipe(
461+ Effect.catch((error) =>
462+ logBackendManagerError("failed to open main window after backend readiness", {
463+ message: error.message,
464+ }),
465+ ),
466+ );
467+ }),
468+ onReadinessFailure: (error) =>
469+ logBackendManagerWarning("backend readiness check failed during bootstrap", {
470+ error: error.message,
471+ }),
472+ onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk),
473+ }).pipe(
474+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
475+ Effect.provideService(HttpClient.HttpClient, httpClient),
476+ Scope.provide(runScope),
477+ Effect.matchEffect({
478+ onFailure: (error) => finalizeRun(error.message),
479+ onSuccess: (exit) => finalizeRun(exit.reason),
480+ }),
481+ Effect.ensuring(Scope.close(runScope, Exit.void).pipe(Effect.ignore)),
482+ );
483+
484+ const fiber = yield* Effect.forkIn(program, parentScope);
485+ yield* updateActiveRun(runId, (run) => ({
486+ ...run,
487+ fiber: Option.some(fiber),
488+ }));
489+ }),
490+ ),
491+ ).pipe(Effect.withSpan("desktop.backendManager.start"));
492+
493+ const scheduleRestart = Effect.fn("desktop.backendManager.scheduleRestart")(function* (
494+ reason: string,
495+ ) {
496+ const scheduled = yield* Ref.modify(state, (latest) => {
497+ if (!latest.desiredRunning || Option.isSome(latest.restartFiber)) {
498+ return [Option.none<Duration.Duration>(), latest] as const;
499+ }
500+
501+ const delay = calculateRestartDelay(latest.restartAttempt);
502+ return [
503+ Option.some(delay),
504+ {
505+ ...latest,
506+ restartAttempt: latest.restartAttempt + 1,
507+ },
508+ ] as const;
509+ });
510+
511+ yield* Option.match(scheduled, {
512+ onNone: () => Effect.void,
513+ onSome: Effect.fn("desktop.backendManager.scheduleRestartFiber")(function* (delay) {
514+ yield* logBackendManagerError("backend exited unexpectedly; restart scheduled", {
515+ reason,
516+ delayMs: Duration.toMillis(delay),
517+ });
518+ const restartFiber = yield* Effect.forkIn(
519+ Effect.sleep(delay).pipe(
520+ Effect.andThen(
521+ Ref.modify(state, (latest) => {
522+ const shouldRestart = latest.desiredRunning;
523+ return [
524+ shouldRestart,
525+ {
526+ ...latest,
527+ restartFiber: Option.none(),
528+ },
529+ ] as const;
530+ }),
531+ ),
532+ Effect.flatMap((shouldRestart) => (shouldRestart ? start : Effect.void)),
533+ Effect.catchCause((cause) =>
534+ logBackendManagerError("desktop backend restart fiber failed", {
535+ cause: Cause.pretty(cause),
536+ }),
537+ ),
538+ ),
539+ parentScope,
540+ );
541+ yield* Ref.update(state, (latest) =>
542+ Option.isNone(latest.restartFiber)
543+ ? {
544+ ...latest,
545+ restartFiber: Option.some(restartFiber),
546+ }
547+ : latest,
548+ );
549+ }),
550+ });
551+ });
552+
553+ const stop = Effect.fn("desktop.backendManager.stop")(function* (options?: {
554+ readonly timeout?: Duration.Duration;
555+ }) {
556+ const { active, restartFiber } = yield* mutex.withPermits(1)(
557+ Effect.gen(function* () {
558+ const result = yield* Ref.modify(state, (latest) => [
559+ {
560+ active: latest.active,
561+ restartFiber: latest.restartFiber,
562+ },
563+ {
564+ ...latest,
565+ desiredRunning: false,
566+ ready: false,
567+ active: Option.none<ActiveBackendRun>(),
568+ restartFiber: Option.none<Fiber.Fiber<void, never>>(),
569+ },
570+ ]);
571+ yield* Ref.set(desktopState.backendReady, false);
572+ return result;
573+ }),
574+ );
575+
576+ yield* Option.match(restartFiber, {
577+ onNone: () => Effect.void,
578+ onSome: (fiber) => Fiber.interrupt(fiber).pipe(Effect.asVoid),
579+ });
580+ yield* Option.match(active, {
581+ onNone: () => Effect.void,
582+ onSome: (run) => closeRun(run, options),
583+ });
584+ });
585+
586+ yield* Effect.addFinalizer(() => stop());
587+
588+ return DesktopBackendManager.of({
589+ start,
590+ stop,
591+ currentConfig,
592+ snapshot,
593+ });
594+});
595+
596+export const layer = Layer.effect(DesktopBackendManager, makeDesktopBackendManager());
apps/desktop/src/backend/DesktopServerExposure.test.ts+365−0
@@ -0,0 +1,365 @@
1+import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem";
2+import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
3+import * as NodeServices from "@effect/platform-node/NodeServices";
4+import { assert, describe, it } from "@effect/vitest";
5+import * as Effect from "effect/Effect";
6+import * as FileSystem from "effect/FileSystem";
7+import * as Layer from "effect/Layer";
8+import * as Sink from "effect/Sink";
9+import * as Stream from "effect/Stream";
10+import { ChildProcessSpawner } from "effect/unstable/process";
11+
12+import {
13+ DesktopEnvironment,
14+ layer as makeDesktopEnvironmentLayer,
15+} from "../app/DesktopEnvironment.ts";
16+import * as DesktopConfig from "../app/DesktopConfig.ts";
17+import * as DesktopServerExposure from "./DesktopServerExposure.ts";
18+import type { DesktopNetworkInterfaces } from "./DesktopServerExposure.ts";
19+import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
20+
21+const encoder = new TextEncoder();
22+
23+const emptyNetworkInterfaces: DesktopNetworkInterfaces = {};
24+const lanNetworkInterfaces: DesktopNetworkInterfaces = {
25+ en0: [
26+ {
27+ address: "192.168.1.20",
28+ family: "IPv4",
29+ internal: false,
30+ },
31+ ],
32+};
33+
34+const tailnetNetworkInterfaces: DesktopNetworkInterfaces = {
35+ tailscale0: [
36+ {
37+ address: "100.90.1.2",
38+ family: "IPv4",
39+ internal: false,
40+ },
41+ ],
42+};
43+
44+function mockSpawnerLayer(statusJson = "{}") {
45+ return Layer.succeed(
46+ ChildProcessSpawner.ChildProcessSpawner,
47+ ChildProcessSpawner.make(() =>
48+ Effect.succeed(
49+ ChildProcessSpawner.makeHandle({
50+ pid: ChildProcessSpawner.ProcessId(1),
51+ exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
52+ isRunning: Effect.succeed(false),
53+ kill: () => Effect.void,
54+ unref: Effect.succeed(Effect.void),
55+ stdin: Sink.drain,
56+ stdout: Stream.make(encoder.encode(statusJson)),
57+ stderr: Stream.empty,
58+ all: Stream.empty,
59+ getInputFd: () => Sink.drain,
60+ getOutputFd: () => Stream.empty,
61+ }),
62+ ),
63+ ),
64+ );
65+}
66+
67+function makeEnvironmentLayer(baseDir: string, env: Record<string, string | undefined> = {}) {
68+ return makeDesktopEnvironmentLayer({
69+ dirname: "/repo/apps/desktop/src",
70+ homeDirectory: baseDir,
71+ platform: "darwin",
72+ processArch: "x64",
73+ appVersion: "1.2.3",
74+ appPath: "/repo",
75+ isPackaged: true,
76+ resourcesPath: "/missing/resources",
77+ runningUnderArm64Translation: false,
78+ }).pipe(
79+ Layer.provide(
80+ Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir, ...env })),
81+ ),
82+ );
83+}
84+
85+function makeLayer(input: {
86+ readonly baseDir: string;
87+ readonly networkInterfaces?: DesktopNetworkInterfaces;
88+ readonly env?: Record<string, string | undefined>;
89+}) {
90+ const env = { T3CODE_HOME: input.baseDir, ...input.env };
91+ const environmentLayer = makeEnvironmentLayer(input.baseDir, env);
92+ const networkLayer = Layer.succeed(DesktopServerExposure.DesktopNetworkInterfacesService, {
93+ read: Effect.succeed(input.networkInterfaces ?? emptyNetworkInterfaces),
94+ });
95+
96+ return DesktopServerExposure.layer.pipe(
97+ Layer.provideMerge(DesktopAppSettings.layer),
98+ Layer.provideMerge(NodeFileSystem.layer),
99+ Layer.provideMerge(NodeHttpClient.layerUndici),
100+ Layer.provideMerge(mockSpawnerLayer()),
101+ Layer.provideMerge(networkLayer),
102+ Layer.provideMerge(DesktopConfig.layerTest(env)),
103+ Layer.provideMerge(environmentLayer),
104+ );
105+}
106+
107+const withHarness = <A, E, R>(
108+ networkInterfaces: DesktopNetworkInterfaces,
109+ effect: Effect.Effect<
110+ A,
111+ E,
112+ | R
113+ | DesktopEnvironment
114+ | FileSystem.FileSystem
115+ | DesktopServerExposure.DesktopServerExposure
116+ | DesktopAppSettings.DesktopAppSettings
117+ >,
118+ env: Record<string, string | undefined> = {},
119+) =>
120+ Effect.gen(function* () {
121+ const fileSystem = yield* FileSystem.FileSystem;
122+ const baseDir = yield* fileSystem.makeTempDirectoryScoped({
123+ prefix: "t3-desktop-server-exposure-test-",
124+ });
125+ return yield* effect.pipe(Effect.provide(makeLayer({ baseDir, networkInterfaces, env })));
126+ }).pipe(Effect.provide(NodeServices.layer), Effect.scoped);
127+
128+describe("DesktopServerExposure", () => {
129+ it.effect("falls back to local-only without losing the requested network preference", () =>
130+ withHarness(
131+ emptyNetworkInterfaces,
132+ Effect.gen(function* () {
133+ const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
134+ const settings = yield* DesktopAppSettings.DesktopAppSettings;
135+
136+ yield* settings.setServerExposureMode("network-accessible");
137+
138+ const state = yield* serverExposure.configureFromSettings({ port: 4173 });
139+ assert.equal(state.mode, "local-only");
140+ assert.equal(state.endpointUrl, null);
141+ assert.equal((yield* settings.get).serverExposureMode, "network-accessible");
142+
143+ const backendConfig = yield* serverExposure.backendConfig;
144+ assert.equal(backendConfig.bindHost, "127.0.0.1");
145+ assert.equal(backendConfig.httpBaseUrl.href, "http://127.0.0.1:4173/");
146+ }),
147+ ),
148+ );
149+
150+ it.effect("returns a typed error when network access is explicitly unavailable", () =>
151+ withHarness(
152+ emptyNetworkInterfaces,
153+ Effect.gen(function* () {
154+ const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
155+ yield* serverExposure.configureFromSettings({ port: 4173 });
156+
157+ const error = yield* serverExposure.setMode("network-accessible").pipe(Effect.flip);
158+ assert.ok(error._tag === "DesktopServerExposureNoNetworkAddressError");
159+ assert.equal(error.port, 4173);
160+ }),
161+ ),
162+ );
163+
164+ it.effect("persists network-accessible mode and updates backend binding state", () =>
165+ withHarness(
166+ lanNetworkInterfaces,
167+ Effect.gen(function* () {
168+ const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
169+ const settings = yield* DesktopAppSettings.DesktopAppSettings;
170+
171+ yield* settings.load;
172+ yield* serverExposure.configureFromSettings({ port: 4173 });
173+
174+ const change = yield* serverExposure.setMode("network-accessible");
175+ assert.equal(change.requiresRelaunch, true);
176+ assert.deepEqual(change.state, {
177+ mode: "network-accessible",
178+ endpointUrl: "http://192.168.1.20:4173",
179+ advertisedHost: "192.168.1.20",
180+ tailscaleServeEnabled: false,
181+ tailscaleServePort: 443,
182+ });
183+
184+ const backendConfig = yield* serverExposure.backendConfig;
185+ assert.equal(backendConfig.bindHost, "0.0.0.0");
186+ assert.equal(backendConfig.httpBaseUrl.href, "http://127.0.0.1:4173/");
187+
188+ const persisted = yield* settings.get;
189+ assert.equal(persisted.serverExposureMode, "network-accessible");
190+ }),
191+ ),
192+ );
193+
194+ it.effect("persists tailscale serve preferences atomically and reports no-op updates", () =>
195+ withHarness(
196+ emptyNetworkInterfaces,
197+ Effect.gen(function* () {
198+ const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
199+ const settings = yield* DesktopAppSettings.DesktopAppSettings;
200+
201+ yield* settings.load;
202+ yield* serverExposure.configureFromSettings({ port: 4173 });
203+
204+ const changed = yield* serverExposure.setTailscaleServeEnabled({
205+ enabled: true,
206+ port: 8443,
207+ });
208+ assert.equal(changed.requiresRelaunch, true);
209+ assert.equal(changed.state.tailscaleServeEnabled, true);
210+ assert.equal(changed.state.tailscaleServePort, 8443);
211+
212+ const unchanged = yield* serverExposure.setTailscaleServeEnabled({
213+ enabled: true,
214+ port: 8443,
215+ });
216+ assert.equal(unchanged.requiresRelaunch, false);
217+
218+ const persisted = yield* settings.get;
219+ assert.equal(persisted.tailscaleServeEnabled, true);
220+ assert.equal(persisted.tailscaleServePort, 8443);
221+ }),
222+ ),
223+ );
224+
225+ it.effect("resolves advertised endpoints from the scoped runtime state", () =>
226+ withHarness(
227+ { ...lanNetworkInterfaces, ...tailnetNetworkInterfaces },
228+ Effect.gen(function* () {
229+ const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
230+ yield* serverExposure.configureFromSettings({ port: 4173 });
231+ yield* serverExposure.setMode("network-accessible");
232+
233+ const endpoints = yield* serverExposure.getAdvertisedEndpoints;
234+ assert.deepEqual(
235+ endpoints.map((endpoint) => endpoint.httpBaseUrl),
236+ ["http://127.0.0.1:4173/", "http://192.168.1.20:4173/", "http://100.90.1.2:4173/"],
237+ );
238+ }),
239+ ),
240+ );
241+
242+ it.effect("uses ConfigProvider desktop exposure overrides", () =>
243+ withHarness(
244+ lanNetworkInterfaces,
245+ Effect.gen(function* () {
246+ const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
247+ yield* serverExposure.configureFromSettings({ port: 4173 });
248+ const change = yield* serverExposure.setMode("network-accessible");
249+
250+ assert.equal(change.state.advertisedHost, "10.0.0.7");
251+ assert.equal(change.state.endpointUrl, "http://10.0.0.7:4173");
252+
253+ const endpoints = yield* serverExposure.getAdvertisedEndpoints;
254+ assert.deepEqual(
255+ endpoints.map((endpoint) => endpoint.httpBaseUrl),
256+ ["http://127.0.0.1:4173/", "http://10.0.0.7:4173/", "https://public.example.test/"],
257+ );
258+ }),
259+ {
260+ T3CODE_DESKTOP_LAN_HOST: "10.0.0.7",
261+ T3CODE_DESKTOP_HTTPS_ENDPOINTS: "https://public.example.test",
262+ },
263+ ),
264+ );
265+
266+ it.effect("advertises loopback, LAN, and configured manual endpoints from runtime state", () =>
267+ withHarness(
268+ lanNetworkInterfaces,
269+ Effect.gen(function* () {
270+ const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
271+ yield* serverExposure.configureFromSettings({ port: 3773 });
272+ yield* serverExposure.setMode("network-accessible");
273+
274+ const endpoints = yield* serverExposure.getAdvertisedEndpoints;
275+ assert.deepEqual(endpoints, [
276+ {
277+ id: "desktop-loopback:3773",
278+ label: "This machine",
279+ provider: {
280+ id: "desktop-core",
281+ label: "Desktop",
282+ kind: "core",
283+ isAddon: false,
284+ },
285+ httpBaseUrl: "http://127.0.0.1:3773/",
286+ wsBaseUrl: "ws://127.0.0.1:3773/",
287+ reachability: "loopback",
288+ compatibility: {
289+ hostedHttpsApp: "mixed-content-blocked",
290+ desktopApp: "compatible",
291+ },
292+ source: "desktop-core",
293+ status: "available",
294+ description: "Loopback endpoint for this desktop app.",
295+ },
296+ {
297+ id: "desktop-lan:http://192.168.1.20:3773",
298+ label: "Local network",
299+ provider: {
300+ id: "desktop-core",
301+ label: "Desktop",
302+ kind: "core",
303+ isAddon: false,
304+ },
305+ httpBaseUrl: "http://192.168.1.20:3773/",
306+ wsBaseUrl: "ws://192.168.1.20:3773/",
307+ reachability: "lan",
308+ compatibility: {
309+ hostedHttpsApp: "mixed-content-blocked",
310+ desktopApp: "compatible",
311+ },
312+ source: "desktop-core",
313+ status: "available",
314+ isDefault: true,
315+ description: "Reachable from devices on the same network.",
316+ },
317+ {
318+ id: "manual:https://desktop.example.ts.net",
319+ label: "Custom HTTPS",
320+ provider: {
321+ id: "manual",
322+ label: "Manual",
323+ kind: "manual",
324+ isAddon: false,
325+ },
326+ httpBaseUrl: "https://desktop.example.ts.net/",
327+ wsBaseUrl: "wss://desktop.example.ts.net/",
328+ reachability: "public",
329+ compatibility: {
330+ hostedHttpsApp: "compatible",
331+ desktopApp: "compatible",
332+ },
333+ source: "user",
334+ status: "unknown",
335+ description: "User-configured HTTPS endpoint for this desktop backend.",
336+ },
337+ {
338+ id: "manual:http://desktop.example.test:3773",
339+ label: "Custom endpoint",
340+ provider: {
341+ id: "manual",
342+ label: "Manual",
343+ kind: "manual",
344+ isAddon: false,
345+ },
346+ httpBaseUrl: "http://desktop.example.test:3773/",
347+ wsBaseUrl: "ws://desktop.example.test:3773/",
348+ reachability: "public",
349+ compatibility: {
350+ hostedHttpsApp: "mixed-content-blocked",
351+ desktopApp: "compatible",
352+ },
353+ source: "user",
354+ status: "unknown",
355+ description: "User-configured endpoint for this desktop backend.",
356+ },
357+ ]);
358+ }),
359+ {
360+ T3CODE_DESKTOP_HTTPS_ENDPOINTS:
361+ "https://desktop.example.ts.net,http://desktop.example.test:3773,not-a-url",
362+ },
363+ ),
364+ );
365+});
apps/desktop/src/backend/DesktopServerExposure.ts+548−0
@@ -0,0 +1,548 @@
1+import * as NodeOS from "node:os";
2+
3+import {
4+ createAdvertisedEndpoint,
5+ type CreateAdvertisedEndpointInput,
6+} from "@t3tools/client-runtime";
7+import type {
8+ AdvertisedEndpoint,
9+ AdvertisedEndpointProvider,
10+ DesktopServerExposureMode,
11+ DesktopServerExposureState,
12+} from "@t3tools/contracts";
13+import * as Context from "effect/Context";
14+import * as Data from "effect/Data";
15+import * as Effect from "effect/Effect";
16+import * as Layer from "effect/Layer";
17+import * as Option from "effect/Option";
18+import * as Ref from "effect/Ref";
19+import { HttpClient } from "effect/unstable/http";
20+import { ChildProcessSpawner } from "effect/unstable/process";
21+
22+import { DEFAULT_DESKTOP_SETTINGS, type DesktopSettings } from "../settings/DesktopAppSettings.ts";
23+import * as DesktopConfig from "../app/DesktopConfig.ts";
24+import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts";
25+import * as DesktopAppSettingsService from "../settings/DesktopAppSettings.ts";
26+
27+export const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
28+const DESKTOP_LAN_BIND_HOST = "0.0.0.0";
29+
30+export interface DesktopNetworkInterfaceInfo {
31+ readonly address: string;
32+ readonly family: string | number;
33+ readonly internal: boolean;
34+ readonly netmask?: string;
35+ readonly mac?: string;
36+ readonly cidr?: string | null;
37+ readonly scopeid?: number;
38+}
39+
40+export type DesktopNetworkInterfaces = Readonly<
41+ Record<string, readonly DesktopNetworkInterfaceInfo[] | undefined>
42+>;
43+
44+interface ResolvedDesktopServerExposure {
45+ readonly mode: DesktopServerExposureMode;
46+ readonly bindHost: string;
47+ readonly localHttpUrl: string;
48+ readonly localWsUrl: string;
49+ readonly endpointUrl: string | null;
50+ readonly advertisedHost: string | null;
51+}
52+
53+interface DesktopAdvertisedEndpointInput {
54+ readonly port: number;
55+ readonly exposure: ResolvedDesktopServerExposure;
56+ readonly customHttpsEndpointUrls?: readonly string[];
57+}
58+
59+const DESKTOP_CORE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = {
60+ id: "desktop-core",
61+ label: "Desktop",
62+ kind: "core",
63+ isAddon: false,
64+};
65+
66+const DESKTOP_MANUAL_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = {
67+ id: "manual",
68+ label: "Manual",
69+ kind: "manual",
70+ isAddon: false,
71+};
72+
73+const normalizeOptionalHost = (value: string | undefined): string | undefined => {
74+ const normalized = value?.trim();
75+ return normalized && normalized.length > 0 ? normalized : undefined;
76+};
77+
78+const isUsableLanIpv4Address = (address: string): boolean =>
79+ !address.startsWith("127.") && !address.startsWith("169.254.");
80+
81+const isHttpsEndpointUrl = (value: string): boolean => {
82+ try {
83+ return new URL(value).protocol === "https:";
84+ } catch {
85+ return false;
86+ }
87+};
88+
89+const resolveLanAdvertisedHost = (
90+ networkInterfaces: DesktopNetworkInterfaces,
91+ explicitHost: string | undefined,
92+): string | null => {
93+ const normalizedExplicitHost = normalizeOptionalHost(explicitHost);
94+ if (normalizedExplicitHost) {
95+ return normalizedExplicitHost;
96+ }
97+
98+ for (const interfaceAddresses of Object.values(networkInterfaces)) {
99+ if (!interfaceAddresses) continue;
100+
101+ for (const address of interfaceAddresses) {
102+ if (address.internal) continue;
103+ if (address.family !== "IPv4") continue;
104+ if (!isUsableLanIpv4Address(address.address)) continue;
105+ return address.address;
106+ }
107+ }
108+
109+ return null;
110+};
111+
112+const resolveDesktopServerExposure = (input: {
113+ readonly mode: DesktopServerExposureMode;
114+ readonly port: number;
115+ readonly networkInterfaces: DesktopNetworkInterfaces;
116+ readonly advertisedHostOverride?: string;
117+}): ResolvedDesktopServerExposure => {
118+ const localHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${input.port}`;
119+ const localWsUrl = `ws://${DESKTOP_LOOPBACK_HOST}:${input.port}`;
120+
121+ if (input.mode === "local-only") {
122+ return {
123+ mode: input.mode,
124+ bindHost: DESKTOP_LOOPBACK_HOST,
125+ localHttpUrl,
126+ localWsUrl,
127+ endpointUrl: null,
128+ advertisedHost: null,
129+ };
130+ }
131+
132+ const advertisedHost = resolveLanAdvertisedHost(
133+ input.networkInterfaces,
134+ input.advertisedHostOverride,
135+ );
136+
137+ return {
138+ mode: input.mode,
139+ bindHost: DESKTOP_LAN_BIND_HOST,
140+ localHttpUrl,
141+ localWsUrl,
142+ endpointUrl: advertisedHost ? `http://${advertisedHost}:${input.port}` : null,
143+ advertisedHost,
144+ };
145+};
146+
147+const createDesktopEndpoint = (
148+ input: Omit<CreateAdvertisedEndpointInput, "provider" | "source">,
149+): AdvertisedEndpoint =>
150+ createAdvertisedEndpoint({
151+ ...input,
152+ provider: DESKTOP_CORE_ENDPOINT_PROVIDER,
153+ source: "desktop-core",
154+ });
155+
156+const createManualEndpoint = (
157+ input: Omit<CreateAdvertisedEndpointInput, "provider" | "source">,
158+): AdvertisedEndpoint =>
159+ createAdvertisedEndpoint({
160+ ...input,
161+ provider: DESKTOP_MANUAL_ENDPOINT_PROVIDER,
162+ source: "user",
163+ });
164+
165+const resolveDesktopCoreAdvertisedEndpoints = (
166+ input: DesktopAdvertisedEndpointInput,
167+): readonly AdvertisedEndpoint[] => {
168+ const endpoints: AdvertisedEndpoint[] = [
169+ createDesktopEndpoint({
170+ id: `desktop-loopback:${input.port}`,
171+ label: "This machine",
172+ httpBaseUrl: input.exposure.localHttpUrl,
173+ reachability: "loopback",
174+ status: "available",
175+ description: "Loopback endpoint for this desktop app.",
176+ }),
177+ ];
178+
179+ if (input.exposure.endpointUrl) {
180+ endpoints.push(
181+ createDesktopEndpoint({
182+ id: `desktop-lan:${input.exposure.endpointUrl}`,
183+ label: "Local network",
184+ httpBaseUrl: input.exposure.endpointUrl,
185+ reachability: "lan",
186+ status: "available",
187+ isDefault: true,
188+ description: "Reachable from devices on the same network.",
189+ }),
190+ );
191+ }
192+
193+ for (const customEndpointUrl of input.customHttpsEndpointUrls ?? []) {
194+ try {
195+ const isHttpsEndpoint = isHttpsEndpointUrl(customEndpointUrl);
196+ endpoints.push(
197+ createManualEndpoint({
198+ id: `manual:${customEndpointUrl}`,
199+ label: isHttpsEndpoint ? "Custom HTTPS" : "Custom endpoint",
200+ httpBaseUrl: customEndpointUrl,
201+ reachability: "public",
202+ ...(isHttpsEndpoint ? ({ hostedHttpsCompatibility: "compatible" } as const) : {}),
203+ status: "unknown",
204+ description: isHttpsEndpoint
205+ ? "User-configured HTTPS endpoint for this desktop backend."
206+ : "User-configured endpoint for this desktop backend.",
207+ }),
208+ );
209+ } catch {
210+ // Ignore malformed user-configured endpoints without dropping valid endpoints.
211+ }
212+ }
213+
214+ return endpoints;
215+};
216+
217+type DesktopServerExposurePersistenceOperation = "server-exposure-mode" | "tailscale-serve";
218+
219+export class DesktopServerExposureNoNetworkAddressError extends Data.TaggedError(
220+ "DesktopServerExposureNoNetworkAddressError",
221+)<{
222+ readonly port: number;
223+}> {
224+ override get message() {
225+ return `No reachable network address is available for desktop network access on port ${this.port}.`;
226+ }
227+}
228+
229+export class DesktopServerExposurePersistenceError extends Data.TaggedError(
230+ "DesktopServerExposurePersistenceError",
231+)<{
232+ readonly operation: DesktopServerExposurePersistenceOperation;
233+ readonly cause: DesktopAppSettingsService.DesktopSettingsWriteError;
234+}> {
235+ override get message() {
236+ return `Failed to persist desktop ${this.operation} settings.`;
237+ }
238+}
239+
240+export type DesktopServerExposureSetModeError =
241+ | DesktopServerExposureNoNetworkAddressError
242+ | DesktopServerExposurePersistenceError;
243+
244+export type DesktopServerExposureError = DesktopServerExposureSetModeError;
245+
246+export interface DesktopServerExposureBackendConfig {
247+ readonly port: number;
248+ readonly bindHost: string;
249+ readonly httpBaseUrl: URL;
250+ readonly tailscaleServeEnabled: boolean;
251+ readonly tailscaleServePort: number;
252+}
253+
254+export interface DesktopServerExposureChange {
255+ readonly state: DesktopServerExposureState;
256+ readonly requiresRelaunch: boolean;
257+}
258+
259+export interface DesktopServerExposureShape {
260+ readonly getState: Effect.Effect<DesktopServerExposureState>;
261+ readonly backendConfig: Effect.Effect<DesktopServerExposureBackendConfig>;
262+ readonly configureFromSettings: (input: {
263+ readonly port: number;
264+ }) => Effect.Effect<DesktopServerExposureState>;
265+ readonly setMode: (
266+ mode: DesktopServerExposureMode,
267+ ) => Effect.Effect<DesktopServerExposureChange, DesktopServerExposureSetModeError>;
268+ readonly setTailscaleServeEnabled: (input: {
269+ readonly enabled: boolean;
270+ readonly port?: number;
271+ }) => Effect.Effect<DesktopServerExposureChange, DesktopServerExposurePersistenceError>;
272+ readonly getAdvertisedEndpoints: Effect.Effect<readonly AdvertisedEndpoint[]>;
273+}
274+
275+export class DesktopServerExposure extends Context.Service<
276+ DesktopServerExposure,
277+ DesktopServerExposureShape
278+>()("t3/desktop/ServerExposure") {}
279+
280+export interface DesktopNetworkInterfacesServiceShape {
281+ readonly read: Effect.Effect<DesktopNetworkInterfaces>;
282+}
283+
284+export class DesktopNetworkInterfacesService extends Context.Service<
285+ DesktopNetworkInterfacesService,
286+ DesktopNetworkInterfacesServiceShape
287+>()("t3/desktop/ServerExposure/NetworkInterfaces") {}
288+
289+interface RuntimeState {
290+ readonly requestedMode: DesktopServerExposureMode;
291+ readonly mode: DesktopServerExposureMode;
292+ readonly port: number;
293+ readonly bindHost: string;
294+ readonly localHttpUrl: string;
295+ readonly localWsUrl: string;
296+ readonly httpBaseUrl: URL;
297+ readonly endpointUrl: Option.Option<string>;
298+ readonly advertisedHost: Option.Option<string>;
299+ readonly tailscaleServeEnabled: boolean;
300+ readonly tailscaleServePort: number;
301+}
302+
303+interface ResolvedRuntimeState {
304+ readonly state: RuntimeState;
305+ readonly unavailable: boolean;
306+}
307+
308+const initialRuntimeState = (): RuntimeState =>
309+ runtimeStateFromResolvedExposure({
310+ requestedMode: DEFAULT_DESKTOP_SETTINGS.serverExposureMode,
311+ settings: DEFAULT_DESKTOP_SETTINGS,
312+ exposure: resolveDesktopServerExposure({
313+ mode: DEFAULT_DESKTOP_SETTINGS.serverExposureMode,
314+ port: 0,
315+ networkInterfaces: {},
316+ }),
317+ port: 0,
318+ });
319+
320+const toContractState = (state: RuntimeState): DesktopServerExposureState => ({
321+ mode: state.mode,
322+ endpointUrl: Option.getOrNull(state.endpointUrl),
323+ advertisedHost: Option.getOrNull(state.advertisedHost),
324+ tailscaleServeEnabled: state.tailscaleServeEnabled,
325+ tailscaleServePort: state.tailscaleServePort,
326+});
327+
328+const toBackendConfig = (state: RuntimeState): DesktopServerExposureBackendConfig => ({
329+ port: state.port,
330+ bindHost: state.bindHost,
331+ httpBaseUrl: state.httpBaseUrl,
332+ tailscaleServeEnabled: state.tailscaleServeEnabled,
333+ tailscaleServePort: state.tailscaleServePort,
334+});
335+
336+const toResolvedExposure = (state: RuntimeState): ResolvedDesktopServerExposure => ({
337+ mode: state.mode,
338+ bindHost: state.bindHost,
339+ localHttpUrl: state.localHttpUrl,
340+ localWsUrl: state.localWsUrl,
341+ endpointUrl: Option.getOrNull(state.endpointUrl),
342+ advertisedHost: Option.getOrNull(state.advertisedHost),
343+});
344+
345+function runtimeStateFromResolvedExposure(input: {
346+ readonly requestedMode: DesktopServerExposureMode;
347+ readonly settings: DesktopSettings;
348+ readonly exposure: ResolvedDesktopServerExposure;
349+ readonly port: number;
350+}): RuntimeState {
351+ return {
352+ requestedMode: input.requestedMode,
353+ mode: input.exposure.mode,
354+ port: input.port,
355+ bindHost: input.exposure.bindHost,
356+ localHttpUrl: input.exposure.localHttpUrl,
357+ localWsUrl: input.exposure.localWsUrl,
358+ httpBaseUrl: new URL(input.exposure.localHttpUrl),
359+ endpointUrl: Option.fromNullishOr(input.exposure.endpointUrl),
360+ advertisedHost: Option.fromNullishOr(input.exposure.advertisedHost),
361+ tailscaleServeEnabled: input.settings.tailscaleServeEnabled,
362+ tailscaleServePort: input.settings.tailscaleServePort,
363+ };
364+}
365+
366+function resolveRuntimeState(input: {
367+ readonly requestedMode: DesktopServerExposureMode;
368+ readonly settings: DesktopSettings;
369+ readonly port: number;
370+ readonly networkInterfaces: DesktopNetworkInterfaces;
371+ readonly advertisedHostOverride: Option.Option<string>;
372+}): ResolvedRuntimeState {
373+ const advertisedHostOverride = Option.getOrUndefined(input.advertisedHostOverride);
374+ const requestedExposure = resolveDesktopServerExposure({
375+ mode: input.requestedMode,
376+ port: input.port,
377+ networkInterfaces: input.networkInterfaces,
378+ ...(advertisedHostOverride ? { advertisedHostOverride } : {}),
379+ });
380+ const unavailable =
381+ input.requestedMode === "network-accessible" && requestedExposure.endpointUrl === null;
382+ const exposure = unavailable
383+ ? resolveDesktopServerExposure({
384+ mode: "local-only",
385+ port: input.port,
386+ networkInterfaces: input.networkInterfaces,
387+ ...(advertisedHostOverride ? { advertisedHostOverride } : {}),
388+ })
389+ : requestedExposure;
390+
391+ return {
392+ state: runtimeStateFromResolvedExposure({
393+ requestedMode: input.requestedMode,
394+ settings: input.settings,
395+ exposure,
396+ port: input.port,
397+ }),
398+ unavailable,
399+ };
400+}
401+
402+const requiresBackendRelaunch = (previous: RuntimeState, next: RuntimeState): boolean =>
403+ previous.port !== next.port ||
404+ previous.bindHost !== next.bindHost ||
405+ previous.localHttpUrl !== next.localHttpUrl;
406+
407+const make = Effect.gen(function* () {
408+ const config = yield* DesktopConfig.DesktopConfig;
409+ const networkInterfaces = yield* DesktopNetworkInterfacesService;
410+ const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
411+ const httpClient = yield* HttpClient.HttpClient;
412+ const desktopSettings = yield* DesktopAppSettingsService.DesktopAppSettings;
413+ const stateRef = yield* Ref.make(initialRuntimeState());
414+
415+ const readNetworkInterfaces = networkInterfaces.read;
416+
417+ const getState = Ref.get(stateRef).pipe(Effect.map(toContractState));
418+ const backendConfig = Ref.get(stateRef).pipe(Effect.map(toBackendConfig));
419+
420+ const configureFromSettings = Effect.fn("desktop.serverExposure.configureFromSettings")(
421+ function* ({ port }: { readonly port: number }) {
422+ yield* Effect.annotateCurrentSpan({ port });
423+ const settings = yield* desktopSettings.get;
424+ const currentNetworkInterfaces = yield* readNetworkInterfaces;
425+ const resolved = resolveRuntimeState({
426+ requestedMode: settings.serverExposureMode,
427+ settings,
428+ port,
429+ networkInterfaces: currentNetworkInterfaces,
430+ advertisedHostOverride: config.desktopLanHostOverride,
431+ });
432+ yield* Ref.set(stateRef, resolved.state);
433+ return toContractState(resolved.state);
434+ },
435+ );
436+
437+ const setMode = Effect.fn("desktop.serverExposure.setMode")(function* (
438+ mode: DesktopServerExposureMode,
439+ ) {
440+ yield* Effect.annotateCurrentSpan({ mode });
441+ const previous = yield* Ref.get(stateRef);
442+ const currentSettings = yield* desktopSettings.get;
443+ const nextSettings = {
444+ ...currentSettings,
445+ serverExposureMode: mode,
446+ };
447+ const currentNetworkInterfaces = yield* readNetworkInterfaces;
448+ const resolved = resolveRuntimeState({
449+ requestedMode: mode,
450+ settings: nextSettings,
451+ port: previous.port,
452+ networkInterfaces: currentNetworkInterfaces,
453+ advertisedHostOverride: config.desktopLanHostOverride,
454+ });
455+
456+ if (resolved.unavailable) {
457+ return yield* new DesktopServerExposureNoNetworkAddressError({ port: previous.port });
458+ }
459+
460+ const change = yield* desktopSettings.setServerExposureMode(mode).pipe(
461+ Effect.mapError(
462+ (cause) =>
463+ new DesktopServerExposurePersistenceError({
464+ operation: "server-exposure-mode",
465+ cause,
466+ }),
467+ ),
468+ );
469+
470+ yield* Ref.set(stateRef, resolved.state);
471+ return {
472+ state: toContractState(resolved.state),
473+ requiresRelaunch: change.changed || requiresBackendRelaunch(previous, resolved.state),
474+ };
475+ });
476+
477+ const setTailscaleServeEnabled = Effect.fn("desktop.serverExposure.setTailscaleServeEnabled")(
478+ function* (input: { readonly enabled: boolean; readonly port?: number }) {
479+ yield* Effect.annotateCurrentSpan({
480+ enabled: input.enabled,
481+ ...(input.port === undefined ? {} : { port: input.port }),
482+ });
483+ const result = yield* desktopSettings
484+ .setTailscaleServe({
485+ enabled: input.enabled,
486+ port: Option.fromNullishOr(input.port),
487+ })
488+ .pipe(
489+ Effect.mapError(
490+ (cause) =>
491+ new DesktopServerExposurePersistenceError({
492+ operation: "tailscale-serve",
493+ cause,
494+ }),
495+ ),
496+ );
497+
498+ const nextState = yield* Ref.updateAndGet(stateRef, (current) => ({
499+ ...current,
500+ tailscaleServeEnabled: result.settings.tailscaleServeEnabled,
501+ tailscaleServePort: result.settings.tailscaleServePort,
502+ }));
503+
504+ return {
505+ state: toContractState(nextState),
506+ requiresRelaunch: result.changed,
507+ };
508+ },
509+ );
510+
511+ const getAdvertisedEndpoints = Effect.gen(function* () {
512+ const state = yield* Ref.get(stateRef);
513+ const currentNetworkInterfaces = yield* readNetworkInterfaces;
514+ const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({
515+ port: state.port,
516+ exposure: toResolvedExposure(state),
517+ customHttpsEndpointUrls: config.desktopHttpsEndpointUrls,
518+ });
519+ const tailscaleEndpoints = yield* resolveTailscaleAdvertisedEndpoints({
520+ port: state.port,
521+ serveEnabled: state.tailscaleServeEnabled,
522+ servePort: state.tailscaleServePort,
523+ networkInterfaces: currentNetworkInterfaces,
524+ }).pipe(
525+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),
526+ Effect.provideService(HttpClient.HttpClient, httpClient),
527+ );
528+ return [...coreEndpoints, ...tailscaleEndpoints];
529+ }).pipe(Effect.withSpan("desktop.serverExposure.getAdvertisedEndpoints"));
530+
531+ return DesktopServerExposure.of({
532+ getState,
533+ backendConfig,
534+ configureFromSettings,
535+ setMode,
536+ setTailscaleServeEnabled,
537+ getAdvertisedEndpoints,
538+ });
539+});
540+
541+export const layer = Layer.effect(DesktopServerExposure, make);
542+
543+export const networkInterfacesLayer = Layer.succeed(
544+ DesktopNetworkInterfacesService,
545+ DesktopNetworkInterfacesService.of({
546+ read: Effect.sync(() => NodeOS.networkInterfaces()),
547+ }),
548+);
apps/desktop/src/backend/tailscaleEndpointProvider.test.ts+142−0
@@ -0,0 +1,142 @@
1+import { assert, describe, it } from "@effect/vitest";
2+import * as Effect from "effect/Effect";
3+import * as Layer from "effect/Layer";
4+import { HttpClient } from "effect/unstable/http";
5+import { ChildProcessSpawner } from "effect/unstable/process";
6+
7+import {
8+ isTailscaleIpv4Address,
9+ parseTailscaleMagicDnsName,
10+ resolveTailscaleAdvertisedEndpoints,
11+} from "./tailscaleEndpointProvider.ts";
12+
13+const unusedTailscaleExternalServicesLayer = Layer.mergeAll(
14+ Layer.succeed(
15+ HttpClient.HttpClient,
16+ HttpClient.make(() => Effect.die("unexpected Tailscale HTTPS probe")),
17+ ),
18+ Layer.succeed(
19+ ChildProcessSpawner.ChildProcessSpawner,
20+ ChildProcessSpawner.make(() => Effect.die("unexpected tailscale status process")),
21+ ),
22+);
23+
24+describe("tailscale endpoint provider", () => {
25+ it("detects Tailnet IPv4 addresses", () => {
26+ assert.equal(isTailscaleIpv4Address("100.64.0.1"), true);
27+ assert.equal(isTailscaleIpv4Address("100.127.255.254"), true);
28+ assert.equal(isTailscaleIpv4Address("100.128.0.1"), false);
29+ assert.equal(isTailscaleIpv4Address("192.168.1.44"), false);
30+ });
31+
32+ it.effect("parses MagicDNS names from tailscale status", () =>
33+ Effect.gen(function* () {
34+ const dnsName = yield* parseTailscaleMagicDnsName(
35+ `{"Self":{"DNSName":"desktop.tail.ts.net."}}`,
36+ );
37+ assert.equal(dnsName, "desktop.tail.ts.net");
38+ assert.equal(yield* parseTailscaleMagicDnsName("{}"), null);
39+ const malformed = yield* Effect.result(parseTailscaleMagicDnsName("not-json"));
40+ assert.isTrue(malformed._tag === "Failure");
41+ }),
42+ );
43+
44+ it.effect("resolves Tailscale endpoints as add-on advertised endpoints", () =>
45+ Effect.gen(function* () {
46+ const endpoints = yield* resolveTailscaleAdvertisedEndpoints({
47+ port: 3773,
48+ networkInterfaces: {
49+ tailscale0: [
50+ {
51+ address: "100.100.100.100",
52+ family: "IPv4",
53+ internal: false,
54+ netmask: "255.192.0.0",
55+ cidr: "100.100.100.100/10",
56+ mac: "00:00:00:00:00:00",
57+ },
58+ ],
59+ },
60+ statusJson: `{"Self":{"DNSName":"desktop.tail.ts.net."}}`,
61+ });
62+ assert.deepEqual(endpoints, [
63+ {
64+ id: "tailscale-ip:http://100.100.100.100:3773",
65+ label: "Tailscale IP",
66+ provider: {
67+ id: "tailscale",
68+ label: "Tailscale",
69+ kind: "private-network",
70+ isAddon: true,
71+ },
72+ httpBaseUrl: "http://100.100.100.100:3773/",
73+ wsBaseUrl: "ws://100.100.100.100:3773/",
74+ reachability: "private-network",
75+ compatibility: {
76+ hostedHttpsApp: "mixed-content-blocked",
77+ desktopApp: "compatible",
78+ },
79+ source: "desktop-addon",
80+ status: "available",
81+ description: "Reachable from devices on the same Tailnet.",
82+ },
83+ {
84+ id: "tailscale-magicdns:https://desktop.tail.ts.net/",
85+ label: "Tailscale HTTPS",
86+ provider: {
87+ id: "tailscale",
88+ label: "Tailscale",
89+ kind: "private-network",
90+ isAddon: true,
91+ },
92+ httpBaseUrl: "https://desktop.tail.ts.net/",
93+ wsBaseUrl: "wss://desktop.tail.ts.net/",
94+ reachability: "private-network",
95+ compatibility: {
96+ hostedHttpsApp: "requires-configuration",
97+ desktopApp: "compatible",
98+ },
99+ source: "desktop-addon",
100+ status: "unavailable",
101+ description: "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.",
102+ },
103+ ]);
104+ }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)),
105+ );
106+
107+ it.effect(
108+ "marks the Tailscale HTTPS endpoint available after Serve is enabled and reachable",
109+ () =>
110+ Effect.gen(function* () {
111+ const endpoints = yield* resolveTailscaleAdvertisedEndpoints({
112+ port: 3773,
113+ networkInterfaces: {},
114+ statusJson: `{"Self":{"DNSName":"desktop.tail.ts.net."}}`,
115+ serveEnabled: true,
116+ probe: () => Effect.succeed(true),
117+ });
118+ assert.deepEqual(endpoints, [
119+ {
120+ id: "tailscale-magicdns:https://desktop.tail.ts.net/",
121+ label: "Tailscale HTTPS",
122+ provider: {
123+ id: "tailscale",
124+ label: "Tailscale",
125+ kind: "private-network",
126+ isAddon: true,
127+ },
128+ httpBaseUrl: "https://desktop.tail.ts.net/",
129+ wsBaseUrl: "wss://desktop.tail.ts.net/",
130+ reachability: "private-network",
131+ compatibility: {
132+ hostedHttpsApp: "compatible",
133+ desktopApp: "compatible",
134+ },
135+ source: "desktop-addon",
136+ status: "available",
137+ description: "HTTPS endpoint served by Tailscale Serve.",
138+ },
139+ ]);
140+ }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)),
141+ );
142+});
apps/desktop/src/backend/tailscaleEndpointProvider.ts+138−0
@@ -0,0 +1,138 @@
1+import { createAdvertisedEndpoint } from "@t3tools/client-runtime";
2+import type { AdvertisedEndpoint, AdvertisedEndpointProvider } from "@t3tools/contracts";
3+import {
4+ buildTailscaleHttpsBaseUrl,
5+ isTailscaleIpv4Address,
6+ parseTailscaleMagicDnsName,
7+ probeTailscaleHttpsEndpoint,
8+ readTailscaleStatus,
9+} from "@t3tools/tailscale";
10+import * as Effect from "effect/Effect";
11+import * as Option from "effect/Option";
12+import { HttpClient } from "effect/unstable/http";
13+import { ChildProcessSpawner } from "effect/unstable/process";
14+
15+import type { DesktopNetworkInterfaces } from "./DesktopServerExposure.ts";
16+
17+export { isTailscaleIpv4Address, parseTailscaleMagicDnsName } from "@t3tools/tailscale";
18+
19+const TAILSCALE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = {
20+ id: "tailscale",
21+ label: "Tailscale",
22+ kind: "private-network",
23+ isAddon: true,
24+};
25+
26+function resolveTailscaleIpAdvertisedEndpoints(input: {
27+ readonly port: number;
28+ readonly networkInterfaces: DesktopNetworkInterfaces;
29+}): readonly AdvertisedEndpoint[] {
30+ const seen = new Set<string>();
31+ const endpoints: AdvertisedEndpoint[] = [];
32+
33+ for (const interfaceAddresses of Object.values(input.networkInterfaces)) {
34+ if (!interfaceAddresses) continue;
35+
36+ for (const address of interfaceAddresses) {
37+ if (address.internal) continue;
38+ if (address.family !== "IPv4") continue;
39+ if (!isTailscaleIpv4Address(address.address)) continue;
40+ if (seen.has(address.address)) continue;
41+ seen.add(address.address);
42+
43+ endpoints.push(
44+ createAdvertisedEndpoint({
45+ provider: TAILSCALE_ENDPOINT_PROVIDER,
46+ source: "desktop-addon",
47+ id: `tailscale-ip:http://${address.address}:${input.port}`,
48+ label: "Tailscale IP",
49+ httpBaseUrl: `http://${address.address}:${input.port}`,
50+ reachability: "private-network",
51+ status: "available",
52+ description: "Reachable from devices on the same Tailnet.",
53+ }),
54+ );
55+ }
56+ }
57+
58+ return endpoints;
59+}
60+
61+const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn(
62+ "resolveTailscaleMagicDnsAdvertisedEndpoint",
63+)(function* (input: {
64+ readonly dnsName: string | null;
65+ readonly serveEnabled: boolean;
66+ readonly servePort?: number;
67+ readonly probe?: (baseUrl: string) => Effect.Effect<boolean, never, HttpClient.HttpClient>;
68+}): Effect.fn.Return<Option.Option<AdvertisedEndpoint>, never, HttpClient.HttpClient> {
69+ if (!input.dnsName) {
70+ return Option.none();
71+ }
72+
73+ const httpBaseUrl = buildTailscaleHttpsBaseUrl({
74+ magicDnsName: input.dnsName,
75+ ...(input.servePort === undefined ? {} : { servePort: input.servePort }),
76+ });
77+ const probe =
78+ input.probe?.(httpBaseUrl) ??
79+ probeTailscaleHttpsEndpoint({
80+ baseUrl: httpBaseUrl,
81+ });
82+ const isReachable = input.serveEnabled ? yield* probe : false;
83+
84+ return Option.some(
85+ createAdvertisedEndpoint({
86+ provider: TAILSCALE_ENDPOINT_PROVIDER,
87+ source: "desktop-addon",
88+ id: `tailscale-magicdns:${httpBaseUrl}`,
89+ label: "Tailscale HTTPS",
90+ httpBaseUrl,
91+ reachability: "private-network",
92+ hostedHttpsCompatibility: isReachable ? "compatible" : "requires-configuration",
93+ status: isReachable ? "available" : "unavailable",
94+ description: isReachable
95+ ? "HTTPS endpoint served by Tailscale Serve."
96+ : "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.",
97+ }),
98+ );
99+});
100+
101+export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAdvertisedEndpoints")(
102+ function* (input: {
103+ readonly port: number;
104+ readonly serveEnabled?: boolean;
105+ readonly servePort?: number;
106+ readonly networkInterfaces: DesktopNetworkInterfaces;
107+ readonly statusJson?: string | null;
108+ readonly probe?: (baseUrl: string) => Effect.Effect<boolean, never, HttpClient.HttpClient>;
109+ }): Effect.fn.Return<
110+ readonly AdvertisedEndpoint[],
111+ never,
112+ ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient
113+ > {
114+ const ipEndpoints = resolveTailscaleIpAdvertisedEndpoints(input);
115+ const dnsName =
116+ input.statusJson === undefined
117+ ? yield* readTailscaleStatus.pipe(
118+ Effect.map((status) => status.magicDnsName),
119+ Effect.catch(() => Effect.succeed(null)),
120+ )
121+ : input.statusJson
122+ ? yield* parseTailscaleMagicDnsName(input.statusJson).pipe(
123+ Effect.catch(() => Effect.succeed(null)),
124+ )
125+ : null;
126+ const magicDnsEndpoint = yield* resolveTailscaleMagicDnsAdvertisedEndpoint({
127+ dnsName,
128+ serveEnabled: input.serveEnabled === true,
129+ ...(input.servePort === undefined ? {} : { servePort: input.servePort }),
130+ ...(input.probe === undefined ? {} : { probe: input.probe }),
131+ });
132+
133+ return Option.match(magicDnsEndpoint, {
134+ onNone: () => ipEndpoints,
135+ onSome: (endpoint) => [...ipEndpoints, endpoint],
136+ });
137+ },
138+);
apps/desktop/src/backendPort.test.ts+0−109
@@ -1,109 +0,0 @@
1−import { describe, expect, it, vi } from "vitest";
2−
3−import { resolveDesktopBackendPort } from "./backendPort.ts";
4−
5−describe("resolveDesktopBackendPort", () => {
6− it("returns the starting port when it is available", async () => {
7− const canListenOnHost = vi.fn(async (port: number) => port === 3773);
8−
9− await expect(
10− resolveDesktopBackendPort({
11− host: "127.0.0.1",
12− startPort: 3773,
13− canListenOnHost,
14− }),
15− ).resolves.toBe(3773);
16−
17− expect(canListenOnHost).toHaveBeenCalledTimes(1);
18− expect(canListenOnHost).toHaveBeenCalledWith(3773, "127.0.0.1");
19− });
20−
21− it("increments sequentially until it finds an available port", async () => {
22− const canListenOnHost = vi.fn(async (port: number) => port === 3775);
23−
24− await expect(
25− resolveDesktopBackendPort({
26− host: "127.0.0.1",
27− startPort: 3773,
28− canListenOnHost,
29− }),
30− ).resolves.toBe(3775);
31−
32− expect(canListenOnHost.mock.calls).toEqual([
33− [3773, "127.0.0.1"],
34− [3774, "127.0.0.1"],
35− [3775, "127.0.0.1"],
36− ]);
37− });
38−
39− it("treats wildcard-bound ports as unavailable even when loopback probing succeeds", async () => {
40− const canListenOnHost = vi.fn(async (port: number, host: string) => {
41− if (port === 3773 && host === "127.0.0.1") return true;
42− if (port === 3773 && host === "0.0.0.0") return false;
43− return port === 3774;
44− });
45−
46− await expect(
47− resolveDesktopBackendPort({
48− host: "127.0.0.1",
49− requiredHosts: ["0.0.0.0"],
50− startPort: 3773,
51− canListenOnHost,
52− }),
53− ).resolves.toBe(3774);
54−
55− expect(canListenOnHost.mock.calls).toEqual([
56− [3773, "127.0.0.1"],
57− [3773, "0.0.0.0"],
58− [3774, "127.0.0.1"],
59− [3774, "0.0.0.0"],
60− ]);
61− });
62−
63− it("checks overlapping hosts sequentially to avoid self-interference", async () => {
64− let inFlightCount = 0;
65− const canListenOnHost = vi.fn(async (_port: number, _host: string) => {
66− inFlightCount += 1;
67− const overlapped = inFlightCount > 1;
68− await Promise.resolve();
69− inFlightCount -= 1;
70− return !overlapped;
71− });
72−
73− await expect(
74− resolveDesktopBackendPort({
75− host: "127.0.0.1",
76− requiredHosts: ["0.0.0.0", "::"],
77− startPort: 3773,
78− maxPort: 3773,
79− canListenOnHost,
80− }),
81− ).resolves.toBe(3773);
82−
83− expect(canListenOnHost.mock.calls).toEqual([
84− [3773, "127.0.0.1"],
85− [3773, "0.0.0.0"],
86− [3773, "::"],
87− ]);
88− });
89−
90− it("fails when the scan range is exhausted", async () => {
91− const canListenOnHost = vi.fn(async () => false);
92−
93− await expect(
94− resolveDesktopBackendPort({
95− host: "127.0.0.1",
96− startPort: 65534,
97− maxPort: 65535,
98− canListenOnHost,
99− }),
100− ).rejects.toThrow(
101− "No desktop backend port is available on hosts 127.0.0.1 between 65534 and 65535",
102− );
103−
104− expect(canListenOnHost.mock.calls).toEqual([
105− [65534, "127.0.0.1"],
106− [65535, "127.0.0.1"],
107− ]);
108− });
109−});
apps/desktop/src/backendPort.ts+0−83
@@ -1,83 +0,0 @@
1−import * as Effect from "effect/Effect";
2−import { NetService } from "@t3tools/shared/Net";
3−
4−export const DEFAULT_DESKTOP_BACKEND_PORT = 3773;
5−const MAX_TCP_PORT = 65_535;
6−
7−export interface ResolveDesktopBackendPortOptions {
8− readonly host: string;
9− readonly startPort?: number;
10− readonly maxPort?: number;
11− readonly requiredHosts?: ReadonlyArray<string>;
12− readonly canListenOnHost?: (port: number, host: string) => Promise<boolean>;
13−}
14−
15−const defaultCanListenOnHost = async (port: number, host: string): Promise<boolean> =>
16− Effect.service(NetService).pipe(
17− Effect.flatMap((net) => net.canListenOnHost(port, host)),
18− Effect.provide(NetService.layer),
19− Effect.runPromise,
20− );
21−
22−const isValidPort = (port: number): boolean =>
23− Number.isInteger(port) && port >= 1 && port <= MAX_TCP_PORT;
24−
25−const normalizeHosts = (
26− host: string,
27− requiredHosts: ReadonlyArray<string>,
28−): ReadonlyArray<string> =>
29− Array.from(
30− new Set(
31− [host, ...requiredHosts]
32− .map((candidate) => candidate.trim())
33− .filter((candidate) => candidate.length > 0),
34− ),
35− );
36−
37−async function canListenOnAllHosts(
38− port: number,
39− hosts: ReadonlyArray<string>,
40− canListenOnHost: (port: number, host: string) => Promise<boolean>,
41−): Promise<boolean> {
42− for (const candidateHost of hosts) {
43− if (!(await canListenOnHost(port, candidateHost))) {
44− return false;
45− }
46− }
47−
48− return true;
49−}
50−
51−export async function resolveDesktopBackendPort({
52− host,
53− startPort = DEFAULT_DESKTOP_BACKEND_PORT,
54− maxPort = MAX_TCP_PORT,
55− requiredHosts = [],
56− canListenOnHost = defaultCanListenOnHost,
57−}: ResolveDesktopBackendPortOptions): Promise<number> {
58− if (!isValidPort(startPort)) {
59− throw new Error(`Invalid desktop backend start port: ${startPort}`);
60− }
61−
62− if (!isValidPort(maxPort)) {
63− throw new Error(`Invalid desktop backend max port: ${maxPort}`);
64− }
65−
66− if (maxPort < startPort) {
67− throw new Error(`Desktop backend max port ${maxPort} is below start port ${startPort}`);
68− }
69−
70− const hostsToCheck = normalizeHosts(host, requiredHosts);
71−
72− // Keep desktop startup predictable across app restarts by probing upward from
73− // the same preferred port instead of picking a fresh ephemeral port.
74− for (let port = startPort; port <= maxPort; port += 1) {
75− if (await canListenOnAllHosts(port, hostsToCheck, canListenOnHost)) {
76− return port;
77− }
78− }
79−
80− throw new Error(
81− `No desktop backend port is available on hosts ${hostsToCheck.join(", ")} between ${startPort} and ${maxPort}`,
82− );
83−}
apps/desktop/src/backendReadiness.test.ts+0−114
@@ -1,114 +0,0 @@
1−import { describe, expect, it, vi } from "vitest";
2−
3−import {
4− BackendReadinessAbortedError,
5− isBackendReadinessAborted,
6− waitForHttpReady,
7−} from "./backendReadiness.ts";
8−
9−describe("waitForHttpReady", () => {
10− it("returns once the backend serves the requested readiness path", async () => {
11− const fetchImpl = vi
12− .fn<typeof fetch>()
13− .mockResolvedValueOnce(new Response(null, { status: 503 }))
14− .mockResolvedValueOnce(new Response(null, { status: 200 }));
15−
16− await waitForHttpReady("http://127.0.0.1:3773", {
17− fetchImpl,
18− timeoutMs: 1_000,
19− intervalMs: 0,
20− });
21−
22− expect(fetchImpl).toHaveBeenCalledTimes(2);
23− expect(fetchImpl).toHaveBeenNthCalledWith(
24− 1,
25− "http://127.0.0.1:3773/",
26− expect.objectContaining({ redirect: "manual" }),
27− );
28− });
29−
30− it("retries after a readiness request stalls past the per-request timeout", async () => {
31− const fetchImpl = vi
32− .fn<typeof fetch>()
33− .mockImplementationOnce(
34− (_input, init) =>
35− new Promise((_resolve, reject) => {
36− init?.signal?.addEventListener(
37− "abort",
38− () => {
39− reject(new Error("request timed out"));
40− },
41− { once: true },
42− );
43− }) as ReturnType<typeof fetch>,
44− )
45− .mockResolvedValueOnce(new Response(null, { status: 200 }));
46−
47− await waitForHttpReady("http://127.0.0.1:3773", {
48− fetchImpl,
49− timeoutMs: 100,
50− intervalMs: 0,
51− requestTimeoutMs: 1,
52− });
53−
54− expect(fetchImpl).toHaveBeenCalledTimes(2);
55− });
56−
57− it("aborts an in-flight readiness wait", async () => {
58− const controller = new AbortController();
59− const fetchImpl = vi.fn<typeof fetch>().mockImplementation(
60− () =>
61− new Promise((_resolve, reject) => {
62− controller.signal.addEventListener(
63− "abort",
64− () => {
65− reject(new BackendReadinessAbortedError());
66− },
67− { once: true },
68− );
69− }) as ReturnType<typeof fetch>,
70− );
71−
72− const waitPromise = waitForHttpReady("http://127.0.0.1:3773", {
73− fetchImpl,
74− timeoutMs: 1_000,
75− intervalMs: 0,
76− signal: controller.signal,
77− });
78−
79− controller.abort();
80−
81− await expect(waitPromise).rejects.toBeInstanceOf(BackendReadinessAbortedError);
82− });
83−
84− it("recognizes aborted readiness errors", () => {
85− expect(isBackendReadinessAborted(new BackendReadinessAbortedError())).toBe(true);
86− expect(isBackendReadinessAborted(new Error("nope"))).toBe(false);
87− });
88−
89− it("supports custom readiness predicates", async () => {
90− const fetchImpl = vi
91− .fn<typeof fetch>()
92− .mockResolvedValueOnce(new Response(null, { status: 200 }))
93− .mockResolvedValueOnce(new Response(null, { status: 204 }));
94−
95− await waitForHttpReady("http://127.0.0.1:3773", {
96− fetchImpl,
97− timeoutMs: 1_000,
98− intervalMs: 0,
99− path: "/api/healthz",
100− isReady: (response) => response.status === 204,
101− });
102−
103− expect(fetchImpl).toHaveBeenNthCalledWith(
104− 1,
105− "http://127.0.0.1:3773/api/healthz",
106− expect.objectContaining({ redirect: "manual" }),
107− );
108− expect(fetchImpl).toHaveBeenNthCalledWith(
109− 2,
110− "http://127.0.0.1:3773/api/healthz",
111− expect.objectContaining({ redirect: "manual" }),
112− );
113− });
114−});
apps/desktop/src/backendReadiness.ts+0−107
@@ -1,107 +0,0 @@
1−export interface WaitForHttpReadyOptions {
2− readonly timeoutMs?: number;
3− readonly intervalMs?: number;
4− readonly requestTimeoutMs?: number;
5− readonly fetchImpl?: typeof fetch;
6− readonly signal?: AbortSignal;
7− readonly path?: string;
8− readonly isReady?: (response: Response) => boolean;
9−}
10−
11−const DEFAULT_TIMEOUT_MS = 30_000;
12−const DEFAULT_INTERVAL_MS = 100;
13−const DEFAULT_REQUEST_TIMEOUT_MS = 1_000;
14−
15−export class BackendReadinessAbortedError extends Error {
16− constructor() {
17− super("Backend readiness wait was aborted.");
18− this.name = "BackendReadinessAbortedError";
19− }
20−}
21−
22−function delay(ms: number, signal: AbortSignal | undefined): Promise<void> {
23− return new Promise<void>((resolve, reject) => {
24− const timer = setTimeout(() => {
25− cleanup();
26− resolve();
27− }, ms);
28−
29− const onAbort = () => {
30− cleanup();
31− reject(new BackendReadinessAbortedError());
32− };
33−
34− const cleanup = () => {
35− clearTimeout(timer);
36− signal?.removeEventListener("abort", onAbort);
37− };
38−
39− if (signal?.aborted) {
40− cleanup();
41− reject(new BackendReadinessAbortedError());
42− return;
43− }
44−
45− signal?.addEventListener("abort", onAbort, { once: true });
46− });
47−}
48−
49−export function isBackendReadinessAborted(error: unknown): error is BackendReadinessAbortedError {
50− return error instanceof BackendReadinessAbortedError;
51−}
52−
53−export async function waitForHttpReady(
54− baseUrl: string,
55− options?: WaitForHttpReadyOptions,
56−): Promise<void> {
57− const fetchImpl = options?.fetchImpl ?? fetch;
58− const signal = options?.signal;
59− const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
60− const intervalMs = options?.intervalMs ?? DEFAULT_INTERVAL_MS;
61− const requestTimeoutMs = options?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
62− const readinessPath = options?.path ?? "/";
63− const isReady = options?.isReady ?? ((response: Response) => response.ok);
64− const deadline = Date.now() + timeoutMs;
65−
66− for (;;) {
67− if (signal?.aborted) {
68− throw new BackendReadinessAbortedError();
69− }
70−
71− const requestController = new AbortController();
72− const requestTimeout = setTimeout(() => {
73− requestController.abort();
74− }, requestTimeoutMs);
75− const abortRequest = () => {
76− requestController.abort();
77− };
78− signal?.addEventListener("abort", abortRequest, { once: true });
79−
80− try {
81− const response = await fetchImpl(new URL(readinessPath, baseUrl).toString(), {
82− redirect: "manual",
83− signal: requestController.signal,
84− });
85− if (isReady(response)) {
86− return;
87− }
88− } catch (error) {
89− if (isBackendReadinessAborted(error)) {
90− throw error;
91− }
92− if (signal?.aborted) {
93− throw new BackendReadinessAbortedError();
94− }
95− // Retry until the backend becomes reachable or the deadline expires.
96− } finally {
97− clearTimeout(requestTimeout);
98− signal?.removeEventListener("abort", abortRequest);
99− }
100−
101− if (Date.now() >= deadline) {
102− throw new Error(`Timed out waiting for backend readiness at ${baseUrl}.`);
103− }
104−
105− await delay(intervalMs, signal);
106− }
107−}
apps/desktop/src/backendStartupReadiness.test.ts+0−58
@@ -1,58 +0,0 @@
1−import { describe, expect, it, vi } from "vitest";
2−
3−import { BackendReadinessAbortedError } from "./backendReadiness.ts";
4−import { waitForBackendStartupReady } from "./backendStartupReadiness.ts";
5−
6−describe("waitForBackendStartupReady", () => {
7− it("falls back to the HTTP probe when no listening signal exists", async () => {
8− const waitForHttpReady = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
9− const cancelHttpWait = vi.fn();
10−
11− await expect(
12− waitForBackendStartupReady({
13− waitForHttpReady,
14− cancelHttpWait,
15− }),
16− ).resolves.toBe("http");
17−
18− expect(waitForHttpReady).toHaveBeenCalledTimes(1);
19− expect(cancelHttpWait).not.toHaveBeenCalled();
20− });
21−
22− it("uses the listening signal and cancels the HTTP probe", async () => {
23− let rejectHttpWait: ((error: unknown) => void) | null = null;
24− const waitForHttpReady = vi.fn(
25− () =>
26− new Promise<void>((_resolve, reject) => {
27− rejectHttpWait = reject;
28− }),
29− );
30− const cancelHttpWait = vi.fn(() => {
31− rejectHttpWait?.(new BackendReadinessAbortedError());
32− });
33−
34− await expect(
35− waitForBackendStartupReady({
36− listeningPromise: Promise.resolve(),
37− waitForHttpReady,
38− cancelHttpWait,
39− }),
40− ).resolves.toBe("listening");
41−
42− expect(waitForHttpReady).toHaveBeenCalledTimes(1);
43− expect(cancelHttpWait).toHaveBeenCalledTimes(1);
44− });
45−
46− it("rejects when the listening signal fails before HTTP readiness", async () => {
47− const error = new Error("backend exited");
48− const waitForHttpReady = vi.fn(() => new Promise<void>(() => {}));
49−
50− await expect(
51− waitForBackendStartupReady({
52− listeningPromise: Promise.reject(error),
53− waitForHttpReady,
54− cancelHttpWait: vi.fn(),
55− }),
56− ).rejects.toBe(error);
57− });
58−});
apps/desktop/src/backendStartupReadiness.ts+0−56
@@ -1,56 +0,0 @@
1−import { isBackendReadinessAborted } from "./backendReadiness.ts";
2−
3−export interface WaitForBackendStartupReadyOptions {
4− readonly listeningPromise?: Promise<void> | null;
5− readonly waitForHttpReady: () => Promise<void>;
6− readonly cancelHttpWait: () => void;
7−}
8−
9−export async function waitForBackendStartupReady(
10− options: WaitForBackendStartupReadyOptions,
11−): Promise<"listening" | "http"> {
12− const httpReadyPromise = options.waitForHttpReady();
13− const listeningPromise = options.listeningPromise;
14−
15− if (!listeningPromise) {
16− await httpReadyPromise;
17− return "http";
18− }
19−
20− return await new Promise<"listening" | "http">((resolve, reject) => {
21− let settled = false;
22−
23− const settleResolve = (source: "listening" | "http") => {
24− if (settled) {
25− return;
26− }
27− settled = true;
28− if (source === "listening") {
29− options.cancelHttpWait();
30− }
31− resolve(source);
32− };
33−
34− const settleReject = (error: unknown) => {
35− if (settled) {
36− return;
37− }
38− settled = true;
39− reject(error);
40− };
41−
42− listeningPromise.then(
43− () => settleResolve("listening"),
44− (error) => settleReject(error),
45− );
46− httpReadyPromise.then(
47− () => settleResolve("http"),
48− (error) => {
49− if (settled && isBackendReadinessAborted(error)) {
50− return;
51− }
52− settleReject(error);
53− },
54− );
55− });
56−}
apps/desktop/src/clientPersistence.test.ts+0−249
@@ -1,249 +0,0 @@
1−import * as fs from "node:fs";
2−import * as os from "node:os";
3−import * as path from "node:path";
4−
5−import {
6− EnvironmentId,
7− type ClientSettings,
8− type PersistedSavedEnvironmentRecord,
9−} from "@t3tools/contracts";
10−import { afterEach, describe, expect, it } from "vitest";
11−
12−import {
13− readClientSettings,
14− readSavedEnvironmentRegistry,
15− readSavedEnvironmentSecret,
16− removeSavedEnvironmentSecret,
17− writeClientSettings,
18− writeSavedEnvironmentRegistry,
19− writeSavedEnvironmentSecret,
20− type DesktopSecretStorage,
21−} from "./clientPersistence.ts";
22−
23−const tempDirectories: string[] = [];
24−
25−afterEach(() => {
26− for (const directory of tempDirectories.splice(0)) {
27− fs.rmSync(directory, { recursive: true, force: true });
28− }
29−});
30−
31−function makeTempPath(fileName: string): string {
32− const directory = fs.mkdtempSync(path.join(os.tmpdir(), "t3-client-persistence-test-"));
33− tempDirectories.push(directory);
34− return path.join(directory, fileName);
35−}
36−
37−function makeSecretStorage(available: boolean): DesktopSecretStorage {
38− return {
39− isEncryptionAvailable: () => available,
40− encryptString: (value) => Buffer.from(`enc:${value}`, "utf8"),
41− decryptString: (value) => {
42− const decoded = value.toString("utf8");
43− if (!decoded.startsWith("enc:")) {
44− throw new Error("invalid secret");
45− }
46− return decoded.slice("enc:".length);
47− },
48− };
49−}
50−
51−const clientSettings: ClientSettings = {
52− autoOpenPlanSidebar: false,
53− confirmThreadArchive: true,
54− confirmThreadDelete: false,
55− dismissedProviderUpdateNotificationKeys: [],
56− diffIgnoreWhitespace: true,
57− diffWordWrap: true,
58− favorites: [],
59− providerModelPreferences: {},
60− sidebarProjectGroupingMode: "repository_path",
61− sidebarProjectGroupingOverrides: {
62− "environment-1:/tmp/project-a": "separate",
63− },
64− sidebarProjectSortOrder: "manual",
65− sidebarThreadSortOrder: "created_at",
66− timestampFormat: "24-hour",
67−};
68−
69−const savedRegistryRecord: PersistedSavedEnvironmentRecord = {
70− environmentId: EnvironmentId.make("environment-1"),
71− label: "Remote environment",
72− httpBaseUrl: "https://remote.example.com/",
73− wsBaseUrl: "wss://remote.example.com/",
74− createdAt: "2026-04-09T00:00:00.000Z",
75− lastConnectedAt: "2026-04-09T01:00:00.000Z",
76− desktopSsh: {
77− alias: "devbox",
78− hostname: "devbox.example.com",
79− username: "julius",
80− port: 22,
81− },
82−};
83−
84−describe("clientPersistence", () => {
85− it("persists and reloads client settings", () => {
86− const settingsPath = makeTempPath("client-settings.json");
87−
88− writeClientSettings(settingsPath, clientSettings);
89−
90− expect(readClientSettings(settingsPath)).toEqual(clientSettings);
91− });
92−
93− it("persists and reloads saved environment metadata", () => {
94− const registryPath = makeTempPath("saved-environments.json");
95−
96− writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]);
97−
98− expect(readSavedEnvironmentRegistry(registryPath)).toEqual([savedRegistryRecord]);
99− });
100−
101− it("persists encrypted saved environment secrets when encryption is available", () => {
102− const registryPath = makeTempPath("saved-environments.json");
103− const secretStorage = makeSecretStorage(true);
104−
105− writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]);
106−
107− expect(
108− writeSavedEnvironmentSecret({
109− registryPath,
110− environmentId: savedRegistryRecord.environmentId,
111− secret: "bearer-token",
112− secretStorage,
113− }),
114− ).toBe(true);
115−
116− expect(
117− readSavedEnvironmentSecret({
118− registryPath,
119− environmentId: savedRegistryRecord.environmentId,
120− secretStorage,
121− }),
122− ).toBe("bearer-token");
123−
124− expect(JSON.parse(fs.readFileSync(registryPath, "utf8"))).toEqual({
125− records: [
126− {
127− ...savedRegistryRecord,
128− encryptedBearerToken: Buffer.from("enc:bearer-token", "utf8").toString("base64"),
129− },
130− ],
131− });
132− });
133−
134− it("preserves existing secrets when encryption is unavailable", () => {
135− const registryPath = makeTempPath("saved-environments.json");
136− const availableSecretStorage = makeSecretStorage(true);
137−
138− writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]);
139−
140− writeSavedEnvironmentSecret({
141− registryPath,
142− environmentId: savedRegistryRecord.environmentId,
143− secret: "bearer-token",
144− secretStorage: availableSecretStorage,
145− });
146−
147− expect(
148− writeSavedEnvironmentSecret({
149− registryPath,
150− environmentId: savedRegistryRecord.environmentId,
151− secret: "next-token",
152− secretStorage: makeSecretStorage(false),
153− }),
154− ).toBe(false);
155−
156− expect(
157− readSavedEnvironmentSecret({
158− registryPath,
159− environmentId: savedRegistryRecord.environmentId,
160− secretStorage: availableSecretStorage,
161− }),
162− ).toBe("bearer-token");
163− });
164−
165− it("removes saved environment secrets", () => {
166− const registryPath = makeTempPath("saved-environments.json");
167− const secretStorage = makeSecretStorage(true);
168−
169− writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]);
170−
171− writeSavedEnvironmentSecret({
172− registryPath,
173− environmentId: savedRegistryRecord.environmentId,
174− secret: "bearer-token",
175− secretStorage,
176− });
177−
178− removeSavedEnvironmentSecret({
179− registryPath,
180− environmentId: savedRegistryRecord.environmentId,
181− });
182−
183− expect(
184− readSavedEnvironmentSecret({
185− registryPath,
186− environmentId: savedRegistryRecord.environmentId,
187− secretStorage,
188− }),
189− ).toBeNull();
190− });
191−
192− it("treats malformed secrets documents as empty", () => {
193− const registryPath = makeTempPath("saved-environments.json");
194− fs.writeFileSync(registryPath, "{}\n", "utf8");
195−
196− expect(
197− readSavedEnvironmentSecret({
198− registryPath,
199− environmentId: savedRegistryRecord.environmentId,
200− secretStorage: makeSecretStorage(true),
201− }),
202− ).toBeNull();
203−
204− expect(() =>
205− removeSavedEnvironmentSecret({
206− registryPath,
207− environmentId: savedRegistryRecord.environmentId,
208− }),
209− ).not.toThrow();
210− });
211−
212− it("returns false when writing a secret without metadata", () => {
213− const registryPath = makeTempPath("saved-environments.json");
214−
215− expect(
216− writeSavedEnvironmentSecret({
217− registryPath,
218− environmentId: savedRegistryRecord.environmentId,
219− secret: "bearer-token",
220− secretStorage: makeSecretStorage(true),
221− }),
222− ).toBe(false);
223− });
224−
225− it("preserves encrypted secrets when metadata is rewritten", () => {
226− const registryPath = makeTempPath("saved-environments.json");
227− const secretStorage = makeSecretStorage(true);
228−
229− writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]);
230−
231− writeSavedEnvironmentSecret({
232− registryPath,
233− environmentId: savedRegistryRecord.environmentId,
234− secret: "bearer-token",
235− secretStorage,
236− });
237−
238− writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]);
239−
240− expect(readSavedEnvironmentRegistry(registryPath)).toEqual([savedRegistryRecord]);
241− expect(
242− readSavedEnvironmentSecret({
243− registryPath,
244− environmentId: savedRegistryRecord.environmentId,
245− secretStorage,
246− }),
247− ).toBe("bearer-token");
248− });
249−});
apps/desktop/src/clientPersistence.ts+0−238
@@ -1,238 +0,0 @@
1−import * as FS from "node:fs";
2−import * as Path from "node:path";
3−
4−import {
5− ClientSettingsSchema,
6− type ClientSettings,
7− type PersistedSavedEnvironmentRecord,
8−} from "@t3tools/contracts";
9−import { Predicate } from "effect";
10−import * as Schema from "effect/Schema";
11−
12−interface ClientSettingsDocument {
13− readonly settings: ClientSettings;
14−}
15−
16−interface PersistedSavedEnvironmentStorageRecord extends PersistedSavedEnvironmentRecord {
17− readonly encryptedBearerToken?: string;
18−}
19−
20−interface SavedEnvironmentRegistryDocument {
21− readonly records: readonly PersistedSavedEnvironmentStorageRecord[];
22−}
23−
24−export interface DesktopSecretStorage {
25− readonly isEncryptionAvailable: () => boolean;
26− readonly encryptString: (value: string) => Buffer;
27− readonly decryptString: (value: Buffer) => string;
28−}
29−
30−function readJsonFile<T>(filePath: string): T | null {
31− try {
32− if (!FS.existsSync(filePath)) {
33− return null;
34− }
35− return JSON.parse(FS.readFileSync(filePath, "utf8")) as T;
36− } catch {
37− return null;
38− }
39−}
40−
41−function writeJsonFile(filePath: string, value: unknown): void {
42− const directory = Path.dirname(filePath);
43− const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
44− FS.mkdirSync(directory, { recursive: true });
45− FS.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
46− FS.renameSync(tempPath, filePath);
47−}
48−
49−function isPersistedSavedEnvironmentStorageRecord(
50− value: unknown,
51−): value is PersistedSavedEnvironmentStorageRecord {
52− return (
53− Predicate.isObject(value) &&
54− typeof value.environmentId === "string" &&
55− typeof value.label === "string" &&
56− typeof value.httpBaseUrl === "string" &&
57− typeof value.wsBaseUrl === "string" &&
58− typeof value.createdAt === "string" &&
59− (value.lastConnectedAt === null || typeof value.lastConnectedAt === "string") &&
60− (value.desktopSsh === undefined ||
61− (Predicate.isObject(value.desktopSsh) &&
62− typeof value.desktopSsh.alias === "string" &&
63− typeof value.desktopSsh.hostname === "string" &&
64− (value.desktopSsh.username === null || typeof value.desktopSsh.username === "string") &&
65− (value.desktopSsh.port === null || typeof value.desktopSsh.port === "number"))) &&
66− (value.encryptedBearerToken === undefined || typeof value.encryptedBearerToken === "string")
67− );
68−}
69−
70−function readSavedEnvironmentRegistryDocument(filePath: string): SavedEnvironmentRegistryDocument {
71− const parsed = readJsonFile<SavedEnvironmentRegistryDocument>(filePath);
72− if (!Predicate.isObject(parsed)) {
73− return { records: [] };
74− }
75−
76− return {
77− records: Array.isArray(parsed.records)
78− ? parsed.records.filter(isPersistedSavedEnvironmentStorageRecord)
79− : [],
80− };
81−}
82−
83−function toPersistedSavedEnvironmentRecord(
84− record: PersistedSavedEnvironmentStorageRecord,
85−): PersistedSavedEnvironmentRecord {
86− const nextRecord = {
87− environmentId: record.environmentId,
88− label: record.label,
89− httpBaseUrl: record.httpBaseUrl,
90− wsBaseUrl: record.wsBaseUrl,
91− createdAt: record.createdAt,
92− lastConnectedAt: record.lastConnectedAt,
93− };
94− return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord;
95−}
96−
97−export function readClientSettings(settingsPath: string): ClientSettings | null {
98− const raw = readJsonFile<ClientSettingsDocument>(settingsPath)?.settings;
99− if (!raw) {
100− return null;
101− }
102− try {
103− return Schema.decodeUnknownSync(ClientSettingsSchema)(raw);
104− } catch {
105− return null;
106− }
107−}
108−
109−export function writeClientSettings(settingsPath: string, settings: ClientSettings): void {
110− writeJsonFile(settingsPath, { settings } satisfies ClientSettingsDocument);
111−}
112−
113−export function readSavedEnvironmentRegistry(
114− registryPath: string,
115−): readonly PersistedSavedEnvironmentRecord[] {
116− return readSavedEnvironmentRegistryDocument(registryPath).records.map((record) =>
117− toPersistedSavedEnvironmentRecord(record),
118− );
119−}
120−
121−export function writeSavedEnvironmentRegistry(
122− registryPath: string,
123− records: readonly PersistedSavedEnvironmentRecord[],
124−): void {
125− const currentDocument = readSavedEnvironmentRegistryDocument(registryPath);
126− const encryptedBearerTokenById = new Map(
127− currentDocument.records.flatMap((record) =>
128− record.encryptedBearerToken
129− ? [[record.environmentId, record.encryptedBearerToken] as const]
130− : [],
131− ),
132− );
133− writeJsonFile(registryPath, {
134− records: records.map((record) => {
135− const encryptedBearerToken = encryptedBearerTokenById.get(record.environmentId);
136− return encryptedBearerToken
137− ? {
138− environmentId: record.environmentId,
139− label: record.label,
140− httpBaseUrl: record.httpBaseUrl,
141− wsBaseUrl: record.wsBaseUrl,
142− createdAt: record.createdAt,
143− lastConnectedAt: record.lastConnectedAt,
144− ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}),
145− encryptedBearerToken,
146− }
147− : record;
148− }),
149− } satisfies SavedEnvironmentRegistryDocument);
150−}
151−
152−export function readSavedEnvironmentSecret(input: {
153− readonly registryPath: string;
154− readonly environmentId: string;
155− readonly secretStorage: DesktopSecretStorage;
156−}): string | null {
157− const document = readSavedEnvironmentRegistryDocument(input.registryPath);
158− const encoded = document.records.find(
159− (record) => record.environmentId === input.environmentId,
160− )?.encryptedBearerToken;
161− if (!encoded) {
162− return null;
163− }
164−
165− if (!input.secretStorage.isEncryptionAvailable()) {
166− return null;
167− }
168−
169− try {
170− return input.secretStorage.decryptString(Buffer.from(encoded, "base64"));
171− } catch {
172− return null;
173− }
174−}
175−
176−export function writeSavedEnvironmentSecret(input: {
177− readonly registryPath: string;
178− readonly environmentId: string;
179− readonly secret: string;
180− readonly secretStorage: DesktopSecretStorage;
181−}): boolean {
182− const document = readSavedEnvironmentRegistryDocument(input.registryPath);
183−
184− if (!input.secretStorage.isEncryptionAvailable()) {
185− return false;
186− }
187−
188− let found = false;
189−
190− writeJsonFile(input.registryPath, {
191− records: document.records.map((record) => {
192− if (record.environmentId !== input.environmentId) {
193− return record;
194− }
195−
196− found = true;
197− const encryptedBearerToken = input.secretStorage
198− .encryptString(input.secret)
199− .toString("base64");
200− const nextRecord = {
201− environmentId: record.environmentId,
202− label: record.label,
203− httpBaseUrl: record.httpBaseUrl,
204− wsBaseUrl: record.wsBaseUrl,
205− createdAt: record.createdAt,
206− lastConnectedAt: record.lastConnectedAt,
207− encryptedBearerToken,
208− };
209− return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord;
210− }),
211− } satisfies SavedEnvironmentRegistryDocument);
212− return found;
213−}
214−
215−export function removeSavedEnvironmentSecret(input: {
216− readonly registryPath: string;
217− readonly environmentId: string;
218−}): void {
219− const document = readSavedEnvironmentRegistryDocument(input.registryPath);
220− if (
221− !document.records.some(
222− (record) =>
223− record.environmentId === input.environmentId && record.encryptedBearerToken !== undefined,
224− )
225− ) {
226− return;
227− }
228−
229− writeJsonFile(input.registryPath, {
230− records: document.records.map((record) => {
231− if (record.environmentId !== input.environmentId) {
232− return record;
233− }
234−
235− return toPersistedSavedEnvironmentRecord(record);
236− }),
237− } satisfies SavedEnvironmentRegistryDocument);
238−}
apps/desktop/src/confirmDialog.test.ts+0−57
@@ -1,57 +0,0 @@
1−import type { BrowserWindow } from "electron";
2−import { beforeEach, describe, expect, it, vi } from "vitest";
3−
4−const { showMessageBoxMock } = vi.hoisted(() => ({
5− showMessageBoxMock: vi.fn(),
6−}));
7−
8−vi.mock("electron", () => ({
9− dialog: {
10− showMessageBox: showMessageBoxMock,
11− },
12−}));
13−
14−import { showDesktopConfirmDialog } from "./confirmDialog.ts";
15−
16−describe("showDesktopConfirmDialog", () => {
17− beforeEach(() => {
18− showMessageBoxMock.mockReset();
19− });
20−
21− it("returns false and does not open a dialog for empty messages", async () => {
22− const result = await showDesktopConfirmDialog(" ", null);
23−
24− expect(result).toBe(false);
25− expect(showMessageBoxMock).not.toHaveBeenCalled();
26− });
27−
28− it("opens a dialog for the focused window and returns true on confirm", async () => {
29− const ownerWindow = { id: 1 } as BrowserWindow;
30− showMessageBoxMock.mockResolvedValue({ response: 1 });
31−
32− const result = await showDesktopConfirmDialog("Delete worktree?", ownerWindow);
33−
34− expect(result).toBe(true);
35− expect(showMessageBoxMock).toHaveBeenCalledWith(
36− ownerWindow,
37− expect.objectContaining({
38− buttons: ["No", "Yes"],
39− message: "Delete worktree?",
40− }),
41− );
42− });
43−
44− it("opens an app-level dialog when there is no focused window", async () => {
45− showMessageBoxMock.mockResolvedValue({ response: 0 });
46−
47− const result = await showDesktopConfirmDialog("Delete worktree?", null);
48−
49− expect(result).toBe(false);
50− expect(showMessageBoxMock).toHaveBeenCalledWith(
51− expect.objectContaining({
52− buttons: ["No", "Yes"],
53− message: "Delete worktree?",
54− }),
55− );
56− });
57−});
apps/desktop/src/confirmDialog.ts+0−26
@@ -1,26 +0,0 @@
1−import { type BrowserWindow, dialog } from "electron";
2−
3−const CONFIRM_BUTTON_INDEX = 1;
4−
5−export async function showDesktopConfirmDialog(
6− message: string,
7− ownerWindow: BrowserWindow | null,
8−): Promise<boolean> {
9− const normalizedMessage = message.trim();
10− if (normalizedMessage.length === 0) {
11− return false;
12− }
13−
14− const options = {
15− type: "question" as const,
16− buttons: ["No", "Yes"],
17− defaultId: 0,
18− cancelId: 0,
19− noLink: true,
20− message: normalizedMessage,
21− };
22− const result = ownerWindow
23− ? await dialog.showMessageBox(ownerWindow, options)
24− : await dialog.showMessageBox(options);
25− return result.response === CONFIRM_BUTTON_INDEX;
26−}
apps/desktop/src/desktopSettings.test.ts+0−230
@@ -1,230 +0,0 @@
1−import * as fs from "node:fs";
2−import * as os from "node:os";
3−import * as path from "node:path";
4−
5−import { afterEach, describe, expect, it } from "vitest";
6−
7−import {
8− DEFAULT_DESKTOP_SETTINGS,
9− readDesktopSettings,
10− resolveDefaultDesktopSettings,
11− setDesktopServerExposurePreference,
12− setDesktopTailscaleServePreference,
13− setDesktopUpdateChannelPreference,
14− writeDesktopSettings,
15−} from "./desktopSettings.ts";
16−
17−const tempDirectories: string[] = [];
18−
19−afterEach(() => {
20− for (const directory of tempDirectories.splice(0)) {
21− fs.rmSync(directory, { recursive: true, force: true });
22− }
23−});
24−
25−function makeSettingsPath() {
26− const directory = fs.mkdtempSync(path.join(os.tmpdir(), "t3-desktop-settings-test-"));
27− tempDirectories.push(directory);
28− return path.join(directory, "desktop-settings.json");
29−}
30−
31−describe("desktopSettings", () => {
32− it("returns defaults when no settings file exists", () => {
33− expect(readDesktopSettings(makeSettingsPath(), "0.0.17")).toEqual(DEFAULT_DESKTOP_SETTINGS);
34− });
35−
36− it("defaults packaged nightly builds to the nightly update channel", () => {
37− expect(resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1")).toEqual({
38− serverExposureMode: "local-only",
39− tailscaleServeEnabled: false,
40− tailscaleServePort: 443,
41− updateChannel: "nightly",
42− updateChannelConfiguredByUser: false,
43− });
44− });
45−
46− it("persists and reloads the configured server exposure mode", () => {
47− const settingsPath = makeSettingsPath();
48−
49− writeDesktopSettings(settingsPath, {
50− serverExposureMode: "network-accessible",
51− tailscaleServeEnabled: true,
52− tailscaleServePort: 8443,
53− updateChannel: "latest",
54− updateChannelConfiguredByUser: true,
55− });
56−
57− expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual({
58− serverExposureMode: "network-accessible",
59− tailscaleServeEnabled: true,
60− tailscaleServePort: 8443,
61− updateChannel: "latest",
62− updateChannelConfiguredByUser: true,
63− });
64− });
65−
66− it("preserves the requested network-accessible preference across temporary fallback", () => {
67− expect(
68− setDesktopServerExposurePreference(
69− {
70− serverExposureMode: "local-only",
71− tailscaleServeEnabled: false,
72− tailscaleServePort: 443,
73− updateChannel: "latest",
74− updateChannelConfiguredByUser: false,
75− },
76− "network-accessible",
77− ),
78− ).toEqual({
79− serverExposureMode: "network-accessible",
80− tailscaleServeEnabled: false,
81− tailscaleServePort: 443,
82− updateChannel: "latest",
83− updateChannelConfiguredByUser: false,
84− });
85− });
86−
87− it("persists the requested Tailscale Serve preference", () => {
88− expect(
89− setDesktopTailscaleServePreference(
90− {
91− serverExposureMode: "local-only",
92− tailscaleServeEnabled: false,
93− tailscaleServePort: 443,
94− updateChannel: "latest",
95− updateChannelConfiguredByUser: false,
96− },
97− { enabled: true, port: 8443 },
98− ),
99− ).toEqual({
100− serverExposureMode: "local-only",
101− tailscaleServeEnabled: true,
102− tailscaleServePort: 8443,
103− updateChannel: "latest",
104− updateChannelConfiguredByUser: false,
105− });
106− });
107−
108− it("preserves the configured Tailscale Serve port when no new port is requested", () => {
109− expect(
110− setDesktopTailscaleServePreference(
111− {
112− serverExposureMode: "local-only",
113− tailscaleServeEnabled: false,
114− tailscaleServePort: 8443,
115− updateChannel: "latest",
116− updateChannelConfiguredByUser: false,
117− },
118− { enabled: true },
119− ),
120− ).toEqual({
121− serverExposureMode: "local-only",
122− tailscaleServeEnabled: true,
123− tailscaleServePort: 8443,
124− updateChannel: "latest",
125− updateChannelConfiguredByUser: false,
126− });
127− });
128−
129− it("persists the requested nightly update channel", () => {
130− expect(
131− setDesktopUpdateChannelPreference(
132− {
133− serverExposureMode: "local-only",
134− tailscaleServeEnabled: false,
135− tailscaleServePort: 443,
136− updateChannel: "latest",
137− updateChannelConfiguredByUser: false,
138− },
139− "nightly",
140− ),
141− ).toEqual({
142− serverExposureMode: "local-only",
143− tailscaleServeEnabled: false,
144− tailscaleServePort: 443,
145− updateChannel: "nightly",
146− updateChannelConfiguredByUser: true,
147− });
148− });
149−
150− it("falls back to defaults when the settings file is malformed", () => {
151− const settingsPath = makeSettingsPath();
152− fs.writeFileSync(settingsPath, "{not-json", "utf8");
153−
154− expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual(DEFAULT_DESKTOP_SETTINGS);
155− });
156−
157− it("falls back to the nightly channel for legacy nightly settings without an update track", () => {
158− const settingsPath = makeSettingsPath();
159− fs.writeFileSync(settingsPath, JSON.stringify({ serverExposureMode: "local-only" }), "utf8");
160−
161− expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({
162− serverExposureMode: "local-only",
163− tailscaleServeEnabled: false,
164− tailscaleServePort: 443,
165− updateChannel: "nightly",
166− updateChannelConfiguredByUser: false,
167− });
168− });
169−
170− it("migrates legacy implicit stable settings to nightly when running a nightly build", () => {
171− const settingsPath = makeSettingsPath();
172− fs.writeFileSync(
173− settingsPath,
174− JSON.stringify({
175− serverExposureMode: "local-only",
176− updateChannel: "latest",
177− }),
178− "utf8",
179− );
180−
181− expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({
182− serverExposureMode: "local-only",
183− tailscaleServeEnabled: false,
184− tailscaleServePort: 443,
185− updateChannel: "nightly",
186− updateChannelConfiguredByUser: false,
187− });
188− });
189−
190− it("preserves an explicit stable choice on nightly builds", () => {
191− const settingsPath = makeSettingsPath();
192− fs.writeFileSync(
193− settingsPath,
194− JSON.stringify({
195− serverExposureMode: "local-only",
196− updateChannel: "latest",
197− updateChannelConfiguredByUser: true,
198− }),
199− "utf8",
200− );
201−
202− expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({
203− serverExposureMode: "local-only",
204− tailscaleServeEnabled: false,
205− tailscaleServePort: 443,
206− updateChannel: "latest",
207− updateChannelConfiguredByUser: true,
208− });
209− });
210−
211− it("falls back to the default Tailscale Serve port when the persisted port is invalid", () => {
212− const settingsPath = makeSettingsPath();
213− fs.writeFileSync(
214− settingsPath,
215− JSON.stringify({
216− tailscaleServeEnabled: true,
217− tailscaleServePort: 0,
218− }),
219− "utf8",
220− );
221−
222− expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual({
223− serverExposureMode: "local-only",
224− tailscaleServeEnabled: true,
225− tailscaleServePort: 443,
226− updateChannel: "latest",
227− updateChannelConfiguredByUser: false,
228− });
229− });
230−});
apps/desktop/src/desktopSettings.ts+0−125
@@ -1,125 +0,0 @@
1−import * as FS from "node:fs";
2−import * as Path from "node:path";
3−import type { DesktopServerExposureMode, DesktopUpdateChannel } from "@t3tools/contracts";
4−
5−import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts";
6−
7−export interface DesktopSettings {
8− readonly serverExposureMode: DesktopServerExposureMode;
9− readonly tailscaleServeEnabled: boolean;
10− readonly tailscaleServePort: number;
11− readonly updateChannel: DesktopUpdateChannel;
12− readonly updateChannelConfiguredByUser: boolean;
13−}
14−
15−export const DEFAULT_TAILSCALE_SERVE_PORT = 443;
16−
17−export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
18− serverExposureMode: "local-only",
19− tailscaleServeEnabled: false,
20− tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT,
21− updateChannel: "latest",
22− updateChannelConfiguredByUser: false,
23−};
24−
25−export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings {
26− return {
27− ...DEFAULT_DESKTOP_SETTINGS,
28− updateChannel: resolveDefaultDesktopUpdateChannel(appVersion),
29− };
30−}
31−
32−export function setDesktopServerExposurePreference(
33− settings: DesktopSettings,
34− requestedMode: DesktopServerExposureMode,
35−): DesktopSettings {
36− return settings.serverExposureMode === requestedMode
37− ? settings
38− : {
39− ...settings,
40− serverExposureMode: requestedMode,
41− };
42−}
43−
44−export function setDesktopTailscaleServePreference(
45− settings: DesktopSettings,
46− input: { readonly enabled: boolean; readonly port?: number },
47−): DesktopSettings {
48− const port =
49− input.port === undefined
50− ? settings.tailscaleServePort
51− : normalizeTailscaleServePort(input.port);
52− return settings.tailscaleServeEnabled === input.enabled && settings.tailscaleServePort === port
53− ? settings
54− : {
55− ...settings,
56− tailscaleServeEnabled: input.enabled,
57− tailscaleServePort: port,
58− };
59−}
60−
61−export function normalizeTailscaleServePort(value: unknown): number {
62− return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65_535
63− ? value
64− : DEFAULT_TAILSCALE_SERVE_PORT;
65−}
66−
67−export function setDesktopUpdateChannelPreference(
68− settings: DesktopSettings,
69− requestedChannel: DesktopUpdateChannel,
70−): DesktopSettings {
71− return {
72− ...settings,
73− updateChannel: requestedChannel,
74− updateChannelConfiguredByUser: true,
75− };
76−}
77−
78−export function readDesktopSettings(settingsPath: string, appVersion: string): DesktopSettings {
79− const defaultSettings = resolveDefaultDesktopSettings(appVersion);
80−
81− try {
82− if (!FS.existsSync(settingsPath)) {
83− return defaultSettings;
84− }
85−
86− const raw = FS.readFileSync(settingsPath, "utf8");
87− const parsed = JSON.parse(raw) as {
88− readonly serverExposureMode?: unknown;
89− readonly tailscaleServeEnabled?: unknown;
90− readonly tailscaleServePort?: unknown;
91− readonly updateChannel?: unknown;
92− readonly updateChannelConfiguredByUser?: unknown;
93− };
94− const parsedUpdateChannel =
95− parsed.updateChannel === "nightly" || parsed.updateChannel === "latest"
96− ? parsed.updateChannel
97− : null;
98− const isLegacySettings = parsed.updateChannelConfiguredByUser === undefined;
99− const updateChannelConfiguredByUser =
100− parsed.updateChannelConfiguredByUser === true ||
101− (isLegacySettings && parsedUpdateChannel === "nightly");
102−
103− return {
104− serverExposureMode:
105− parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only",
106− tailscaleServeEnabled: parsed.tailscaleServeEnabled === true,
107− tailscaleServePort: normalizeTailscaleServePort(parsed.tailscaleServePort),
108− updateChannel:
109− updateChannelConfiguredByUser && parsedUpdateChannel !== null
110− ? parsedUpdateChannel
111− : defaultSettings.updateChannel,
112− updateChannelConfiguredByUser,
113− };
114− } catch {
115− return defaultSettings;
116− }
117−}
118−
119−export function writeDesktopSettings(settingsPath: string, settings: DesktopSettings): void {
120− const directory = Path.dirname(settingsPath);
121− const tempPath = `${settingsPath}.${process.pid}.${Date.now()}.tmp`;
122− FS.mkdirSync(directory, { recursive: true });
123− FS.writeFileSync(tempPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
124− FS.renameSync(tempPath, settingsPath);
125−}
apps/desktop/src/electron/ElectronApp.test.ts+109−0
@@ -0,0 +1,109 @@
1+import { assert, describe, it } from "@effect/vitest";
2+import * as Effect from "effect/Effect";
3+import { beforeEach, vi } from "vitest";
4+
5+const {
6+ appendSwitchMock,
7+ exitMock,
8+ getAppPathMock,
9+ getVersionMock,
10+ onMock,
11+ quitMock,
12+ relaunchMock,
13+ removeListenerMock,
14+ setAboutPanelOptionsMock,
15+ setAppUserModelIdMock,
16+ setDesktopNameMock,
17+ setDockIconMock,
18+ setNameMock,
19+ setPathMock,
20+ whenReadyMock,
21+} = vi.hoisted(() => ({
22+ appendSwitchMock: vi.fn(),
23+ exitMock: vi.fn(),
24+ getAppPathMock: vi.fn(() => "/app"),
25+ getVersionMock: vi.fn(() => "1.2.3"),
26+ onMock: vi.fn(),
27+ quitMock: vi.fn(),
28+ relaunchMock: vi.fn(),
29+ removeListenerMock: vi.fn(),
30+ setAboutPanelOptionsMock: vi.fn(),
31+ setAppUserModelIdMock: vi.fn(),
32+ setDesktopNameMock: vi.fn(),
33+ setDockIconMock: vi.fn(),
34+ setNameMock: vi.fn(),
35+ setPathMock: vi.fn(),
36+ whenReadyMock: vi.fn(() => Promise.resolve()),
37+}));
38+
39+vi.mock("electron", () => ({
40+ app: {
41+ commandLine: {
42+ appendSwitch: appendSwitchMock,
43+ },
44+ dock: {
45+ setIcon: setDockIconMock,
46+ },
47+ getAppPath: getAppPathMock,
48+ getVersion: getVersionMock,
49+ isPackaged: true,
50+ name: "T3 Code",
51+ on: onMock,
52+ quit: quitMock,
53+ relaunch: relaunchMock,
54+ removeListener: removeListenerMock,
55+ runningUnderARM64Translation: false,
56+ setAboutPanelOptions: setAboutPanelOptionsMock,
57+ setAppUserModelId: setAppUserModelIdMock,
58+ setDesktopName: setDesktopNameMock,
59+ setName: setNameMock,
60+ setPath: setPathMock,
61+ whenReady: whenReadyMock,
62+ exit: exitMock,
63+ },
64+}));
65+
66+import * as ElectronApp from "./ElectronApp.ts";
67+
68+describe("ElectronApp", () => {
69+ beforeEach(() => {
70+ appendSwitchMock.mockClear();
71+ exitMock.mockClear();
72+ onMock.mockClear();
73+ quitMock.mockClear();
74+ relaunchMock.mockClear();
75+ removeListenerMock.mockClear();
76+ setPathMock.mockClear();
77+ });
78+
79+ it.effect("reads app metadata through the service", () =>
80+ Effect.gen(function* () {
81+ const electronApp = yield* ElectronApp.ElectronApp;
82+ const metadata = yield* electronApp.metadata;
83+
84+ assert.deepEqual(metadata, {
85+ appVersion: "1.2.3",
86+ appPath: "/app",
87+ isPackaged: true,
88+ resourcesPath: process.resourcesPath,
89+ runningUnderArm64Translation: false,
90+ });
91+ }).pipe(Effect.provide(ElectronApp.layer)),
92+ );
93+
94+ it.effect("scopes app event listeners", () =>
95+ Effect.gen(function* () {
96+ const listener = vi.fn();
97+
98+ yield* Effect.scoped(
99+ Effect.gen(function* () {
100+ const electronApp = yield* ElectronApp.ElectronApp;
101+ yield* electronApp.on("activate", listener);
102+ }),
103+ );
104+
105+ assert.deepEqual(onMock.mock.calls, [["activate", listener]]);
106+ assert.deepEqual(removeListenerMock.mock.calls, [["activate", listener]]);
107+ }).pipe(Effect.provide(ElectronApp.layer)),
108+ );
109+});
apps/desktop/src/electron/ElectronApp.ts+118−0
@@ -0,0 +1,118 @@
1+import * as Context from "effect/Context";
2+import * as Effect from "effect/Effect";
3+import * as Layer from "effect/Layer";
4+import * as Scope from "effect/Scope";
5+
6+import * as Electron from "electron";
7+
8+export interface ElectronAppMetadata {
9+ readonly appVersion: string;
10+ readonly appPath: string;
11+ readonly isPackaged: boolean;
12+ readonly resourcesPath: string;
13+ readonly runningUnderArm64Translation: boolean;
14+}
15+
16+export interface ElectronAppShape {
17+ readonly metadata: Effect.Effect<ElectronAppMetadata>;
18+ readonly name: Effect.Effect<string>;
19+ readonly whenReady: Effect.Effect<void>;
20+ readonly quit: Effect.Effect<void>;
21+ readonly exit: (code: number) => Effect.Effect<void>;
22+ readonly relaunch: (options: Electron.RelaunchOptions) => Effect.Effect<void>;
23+ readonly setPath: (
24+ name: Parameters<Electron.App["setPath"]>[0],
25+ path: string,
26+ ) => Effect.Effect<void>;
27+ readonly setName: (name: string) => Effect.Effect<void>;
28+ readonly setAboutPanelOptions: (
29+ options: Electron.AboutPanelOptionsOptions,
30+ ) => Effect.Effect<void>;
31+ readonly setAppUserModelId: (id: string) => Effect.Effect<void>;
32+ readonly setDesktopName: (desktopName: string) => Effect.Effect<void>;
33+ readonly setDockIcon: (iconPath: string) => Effect.Effect<void>;
34+ readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect<void>;
35+ readonly on: <Args extends ReadonlyArray<unknown>>(
36+ eventName: string,
37+ listener: (...args: Args) => void,
38+ ) => Effect.Effect<void, never, Scope.Scope>;
39+}
40+
41+export class ElectronApp extends Context.Service<ElectronApp, ElectronAppShape>()(
42+ "t3/desktop/electron/App",
43+) {}
44+
45+const addScopedAppListener = <Args extends ReadonlyArray<unknown>>(
46+ eventName: string,
47+ listener: (...args: Args) => void,
48+): Effect.Effect<void, never, Scope.Scope> =>
49+ Effect.acquireRelease(
50+ Effect.sync(() => {
51+ Electron.app.on(eventName as any, listener as any);
52+ }),
53+ () =>
54+ Effect.sync(() => {
55+ Electron.app.removeListener(eventName as any, listener as any);
56+ }),
57+ ).pipe(Effect.asVoid);
58+
59+const make = ElectronApp.of({
60+ metadata: Effect.sync(() => ({
61+ appVersion: Electron.app.getVersion(),
62+ appPath: Electron.app.getAppPath(),
63+ isPackaged: Electron.app.isPackaged,
64+ resourcesPath: process.resourcesPath,
65+ runningUnderArm64Translation: Electron.app.runningUnderARM64Translation === true,
66+ })),
67+ name: Effect.sync(() => Electron.app.name),
68+ whenReady: Effect.promise(() => Electron.app.whenReady()).pipe(Effect.asVoid),
69+ quit: Effect.sync(() => {
70+ Electron.app.quit();
71+ }),
72+ exit: (code) =>
73+ Effect.sync(() => {
74+ Electron.app.exit(code);
75+ }),
76+ relaunch: (options) =>
77+ Effect.sync(() => {
78+ Electron.app.relaunch(options);
79+ }),
80+ setPath: (name, path) =>
81+ Effect.sync(() => {
82+ Electron.app.setPath(name, path);
83+ }),
84+ setName: (name) =>
85+ Effect.sync(() => {
86+ Electron.app.setName(name);
87+ }),
88+ setAboutPanelOptions: (options) =>
89+ Effect.sync(() => {
90+ Electron.app.setAboutPanelOptions(options);
91+ }),
92+ setAppUserModelId: (id) =>
93+ Effect.sync(() => {
94+ Electron.app.setAppUserModelId(id);
95+ }),
96+ setDesktopName: (desktopName) =>
97+ Effect.sync(() => {
98+ const linuxApp = Electron.app as Electron.App & {
99+ setDesktopName?: (desktopName: string) => void;
100+ };
101+ linuxApp.setDesktopName?.(desktopName);
102+ }),
103+ setDockIcon: (iconPath) =>
104+ Effect.sync(() => {
105+ Electron.app.dock?.setIcon(iconPath);
106+ }),
107+ appendCommandLineSwitch: (switchName, value) =>
108+ Effect.sync(() => {
109+ if (value === undefined) {
110+ Electron.app.commandLine.appendSwitch(switchName);
111+ return;
112+ }
113+ Electron.app.commandLine.appendSwitch(switchName, value);
114+ }),
115+ on: addScopedAppListener,
116+});
117+
118+export const layer = Layer.succeed(ElectronApp, make);
apps/desktop/src/electron/ElectronDialog.test.ts+93−0
@@ -0,0 +1,93 @@
1+import { assert, describe, it } from "@effect/vitest";
2+import * as Effect from "effect/Effect";
3+import * as Option from "effect/Option";
4+import type { BrowserWindow } from "electron";
5+import { beforeEach, vi } from "vitest";
6+
7+import * as ElectronDialog from "./ElectronDialog.ts";
8+
9+const { showMessageBoxMock, showOpenDialogMock, showErrorBoxMock } = vi.hoisted(() => ({
10+ showMessageBoxMock: vi.fn(),
11+ showOpenDialogMock: vi.fn(),
12+ showErrorBoxMock: vi.fn(),
13+}));
14+
15+vi.mock("electron", () => ({
16+ dialog: {
17+ showMessageBox: showMessageBoxMock,
18+ showOpenDialog: showOpenDialogMock,
19+ showErrorBox: showErrorBoxMock,
20+ },
21+}));
22+
23+describe("ElectronDialog", () => {
24+ beforeEach(() => {
25+ showMessageBoxMock.mockReset();
26+ showOpenDialogMock.mockReset();
27+ showErrorBoxMock.mockReset();
28+ });
29+
30+ it.effect("returns false without opening a confirm dialog for empty messages", () =>
31+ Effect.gen(function* () {
32+ const dialog = yield* ElectronDialog.ElectronDialog;
33+
34+ const result = yield* dialog.confirm({
35+ message: " ",
36+ owner: Option.none(),
37+ });
38+
39+ assert.isFalse(result);
40+ assert.equal(showMessageBoxMock.mock.calls.length, 0);
41+ }).pipe(Effect.provide(ElectronDialog.layer)),
42+ );
43+
44+ it.effect("opens a confirm dialog for the owner window", () =>
45+ Effect.gen(function* () {
46+ const owner = { id: 1 } as BrowserWindow;
47+ showMessageBoxMock.mockResolvedValue({ response: 1 });
48+ const dialog = yield* ElectronDialog.ElectronDialog;
49+
50+ const result = yield* dialog.confirm({
51+ message: "Delete worktree?",
52+ owner: Option.some(owner),
53+ });
54+
55+ assert.isTrue(result);
56+ assert.deepEqual(showMessageBoxMock.mock.calls[0], [
57+ owner,
58+ {
59+ type: "question",
60+ buttons: ["No", "Yes"],
61+ defaultId: 0,
62+ cancelId: 0,
63+ noLink: true,
64+ message: "Delete worktree?",
65+ },
66+ ]);
67+ }).pipe(Effect.provide(ElectronDialog.layer)),
68+ );
69+
70+ it.effect("opens an app-level confirm dialog when there is no owner window", () =>
71+ Effect.gen(function* () {
72+ showMessageBoxMock.mockResolvedValue({ response: 0 });
73+ const dialog = yield* ElectronDialog.ElectronDialog;
74+
75+ const result = yield* dialog.confirm({
76+ message: "Delete worktree?",
77+ owner: Option.none(),
78+ });
79+
80+ assert.isFalse(result);
81+ assert.deepEqual(showMessageBoxMock.mock.calls[0], [
82+ {
83+ type: "question",
84+ buttons: ["No", "Yes"],
85+ defaultId: 0,
86+ cancelId: 0,
87+ noLink: true,
88+ message: "Delete worktree?",
89+ },
90+ ]);
91+ }).pipe(Effect.provide(ElectronDialog.layer)),
92+ );
93+});
apps/desktop/src/electron/ElectronDialog.ts+84−0
@@ -0,0 +1,84 @@
1+import * as Context from "effect/Context";
2+import * as Effect from "effect/Effect";
3+import * as Layer from "effect/Layer";
4+import * as Option from "effect/Option";
5+
6+import * as Electron from "electron";
7+
8+const CONFIRM_BUTTON_INDEX = 1;
9+
10+export interface ElectronDialogPickFolderInput {
11+ readonly owner: Option.Option<Electron.BrowserWindow>;
12+ readonly defaultPath: Option.Option<string>;
13+}
14+
15+export interface ElectronDialogConfirmInput {
16+ readonly owner: Option.Option<Electron.BrowserWindow>;
17+ readonly message: string;
18+}
19+
20+export interface ElectronDialogShape {
21+ readonly pickFolder: (
22+ input: ElectronDialogPickFolderInput,
23+ ) => Effect.Effect<Option.Option<string>>;
24+ readonly confirm: (input: ElectronDialogConfirmInput) => Effect.Effect<boolean>;
25+ readonly showMessageBox: (
26+ options: Electron.MessageBoxOptions,
27+ ) => Effect.Effect<Electron.MessageBoxReturnValue>;
28+ readonly showErrorBox: (title: string, content: string) => Effect.Effect<void>;
29+}
30+
31+export class ElectronDialog extends Context.Service<ElectronDialog, ElectronDialogShape>()(
32+ "t3/desktop/electron/Dialog",
33+) {}
34+
35+const make = ElectronDialog.of({
36+ pickFolder: Effect.fn("desktop.electron.dialog.pickFolder")(function* (input) {
37+ const openDialogOptions: Electron.OpenDialogOptions = Option.match(input.defaultPath, {
38+ onNone: () => ({
39+ properties: ["openDirectory", "createDirectory"],
40+ }),
41+ onSome: (defaultPath) => ({
42+ properties: ["openDirectory", "createDirectory"],
43+ defaultPath,
44+ }),
45+ });
46+ const result = yield* Option.match(input.owner, {
47+ onNone: () => Effect.promise(() => Electron.dialog.showOpenDialog(openDialogOptions)),
48+ onSome: (owner) =>
49+ Effect.promise(() => Electron.dialog.showOpenDialog(owner, openDialogOptions)),
50+ });
51+
52+ if (result.canceled) {
53+ return Option.none();
54+ }
55+ return Option.fromNullishOr(result.filePaths[0]);
56+ }),
57+ confirm: Effect.fn("desktop.electron.dialog.confirm")(function* (input) {
58+ const normalizedMessage = input.message.trim();
59+ if (normalizedMessage.length === 0) {
60+ return false;
61+ }
62+
63+ const options = {
64+ type: "question" as const,
65+ buttons: ["No", "Yes"],
66+ defaultId: 0,
67+ cancelId: 0,
68+ noLink: true,
69+ message: normalizedMessage,
70+ };
71+ const result = yield* Option.match(input.owner, {
72+ onNone: () => Effect.promise(() => Electron.dialog.showMessageBox(options)),
73+ onSome: (owner) => Effect.promise(() => Electron.dialog.showMessageBox(owner, options)),
74+ });
75+ return result.response === CONFIRM_BUTTON_INDEX;
76+ }),
77+ showMessageBox: (options) => Effect.promise(() => Electron.dialog.showMessageBox(options)),
78+ showErrorBox: (title, content) =>
79+ Effect.sync(() => {
80+ Electron.dialog.showErrorBox(title, content);
81+ }),
82+});
83+
84+export const layer = Layer.succeed(ElectronDialog, make);
apps/desktop/src/electron/ElectronMenu.test.ts+119−0
@@ -0,0 +1,119 @@
1+import { assert, describe, it } from "@effect/vitest";
2+import * as Effect from "effect/Effect";
3+import * as Option from "effect/Option";
4+import type * as Electron from "electron";
5+import { beforeEach, vi } from "vitest";
6+
7+const { buildFromTemplateMock, createFromNamedImageMock, setApplicationMenuMock } = vi.hoisted(
8+ () => ({
9+ buildFromTemplateMock: vi.fn(),
10+ createFromNamedImageMock: vi.fn(),
11+ setApplicationMenuMock: vi.fn(),
12+ }),
13+);
14+
15+vi.mock("electron", () => ({
16+ Menu: {
17+ buildFromTemplate: buildFromTemplateMock,
18+ setApplicationMenu: setApplicationMenuMock,
19+ },
20+ nativeImage: {
21+ createFromNamedImage: createFromNamedImageMock,
22+ },
23+}));
24+
25+import * as ElectronMenu from "./ElectronMenu.ts";
26+
27+describe("ElectronMenu", () => {
28+ beforeEach(() => {
29+ buildFromTemplateMock.mockReset();
30+ createFromNamedImageMock.mockReset();
31+ setApplicationMenuMock.mockReset();
32+ });
33+
34+ it.effect("returns none without building a menu when there are no valid items", () =>
35+ Effect.gen(function* () {
36+ const electronMenu = yield* ElectronMenu.ElectronMenu;
37+ const selectedItemId = yield* electronMenu.showContextMenu({
38+ window: {} as Electron.BrowserWindow,
39+ items: [],
40+ position: Option.none(),
41+ });
42+
43+ assert.isTrue(Option.isNone(selectedItemId));
44+ assert.equal(buildFromTemplateMock.mock.calls.length, 0);
45+ }).pipe(Effect.provide(ElectronMenu.layer)),
46+ );
47+
48+ it.effect("resolves with the clicked leaf item id", () =>
49+ Effect.gen(function* () {
50+ buildFromTemplateMock.mockImplementation(
51+ (template: Electron.MenuItemConstructorOptions[]) => ({
52+ popup: () => {
53+ const firstItem = template[0];
54+ assert.isDefined(firstItem);
55+ const click = firstItem.click;
56+ if (!click) {
57+ throw new Error("Expected menu item to have a click handler.");
58+ }
59+ click({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent);
60+ },
61+ }),
62+ );
63+
64+ const electronMenu = yield* ElectronMenu.ElectronMenu;
65+ const selectedItemId = yield* electronMenu.showContextMenu({
66+ window: {} as Electron.BrowserWindow,
67+ items: [{ id: "copy", label: "Copy" }],
68+ position: Option.none(),
69+ });
70+
71+ assert.equal(Option.getOrNull(selectedItemId), "copy");
72+ }).pipe(Effect.provide(ElectronMenu.layer)),
73+ );
74+
75+ it.effect("resolves with none when the menu closes without a click", () =>
76+ Effect.gen(function* () {
77+ buildFromTemplateMock.mockImplementation(() => ({
78+ popup: (options: Electron.PopupOptions) => {
79+ options.callback?.();
80+ },
81+ }));
82+
83+ const electronMenu = yield* ElectronMenu.ElectronMenu;
84+ const selectedItemId = yield* electronMenu.showContextMenu({
85+ window: {} as Electron.BrowserWindow,
86+ items: [{ id: "copy", label: "Copy" }],
87+ position: Option.some({ x: 10.8, y: 20.2 }),
88+ });
89+
90+ assert.isTrue(Option.isNone(selectedItemId));
91+ assert.deepEqual(buildFromTemplateMock.mock.calls[0]?.[0][0], {
92+ label: "Copy",
93+ enabled: true,
94+ click: buildFromTemplateMock.mock.calls[0]?.[0][0].click,
95+ });
96+ }).pipe(Effect.provide(ElectronMenu.layer)),
97+ );
98+
99+ it.effect("defers popupTemplate side effects until the returned Effect runs", () =>
100+ Effect.gen(function* () {
101+ const popupMock = vi.fn();
102+ buildFromTemplateMock.mockImplementation(() => ({ popup: popupMock }));
103+
104+ const electronMenu = yield* ElectronMenu.ElectronMenu;
105+ const popup = electronMenu.popupTemplate({
106+ window: {} as Electron.BrowserWindow,
107+ template: [{ label: "Copy" }],
108+ });
109+
110+ assert.equal(buildFromTemplateMock.mock.calls.length, 0);
111+ assert.equal(popupMock.mock.calls.length, 0);
112+
113+ yield* popup;
114+
115+ assert.equal(buildFromTemplateMock.mock.calls.length, 1);
116+ assert.equal(popupMock.mock.calls.length, 1);
117+ }).pipe(Effect.provide(ElectronMenu.layer)),
118+ );
119+});
apps/desktop/src/electron/ElectronMenu.ts+181−0
@@ -0,0 +1,181 @@
1+import type { ContextMenuItem } from "@t3tools/contracts";
2+import * as Context from "effect/Context";
3+import * as Effect from "effect/Effect";
4+import * as Layer from "effect/Layer";
5+import * as Option from "effect/Option";
6+
7+import * as Electron from "electron";
8+
9+export interface ElectronMenuPosition {
10+ readonly x: number;
11+ readonly y: number;
12+}
13+
14+export interface ElectronMenuContextInput {
15+ readonly window: Electron.BrowserWindow;
16+ readonly items: readonly ContextMenuItem[];
17+ readonly position: Option.Option<ElectronMenuPosition>;
18+}
19+
20+export interface ElectronMenuTemplateInput {
21+ readonly window: Electron.BrowserWindow;
22+ readonly template: readonly Electron.MenuItemConstructorOptions[];
23+}
24+
25+export interface ElectronMenuShape {
26+ readonly setApplicationMenu: (
27+ template: readonly Electron.MenuItemConstructorOptions[],
28+ ) => Effect.Effect<void>;
29+ readonly showContextMenu: (
30+ input: ElectronMenuContextInput,
31+ ) => Effect.Effect<Option.Option<string>>;
32+ readonly popupTemplate: (input: ElectronMenuTemplateInput) => Effect.Effect<void>;
33+}
34+
35+export class ElectronMenu extends Context.Service<ElectronMenu, ElectronMenuShape>()(
36+ "t3/desktop/electron/Menu",
37+) {}
38+
39+function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextMenuItem[] {
40+ const normalizedItems: ContextMenuItem[] = [];
41+
42+ for (const sourceItem of source) {
43+ if (typeof sourceItem.id !== "string" || typeof sourceItem.label !== "string") {
44+ continue;
45+ }
46+
47+ const normalizedItem: ContextMenuItem = {
48+ id: sourceItem.id,
49+ label: sourceItem.label,
50+ destructive: sourceItem.destructive === true,
51+ disabled: sourceItem.disabled === true,
52+ };
53+
54+ if (sourceItem.children) {
55+ const normalizedChildren = normalizeContextMenuItems(sourceItem.children);
56+ if (normalizedChildren.length === 0) {
57+ continue;
58+ }
59+ normalizedItem.children = normalizedChildren;
60+ }
61+
62+ normalizedItems.push(normalizedItem);
63+ }
64+
65+ return normalizedItems;
66+}
67+
68+const normalizePosition = (
69+ position: Option.Option<ElectronMenuPosition>,
70+): Option.Option<ElectronMenuPosition> =>
71+ Option.filter(
72+ position,
73+ ({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0,
74+ ).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) })));
75+
76+export const layer = Layer.sync(ElectronMenu, () => {
77+ let destructiveMenuIconCache: Option.Option<Electron.NativeImage> | undefined;
78+
79+ const getDestructiveMenuIcon = (): Option.Option<Electron.NativeImage> => {
80+ if (process.platform !== "darwin") {
81+ return Option.none();
82+ }
83+ if (destructiveMenuIconCache !== undefined) {
84+ return destructiveMenuIconCache;
85+ }
86+
87+ try {
88+ const icon = Electron.nativeImage.createFromNamedImage("trash").resize({
89+ width: 12,
90+ height: 12,
91+ });
92+ destructiveMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon);
93+ } catch {
94+ destructiveMenuIconCache = Option.none();
95+ }
96+
97+ return destructiveMenuIconCache;
98+ };
99+
100+ const buildTemplate = (
101+ entries: readonly ContextMenuItem[],
102+ complete: (selectedItemId: Option.Option<string>) => void,
103+ ): Electron.MenuItemConstructorOptions[] => {
104+ const template: Electron.MenuItemConstructorOptions[] = [];
105+ let hasInsertedDestructiveSeparator = false;
106+
107+ for (const item of entries) {
108+ if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) {
109+ template.push({ type: "separator" });
110+ hasInsertedDestructiveSeparator = true;
111+ }
112+
113+ const itemOption: Electron.MenuItemConstructorOptions = {
114+ label: item.label,
115+ enabled: !item.disabled,
116+ };
117+ if (item.children && item.children.length > 0) {
118+ itemOption.submenu = buildTemplate(item.children, complete);
119+ } else {
120+ itemOption.click = () => complete(Option.some(item.id));
121+ }
122+ if (item.destructive && (!item.children || item.children.length === 0)) {
123+ const destructiveIcon = getDestructiveMenuIcon();
124+ if (Option.isSome(destructiveIcon)) {
125+ itemOption.icon = destructiveIcon.value;
126+ }
127+ }
128+
129+ template.push(itemOption);
130+ }
131+
132+ return template;
133+ };
134+
135+ return ElectronMenu.of({
136+ setApplicationMenu: (template) =>
137+ Effect.sync(() => {
138+ Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template]));
139+ }),
140+ popupTemplate: (input) =>
141+ Effect.sync(() => {
142+ if (input.template.length === 0) {
143+ return;
144+ }
145+ Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window });
146+ }),
147+ showContextMenu: (input) =>
148+ Effect.callback<Option.Option<string>>((resume) => {
149+ const normalizedItems = normalizeContextMenuItems(input.items);
150+ if (normalizedItems.length === 0) {
151+ resume(Effect.succeed(Option.none()));
152+ return;
153+ }
154+
155+ let completed = false;
156+ const complete = (selectedItemId: Option.Option<string>) => {
157+ if (completed) {
158+ return;
159+ }
160+ completed = true;
161+ resume(Effect.succeed(selectedItemId));
162+ };
163+
164+ const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete));
165+ const popupPosition = normalizePosition(input.position);
166+ const popupOptions = Option.match(popupPosition, {
167+ onNone: (): Electron.PopupOptions => ({
168+ window: input.window,
169+ callback: () => complete(Option.none()),
170+ }),
171+ onSome: (position): Electron.PopupOptions => ({
172+ window: input.window,
173+ x: position.x,
174+ y: position.y,
175+ callback: () => complete(Option.none()),
176+ }),
177+ });
178+ menu.popup(popupOptions);
179+ }),
180+ });
181+});
apps/desktop/src/electron/ElectronProtocol.test.ts+105−0
@@ -0,0 +1,105 @@
1+import { assert, describe, it } from "@effect/vitest";
2+import * as Effect from "effect/Effect";
3+import * as Layer from "effect/Layer";
4+import * as Option from "effect/Option";
5+import type * as Electron from "electron";
6+import { beforeEach, vi } from "vitest";
7+
8+const { registerFileProtocolMock, registerSchemesAsPrivilegedMock, unregisterProtocolMock } =
9+ vi.hoisted(() => ({
10+ registerFileProtocolMock: vi.fn(),
11+ registerSchemesAsPrivilegedMock: vi.fn(),
12+ unregisterProtocolMock: vi.fn(),
13+ }));
14+
15+vi.mock("electron", () => ({
16+ protocol: {
17+ registerFileProtocol: registerFileProtocolMock,
18+ registerSchemesAsPrivileged: registerSchemesAsPrivilegedMock,
19+ unregisterProtocol: unregisterProtocolMock,
20+ },
21+}));
22+
23+import * as ElectronProtocol from "./ElectronProtocol.ts";
24+
25+describe("ElectronProtocol", () => {
26+ beforeEach(() => {
27+ registerFileProtocolMock.mockReset();
28+ registerSchemesAsPrivilegedMock.mockReset();
29+ unregisterProtocolMock.mockReset();
30+ });
31+
32+ it("normalizes safe desktop protocol pathnames", () => {
33+ assert.equal(
34+ Option.getOrNull(ElectronProtocol.normalizeDesktopProtocolPathname("/settings/./general")),
35+ "settings/general",
36+ );
37+ assert.isTrue(Option.isNone(ElectronProtocol.normalizeDesktopProtocolPathname("/../secret")));
38+ });
39+
40+ it.effect("registers desktop scheme privileges through a layer", () =>
41+ Effect.scoped(
42+ Layer.build(ElectronProtocol.layerSchemePrivileges).pipe(
43+ Effect.andThen(
44+ Effect.sync(() => {
45+ assert.deepEqual(registerSchemesAsPrivilegedMock.mock.calls, [
46+ [
47+ [
48+ {
49+ scheme: "t3",
50+ privileges: {
51+ standard: true,
52+ secure: true,
53+ supportFetchAPI: true,
54+ corsEnabled: true,
55+ },
56+ },
57+ ],
58+ ],
59+ ]);
60+ }),
61+ ),
62+ ),
63+ ),
64+ );
65+
66+ it.effect("scopes registered file protocols", () =>
67+ Effect.gen(function* () {
68+ let capturedHandler:
69+ | ((
70+ request: Electron.ProtocolRequest,
71+ callback: (response: Electron.ProtocolResponse) => void,
72+ ) => void)
73+ | undefined;
74+
75+ registerFileProtocolMock.mockImplementation((_scheme, handler) => {
76+ capturedHandler = handler;
77+ return true;
78+ });
79+
80+ const response = yield* Effect.scoped(
81+ Effect.gen(function* () {
82+ const electronProtocol = yield* ElectronProtocol.ElectronProtocol;
83+ yield* electronProtocol.registerFileProtocol({
84+ scheme: "t3",
85+ handler: () => Effect.succeed({ path: "/app/index.html" }),
86+ });
87+
88+ assert.isDefined(capturedHandler);
89+ return yield* Effect.callback<Electron.ProtocolResponse>((resume) => {
90+ capturedHandler?.({ url: "t3://app/" } as Electron.ProtocolRequest, (response) =>
91+ resume(Effect.succeed(response)),
92+ );
93+ });
94+ }),
95+ );
96+
97+ assert.deepEqual(response, { path: "/app/index.html" });
98+ assert.deepEqual(
99+ registerFileProtocolMock.mock.calls.map((call) => call[0]),
100+ ["t3"],
101+ );
102+ assert.deepEqual(unregisterProtocolMock.mock.calls, [["t3"]]);
103+ }).pipe(Effect.provide(ElectronProtocol.layer)),
104+ );
105+});
apps/desktop/src/electron/ElectronProtocol.ts+272−0
@@ -0,0 +1,272 @@
1+import * as Cause from "effect/Cause";
2+import * as Context from "effect/Context";
3+import * as Data from "effect/Data";
4+import * as Effect from "effect/Effect";
5+import * as FileSystem from "effect/FileSystem";
6+import * as Layer from "effect/Layer";
7+import * as Option from "effect/Option";
8+import * as Ref from "effect/Ref";
9+import * as Scope from "effect/Scope";
10+
11+import * as Electron from "electron";
12+
13+import { DesktopEnvironment, type DesktopEnvironmentShape } from "../app/DesktopEnvironment.ts";
14+
15+export const DESKTOP_SCHEME = "t3";
16+
17+export class ElectronProtocolRegistrationError extends Data.TaggedError(
18+ "ElectronProtocolRegistrationError",
19+)<{
20+ readonly scheme: string;
21+ readonly cause: unknown;
22+}> {
23+ override get message() {
24+ return `Failed to register ${this.scheme}: file protocol.`;
25+ }
26+}
27+
28+export class ElectronProtocolStaticBundleMissingError extends Data.TaggedError(
29+ "ElectronProtocolStaticBundleMissingError",
30+)<{}> {
31+ override get message() {
32+ return "Desktop static bundle missing. Build apps/server (with bundled client) first.";
33+ }
34+}
35+
36+export interface ElectronProtocolShape {
37+ readonly registerFileProtocol: <E, R>(input: {
38+ readonly scheme: string;
39+ readonly handler: (
40+ request: Electron.ProtocolRequest,
41+ ) => Effect.Effect<Electron.ProtocolResponse, E, R>;
42+ readonly onFailure?: (
43+ request: Electron.ProtocolRequest,
44+ cause: Cause.Cause<E>,
45+ ) => Electron.ProtocolResponse;
46+ }) => Effect.Effect<void, ElectronProtocolRegistrationError, R | Scope.Scope>;
47+ readonly registerDesktopFileProtocol: Effect.Effect<
48+ void,
49+ ElectronProtocolRegistrationError | ElectronProtocolStaticBundleMissingError,
50+ FileSystem.FileSystem | DesktopEnvironment | Scope.Scope
51+ >;
52+}
53+
54+export class ElectronProtocol extends Context.Service<ElectronProtocol, ElectronProtocolShape>()(
55+ "t3/desktop/electron/Protocol",
56+) {}
57+
58+export function normalizeDesktopProtocolPathname(rawPath: string): Option.Option<string> {
59+ const segments: string[] = [];
60+ for (const segment of rawPath.split("/")) {
61+ if (segment.length === 0 || segment === ".") {
62+ continue;
63+ }
64+ if (segment === "..") {
65+ return Option.none();
66+ }
67+ segments.push(segment);
68+ }
69+ return Option.some(segments.join("/"));
70+}
71+
72+const registerDesktopSchemePrivileges = Effect.sync(() => {
73+ Electron.protocol.registerSchemesAsPrivileged([
74+ {
75+ scheme: DESKTOP_SCHEME,
76+ privileges: {
77+ standard: true,
78+ secure: true,
79+ supportFetchAPI: true,
80+ corsEnabled: true,
81+ },
82+ },
83+ ]);
84+}).pipe(Effect.withSpan("desktop.electron.protocol.registerSchemePrivileges"));
85+
86+export const layerSchemePrivileges = Layer.effectDiscard(registerDesktopSchemePrivileges);
87+
88+const resolveDesktopStaticDir: Effect.Effect<
89+ Option.Option<string>,
90+ never,
91+ FileSystem.FileSystem | DesktopEnvironment
92+> = Effect.gen(function* () {
93+ const fileSystem = yield* FileSystem.FileSystem;
94+ const environment = yield* DesktopEnvironment;
95+ const candidates = [
96+ environment.path.join(environment.appRoot, "apps/server/dist/client"),
97+ environment.path.join(environment.appRoot, "apps/web/dist"),
98+ ];
99+ for (const candidate of candidates) {
100+ const hasIndex = yield* fileSystem
101+ .exists(environment.path.join(candidate, "index.html"))
102+ .pipe(Effect.orElseSucceed(() => false));
103+ if (hasIndex) {
104+ return Option.some(candidate);
105+ }
106+ }
107+ return Option.none<string>();
108+});
109+
110+const resolveDesktopStaticPath = Effect.fn("desktop.electron.protocol.resolveDesktopStaticPath")(
111+ function* (
112+ staticRoot: string,
113+ requestUrl: string,
114+ ): Effect.fn.Return<string, never, FileSystem.FileSystem | DesktopEnvironment> {
115+ const fileSystem = yield* FileSystem.FileSystem;
116+ const environment = yield* DesktopEnvironment;
117+ const url = new URL(requestUrl);
118+ const rawPath = decodeURIComponent(url.pathname);
119+ const normalizedPath = normalizeDesktopProtocolPathname(rawPath);
120+ if (Option.isNone(normalizedPath)) {
121+ return environment.path.join(staticRoot, "index.html");
122+ }
123+
124+ const requestedPath = normalizedPath.value.length > 0 ? normalizedPath.value : "index.html";
125+ const resolvedPath = environment.path.join(staticRoot, requestedPath);
126+
127+ if (environment.path.extname(resolvedPath)) {
128+ return resolvedPath;
129+ }
130+
131+ const nestedIndex = environment.path.join(resolvedPath, "index.html");
132+ const nestedIndexExists = yield* fileSystem
133+ .exists(nestedIndex)
134+ .pipe(Effect.orElseSucceed(() => false));
135+ if (nestedIndexExists) {
136+ return nestedIndex;
137+ }
138+
139+ return environment.path.join(staticRoot, "index.html");
140+ },
141+);
142+
143+function isStaticAssetRequest(requestUrl: string, environment: DesktopEnvironmentShape): boolean {
144+ try {
145+ const url = new URL(requestUrl);
146+ return environment.path.extname(url.pathname).length > 0;
147+ } catch {
148+ return false;
149+ }
150+}
151+
152+const make = Effect.gen(function* () {
153+ const registeredProtocols = yield* Ref.make<ReadonlySet<string>>(new Set());
154+
155+ const registerFileProtocol = Effect.fn("desktop.electron.protocol.registerFileProtocol")(
156+ function* <E, R>({
157+ scheme,
158+ handler,
159+ onFailure,
160+ }: {
161+ readonly scheme: string;
162+ readonly handler: (
163+ request: Electron.ProtocolRequest,
164+ ) => Effect.Effect<Electron.ProtocolResponse, E, R>;
165+ readonly onFailure?: (
166+ request: Electron.ProtocolRequest,
167+ cause: Cause.Cause<E>,
168+ ) => Electron.ProtocolResponse;
169+ }): Effect.fn.Return<void, ElectronProtocolRegistrationError, R | Scope.Scope> {
170+ yield* Effect.annotateCurrentSpan({ scheme });
171+ const alreadyRegistered = yield* Ref.get(registeredProtocols).pipe(
172+ Effect.map((protocols) => protocols.has(scheme)),
173+ );
174+ if (alreadyRegistered) {
175+ return;
176+ }
177+
178+ const context = yield* Effect.context<R>();
179+ const runPromise = Effect.runPromiseWith(context);
180+
181+ yield* Effect.acquireRelease(
182+ Effect.try({
183+ try: () => {
184+ const registered = Electron.protocol.registerFileProtocol(
185+ scheme,
186+ (request, callback) => {
187+ const response = handler(request).pipe(
188+ Effect.withSpan("desktop.electron.protocol.handleFileRequest"),
189+ Effect.catchCause((cause) =>
190+ Effect.succeed(onFailure?.(request, cause) ?? ({ error: -2 } as const)),
191+ ),
192+ );
193+
194+ void runPromise(response).then(callback, () => callback({ error: -2 }));
195+ },
196+ );
197+ if (!registered) {
198+ throw new ElectronProtocolRegistrationError({
199+ scheme,
200+ cause: "registerFileProtocol returned false",
201+ });
202+ }
203+ },
204+ catch: (cause) =>
205+ cause instanceof ElectronProtocolRegistrationError
206+ ? cause
207+ : new ElectronProtocolRegistrationError({ scheme, cause }),
208+ }).pipe(
209+ Effect.andThen(
210+ Ref.update(registeredProtocols, (protocols) => new Set(protocols).add(scheme)),
211+ ),
212+ ),
213+ () =>
214+ Effect.sync(() => {
215+ Electron.protocol.unregisterProtocol(scheme);
216+ }).pipe(
217+ Effect.andThen(
218+ Ref.update(registeredProtocols, (protocols) => {
219+ const next = new Set(protocols);
220+ next.delete(scheme);
221+ return next;
222+ }),
223+ ),
224+ ),
225+ );
226+ },
227+ );
228+
229+ const registerDesktopFileProtocol = Effect.gen(function* () {
230+ const environment = yield* DesktopEnvironment;
231+ if (environment.isDevelopment) return;
232+
233+ const staticRoot = yield* resolveDesktopStaticDir;
234+ if (Option.isNone(staticRoot)) {
235+ return yield* new ElectronProtocolStaticBundleMissingError();
236+ }
237+
238+ const staticRootResolved = environment.path.resolve(staticRoot.value);
239+ const staticRootPrefix = `${staticRootResolved}${environment.path.sep}`;
240+ const fallbackIndex = environment.path.join(staticRootResolved, "index.html");
241+
242+ yield* registerFileProtocol({
243+ scheme: DESKTOP_SCHEME,
244+ handler: Effect.fn("desktop.electron.protocol.handleDesktopFileRequest")(function* (request) {
245+ const fileSystem = yield* FileSystem.FileSystem;
246+ const environment = yield* DesktopEnvironment;
247+ const candidate = yield* resolveDesktopStaticPath(staticRootResolved, request.url);
248+ const resolvedCandidate = environment.path.resolve(candidate);
249+ const isInRoot =
250+ resolvedCandidate === fallbackIndex || resolvedCandidate.startsWith(staticRootPrefix);
251+ const isAssetRequest = isStaticAssetRequest(request.url, environment);
252+ const exists = yield* fileSystem
253+ .exists(resolvedCandidate)
254+ .pipe(Effect.orElseSucceed(() => false));
255+
256+ if (!isInRoot || !exists) {
257+ return isAssetRequest ? ({ error: -6 } as const) : ({ path: fallbackIndex } as const);
258+ }
259+
260+ return { path: resolvedCandidate } as const;
261+ }),
262+ onFailure: () => ({ path: fallbackIndex }),
263+ });
264+ }).pipe(Effect.withSpan("desktop.electron.protocol.registerDesktopFileProtocol"));
265+
266+ return ElectronProtocol.of({
267+ registerFileProtocol,
268+ registerDesktopFileProtocol,
269+ });
270+});
271+
272+export const layer = Layer.effect(ElectronProtocol, make);
apps/desktop/src/electron/ElectronSafeStorage.ts+70−0
@@ -0,0 +1,70 @@
1+import * as Context from "effect/Context";
2+import * as Data from "effect/Data";
3+import * as Effect from "effect/Effect";
4+import * as Layer from "effect/Layer";
5+
6+import * as Electron from "electron";
7+
8+export class ElectronSafeStorageAvailabilityError extends Data.TaggedError(
9+ "ElectronSafeStorageAvailabilityError",
10+)<{
11+ readonly cause: unknown;
12+}> {
13+ override get message() {
14+ return "Electron safe storage failed to check encryption availability.";
15+ }
16+}
17+
18+export class ElectronSafeStorageEncryptError extends Data.TaggedError(
19+ "ElectronSafeStorageEncryptError",
20+)<{
21+ readonly cause: unknown;
22+}> {
23+ override get message() {
24+ return "Electron safe storage failed to encrypt a string.";
25+ }
26+}
27+
28+export class ElectronSafeStorageDecryptError extends Data.TaggedError(
29+ "ElectronSafeStorageDecryptError",
30+)<{
31+ readonly cause: unknown;
32+}> {
33+ override get message() {
34+ return "Electron safe storage failed to decrypt a string.";
35+ }
36+}
37+
38+export interface ElectronSafeStorageShape {
39+ readonly isEncryptionAvailable: Effect.Effect<boolean, ElectronSafeStorageAvailabilityError>;
40+ readonly encryptString: (
41+ value: string,
42+ ) => Effect.Effect<Uint8Array, ElectronSafeStorageEncryptError>;
43+ readonly decryptString: (
44+ value: Uint8Array,
45+ ) => Effect.Effect<string, ElectronSafeStorageDecryptError>;
46+}
47+
48+export class ElectronSafeStorage extends Context.Service<
49+ ElectronSafeStorage,
50+ ElectronSafeStorageShape
51+>()("@t3tools/desktop/ElectronSafeStorage") {}
52+
53+const make = ElectronSafeStorage.of({
54+ isEncryptionAvailable: Effect.try({
55+ try: () => Electron.safeStorage.isEncryptionAvailable(),
56+ catch: (cause) => new ElectronSafeStorageAvailabilityError({ cause }),
57+ }),
58+ encryptString: (value) =>
59+ Effect.try({
60+ try: () => Electron.safeStorage.encryptString(value),
61+ catch: (cause) => new ElectronSafeStorageEncryptError({ cause }),
62+ }),
63+ decryptString: (value) =>
64+ Effect.try({
65+ try: () => Electron.safeStorage.decryptString(Buffer.from(value)),
66+ catch: (cause) => new ElectronSafeStorageDecryptError({ cause }),
67+ }),
68+});
69+
70+export const layer = Layer.succeed(ElectronSafeStorage, make);
apps/desktop/src/electron/ElectronShell.test.ts+59−0
@@ -0,0 +1,59 @@
1+import { assert, describe, it } from "@effect/vitest";
2+import * as Effect from "effect/Effect";
3+import { beforeEach, vi } from "vitest";
4+
5+const { openExternalMock, writeTextMock } = vi.hoisted(() => ({
6+ openExternalMock: vi.fn(),
7+ writeTextMock: vi.fn(),
8+}));
9+
10+vi.mock("electron", () => ({
11+ shell: {
12+ openExternal: openExternalMock,
13+ },
14+ clipboard: {
15+ writeText: writeTextMock,
16+ },
17+}));
18+
19+import * as ElectronShell from "./ElectronShell.ts";
20+
21+describe("ElectronShell", () => {
22+ beforeEach(() => {
23+ openExternalMock.mockReset();
24+ writeTextMock.mockReset();
25+ });
26+
27+ it.effect("opens safe external URLs", () =>
28+ Effect.gen(function* () {
29+ openExternalMock.mockResolvedValue(undefined);
30+
31+ const electronShell = yield* ElectronShell.ElectronShell;
32+ const result = yield* electronShell.openExternal("https://example.com/path");
33+
34+ assert.equal(result, true);
35+ assert.deepEqual(openExternalMock.mock.calls, [["https://example.com/path"]]);
36+ }).pipe(Effect.provide(ElectronShell.layer)),
37+ );
38+
39+ it.effect("does not open unsafe external URLs", () =>
40+ Effect.gen(function* () {
41+ const electronShell = yield* ElectronShell.ElectronShell;
42+ const result = yield* electronShell.openExternal("file:///etc/passwd");
43+
44+ assert.equal(result, false);
45+ assert.equal(openExternalMock.mock.calls.length, 0);
46+ }).pipe(Effect.provide(ElectronShell.layer)),
47+ );
48+
49+ it.effect("returns false when Electron rejects openExternal", () =>
50+ Effect.gen(function* () {
51+ openExternalMock.mockRejectedValue(new Error("open failed"));
52+
53+ const electronShell = yield* ElectronShell.ElectronShell;
54+ const result = yield* electronShell.openExternal("https://example.com/path");
55+
56+ assert.equal(result, false);
57+ }).pipe(Effect.provide(ElectronShell.layer)),
58+ );
59+});
apps/desktop/src/electron/ElectronShell.ts+50−0
@@ -0,0 +1,50 @@
1+import * as Context from "effect/Context";
2+import * as Effect from "effect/Effect";
3+import * as Layer from "effect/Layer";
4+import * as Option from "effect/Option";
5+
6+import * as Electron from "electron";
7+
8+const SAFE_EXTERNAL_PROTOCOLS = new Set(["http:", "https:"]);
9+
10+export function parseSafeExternalUrl(rawUrl: unknown): Option.Option<string> {
11+ if (typeof rawUrl !== "string") {
12+ return Option.none();
13+ }
14+
15+ try {
16+ const url = new URL(rawUrl);
17+ return SAFE_EXTERNAL_PROTOCOLS.has(url.protocol) ? Option.some(url.href) : Option.none();
18+ } catch {
19+ return Option.none();
20+ }
21+}
22+
23+export interface ElectronShellShape {
24+ readonly openExternal: (rawUrl: unknown) => Effect.Effect<boolean>;
25+ readonly copyText: (text: string) => Effect.Effect<void>;
26+}
27+
28+export class ElectronShell extends Context.Service<ElectronShell, ElectronShellShape>()(
29+ "t3/desktop/electron/Shell",
30+) {}
31+
32+const make = ElectronShell.of({
33+ openExternal: (rawUrl) =>
34+ Option.match(parseSafeExternalUrl(rawUrl), {
35+ onNone: () => Effect.succeed(false),
36+ onSome: (externalUrl) =>
37+ Effect.promise(() =>
38+ Electron.shell.openExternal(externalUrl).then(
39+ () => true,
40+ () => false,
41+ ),
42+ ),
43+ }),
44+ copyText: (text) =>
45+ Effect.sync(() => {
46+ Electron.clipboard.writeText(text);
47+ }),
48+});
49+
50+export const layer = Layer.succeed(ElectronShell, make);
apps/desktop/src/electron/ElectronTheme.test.ts+52−0
@@ -0,0 +1,52 @@
1+import { assert, describe, it } from "@effect/vitest";
2+import * as Effect from "effect/Effect";
3+import { beforeEach, vi } from "vitest";
4+
5+const { onMock, removeListenerMock, themeState } = vi.hoisted(() => ({
6+ onMock: vi.fn(),
7+ removeListenerMock: vi.fn(),
8+ themeState: {
9+ shouldUseDarkColors: true,
10+ themeSource: "system",
11+ },
12+}));
13+
14+vi.mock("electron", () => ({
15+ nativeTheme: {
16+ get shouldUseDarkColors() {
17+ return themeState.shouldUseDarkColors;
18+ },
19+ set themeSource(value: string) {
20+ themeState.themeSource = value;
21+ },
22+ on: onMock,
23+ removeListener: removeListenerMock,
24+ },
25+}));
26+
27+import * as ElectronTheme from "./ElectronTheme.ts";
28+
29+describe("ElectronTheme", () => {
30+ beforeEach(() => {
31+ onMock.mockClear();
32+ removeListenerMock.mockClear();
33+ themeState.shouldUseDarkColors = true;
34+ themeState.themeSource = "system";
35+ });
36+
37+ it.effect("scopes native theme update listeners", () =>
38+ Effect.gen(function* () {
39+ const listener = vi.fn();
40+
41+ yield* Effect.scoped(
42+ Effect.gen(function* () {
43+ const electronTheme = yield* ElectronTheme.ElectronTheme;
44+ yield* electronTheme.onUpdated(listener);
45+ }),
46+ );
47+
48+ assert.deepEqual(onMock.mock.calls, [["updated", listener]]);
49+ assert.deepEqual(removeListenerMock.mock.calls, [["updated", listener]]);
50+ }).pipe(Effect.provide(ElectronTheme.layer)),
51+ );
52+});
apps/desktop/src/electron/ElectronTheme.ts+40−0
@@ -0,0 +1,40 @@
1+import type { DesktopTheme } from "@t3tools/contracts";
2+import * as Context from "effect/Context";
3+import * as Effect from "effect/Effect";
4+import * as Layer from "effect/Layer";
5+import * as Scope from "effect/Scope";
6+
7+import * as Electron from "electron";
8+
9+export interface ElectronThemeShape {
10+ readonly shouldUseDarkColors: Effect.Effect<boolean>;
11+ readonly setSource: (theme: DesktopTheme) => Effect.Effect<void>;
12+ readonly onUpdated: (listener: () => void) => Effect.Effect<void, never, Scope.Scope>;
13+}
14+
15+export class ElectronTheme extends Context.Service<ElectronTheme, ElectronThemeShape>()(
16+ "t3/desktop/electron/Theme",
17+) {}
18+
19+const make = ElectronTheme.of({
20+ shouldUseDarkColors: Effect.sync(() => Electron.nativeTheme.shouldUseDarkColors),
21+ setSource: (theme) =>
22+ Effect.suspend(() => {
23+ Electron.nativeTheme.themeSource = theme;
24+ return Effect.void;
25+ }),
26+ onUpdated: (listener) =>
27+ Effect.acquireRelease(
28+ Effect.suspend(() => {
29+ Electron.nativeTheme.on("updated", listener);
30+ return Effect.void;
31+ }),
32+ () =>
33+ Effect.suspend(() => {
34+ Electron.nativeTheme.removeListener("updated", listener);
35+ return Effect.void;
36+ }),
37+ ),
38+});
39+
40+export const layer = Layer.succeed(ElectronTheme, make);
apps/desktop/src/electron/ElectronUpdater.test.ts+79−0
@@ -0,0 +1,79 @@
1+import { assert, describe, it } from "@effect/vitest";
2+import * as Cause from "effect/Cause";
3+import * as Effect from "effect/Effect";
4+import { beforeEach, vi } from "vitest";
5+
6+const { autoUpdaterMock } = vi.hoisted(() => ({
7+ autoUpdaterMock: {
8+ allowDowngrade: false,
9+ allowPrerelease: false,
10+ autoDownload: true,
11+ autoInstallOnAppQuit: true,
12+ channel: "latest",
13+ disableDifferentialDownload: false,
14+ checkForUpdates: vi.fn(() => Promise.resolve(null)),
15+ downloadUpdate: vi.fn(() => Promise.resolve([])),
16+ on: vi.fn(),
17+ quitAndInstall: vi.fn(),
18+ removeListener: vi.fn(),
19+ setFeedURL: vi.fn(),
20+ },
21+}));
22+
23+vi.mock("electron-updater", () => ({
24+ autoUpdater: autoUpdaterMock,
25+}));
26+
27+import * as ElectronUpdater from "./ElectronUpdater.ts";
28+
29+describe("ElectronUpdater", () => {
30+ beforeEach(() => {
31+ autoUpdaterMock.allowDowngrade = false;
32+ autoUpdaterMock.allowPrerelease = false;
33+ autoUpdaterMock.autoDownload = true;
34+ autoUpdaterMock.autoInstallOnAppQuit = true;
35+ autoUpdaterMock.channel = "latest";
36+ autoUpdaterMock.disableDifferentialDownload = false;
37+ autoUpdaterMock.checkForUpdates.mockClear();
38+ autoUpdaterMock.checkForUpdates.mockImplementation(() => Promise.resolve(null));
39+ autoUpdaterMock.downloadUpdate.mockClear();
40+ autoUpdaterMock.downloadUpdate.mockImplementation(() => Promise.resolve([]));
41+ autoUpdaterMock.on.mockClear();
42+ autoUpdaterMock.quitAndInstall.mockClear();
43+ autoUpdaterMock.removeListener.mockClear();
44+ autoUpdaterMock.setFeedURL.mockClear();
45+ });
46+
47+ it.effect("scopes updater event listeners", () =>
48+ Effect.gen(function* () {
49+ const listener = vi.fn();
50+
51+ yield* Effect.scoped(
52+ Effect.gen(function* () {
53+ const updater = yield* ElectronUpdater.ElectronUpdater;
54+ yield* updater.on("update-available", listener);
55+ }),
56+ );
57+
58+ assert.deepEqual(autoUpdaterMock.on.mock.calls, [["update-available", listener]]);
59+ assert.deepEqual(autoUpdaterMock.removeListener.mock.calls, [["update-available", listener]]);
60+ }).pipe(Effect.provide(ElectronUpdater.layer)),
61+ );
62+
63+ it.effect("wraps rejected update checks in the method-specific typed error", () =>
64+ Effect.gen(function* () {
65+ const cause = new Error("network unavailable");
66+ autoUpdaterMock.checkForUpdates.mockImplementationOnce(() => Promise.reject(cause));
67+ const updater = yield* ElectronUpdater.ElectronUpdater;
68+
69+ const exit = yield* Effect.exit(updater.checkForUpdates);
70+
71+ assert.equal(exit._tag, "Failure");
72+ if (exit._tag === "Failure") {
73+ const error = Cause.squash(exit.cause);
74+ assert.instanceOf(error, ElectronUpdater.ElectronUpdaterCheckForUpdatesError);
75+ assert.equal(error.cause, cause);
76+ }
77+ }).pipe(Effect.provide(ElectronUpdater.layer)),
78+ );
79+});
apps/desktop/src/electron/ElectronUpdater.ts+139−0
@@ -0,0 +1,139 @@
1+import * as Context from "effect/Context";
2+import * as Data from "effect/Data";
3+import * as Effect from "effect/Effect";
4+import * as Layer from "effect/Layer";
5+import * as Scope from "effect/Scope";
6+
7+import { autoUpdater } from "electron-updater";
8+
9+type AutoUpdater = typeof autoUpdater;
10+
11+export type ElectronUpdaterFeedUrl = Parameters<AutoUpdater["setFeedURL"]>[0];
12+
13+export class ElectronUpdaterCheckForUpdatesError extends Data.TaggedError(
14+ "ElectronUpdaterCheckForUpdatesError",
15+)<{
16+ readonly cause: unknown;
17+}> {
18+ override get message() {
19+ return "Electron updater failed to check for updates.";
20+ }
21+}
22+
23+export class ElectronUpdaterDownloadUpdateError extends Data.TaggedError(
24+ "ElectronUpdaterDownloadUpdateError",
25+)<{
26+ readonly cause: unknown;
27+}> {
28+ override get message() {
29+ return "Electron updater failed to download the update.";
30+ }
31+}
32+
33+export class ElectronUpdaterQuitAndInstallError extends Data.TaggedError(
34+ "ElectronUpdaterQuitAndInstallError",
35+)<{
36+ readonly cause: unknown;
37+}> {
38+ override get message() {
39+ return "Electron updater failed to quit and install the update.";
40+ }
41+}
42+
43+export type ElectronUpdaterError =
44+ | ElectronUpdaterCheckForUpdatesError
45+ | ElectronUpdaterDownloadUpdateError
46+ | ElectronUpdaterQuitAndInstallError;
47+
48+export interface ElectronUpdaterShape {
49+ readonly setFeedURL: (options: ElectronUpdaterFeedUrl) => Effect.Effect<void>;
50+ readonly setAutoDownload: (value: boolean) => Effect.Effect<void>;
51+ readonly setAutoInstallOnAppQuit: (value: boolean) => Effect.Effect<void>;
52+ readonly setChannel: (channel: string) => Effect.Effect<void>;
53+ readonly setAllowPrerelease: (value: boolean) => Effect.Effect<void>;
54+ readonly allowDowngrade: Effect.Effect<boolean>;
55+ readonly setAllowDowngrade: (value: boolean) => Effect.Effect<void>;
56+ readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect<void>;
57+ readonly checkForUpdates: Effect.Effect<void, ElectronUpdaterCheckForUpdatesError>;
58+ readonly downloadUpdate: Effect.Effect<void, ElectronUpdaterDownloadUpdateError>;
59+ readonly quitAndInstall: (options: {
60+ readonly isSilent: boolean;
61+ readonly isForceRunAfter: boolean;
62+ }) => Effect.Effect<void, ElectronUpdaterQuitAndInstallError>;
63+ readonly on: <Args extends ReadonlyArray<unknown>>(
64+ eventName: string,
65+ listener: (...args: Args) => void,
66+ ) => Effect.Effect<void, never, Scope.Scope>;
67+}
68+
69+export class ElectronUpdater extends Context.Service<ElectronUpdater, ElectronUpdaterShape>()(
70+ "t3/desktop/electron/Updater",
71+) {}
72+
73+export const layer = Layer.succeed(ElectronUpdater, {
74+ setFeedURL: (options) =>
75+ Effect.suspend(() => {
76+ autoUpdater.setFeedURL(options);
77+ return Effect.void;
78+ }),
79+ setAutoDownload: (value) =>
80+ Effect.suspend(() => {
81+ autoUpdater.autoDownload = value;
82+ return Effect.void;
83+ }),
84+ setAutoInstallOnAppQuit: (value) =>
85+ Effect.suspend(() => {
86+ autoUpdater.autoInstallOnAppQuit = value;
87+ return Effect.void;
88+ }),
89+ setChannel: (channel) =>
90+ Effect.suspend(() => {
91+ autoUpdater.channel = channel;
92+ return Effect.void;
93+ }),
94+ setAllowPrerelease: (value) =>
95+ Effect.suspend(() => {
96+ autoUpdater.allowPrerelease = value;
97+ return Effect.void;
98+ }),
99+ allowDowngrade: Effect.sync(() => autoUpdater.allowDowngrade),
100+ setAllowDowngrade: (value) =>
101+ Effect.suspend(() => {
102+ autoUpdater.allowDowngrade = value;
103+ return Effect.void;
104+ }),
105+ setDisableDifferentialDownload: (value) =>
106+ Effect.suspend(() => {
107+ autoUpdater.disableDifferentialDownload = value;
108+ return Effect.void;
109+ }),
110+ checkForUpdates: Effect.tryPromise({
111+ try: () => autoUpdater.checkForUpdates(),
112+ catch: (cause) => new ElectronUpdaterCheckForUpdatesError({ cause }),
113+ }).pipe(Effect.asVoid),
114+ downloadUpdate: Effect.tryPromise({
115+ try: () => autoUpdater.downloadUpdate(),
116+ catch: (cause) => new ElectronUpdaterDownloadUpdateError({ cause }),
117+ }).pipe(Effect.asVoid),
118+ quitAndInstall: ({ isSilent, isForceRunAfter }) =>
119+ Effect.try({
120+ try: () => autoUpdater.quitAndInstall(isSilent, isForceRunAfter),
121+ catch: (cause) => new ElectronUpdaterQuitAndInstallError({ cause }),
122+ }),
123+ on: (eventName, listener) => {
124+ const eventTarget = autoUpdater as unknown as {
125+ on: (eventName: string, listener: (...args: Array<unknown>) => void) => void;
126+ removeListener: (eventName: string, listener: (...args: Array<unknown>) => void) => void;
127+ };
128+ const untypedListener = listener as unknown as (...args: Array<unknown>) => void;
129+ return Effect.acquireRelease(
130+ Effect.sync(() => {
131+ eventTarget.on(eventName, untypedListener);
132+ }),
133+ () =>
134+ Effect.sync(() => {
135+ eventTarget.removeListener(eventName, untypedListener);
136+ }),
137+ ).pipe(Effect.asVoid);
138+ },
139+} satisfies ElectronUpdaterShape);
apps/desktop/src/electron/ElectronWindow.test.ts+51−0
@@ -0,0 +1,51 @@
1+import { assert, describe, it } from "@effect/vitest";
2+import * as Effect from "effect/Effect";
3+import type * as Electron from "electron";
4+import { beforeEach, vi } from "vitest";
5+
6+const { appFocusMock, getAllWindowsMock } = vi.hoisted(() => ({
7+ appFocusMock: vi.fn(),
8+ getAllWindowsMock: vi.fn(),
9+}));
10+
11+vi.mock("electron", () => ({
12+ app: {
13+ focus: appFocusMock,
14+ },
15+ BrowserWindow: {
16+ getAllWindows: getAllWindowsMock,
17+ },
18+}));
19+
20+import * as ElectronWindow from "./ElectronWindow.ts";
21+
22+function makeBrowserWindow(input: { readonly destroyed: boolean }) {
23+ return {
24+ isDestroyed: vi.fn(() => input.destroyed),
25+ } as unknown as Electron.BrowserWindow;
26+}
27+
28+describe("ElectronWindow", () => {
29+ beforeEach(() => {
30+ appFocusMock.mockReset();
31+ getAllWindowsMock.mockReset();
32+ });
33+
34+ it.effect("skips windows destroyed before appearance sync runs", () =>
35+ Effect.gen(function* () {
36+ const liveWindow = makeBrowserWindow({ destroyed: false });
37+ const destroyedWindow = makeBrowserWindow({ destroyed: true });
38+ getAllWindowsMock.mockReturnValue([destroyedWindow, liveWindow]);
39+
40+ const syncedWindows: Electron.BrowserWindow[] = [];
41+ const electronWindow = yield* ElectronWindow.ElectronWindow;
42+ yield* electronWindow.syncAllAppearance((window) =>
43+ Effect.sync(() => {
44+ syncedWindows.push(window);
45+ }),
46+ );
47+
48+ assert.deepEqual(syncedWindows, [liveWindow]);
49+ }).pipe(Effect.provide(ElectronWindow.layer)),
50+ );
51+});
apps/desktop/src/electron/ElectronWindow.ts+135−0
@@ -0,0 +1,135 @@
1+import * as Context from "effect/Context";
2+import * as Data from "effect/Data";
3+import * as Effect from "effect/Effect";
4+import * as Layer from "effect/Layer";
5+import * as Option from "effect/Option";
6+import * as Ref from "effect/Ref";
7+
8+import * as Electron from "electron";
9+
10+export class ElectronWindowCreateError extends Data.TaggedError("ElectronWindowCreateError")<{
11+ readonly cause: unknown;
12+}> {
13+ override get message() {
14+ return "Failed to create Electron BrowserWindow.";
15+ }
16+}
17+
18+export interface ElectronWindowShape {
19+ readonly create: (
20+ options: Electron.BrowserWindowConstructorOptions,
21+ ) => Effect.Effect<Electron.BrowserWindow, ElectronWindowCreateError>;
22+ readonly main: Effect.Effect<Option.Option<Electron.BrowserWindow>>;
23+ readonly currentMainOrFirst: Effect.Effect<Option.Option<Electron.BrowserWindow>>;
24+ readonly focusedMainOrFirst: Effect.Effect<Option.Option<Electron.BrowserWindow>>;
25+ readonly setMain: (window: Electron.BrowserWindow) => Effect.Effect<void>;
26+ readonly clearMain: (window: Option.Option<Electron.BrowserWindow>) => Effect.Effect<void>;
27+ readonly reveal: (window: Electron.BrowserWindow) => Effect.Effect<void>;
28+ readonly sendAll: (channel: string, ...args: readonly unknown[]) => Effect.Effect<void>;
29+ readonly destroyAll: Effect.Effect<void>;
30+ readonly syncAllAppearance: <E, R>(
31+ sync: (window: Electron.BrowserWindow) => Effect.Effect<void, E, R>,
32+ ) => Effect.Effect<void, E, R>;
33+}
34+
35+export class ElectronWindow extends Context.Service<ElectronWindow, ElectronWindowShape>()(
36+ "t3/desktop/electron/Window",
37+) {}
38+
39+const make = Effect.gen(function* () {
40+ const mainWindowRef = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
41+
42+ const liveMain = Ref.get(mainWindowRef).pipe(
43+ Effect.map(Option.filter((value) => !value.isDestroyed())),
44+ );
45+
46+ const currentMainOrFirst = Effect.gen(function* () {
47+ const main = yield* liveMain;
48+ if (Option.isSome(main)) {
49+ return main;
50+ }
51+
52+ return Option.fromNullishOr(Electron.BrowserWindow.getAllWindows()[0] ?? null).pipe(
53+ Option.filter((window) => !window.isDestroyed()),
54+ );
55+ });
56+
57+ const focusedMainOrFirst = Effect.sync(() =>
58+ Option.fromNullishOr(Electron.BrowserWindow.getFocusedWindow() ?? null).pipe(
59+ Option.filter((window) => !window.isDestroyed()),
60+ ),
61+ ).pipe(
62+ Effect.flatMap((focused) =>
63+ Option.isSome(focused) ? Effect.succeed(focused) : currentMainOrFirst,
64+ ),
65+ );
66+
67+ return ElectronWindow.of({
68+ create: (options) =>
69+ Effect.try({
70+ try: () => new Electron.BrowserWindow(options),
71+ catch: (cause) => new ElectronWindowCreateError({ cause }),
72+ }),
73+ main: liveMain,
74+ currentMainOrFirst,
75+ focusedMainOrFirst,
76+ setMain: (window) => Ref.set(mainWindowRef, Option.some(window)),
77+ clearMain: (window) =>
78+ Ref.update(mainWindowRef, (current) => {
79+ if (Option.isNone(current)) {
80+ return current;
81+ }
82+ if (Option.isSome(window) && current.value !== window.value) {
83+ return current;
84+ }
85+ return Option.none();
86+ }),
87+ reveal: (window) =>
88+ Effect.sync(() => {
89+ if (window.isDestroyed()) {
90+ return;
91+ }
92+
93+ if (window.isMinimized()) {
94+ window.restore();
95+ }
96+
97+ if (!window.isVisible()) {
98+ window.show();
99+ }
100+
101+ if (process.platform === "darwin") {
102+ Electron.app.focus({ steal: true });
103+ }
104+
105+ window.focus();
106+ }),
107+ sendAll: (channel, ...args) =>
108+ Effect.sync(() => {
109+ for (const window of Electron.BrowserWindow.getAllWindows()) {
110+ if (window.isDestroyed()) {
111+ continue;
112+ }
113+ window.webContents.send(channel, ...args);
114+ }
115+ }),
116+ destroyAll: Effect.sync(() => {
117+ for (const window of Electron.BrowserWindow.getAllWindows()) {
118+ window.destroy();
119+ }
120+ }),
121+ syncAllAppearance: Effect.fn("desktop.electron.window.syncAllAppearance")(function* <E, R>(
122+ sync: (window: Electron.BrowserWindow) => Effect.Effect<void, E, R>,
123+ ) {
124+ const windows = Electron.BrowserWindow.getAllWindows();
125+ for (const window of windows) {
126+ if (window.isDestroyed()) {
127+ continue;
128+ }
129+ yield* sync(window);
130+ }
131+ }),
132+ });
133+});
134+
135+export const layer = Layer.effect(ElectronWindow, make);
apps/desktop/src/ipc/DesktopIpc.ts+219−0
@@ -0,0 +1,219 @@
1+import * as Context from "effect/Context";
2+import * as Effect from "effect/Effect";
3+import * as Schema from "effect/Schema";
4+import * as Scope from "effect/Scope";
5+
6+export interface DesktopIpcInvokeEvent {}
7+
8+export interface DesktopIpcSyncEvent {
9+ returnValue: unknown;
10+}
11+
12+export type DesktopIpcHandleListener = (
13+ event: DesktopIpcInvokeEvent,
14+ raw: unknown,
15+) => unknown | Promise<unknown>;
16+
17+export type DesktopIpcSyncListener = (event: DesktopIpcSyncEvent) => void;
18+
19+export interface DesktopIpcMain {
20+ removeHandler(channel: string): void;
21+ handle(channel: string, listener: DesktopIpcHandleListener): void;
22+ removeAllListeners(channel: string): void;
23+ on(channel: string, listener: DesktopIpcSyncListener): void;
24+}
25+
26+export interface DesktopIpcMethod<E, R> {
27+ readonly channel: string;
28+ readonly handler: (raw: unknown) => Effect.Effect<unknown, E, R>;
29+}
30+
31+export interface DesktopSyncIpcMethod<E, R> {
32+ readonly channel: string;
33+ readonly handler: () => Effect.Effect<unknown, E, R>;
34+}
35+
36+export interface DesktopIpcShape {
37+ readonly handle: <E, R>(
38+ input: DesktopIpcMethod<E, R>,
39+ ) => Effect.Effect<void, never, R | Scope.Scope>;
40+ readonly handleSync: <E, R>(
41+ input: DesktopSyncIpcMethod<E, R>,
42+ ) => Effect.Effect<void, never, R | Scope.Scope>;
43+}
44+
45+export class DesktopIpc extends Context.Service<DesktopIpc, DesktopIpcShape>()("t3/desktop/Ipc") {}
46+
47+export const make = (ipcMain: DesktopIpcMain): DesktopIpcShape =>
48+ DesktopIpc.of({
49+ handle: Effect.fn("desktop.ipc.registerInvoke")(function* <E, R>({
50+ channel,
51+ handler,
52+ }: DesktopIpcMethod<E, R>) {
53+ yield* Effect.annotateCurrentSpan({ channel });
54+ const context = yield* Effect.context<R>();
55+ const runPromise = Effect.runPromiseWith(context);
56+
57+ yield* Effect.acquireRelease(
58+ Effect.sync(() => {
59+ ipcMain.removeHandler(channel);
60+ ipcMain.handle(channel, (_event, raw) =>
61+ runPromise(
62+ Effect.gen(function* () {
63+ yield* Effect.annotateCurrentSpan({ channel });
64+ return yield* handler(raw);
65+ }).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invoke")),
66+ ),
67+ );
68+ }),
69+ () => Effect.sync(() => ipcMain.removeHandler(channel)),
70+ );
71+ }),
72+
73+ handleSync: Effect.fn("desktop.ipc.registerSync")(function* <E, R>({
74+ channel,
75+ handler,
76+ }: DesktopSyncIpcMethod<E, R>) {
77+ yield* Effect.annotateCurrentSpan({ channel });
78+ const context = yield* Effect.context<R>();
79+ const runSync = Effect.runSyncWith(context);
80+
81+ yield* Effect.acquireRelease(
82+ Effect.sync(() => {
83+ ipcMain.removeAllListeners(channel);
84+ ipcMain.on(channel, (event) => {
85+ event.returnValue = runSync(
86+ Effect.gen(function* () {
87+ yield* Effect.annotateCurrentSpan({ channel });
88+ return yield* handler();
89+ }).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invokeSync")),
90+ );
91+ });
92+ }),
93+ () => Effect.sync(() => ipcMain.removeAllListeners(channel)),
94+ );
95+ }),
96+ });
97+
98+/**
99+ * Convenience helpers for creating IPC methods
100+ */
101+
102+export interface DesktopIpcMethodRegistration<
103+ Payload,
104+ EncodedPayload,
105+ Result,
106+ EncodedResult,
107+ E,
108+ R,
109+ PayloadDecodingServices = never,
110+ PayloadEncodingServices = never,
111+ ResultDecodingServices = never,
112+ ResultEncodingServices = never,
113+> {
114+ readonly channel: string;
115+ readonly payload: Schema.Codec<
116+ Payload,
117+ EncodedPayload,
118+ PayloadDecodingServices,
119+ PayloadEncodingServices
120+ >;
121+ readonly result: Schema.Codec<
122+ Result,
123+ EncodedResult,
124+ ResultDecodingServices,
125+ ResultEncodingServices
126+ >;
127+ readonly handler: (input: Payload) => Effect.Effect<Result, E, R>;
128+}
129+
130+export const makeIpcMethod = <
131+ Payload,
132+ EncodedPayload,
133+ Result,
134+ EncodedResult,
135+ E,
136+ R,
137+ PayloadDecodingServices = never,
138+ PayloadEncodingServices = never,
139+ ResultDecodingServices = never,
140+ ResultEncodingServices = never,
141+>(
142+ method: DesktopIpcMethodRegistration<
143+ Payload,
144+ EncodedPayload,
145+ Result,
146+ EncodedResult,
147+ E,
148+ R,
149+ PayloadDecodingServices,
150+ PayloadEncodingServices,
151+ ResultDecodingServices,
152+ ResultEncodingServices
153+ >,
154+): DesktopIpcMethod<
155+ E | Schema.SchemaError,
156+ R | PayloadDecodingServices | ResultEncodingServices
157+> => {
158+ const decode = Schema.decodeUnknownEffect(method.payload);
159+ const encode = Schema.encodeUnknownEffect(method.result);
160+
161+ return {
162+ channel: method.channel,
163+ handler: (raw) =>
164+ decode(raw).pipe(
165+ Effect.flatMap(method.handler),
166+ Effect.flatMap(encode),
167+ Effect.withSpan("desktop.ipc.method", { attributes: { channel: method.channel } }),
168+ ),
169+ };
170+};
171+
172+export interface DesktopSyncIpcMethodRegistration<
173+ Result,
174+ EncodedResult,
175+ E,
176+ R,
177+ ResultDecodingServices = never,
178+ ResultEncodingServices = never,
179+> {
180+ readonly channel: string;
181+ readonly result: Schema.Codec<
182+ Result,
183+ EncodedResult,
184+ ResultDecodingServices,
185+ ResultEncodingServices
186+ >;
187+ readonly handler: () => Effect.Effect<Result, E, R>;
188+}
189+
190+export const makeSyncIpcMethod = <
191+ Result,
192+ EncodedResult,
193+ E,
194+ R,
195+ ResultDecodingServices = never,
196+ ResultEncodingServices = never,
197+>(
198+ method: DesktopSyncIpcMethodRegistration<
199+ Result,
200+ EncodedResult,
201+ E,
202+ R,
203+ ResultDecodingServices,
204+ ResultEncodingServices
205+ >,
206+): DesktopSyncIpcMethod<E | Schema.SchemaError, R | ResultEncodingServices> => {
207+ const encode = Schema.encodeUnknownEffect(method.result);
208+
209+ return {
210+ channel: method.channel,
211+ handler: () =>
212+ method
213+ .handler()
214+ .pipe(
215+ Effect.flatMap(encode),
216+ Effect.withSpan("desktop.ipc.method", { attributes: { channel: method.channel } }),
217+ ),
218+ };
219+};
apps/desktop/src/ipc/DesktopIpcHandlers.ts+84−0
@@ -0,0 +1,84 @@
1+import * as Effect from "effect/Effect";
2+
3+import * as DesktopIpc from "./DesktopIpc.ts";
4+import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts";
5+import {
6+ getSavedEnvironmentRegistry,
7+ getSavedEnvironmentSecret,
8+ removeSavedEnvironmentSecret,
9+ setSavedEnvironmentRegistry,
10+ setSavedEnvironmentSecret,
11+} from "./methods/savedEnvironments.ts";
12+import {
13+ getAdvertisedEndpoints,
14+ getServerExposureState,
15+ setServerExposureMode,
16+ setTailscaleServeEnabled,
17+} from "./methods/serverExposure.ts";
18+import {
19+ bootstrapSshBearerSession,
20+ disconnectSshEnvironment,
21+ discoverSshHosts,
22+ ensureSshEnvironment,
23+ fetchSshEnvironmentDescriptor,
24+ fetchSshSessionState,
25+ issueSshWebSocketToken,
26+ resolveSshPasswordPrompt,
27+} from "./methods/sshEnvironment.ts";
28+import {
29+ checkForUpdate,
30+ downloadUpdate,
31+ getUpdateState,
32+ installUpdate,
33+ setUpdateChannel,
34+} from "./methods/updates.ts";
35+import {
36+ confirm,
37+ getAppBranding,
38+ getLocalEnvironmentBootstrap,
39+ openExternal,
40+ pickFolder,
41+ setTheme,
42+ showContextMenu,
43+} from "./methods/window.ts";
44+
45+export const installDesktopIpcHandlers = Effect.gen(function* () {
46+ const ipc = yield* DesktopIpc.DesktopIpc;
47+
48+ yield* ipc.handleSync(getAppBranding);
49+ yield* ipc.handleSync(getLocalEnvironmentBootstrap);
50+
51+ yield* ipc.handle(getClientSettings);
52+ yield* ipc.handle(setClientSettings);
53+ yield* ipc.handle(getSavedEnvironmentRegistry);
54+ yield* ipc.handle(setSavedEnvironmentRegistry);
55+ yield* ipc.handle(getSavedEnvironmentSecret);
56+ yield* ipc.handle(setSavedEnvironmentSecret);
57+ yield* ipc.handle(removeSavedEnvironmentSecret);
58+
59+ yield* ipc.handle(discoverSshHosts);
60+ yield* ipc.handle(ensureSshEnvironment);
61+ yield* ipc.handle(disconnectSshEnvironment);
62+ yield* ipc.handle(fetchSshEnvironmentDescriptor);
63+ yield* ipc.handle(bootstrapSshBearerSession);
64+ yield* ipc.handle(fetchSshSessionState);
65+ yield* ipc.handle(issueSshWebSocketToken);
66+ yield* ipc.handle(resolveSshPasswordPrompt);
67+
68+ yield* ipc.handle(getServerExposureState);
69+ yield* ipc.handle(setServerExposureMode);
70+ yield* ipc.handle(setTailscaleServeEnabled);
71+ yield* ipc.handle(getAdvertisedEndpoints);
72+
73+ yield* ipc.handle(pickFolder);
74+ yield* ipc.handle(confirm);
75+ yield* ipc.handle(setTheme);
76+ yield* ipc.handle(showContextMenu);
77+ yield* ipc.handle(openExternal);
78+
79+ yield* ipc.handle(getUpdateState);
80+ yield* ipc.handle(setUpdateChannel);
81+ yield* ipc.handle(downloadUpdate);
82+ yield* ipc.handle(installUpdate);
83+ yield* ipc.handle(checkForUpdate);
84+}).pipe(Effect.withSpan("desktop.ipc.installHandlers"));
apps/desktop/src/ipc/channels.ts+35−0
@@ -0,0 +1,35 @@
1+export const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
2+export const CONFIRM_CHANNEL = "desktop:confirm";
3+export const SET_THEME_CHANNEL = "desktop:set-theme";
4+export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
5+export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
6+export const MENU_ACTION_CHANNEL = "desktop:menu-action";
7+export const UPDATE_STATE_CHANNEL = "desktop:update-state";
8+export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
9+export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel";
10+export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
11+export const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
12+export const UPDATE_CHECK_CHANNEL = "desktop:update-check";
13+export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";
14+export const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";
15+export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings";
16+export const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings";
17+export const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry";
18+export const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry";
19+export const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret";
20+export const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret";
21+export const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret";
22+export const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts";
23+export const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment";
24+export const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment";
25+export const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor";
26+export const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session";
27+export const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state";
28+export const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-token";
29+export const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt";
30+export const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt";
31+export const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state";
32+export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode";
33+export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled";
34+export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints";
35+export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled";
apps/desktop/src/ipc/methods/clientSettings.ts+28−0
@@ -0,0 +1,28 @@
1+import { ClientSettingsSchema } from "@t3tools/contracts";
2+import * as Effect from "effect/Effect";
3+import * as Option from "effect/Option";
4+import * as Schema from "effect/Schema";
5+
6+import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts";
7+import * as IpcChannels from "../channels.ts";
8+import { makeIpcMethod } from "../DesktopIpc.ts";
9+
10+export const getClientSettings = makeIpcMethod({
11+ channel: IpcChannels.GET_CLIENT_SETTINGS_CHANNEL,
12+ payload: Schema.Void,
13+ result: Schema.NullOr(ClientSettingsSchema),
14+ handler: Effect.fn("desktop.ipc.clientSettings.get")(function* () {
15+ const clientSettings = yield* DesktopClientSettings.DesktopClientSettings;
16+ return Option.getOrNull(yield* clientSettings.get);
17+ }),
18+});
19+
20+export const setClientSettings = makeIpcMethod({
21+ channel: IpcChannels.SET_CLIENT_SETTINGS_CHANNEL,
22+ payload: ClientSettingsSchema,
23+ result: Schema.Void,
24+ handler: Effect.fn("desktop.ipc.clientSettings.set")(function* (settings) {
25+ const clientSettings = yield* DesktopClientSettings.DesktopClientSettings;
26+ yield* clientSettings.set(settings);
27+ }),
28+});
apps/desktop/src/ipc/methods/savedEnvironments.ts+76−0
@@ -0,0 +1,76 @@
1+import { EnvironmentId, PersistedSavedEnvironmentRecordSchema } from "@t3tools/contracts";
2+import * as Effect from "effect/Effect";
3+import * as Option from "effect/Option";
4+import * as Schema from "effect/Schema";
5+
6+import * as DesktopSavedEnvironments from "../../settings/DesktopSavedEnvironments.ts";
7+import * as IpcChannels from "../channels.ts";
8+import { makeIpcMethod } from "../DesktopIpc.ts";
9+
10+const SavedEnvironmentRegistryPayload = Schema.Array(PersistedSavedEnvironmentRecordSchema);
11+const NonBlankString = Schema.String.check(
12+ Schema.makeFilter((value) =>
13+ value.trim().length > 0 ? undefined : "Expected a non-empty string",
14+ ),
15+);
16+
17+const SetSavedEnvironmentSecretInput = Schema.Struct({
18+ environmentId: EnvironmentId,
19+ secret: NonBlankString,
20+});
21+
22+export const getSavedEnvironmentRegistry = makeIpcMethod({
23+ channel: IpcChannels.GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL,
24+ payload: Schema.Void,
25+ result: SavedEnvironmentRegistryPayload,
26+ handler: Effect.fn("desktop.ipc.savedEnvironments.getRegistry")(function* () {
27+ const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
28+ return yield* savedEnvironments.getRegistry;
29+ }),
30+});
31+
32+export const setSavedEnvironmentRegistry = makeIpcMethod({
33+ channel: IpcChannels.SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL,
34+ payload: SavedEnvironmentRegistryPayload,
35+ result: Schema.Void,
36+ handler: Effect.fn("desktop.ipc.savedEnvironments.setRegistry")(function* (records) {
37+ const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
38+ yield* savedEnvironments.setRegistry(records);
39+ }),
40+});
41+
42+export const getSavedEnvironmentSecret = makeIpcMethod({
43+ channel: IpcChannels.GET_SAVED_ENVIRONMENT_SECRET_CHANNEL,
44+ payload: EnvironmentId,
45+ result: Schema.NullOr(Schema.String),
46+ handler: Effect.fn("desktop.ipc.savedEnvironments.getSecret")(function* (environmentId) {
47+ const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
48+ return Option.getOrNull(yield* savedEnvironments.getSecret(environmentId));
49+ }),
50+});
51+
52+export const setSavedEnvironmentSecret = makeIpcMethod({
53+ channel: IpcChannels.SET_SAVED_ENVIRONMENT_SECRET_CHANNEL,
54+ payload: SetSavedEnvironmentSecretInput,
55+ result: Schema.Boolean,
56+ handler: Effect.fn("desktop.ipc.savedEnvironments.setSecret")(function* ({
57+ environmentId,
58+ secret,
59+ }) {
60+ const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
61+ return yield* savedEnvironments.setSecret({
62+ environmentId,
63+ secret,
64+ });
65+ }),
66+});
67+
68+export const removeSavedEnvironmentSecret = makeIpcMethod({
69+ channel: IpcChannels.REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL,
70+ payload: EnvironmentId,
71+ result: Schema.Void,
72+ handler: Effect.fn("desktop.ipc.savedEnvironments.removeSecret")(function* (environmentId) {
73+ const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
74+ yield* savedEnvironments.removeSecret(environmentId);
75+ }),
76+});
apps/desktop/src/ipc/methods/serverExposure.ts+69−0
@@ -0,0 +1,69 @@
1+import {
2+ AdvertisedEndpoint,
3+ DesktopServerExposureModeSchema,
4+ DesktopServerExposureStateSchema,
5+} from "@t3tools/contracts";
6+import * as Effect from "effect/Effect";
7+import * as Schema from "effect/Schema";
8+
9+import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts";
10+import * as DesktopServerExposure from "../../backend/DesktopServerExposure.ts";
11+import * as IpcChannels from "../channels.ts";
12+import { makeIpcMethod } from "../DesktopIpc.ts";
13+
14+const SetTailscaleServeEnabledInput = Schema.Struct({
15+ enabled: Schema.Boolean,
16+ port: Schema.optionalKey(Schema.Number),
17+});
18+
19+export const getServerExposureState = makeIpcMethod({
20+ channel: IpcChannels.GET_SERVER_EXPOSURE_STATE_CHANNEL,
21+ payload: Schema.Void,
22+ result: DesktopServerExposureStateSchema,
23+ handler: Effect.fn("desktop.ipc.serverExposure.getState")(function* () {
24+ const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
25+ return yield* serverExposure.getState;
26+ }),
27+});
28+
29+export const setServerExposureMode = makeIpcMethod({
30+ channel: IpcChannels.SET_SERVER_EXPOSURE_MODE_CHANNEL,
31+ payload: DesktopServerExposureModeSchema,
32+ result: DesktopServerExposureStateSchema,
33+ handler: Effect.fn("desktop.ipc.serverExposure.setMode")(function* (mode) {
34+ const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
35+ const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
36+ const change = yield* serverExposure.setMode(mode);
37+ if (change.requiresRelaunch) {
38+ yield* lifecycle.relaunch(`serverExposureMode=${mode}`);
39+ }
40+ return change.state;
41+ }),
42+});
43+
44+export const setTailscaleServeEnabled = makeIpcMethod({
45+ channel: IpcChannels.SET_TAILSCALE_SERVE_ENABLED_CHANNEL,
46+ payload: SetTailscaleServeEnabledInput,
47+ result: DesktopServerExposureStateSchema,
48+ handler: Effect.fn("desktop.ipc.serverExposure.setTailscaleServeEnabled")(function* (input) {
49+ const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
50+ const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
51+ const change = yield* serverExposure.setTailscaleServeEnabled(input);
52+ if (change.requiresRelaunch) {
53+ yield* lifecycle.relaunch(
54+ change.state.tailscaleServeEnabled ? "tailscale-serve-enabled" : "tailscale-serve-disabled",
55+ );
56+ }
57+ return change.state;
58+ }),
59+});
60+
61+export const getAdvertisedEndpoints = makeIpcMethod({
62+ channel: IpcChannels.GET_ADVERTISED_ENDPOINTS_CHANNEL,
63+ payload: Schema.Void,
64+ result: Schema.Array(AdvertisedEndpoint),
65+ handler: Effect.fn("desktop.ipc.serverExposure.getAdvertisedEndpoints")(function* () {
66+ const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
67+ return yield* serverExposure.getAdvertisedEndpoints;
68+ }),
69+});
apps/desktop/src/ipc/methods/sshEnvironment.ts+127−0
@@ -0,0 +1,127 @@
1+import {
2+ DesktopDiscoveredSshHostSchema,
3+ DesktopSshBearerBootstrapInputSchema,
4+ DesktopSshBearerRequestInputSchema,
5+ DesktopSshEnvironmentEnsureInputSchema,
6+ DesktopSshEnvironmentEnsureResultSchema,
7+ DesktopSshEnvironmentTargetSchema,
8+ DesktopSshHttpBaseUrlInputSchema,
9+ DesktopSshPasswordPromptCancelledType,
10+ DesktopSshPasswordPromptResolutionInputSchema,
11+ ExecutionEnvironmentDescriptor,
12+ AuthBearerBootstrapResult,
13+ AuthSessionState,
14+ AuthWebSocketTokenResult,
15+} from "@t3tools/contracts";
16+import * as Effect from "effect/Effect";
17+import * as Schema from "effect/Schema";
18+
19+import * as IpcChannels from "../channels.ts";
20+import { makeIpcMethod } from "../DesktopIpc.ts";
21+import * as DesktopSshEnvironment from "../../ssh/DesktopSshEnvironment.ts";
22+import * as DesktopSshPasswordPrompts from "../../ssh/DesktopSshPasswordPrompts.ts";
23+import * as DesktopSshRemoteApi from "../../ssh/DesktopSshRemoteApi.ts";
24+
25+export const discoverSshHosts = makeIpcMethod({
26+ channel: IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL,
27+ payload: Schema.Void,
28+ result: Schema.Array(DesktopDiscoveredSshHostSchema),
29+ handler: Effect.fn("desktop.ipc.sshEnvironment.discoverHosts")(function* () {
30+ const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment;
31+ return yield* sshEnvironment.discoverHosts();
32+ }),
33+});
34+
35+export const ensureSshEnvironment = makeIpcMethod({
36+ channel: IpcChannels.ENSURE_SSH_ENVIRONMENT_CHANNEL,
37+ payload: DesktopSshEnvironmentEnsureInputSchema,
38+ result: DesktopSshEnvironmentEnsureResultSchema,
39+ handler: Effect.fn("desktop.ipc.sshEnvironment.ensureEnvironment")(function* ({
40+ target,
41+ options,
42+ }) {
43+ const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment;
44+ return yield* sshEnvironment.ensureEnvironment(target, options).pipe(
45+ Effect.catch((error) =>
46+ DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation(error)
47+ ? Effect.succeed({
48+ type: DesktopSshPasswordPromptCancelledType,
49+ message: error.message,
50+ })
51+ : Effect.fail(error),
52+ ),
53+ );
54+ }),
55+});
56+
57+export const disconnectSshEnvironment = makeIpcMethod({
58+ channel: IpcChannels.DISCONNECT_SSH_ENVIRONMENT_CHANNEL,
59+ payload: DesktopSshEnvironmentTargetSchema,
60+ result: Schema.Void,
61+ handler: Effect.fn("desktop.ipc.sshEnvironment.disconnectEnvironment")(function* (target) {
62+ const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment;
63+ yield* sshEnvironment.disconnectEnvironment(target);
64+ }),
65+});
66+
67+export const fetchSshEnvironmentDescriptor = makeIpcMethod({
68+ channel: IpcChannels.FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL,
69+ payload: DesktopSshHttpBaseUrlInputSchema,
70+ result: ExecutionEnvironmentDescriptor,
71+ handler: Effect.fn("desktop.ipc.sshEnvironment.fetchDescriptor")(function* ({ httpBaseUrl }) {
72+ const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi;
73+ return yield* remoteApi.fetchEnvironmentDescriptor({ httpBaseUrl });
74+ }),
75+});
76+
77+export const bootstrapSshBearerSession = makeIpcMethod({
78+ channel: IpcChannels.BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL,
79+ payload: DesktopSshBearerBootstrapInputSchema,
80+ result: AuthBearerBootstrapResult,
81+ handler: Effect.fn("desktop.ipc.sshEnvironment.bootstrapBearerSession")(function* ({
82+ httpBaseUrl,
83+ credential,
84+ }) {
85+ const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi;
86+ return yield* remoteApi.bootstrapBearerSession({ httpBaseUrl, credential });
87+ }),
88+});
89+
90+export const fetchSshSessionState = makeIpcMethod({
91+ channel: IpcChannels.FETCH_SSH_SESSION_STATE_CHANNEL,
92+ payload: DesktopSshBearerRequestInputSchema,
93+ result: AuthSessionState,
94+ handler: Effect.fn("desktop.ipc.sshEnvironment.fetchSessionState")(function* ({
95+ httpBaseUrl,
96+ bearerToken,
97+ }) {
98+ const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi;
99+ return yield* remoteApi.fetchSessionState({ httpBaseUrl, bearerToken });
100+ }),
101+});
102+
103+export const issueSshWebSocketToken = makeIpcMethod({
104+ channel: IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL,
105+ payload: DesktopSshBearerRequestInputSchema,
106+ result: AuthWebSocketTokenResult,
107+ handler: Effect.fn("desktop.ipc.sshEnvironment.issueWebSocketToken")(function* ({
108+ httpBaseUrl,
109+ bearerToken,
110+ }) {
111+ const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi;
112+ return yield* remoteApi.issueWebSocketToken({ httpBaseUrl, bearerToken });
113+ }),
114+});
115+
116+export const resolveSshPasswordPrompt = makeIpcMethod({
117+ channel: IpcChannels.RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL,
118+ payload: DesktopSshPasswordPromptResolutionInputSchema,
119+ result: Schema.Void,
120+ handler: Effect.fn("desktop.ipc.sshEnvironment.resolvePasswordPrompt")(function* ({
121+ requestId,
122+ password,
123+ }) {
124+ const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts;
125+ yield* prompts.resolve({ requestId, password });
126+ }),
127+});
apps/desktop/src/ipc/methods/updates.ts+62−0
@@ -0,0 +1,62 @@
1+import {
2+ DesktopUpdateActionResultSchema,
3+ DesktopUpdateChannelSchema,
4+ DesktopUpdateCheckResultSchema,
5+ DesktopUpdateStateSchema,
6+} from "@t3tools/contracts";
7+import * as Effect from "effect/Effect";
8+import * as Schema from "effect/Schema";
9+
10+import * as DesktopUpdates from "../../updates/DesktopUpdates.ts";
11+import * as IpcChannels from "../channels.ts";
12+import { makeIpcMethod } from "../DesktopIpc.ts";
13+
14+export const getUpdateState = makeIpcMethod({
15+ channel: IpcChannels.UPDATE_GET_STATE_CHANNEL,
16+ payload: Schema.Void,
17+ result: DesktopUpdateStateSchema,
18+ handler: Effect.fn("desktop.ipc.updates.getState")(function* () {
19+ const updates = yield* DesktopUpdates.DesktopUpdates;
20+ return yield* updates.getState;
21+ }),
22+});
23+
24+export const setUpdateChannel = makeIpcMethod({
25+ channel: IpcChannels.UPDATE_SET_CHANNEL_CHANNEL,
26+ payload: DesktopUpdateChannelSchema,
27+ result: DesktopUpdateStateSchema,
28+ handler: Effect.fn("desktop.ipc.updates.setChannel")(function* (channel) {
29+ const updates = yield* DesktopUpdates.DesktopUpdates;
30+ return yield* updates.setChannel(channel);
31+ }),
32+});
33+
34+export const downloadUpdate = makeIpcMethod({
35+ channel: IpcChannels.UPDATE_DOWNLOAD_CHANNEL,
36+ payload: Schema.Void,
37+ result: DesktopUpdateActionResultSchema,
38+ handler: Effect.fn("desktop.ipc.updates.download")(function* () {
39+ const updates = yield* DesktopUpdates.DesktopUpdates;
40+ return yield* updates.download;
41+ }),
42+});
43+
44+export const installUpdate = makeIpcMethod({
45+ channel: IpcChannels.UPDATE_INSTALL_CHANNEL,
46+ payload: Schema.Void,
47+ result: DesktopUpdateActionResultSchema,
48+ handler: Effect.fn("desktop.ipc.updates.install")(function* () {
49+ const updates = yield* DesktopUpdates.DesktopUpdates;
50+ return yield* updates.install;
51+ }),
52+});
53+
54+export const checkForUpdate = makeIpcMethod({
55+ channel: IpcChannels.UPDATE_CHECK_CHANNEL,
56+ payload: Schema.Void,
57+ result: DesktopUpdateCheckResultSchema,
58+ handler: Effect.fn("desktop.ipc.updates.check")(function* () {
59+ const updates = yield* DesktopUpdates.DesktopUpdates;
60+ return yield* updates.check("web-ui");
61+ }),
62+});
apps/desktop/src/ipc/methods/window.ts+135−0
@@ -0,0 +1,135 @@
1+import {
2+ ContextMenuItemSchema,
3+ DesktopAppBrandingSchema,
4+ DesktopEnvironmentBootstrapSchema,
5+ DesktopThemeSchema,
6+ PickFolderOptionsSchema,
7+} from "@t3tools/contracts";
8+import * as Effect from "effect/Effect";
9+import * as Option from "effect/Option";
10+import * as Schema from "effect/Schema";
11+
12+import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts";
13+import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts";
14+import * as ElectronDialog from "../../electron/ElectronDialog.ts";
15+import * as ElectronMenu from "../../electron/ElectronMenu.ts";
16+import * as ElectronShell from "../../electron/ElectronShell.ts";
17+import * as ElectronTheme from "../../electron/ElectronTheme.ts";
18+import * as ElectronWindow from "../../electron/ElectronWindow.ts";
19+import * as IpcChannels from "../channels.ts";
20+import { makeIpcMethod, makeSyncIpcMethod } from "../DesktopIpc.ts";
21+
22+const ContextMenuPosition = Schema.Struct({
23+ x: Schema.Number,
24+ y: Schema.Number,
25+});
26+
27+const ContextMenuInput = Schema.Struct({
28+ items: Schema.Array(ContextMenuItemSchema),
29+ position: Schema.optionalKey(ContextMenuPosition),
30+});
31+
32+function toWebSocketBaseUrl(httpBaseUrl: URL): string {
33+ const url = new URL(httpBaseUrl.href);
34+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
35+ return url.href;
36+}
37+
38+export const getAppBranding = makeSyncIpcMethod({
39+ channel: IpcChannels.GET_APP_BRANDING_CHANNEL,
40+ result: Schema.NullOr(DesktopAppBrandingSchema),
41+ handler: Effect.fn("desktop.ipc.window.getAppBranding")(function* () {
42+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
43+ return environment.branding;
44+ }),
45+});
46+
47+export const getLocalEnvironmentBootstrap = makeSyncIpcMethod({
48+ channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL,
49+ result: Schema.NullOr(DesktopEnvironmentBootstrapSchema),
50+ handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBootstrap")(function* () {
51+ const backendManager = yield* DesktopBackendManager.DesktopBackendManager;
52+ const config = yield* backendManager.currentConfig;
53+ return Option.match(config, {
54+ onNone: () => null,
55+ onSome: ({ bootstrap, httpBaseUrl }) => ({
56+ label: "Local environment",
57+ httpBaseUrl: httpBaseUrl.href,
58+ wsBaseUrl: toWebSocketBaseUrl(httpBaseUrl),
59+ ...(bootstrap.desktopBootstrapToken
60+ ? { bootstrapToken: bootstrap.desktopBootstrapToken }
61+ : {}),
62+ }),
63+ });
64+ }),
65+});
66+
67+export const pickFolder = makeIpcMethod({
68+ channel: IpcChannels.PICK_FOLDER_CHANNEL,
69+ payload: Schema.UndefinedOr(PickFolderOptionsSchema),
70+ result: Schema.NullOr(Schema.String),
71+ handler: Effect.fn("desktop.ipc.window.pickFolder")(function* (options) {
72+ const dialog = yield* ElectronDialog.ElectronDialog;
73+ const electronWindow = yield* ElectronWindow.ElectronWindow;
74+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
75+ const selectedPath = yield* dialog.pickFolder({
76+ owner: yield* electronWindow.focusedMainOrFirst,
77+ defaultPath: environment.resolvePickFolderDefaultPath(options),
78+ });
79+ return Option.getOrNull(selectedPath);
80+ }),
81+});
82+
83+export const confirm = makeIpcMethod({
84+ channel: IpcChannels.CONFIRM_CHANNEL,
85+ payload: Schema.String,
86+ result: Schema.Boolean,
87+ handler: Effect.fn("desktop.ipc.window.confirm")(function* (message) {
88+ const dialog = yield* ElectronDialog.ElectronDialog;
89+ const electronWindow = yield* ElectronWindow.ElectronWindow;
90+ return yield* electronWindow.focusedMainOrFirst.pipe(
91+ Effect.flatMap((owner) => dialog.confirm({ owner, message })),
92+ );
93+ }),
94+});
95+
96+export const setTheme = makeIpcMethod({
97+ channel: IpcChannels.SET_THEME_CHANNEL,
98+ payload: DesktopThemeSchema,
99+ result: Schema.Void,
100+ handler: Effect.fn("desktop.ipc.window.setTheme")(function* (theme) {
101+ const electronTheme = yield* ElectronTheme.ElectronTheme;
102+ yield* electronTheme.setSource(theme);
103+ }),
104+});
105+
106+export const showContextMenu = makeIpcMethod({
107+ channel: IpcChannels.CONTEXT_MENU_CHANNEL,
108+ payload: ContextMenuInput,
109+ result: Schema.NullOr(Schema.String),
110+ handler: Effect.fn("desktop.ipc.window.showContextMenu")(function* (input) {
111+ const electronMenu = yield* ElectronMenu.ElectronMenu;
112+ const electronWindow = yield* ElectronWindow.ElectronWindow;
113+ const window = yield* electronWindow.focusedMainOrFirst;
114+ if (Option.isNone(window)) {
115+ return null;
116+ }
117+
118+ const selectedItemId = yield* electronMenu.showContextMenu({
119+ window: window.value,
120+ items: input.items,
121+ position: Option.fromNullishOr(input.position),
122+ });
123+ return Option.getOrNull(selectedItemId);
124+ }),
125+});
126+
127+export const openExternal = makeIpcMethod({
128+ channel: IpcChannels.OPEN_EXTERNAL_CHANNEL,
129+ payload: Schema.String,
130+ result: Schema.Boolean,
131+ handler: Effect.fn("desktop.ipc.window.openExternal")(function* (url) {
132+ const shell = yield* ElectronShell.ElectronShell;
133+ return yield* shell.openExternal(url);
134+ }),
135+});
apps/desktop/src/main.ts+138−2287
@@ -1,2298 +1,149 @@
1−import * as ChildProcess from "node:child_process";
2−import * as Crypto from "node:crypto";
3−import * as FS from "node:fs";
4−import * as OS from "node:os";
5−import * as Path from "node:path";
6−
7−import {
8− app,
9− BrowserWindow,
10− type BrowserWindowConstructorOptions,
11− clipboard,
12− dialog,
13− ipcMain,
14− Menu,
15− nativeImage,
16− nativeTheme,
17− protocol,
18− safeStorage,
19− shell,
20−} from "electron";
21−import type { MenuItemConstructorOptions, OpenDialogOptions } from "electron";
22−import type {
23− ClientSettings,
24− DesktopTheme,
25− DesktopAppBranding,
26− DesktopServerExposureMode,
27− DesktopServerExposureState,
28− DesktopUpdateChannel,
29− PersistedSavedEnvironmentRecord,
30− DesktopUpdateActionResult,
31− DesktopUpdateCheckResult,
32− DesktopUpdateState,
33−} from "@t3tools/contracts";
34−import { autoUpdater } from "electron-updater";
35−
36−import type { ContextMenuItem } from "@t3tools/contracts";
37−import { RotatingFileSink } from "@t3tools/shared/logging";
38−import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
1+import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
2+import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
3+import * as NodeServices from "@effect/platform-node/NodeServices";
4+import * as NodeOS from "node:os";
5+import * as Effect from "effect/Effect";
6+import * as Layer from "effect/Layer";
7+import * as Option from "effect/Option";
8+
9+import * as Electron from "electron";
10+
11+import * as NetService from "@t3tools/shared/Net";
12+import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command";
3913 import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel";
40−import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPort } from "./backendPort.ts";
41−import {
42− type DesktopSettings,
43− DEFAULT_DESKTOP_SETTINGS,
44− readDesktopSettings,
45− setDesktopServerExposurePreference,
46− setDesktopTailscaleServePreference,
47− setDesktopUpdateChannelPreference,
48− writeDesktopSettings,
49−} from "./desktopSettings.ts";
50−import {
51− readClientSettings,
52− readSavedEnvironmentRegistry,
53− readSavedEnvironmentSecret,
54− removeSavedEnvironmentSecret,
55− writeClientSettings,
56− writeSavedEnvironmentRegistry,
57− writeSavedEnvironmentSecret,
58−} from "./clientPersistence.ts";
59−import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness.ts";
60−import { showDesktopConfirmDialog } from "./confirmDialog.ts";
61−import {
62− resolveDesktopCoreAdvertisedEndpoints,
63− resolveDesktopServerExposure,
64−} from "./serverExposure.ts";
65−import { DesktopSshEnvironmentBridge, resolveRemoteT3CliPackageSpec } from "./sshEnvironment.ts";
66−import { syncShellEnvironment } from "./syncShellEnvironment.ts";
67−import { waitForBackendStartupReady } from "./backendStartupReadiness.ts";
68−import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts";
69−import { doesVersionMatchDesktopUpdateChannel } from "./updateChannels.ts";
70−import { ServerListeningDetector } from "./serverListeningDetector.ts";
71−import {
72− createInitialDesktopUpdateState,
73− reduceDesktopUpdateStateOnCheckFailure,
74− reduceDesktopUpdateStateOnCheckStart,
75− reduceDesktopUpdateStateOnDownloadComplete,
76− reduceDesktopUpdateStateOnDownloadFailure,
77− reduceDesktopUpdateStateOnDownloadProgress,
78− reduceDesktopUpdateStateOnDownloadStart,
79− reduceDesktopUpdateStateOnInstallFailure,
80− reduceDesktopUpdateStateOnNoUpdate,
81− reduceDesktopUpdateStateOnUpdateAvailable,
82−} from "./updateMachine.ts";
83−import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts";
84−import { resolveDesktopAppBranding } from "./appBranding.ts";
85−import { bindFirstRevealTrigger, type RevealSubscription } from "./windowReveal.ts";
86−import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts";
87−
88−syncShellEnvironment();
89−
90−const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
91−const CONFIRM_CHANNEL = "desktop:confirm";
92−const SET_THEME_CHANNEL = "desktop:set-theme";
93−const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
94−const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
95−const MENU_ACTION_CHANNEL = "desktop:menu-action";
96−const UPDATE_STATE_CHANNEL = "desktop:update-state";
97−const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
98−const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel";
99−const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
100−const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
101−const UPDATE_CHECK_CHANNEL = "desktop:update-check";
102−const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";
103−const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";
104−const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings";
105−const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings";
106−const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry";
107−const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry";
108−const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret";
109−const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret";
110−const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret";
111−const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state";
112−const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode";
113−const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled";
114−const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints";
115−const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");
116−const STATE_DIR = Path.join(BASE_DIR, "userdata");
117−const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json");
118−const CLIENT_SETTINGS_PATH = Path.join(STATE_DIR, "client-settings.json");
119−const SAVED_ENVIRONMENT_REGISTRY_PATH = Path.join(STATE_DIR, "saved-environments.json");
120−const DESKTOP_SCHEME = "t3";
121−const ROOT_DIR = Path.resolve(__dirname, "../../..");
122−const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
123−// Dev-only SSH launcher override. Set this to an absolute path on the SSH host
124−// for a built server entry, for example:
125−// "/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs"
126−const DEV_REMOTE_T3_SERVER_ENTRY_PATH =
127− process.env.T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH?.trim() ?? "";
128−const desktopAppBranding: DesktopAppBranding = resolveDesktopAppBranding({
129− isDevelopment,
130− appVersion: app.getVersion(),
131−});
132−const APP_DISPLAY_NAME = desktopAppBranding.displayName;
133−const APP_USER_MODEL_ID = isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code";
134−const LINUX_DESKTOP_ENTRY_NAME = isDevelopment ? "t3code-dev.desktop" : "t3code.desktop";
135−const LINUX_WM_CLASS = isDevelopment ? "t3code-dev" : "t3code";
136−const USER_DATA_DIR_NAME = isDevelopment ? "t3code-dev" : "t3code";
137−const LEGACY_USER_DATA_DIR_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
138−const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i;
139−const COMMIT_HASH_DISPLAY_LENGTH = 12;
140−const LOG_DIR = Path.join(STATE_DIR, "logs");
141−const LOG_FILE_MAX_BYTES = 10 * 1024 * 1024;
142−const LOG_FILE_MAX_FILES = 10;
143−const APP_RUN_ID = Crypto.randomBytes(6).toString("hex");
144−const SERVER_SETTINGS_PATH = Path.join(STATE_DIR, "settings.json");
145−const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000;
146−const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000;
147−
148−function resolvePickFolderDefaultPath(rawOptions: unknown): string | undefined {
149− if (typeof rawOptions !== "object" || rawOptions === null) {
150− return undefined;
151− }
152−
153− const { initialPath } = rawOptions as { initialPath?: unknown };
154− if (typeof initialPath !== "string") {
155− return undefined;
156− }
157−
158− const trimmedPath = initialPath.trim();
159− if (trimmedPath.length === 0) {
160− return undefined;
161− }
162−
163− if (trimmedPath === "~") {
164− return OS.homedir();
165− }
166−
167− if (trimmedPath.startsWith("~/") || trimmedPath.startsWith("~\\")) {
168− return Path.join(OS.homedir(), trimmedPath.slice(2));
169− }
170−
171− return Path.resolve(trimmedPath);
172−}
173−const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
174−const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const;
175−const TITLEBAR_HEIGHT = 40;
176−const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux
177−const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937";
178−const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc";
179−
180−function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextMenuItem[] {
181− const normalizedItems: ContextMenuItem[] = [];
182−
183− for (const sourceItem of source) {
184− if (typeof sourceItem.id !== "string" || typeof sourceItem.label !== "string") {
185− continue;
186− }
187−
188− const normalizedItem: ContextMenuItem = {
189− id: sourceItem.id,
190− label: sourceItem.label,
191− destructive: sourceItem.destructive === true,
192− disabled: sourceItem.disabled === true,
193− };
194−
195− if (sourceItem.children) {
196− const normalizedChildren = normalizeContextMenuItems(sourceItem.children);
197− if (normalizedChildren.length === 0) {
198− continue;
199− }
200− normalizedItem.children = normalizedChildren;
201− }
202−
203− normalizedItems.push(normalizedItem);
204− }
205−
206− return normalizedItems;
207−}
208−
209−type WindowTitleBarOptions = Pick<
210− BrowserWindowConstructorOptions,
211− "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition"
212−>;
213−
214−type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"];
215−type LinuxDesktopNamedApp = Electron.App & {
216− setDesktopName?: (desktopName: string) => void;
217−};
218−
219−let mainWindow: BrowserWindow | null = null;
220−let backendProcess: ChildProcess.ChildProcess | null = null;
221−let backendPort = 0;
222−let backendBindHost = DESKTOP_LOOPBACK_HOST;
223−let backendBootstrapToken = "";
224−let backendHttpUrl = "";
225−let backendWsUrl = "";
226−let backendEndpointUrl: string | null = null;
227−let backendAdvertisedHost: string | null = null;
228−let backendReadinessAbortController: AbortController | null = null;
229−let backendInitialWindowOpenInFlight: Promise<void> | null = null;
230−let backendListeningDetector: ServerListeningDetector | null = null;
231−let restartAttempt = 0;
232−let restartTimer: ReturnType<typeof setTimeout> | null = null;
233−let isQuitting = false;
234−let desktopProtocolRegistered = false;
235−let aboutCommitHashCache: string | null | undefined;
236−let desktopLogSink: RotatingFileSink | null = null;
237−let backendLogSink: RotatingFileSink | null = null;
238−let restoreStdIoCapture: (() => void) | null = null;
239−let backendObservabilitySettings = readPersistedBackendObservabilitySettings();
240−let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH, app.getVersion());
241−let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode;
242−
243−let destructiveMenuIconCache: Electron.NativeImage | null | undefined;
244−const expectedBackendExitChildren = new WeakSet<ChildProcess.ChildProcess>();
245−const desktopRuntimeInfo = resolveDesktopRuntimeInfo({
246− platform: process.platform,
247− processArch: process.arch,
248− runningUnderArm64Translation: app.runningUnderARM64Translation === true,
249−});
250−const initialUpdateState = (): DesktopUpdateState =>
251− createInitialDesktopUpdateState(
252− app.getVersion(),
253− desktopRuntimeInfo,
254− desktopSettings.updateChannel,
255− );
256−
257−function logTimestamp(): string {
258− return new Date().toISOString();
259−}
260−
261−function logScope(scope: string): string {
262− return `${scope} run=${APP_RUN_ID}`;
263−}
264−
265−function sanitizeLogValue(value: string): string {
266− return value.replace(/\s+/g, " ").trim();
267−}
268−
269−function readPersistedBackendObservabilitySettings(): {
270− readonly otlpTracesUrl: string | undefined;
271− readonly otlpMetricsUrl: string | undefined;
272−} {
273− try {
274− if (!FS.existsSync(SERVER_SETTINGS_PATH)) {
275− return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined };
276− }
277− return parsePersistedServerObservabilitySettings(FS.readFileSync(SERVER_SETTINGS_PATH, "utf8"));
278− } catch (error) {
279− console.warn("[desktop] failed to read persisted backend observability settings", error);
280− return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined };
281− }
282−}
283−
284−function resolveConfiguredDesktopBackendPort(rawPort: string | undefined): number | undefined {
285− if (!rawPort) {
286− return undefined;
287− }
288−
289− const parsedPort = Number.parseInt(rawPort, 10);
290− if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65_535) {
291− return undefined;
292− }
293−
294− return parsedPort;
295−}
296−
297−function resolveDesktopDevServerUrl(): string {
298− const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim();
299− if (!devServerUrl) {
300− throw new Error("VITE_DEV_SERVER_URL is required in desktop development.");
301− }
302−
303− return devServerUrl;
304−}
305−
306−function backendChildEnv(): NodeJS.ProcessEnv {
307− const env = { ...process.env };
308− delete env.T3CODE_PORT;
309− delete env.T3CODE_MODE;
310− delete env.T3CODE_NO_BROWSER;
311− delete env.T3CODE_HOST;
312− delete env.T3CODE_DESKTOP_WS_URL;
313− delete env.T3CODE_DESKTOP_LAN_ACCESS;
314− delete env.T3CODE_DESKTOP_LAN_HOST;
315− delete env.T3CODE_DESKTOP_HTTPS_ENDPOINTS;
316− delete env.T3CODE_TAILSCALE_SERVE;
317− delete env.T3CODE_TAILSCALE_SERVE_PORT;
318− return env;
319−}
320−
321−function getDesktopServerExposureState(): DesktopServerExposureState {
322− return {
323− mode: desktopServerExposureMode,
324− endpointUrl: backendEndpointUrl,
325− advertisedHost: backendAdvertisedHost,
326− tailscaleServeEnabled: desktopSettings.tailscaleServeEnabled,
327− tailscaleServePort: desktopSettings.tailscaleServePort,
328− };
329−}
330−
331−async function getDesktopAdvertisedEndpoints() {
332− const exposure = resolveDesktopServerExposure({
333− mode: desktopServerExposureMode,
334− port: backendPort,
335− networkInterfaces: OS.networkInterfaces(),
336− ...(backendAdvertisedHost ? { advertisedHostOverride: backendAdvertisedHost } : {}),
337− });
338− const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({
339− port: backendPort,
340− exposure,
341− customHttpsEndpointUrls: resolveCustomHttpsEndpointUrls(),
342− });
343− const tailscaleEndpoints = await resolveTailscaleAdvertisedEndpoints({
344− port: backendPort,
345− serveEnabled: desktopSettings.tailscaleServeEnabled,
346− servePort: desktopSettings.tailscaleServePort,
347− networkInterfaces: OS.networkInterfaces(),
348− });
349− return [...coreEndpoints, ...tailscaleEndpoints];
350−}
351−
352−function getDesktopSecretStorage() {
353− return {
354− isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(),
355− encryptString: (value: string) => safeStorage.encryptString(value),
356− decryptString: (value: Buffer) => safeStorage.decryptString(value),
357− } as const;
358−}
35914
360−function resolveAdvertisedHostOverride(): string | undefined {
361− const override = process.env.T3CODE_DESKTOP_LAN_HOST?.trim();
362− return override && override.length > 0 ? override : undefined;
363−}
364−
365−function resolveCustomHttpsEndpointUrls(): readonly string[] {
366− return (process.env.T3CODE_DESKTOP_HTTPS_ENDPOINTS ?? "")
367− .split(",")
368− .map((entry) => entry.trim())
369− .filter((entry) => entry.length > 0);
370−}
371−
372−async function applyDesktopServerExposureMode(
373− mode: DesktopServerExposureMode,
374− options?: {
375− readonly persist?: boolean;
376− readonly rejectIfUnavailable?: boolean;
377− },
378−): Promise<DesktopServerExposureState> {
379− const advertisedHostOverride = resolveAdvertisedHostOverride();
380− const requestedMode = mode;
381− let exposure = resolveDesktopServerExposure({
382− mode,
383− port: backendPort,
384− networkInterfaces: OS.networkInterfaces(),
385− ...(advertisedHostOverride ? { advertisedHostOverride } : {}),
386− });
387−
388− if (requestedMode === "network-accessible" && exposure.endpointUrl === null) {
389− if (options?.rejectIfUnavailable) {
390− throw new Error("No reachable network address is available for this desktop right now.");
391− }
392− exposure = resolveDesktopServerExposure({
393− mode: "local-only",
394− port: backendPort,
395− networkInterfaces: OS.networkInterfaces(),
396− ...(advertisedHostOverride ? { advertisedHostOverride } : {}),
397− });
398− }
399−
400− desktopServerExposureMode = exposure.mode;
401− desktopSettings = setDesktopServerExposurePreference(desktopSettings, requestedMode);
402− backendBindHost = exposure.bindHost;
403− backendHttpUrl = exposure.localHttpUrl;
404− backendWsUrl = exposure.localWsUrl;
405− backendEndpointUrl = exposure.endpointUrl;
406− backendAdvertisedHost = exposure.advertisedHost;
407−
408− if (options?.persist) {
409− writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings);
410− }
411−
412− return getDesktopServerExposureState();
413−}
414−
415−async function applyDesktopTailscaleServeEnabled(
416− nextSettings: DesktopSettings,
417−): Promise<DesktopServerExposureState> {
418− desktopSettings = nextSettings;
419− writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings);
420− relaunchDesktopApp(
421− desktopSettings.tailscaleServeEnabled ? "tailscale-serve-enabled" : "tailscale-serve-disabled",
422− );
423− return getDesktopServerExposureState();
424−}
425−
426−function relaunchDesktopApp(reason: string): void {
427− writeDesktopLogHeader(`desktop relaunch requested reason=${reason}`);
428− setImmediate(() => {
429− isQuitting = true;
430− clearUpdatePollTimer();
431− cancelBackendReadinessWait();
432− void stopBackendAndWaitForExit()
433− .catch((error) => {
434− writeDesktopLogHeader(
435− `desktop relaunch backend shutdown warning message=${formatErrorMessage(error)}`,
436− );
437− })
438− .then(() => desktopSshEnvironmentBridge.dispose().catch(() => undefined))
439− .finally(() => {
440− restoreStdIoCapture?.();
441− if (isDevelopment) {
442− app.exit(75);
443− return;
444− }
445− app.relaunch({
446− execPath: process.execPath,
447− args: process.argv.slice(1),
448− });
449− app.exit(0);
450− });
451− });
452−}
453−
454−function writeDesktopLogHeader(message: string): void {
455− if (!desktopLogSink) return;
456− desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`);
457−}
458−
459−function writeBackendSessionBoundary(phase: "START" | "END", details: string): void {
460− if (!backendLogSink) return;
461− const normalizedDetails = sanitizeLogValue(details);
462− backendLogSink.write(
463− `[${logTimestamp()}] ---- APP SESSION ${phase} run=${APP_RUN_ID} ${normalizedDetails} ----\n`,
464− );
465−}
466−
467−function formatErrorMessage(error: unknown): string {
468− if (error instanceof Error) {
469− return error.message;
470− }
471− return String(error);
472−}
473−
474−function getSafeExternalUrl(rawUrl: unknown): string | null {
475− if (typeof rawUrl !== "string" || rawUrl.length === 0) {
476− return null;
477− }
478−
479− let parsedUrl: URL;
480− try {
481− parsedUrl = new URL(rawUrl);
482− } catch {
483− return null;
484− }
485−
486− if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") {
487− return null;
488− }
489−
490− return parsedUrl.toString();
491−}
492−
493−function getSafeTheme(rawTheme: unknown): DesktopTheme | null {
494− if (rawTheme === "light" || rawTheme === "dark" || rawTheme === "system") {
495− return rawTheme;
496− }
497−
498− return null;
499−}
500−
501−async function waitForBackendHttpReady(
502− baseUrl: string,
503− options?: Parameters<typeof waitForHttpReady>[1],
504−): Promise<void> {
505− cancelBackendReadinessWait();
506− const controller = new AbortController();
507− backendReadinessAbortController = controller;
508−
509− try {
510− await waitForHttpReady(baseUrl, {
511− ...options,
512− signal: controller.signal,
513− });
514− } finally {
515− if (backendReadinessAbortController === controller) {
516− backendReadinessAbortController = null;
517− }
518− }
519−}
520−
521−function cancelBackendReadinessWait(): void {
522− backendReadinessAbortController?.abort();
523− backendReadinessAbortController = null;
524−}
525−
526−async function waitForBackendWindowReady(baseUrl: string): Promise<"listening" | "http"> {
527− return await waitForBackendStartupReady({
528− listeningPromise: backendListeningDetector?.promise ?? null,
529− waitForHttpReady: () =>
530− waitForBackendHttpReady(baseUrl, {
531− timeoutMs: 60_000,
532− }),
533− cancelHttpWait: cancelBackendReadinessWait,
534− });
535−}
536−
537−function ensureInitialBackendWindowOpen(): void {
538− const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null;
539− if (isDevelopment || existingWindow !== null || backendInitialWindowOpenInFlight !== null) {
540− return;
541− }
542−
543− const nextOpen = waitForBackendWindowReady(backendHttpUrl)
544− .then((source) => {
545− writeDesktopLogHeader(`bootstrap backend ready source=${source}`);
546− if (mainWindow ?? BrowserWindow.getAllWindows()[0]) {
547− return;
548− }
549− mainWindow = createWindow();
550− writeDesktopLogHeader("bootstrap main window created");
551− })
552− .catch((error) => {
553− if (isBackendReadinessAborted(error)) {
554− return;
555− }
556− writeDesktopLogHeader(
557− `bootstrap backend readiness warning message=${formatErrorMessage(error)}`,
558− );
559− console.warn("[desktop] backend readiness check timed out during packaged bootstrap", error);
560− })
561− .finally(() => {
562− if (backendInitialWindowOpenInFlight === nextOpen) {
563− backendInitialWindowOpenInFlight = null;
564− }
565− });
566−
567− backendInitialWindowOpenInFlight = nextOpen;
568−}
569−
570−function writeDesktopStreamChunk(
571− streamName: "stdout" | "stderr",
572− chunk: unknown,
573− encoding: BufferEncoding | undefined,
574−): void {
575− if (!desktopLogSink) return;
576− const buffer = Buffer.isBuffer(chunk)
577− ? chunk
578− : Buffer.from(String(chunk), typeof chunk === "string" ? encoding : undefined);
579− desktopLogSink.write(`[${logTimestamp()}] [${logScope(streamName)}] `);
580− desktopLogSink.write(buffer);
581− if (buffer.length === 0 || buffer[buffer.length - 1] !== 0x0a) {
582− desktopLogSink.write("\n");
583− }
584−}
585−
586−function installStdIoCapture(): void {
587− if (!app.isPackaged || desktopLogSink === null || restoreStdIoCapture !== null) {
588− return;
589− }
590−
591− const originalStdoutWrite = process.stdout.write.bind(process.stdout);
592− const originalStderrWrite = process.stderr.write.bind(process.stderr);
593−
594− const patchWrite =
595− (streamName: "stdout" | "stderr", originalWrite: typeof process.stdout.write) =>
596− (
597− chunk: string | Uint8Array,
598− encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void),
599− callback?: (error?: Error | null) => void,
600− ): boolean => {
601− const encoding = typeof encodingOrCallback === "string" ? encodingOrCallback : undefined;
602− writeDesktopStreamChunk(streamName, chunk, encoding);
603− if (typeof encodingOrCallback === "function") {
604− return originalWrite(chunk, encodingOrCallback);
605− }
606− if (callback !== undefined) {
607− return originalWrite(chunk, encoding, callback);
608− }
609− if (encoding !== undefined) {
610− return originalWrite(chunk, encoding);
611− }
612− return originalWrite(chunk);
613− };
614−
615− process.stdout.write = patchWrite("stdout", originalStdoutWrite);
616− process.stderr.write = patchWrite("stderr", originalStderrWrite);
617−
618− restoreStdIoCapture = () => {
619− process.stdout.write = originalStdoutWrite;
620− process.stderr.write = originalStderrWrite;
621− restoreStdIoCapture = null;
622− };
623−}
624−
625−function initializePackagedLogging(): void {
626− if (!app.isPackaged) return;
627− try {
628− desktopLogSink = new RotatingFileSink({
629− filePath: Path.join(LOG_DIR, "desktop-main.log"),
630− maxBytes: LOG_FILE_MAX_BYTES,
631− maxFiles: LOG_FILE_MAX_FILES,
632− });
633− backendLogSink = new RotatingFileSink({
634− filePath: Path.join(LOG_DIR, "server-child.log"),
635− maxBytes: LOG_FILE_MAX_BYTES,
636− maxFiles: LOG_FILE_MAX_FILES,
637− });
638− installStdIoCapture();
639− writeDesktopLogHeader(`runtime log capture enabled logDir=${LOG_DIR}`);
640− } catch (error) {
641− // Logging setup should never block app startup.
642− console.error("[desktop] failed to initialize packaged logging", error);
643− }
644−}
645−
646−function captureBackendOutput(child: ChildProcess.ChildProcess): void {
647− const attachStream = (stream: NodeJS.ReadableStream | null | undefined): void => {
648− stream?.on("data", (chunk: unknown) => {
649− const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8");
650− backendLogSink?.write(buffer);
651− backendListeningDetector?.push(buffer);
652− });
653− };
654−
655− attachStream(child.stdout);
656− attachStream(child.stderr);
657−}
658−
659−initializePackagedLogging();
660−
661−if (process.platform === "linux") {
662− app.commandLine.appendSwitch("class", LINUX_WM_CLASS);
663−}
664−
665−function getDestructiveMenuIcon(): Electron.NativeImage | undefined {
666− if (process.platform !== "darwin") return undefined;
667− if (destructiveMenuIconCache !== undefined) {
668− return destructiveMenuIconCache ?? undefined;
669− }
670− try {
671− const icon = nativeImage.createFromNamedImage("trash").resize({
672− width: 14,
673− height: 14,
674− });
675− if (icon.isEmpty()) {
676− destructiveMenuIconCache = null;
677− return undefined;
678− }
679− icon.setTemplateImage(true);
680− destructiveMenuIconCache = icon;
681− return icon;
682− } catch {
683− destructiveMenuIconCache = null;
684− return undefined;
685− }
686−}
687−let updatePollTimer: ReturnType<typeof setInterval> | null = null;
688−let updateStartupTimer: ReturnType<typeof setTimeout> | null = null;
689−let updateCheckInFlight = false;
690−let updateDownloadInFlight = false;
691−let updateInstallInFlight = false;
692−let updaterConfigured = false;
693−let updateState: DesktopUpdateState = initialUpdateState();
694−
695−const desktopSshEnvironmentBridge = new DesktopSshEnvironmentBridge({
696− getMainWindow: () => mainWindow,
697− resolveCliRunner: (): RemoteT3RunnerOptions => {
698− if (isDevelopment && DEV_REMOTE_T3_SERVER_ENTRY_PATH.length > 0) {
699− return { nodeScriptPath: DEV_REMOTE_T3_SERVER_ENTRY_PATH };
700− }
701− return {
702− packageSpec: resolveRemoteT3CliPackageSpec({
703− appVersion: app.getVersion(),
704− updateChannel: desktopSettings.updateChannel,
705− isDevelopment,
706− }),
707− };
708− },
709−});
710−
711−function resolveUpdaterErrorContext(): DesktopUpdateErrorContext {
712− if (updateInstallInFlight) return "install";
713− if (updateDownloadInFlight) return "download";
714− if (updateCheckInFlight) return "check";
715− return updateState.errorContext;
716−}
717−
718−protocol.registerSchemesAsPrivileged([
719− {
720− scheme: DESKTOP_SCHEME,
721− privileges: {
722− standard: true,
723− secure: true,
724− supportFetchAPI: true,
725− corsEnabled: true,
726− },
727− },
728−]);
729−
730−function resolveAppRoot(): string {
731− if (!app.isPackaged) {
732− return ROOT_DIR;
733− }
734− return app.getAppPath();
735−}
736−
737−/** Read the baked-in app-update.yml config (if applicable). */
738−function readAppUpdateYml(): Record<string, string> | null {
739− try {
740− // electron-updater reads from process.resourcesPath in packaged builds,
741− // or dev-app-update.yml via app.getAppPath() in dev.
742− const ymlPath = app.isPackaged
743− ? Path.join(process.resourcesPath, "app-update.yml")
744− : Path.join(app.getAppPath(), "dev-app-update.yml");
745− const raw = FS.readFileSync(ymlPath, "utf-8");
746− // The YAML is simple key-value pairs — avoid pulling in a YAML parser by
747− // doing a line-based parse (fields: provider, owner, repo, releaseType, …).
748− const entries: Record<string, string> = {};
749− for (const line of raw.split("\n")) {
750− const match = line.match(/^(\w+):\s*(.+)$/);
751− if (match?.[1] && match[2]) entries[match[1]] = match[2].trim();
752− }
753− return entries.provider ? entries : null;
754− } catch {
755− return null;
756− }
757−}
758−
759−function normalizeCommitHash(value: unknown): string | null {
760− if (typeof value !== "string") {
761− return null;
762− }
763− const trimmed = value.trim();
764− if (!COMMIT_HASH_PATTERN.test(trimmed)) {
765− return null;
766− }
767− return trimmed.slice(0, COMMIT_HASH_DISPLAY_LENGTH).toLowerCase();
768−}
769−
770−function resolveEmbeddedCommitHash(): string | null {
771− const packageJsonPath = Path.join(resolveAppRoot(), "package.json");
772− if (!FS.existsSync(packageJsonPath)) {
773− return null;
774− }
775−
776− try {
777− const raw = FS.readFileSync(packageJsonPath, "utf8");
778− const parsed = JSON.parse(raw) as { t3codeCommitHash?: unknown };
779− return normalizeCommitHash(parsed.t3codeCommitHash);
780− } catch {
781− return null;
782− }
783−}
784−
785−function resolveAboutCommitHash(): string | null {
786− if (aboutCommitHashCache !== undefined) {
787− return aboutCommitHashCache;
788− }
789−
790− const envCommitHash = normalizeCommitHash(process.env.T3CODE_COMMIT_HASH);
791− if (envCommitHash) {
792− aboutCommitHashCache = envCommitHash;
793− return aboutCommitHashCache;
794− }
795−
796− // Only packaged builds are required to expose commit metadata.
797− if (!app.isPackaged) {
798− aboutCommitHashCache = null;
799− return aboutCommitHashCache;
800− }
801−
802− aboutCommitHashCache = resolveEmbeddedCommitHash();
803−
804− return aboutCommitHashCache;
805−}
806−
807−function resolveBackendEntry(): string {
808− return Path.join(resolveAppRoot(), "apps/server/dist/bin.mjs");
809−}
810−
811−function resolveBackendCwd(): string {
812− if (!app.isPackaged) {
813− return resolveAppRoot();
814− }
815− return OS.homedir();
816−}
817−
818−function resolveDesktopStaticDir(): string | null {
819− const appRoot = resolveAppRoot();
820− const candidates = [
821− Path.join(appRoot, "apps/server/dist/client"),
822− Path.join(appRoot, "apps/web/dist"),
823− ];
824−
825− for (const candidate of candidates) {
826− if (FS.existsSync(Path.join(candidate, "index.html"))) {
827− return candidate;
828− }
829− }
830−
831− return null;
832−}
833−
834−function resolveDesktopStaticPath(staticRoot: string, requestUrl: string): string {
835− const url = new URL(requestUrl);
836− const rawPath = decodeURIComponent(url.pathname);
837− const normalizedPath = Path.posix.normalize(rawPath).replace(/^\/+/, "");
838− if (normalizedPath.includes("..")) {
839− return Path.join(staticRoot, "index.html");
840− }
841−
842− const requestedPath = normalizedPath.length > 0 ? normalizedPath : "index.html";
843− const resolvedPath = Path.join(staticRoot, requestedPath);
844−
845− if (Path.extname(resolvedPath)) {
846− return resolvedPath;
847− }
848−
849− const nestedIndex = Path.join(resolvedPath, "index.html");
850− if (FS.existsSync(nestedIndex)) {
851− return nestedIndex;
852− }
853−
854− return Path.join(staticRoot, "index.html");
855−}
856−
857−function isStaticAssetRequest(requestUrl: string): boolean {
858− try {
859− const url = new URL(requestUrl);
860− return Path.extname(url.pathname).length > 0;
861− } catch {
862− return false;
863− }
864−}
865−
866−function handleFatalStartupError(stage: string, error: unknown): void {
867− const message = formatErrorMessage(error);
868− const detail =
869− error instanceof Error && typeof error.stack === "string" ? `\n${error.stack}` : "";
870− writeDesktopLogHeader(`fatal startup error stage=${stage} message=${message}`);
871− console.error(`[desktop] fatal startup error (${stage})`, error);
872− if (!isQuitting) {
873− isQuitting = true;
874− dialog.showErrorBox("T3 Code failed to start", `Stage: ${stage}\n${message}${detail}`);
875− }
876− stopBackend();
877− restoreStdIoCapture?.();
878− app.quit();
879−}
880−
881−function registerDesktopProtocol(): void {
882− if (isDevelopment || desktopProtocolRegistered) return;
883−
884− const staticRoot = resolveDesktopStaticDir();
885− if (!staticRoot) {
886− throw new Error(
887− "Desktop static bundle missing. Build apps/server (with bundled client) first.",
888− );
889− }
890−
891− const staticRootResolved = Path.resolve(staticRoot);
892− const staticRootPrefix = `${staticRootResolved}${Path.sep}`;
893− const fallbackIndex = Path.join(staticRootResolved, "index.html");
894−
895− protocol.registerFileProtocol(DESKTOP_SCHEME, (request, callback) => {
896− try {
897− const candidate = resolveDesktopStaticPath(staticRootResolved, request.url);
898− const resolvedCandidate = Path.resolve(candidate);
899− const isInRoot =
900− resolvedCandidate === fallbackIndex || resolvedCandidate.startsWith(staticRootPrefix);
901− const isAssetRequest = isStaticAssetRequest(request.url);
902−
903− if (!isInRoot || !FS.existsSync(resolvedCandidate)) {
904− if (isAssetRequest) {
905− callback({ error: -6 });
906− return;
907− }
908− callback({ path: fallbackIndex });
909− return;
910− }
911−
912− callback({ path: resolvedCandidate });
913− } catch {
914− callback({ path: fallbackIndex });
915− }
916− });
917−
918− desktopProtocolRegistered = true;
919−}
920−
921−function dispatchMenuAction(action: string): void {
922− const existingWindow =
923− BrowserWindow.getFocusedWindow() ?? mainWindow ?? BrowserWindow.getAllWindows()[0];
924− const targetWindow = existingWindow ?? createWindow();
925− if (!existingWindow) {
926− mainWindow = targetWindow;
927− }
928−
929− const send = () => {
930− if (targetWindow.isDestroyed()) return;
931− targetWindow.webContents.send(MENU_ACTION_CHANNEL, action);
932− revealWindow(targetWindow);
933− };
934−
935− if (targetWindow.webContents.isLoadingMainFrame()) {
936− targetWindow.webContents.once("did-finish-load", send);
937− return;
938− }
939−
940− send();
941−}
942−
943−function handleCheckForUpdatesMenuClick(): void {
944− const hasUpdateFeedConfig =
945− readAppUpdateYml() !== null || Boolean(process.env.T3CODE_DESKTOP_MOCK_UPDATES);
946− const disabledReason = getAutoUpdateDisabledReason({
947− isDevelopment,
948− isPackaged: app.isPackaged,
949− platform: process.platform,
950− appImage: process.env.APPIMAGE,
951− disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1",
952− hasUpdateFeedConfig,
953− });
954− if (disabledReason) {
955− console.info("[desktop-updater] Manual update check requested, but updates are disabled.");
956− void dialog.showMessageBox({
957− type: "info",
958− title: "Updates unavailable",
959− message: "Automatic updates are not available right now.",
960− detail: disabledReason,
961− buttons: ["OK"],
962− });
963− return;
964− }
965−
966− if (!BrowserWindow.getAllWindows().length) {
967− mainWindow = createWindow();
968− }
969− void checkForUpdatesFromMenu();
970−}
971−
972−async function checkForUpdatesFromMenu(): Promise<void> {
973− await checkForUpdates("menu");
974−
975− if (updateState.status === "up-to-date") {
976− void dialog.showMessageBox({
977− type: "info",
978− title: "You're up to date!",
979− message: `T3 Code ${updateState.currentVersion} is currently the newest version available.`,
980− buttons: ["OK"],
981− });
982− } else if (updateState.status === "error") {
983− void dialog.showMessageBox({
984− type: "warning",
985− title: "Update check failed",
986− message: "Could not check for updates.",
987− detail: updateState.message ?? "An unknown error occurred. Please try again later.",
988− buttons: ["OK"],
989− });
990− }
991−}
992−
993−function configureApplicationMenu(): void {
994− const template: MenuItemConstructorOptions[] = [];
995−
996− if (process.platform === "darwin") {
997− template.push({
998− label: app.name,
999− submenu: [
1000− { role: "about" },
1001− {
1002− label: "Check for Updates...",
1003− click: () => handleCheckForUpdatesMenuClick(),
1004− },
1005− { type: "separator" },
1006− {
1007− label: "Settings...",
1008− accelerator: "CmdOrCtrl+,",
1009− click: () => dispatchMenuAction("open-settings"),
1010− },
1011− { type: "separator" },
1012− { role: "services" },
1013− { type: "separator" },
1014− { role: "hide" },
1015− { role: "hideOthers" },
1016− { role: "unhide" },
1017− { type: "separator" },
1018− { role: "quit" },
1019− ],
1020− });
1021− }
1022−
1023− template.push(
1024− {
1025− label: "File",
1026− submenu: [
1027− ...(process.platform === "darwin"
1028− ? []
1029− : [
1030− {
1031− label: "Settings...",
1032− accelerator: "CmdOrCtrl+,",
1033− click: () => dispatchMenuAction("open-settings"),
1034− },
1035− { type: "separator" as const },
1036− ]),
1037− { role: process.platform === "darwin" ? "close" : "quit" },
1038− ],
1039− },
1040− { role: "editMenu" },
1041− {
1042− label: "View",
1043− submenu: [
1044− { role: "reload" },
1045− { role: "forceReload" },
1046− { role: "toggleDevTools" },
1047− { type: "separator" },
1048− { role: "resetZoom" },
1049− { role: "zoomIn", accelerator: "CmdOrCtrl+=" },
1050− { role: "zoomIn", accelerator: "CmdOrCtrl+Plus", visible: false },
1051− { role: "zoomOut" },
1052− { type: "separator" },
1053− { role: "togglefullscreen" },
1054− ],
1055− },
1056− { role: "windowMenu" },
1057− {
1058− role: "help",
1059− submenu: [
1060− {
1061− label: "Check for Updates...",
1062− click: () => handleCheckForUpdatesMenuClick(),
1063− },
1064− ],
1065− },
1066− );
1067−
1068− Menu.setApplicationMenu(Menu.buildFromTemplate(template));
1069−}
1070−
1071−function resolveResourcePath(fileName: string): string | null {
1072− const candidates = [
1073− Path.join(__dirname, "../resources", fileName),
1074− Path.join(__dirname, "../prod-resources", fileName),
1075− Path.join(process.resourcesPath, "resources", fileName),
1076− Path.join(process.resourcesPath, fileName),
1077− ];
1078−
1079− for (const candidate of candidates) {
1080− if (FS.existsSync(candidate)) {
1081− return candidate;
1082− }
1083− }
1084−
1085− return null;
1086−}
1087−
1088−function resolveIconPath(ext: "ico" | "icns" | "png"): string | null {
1089− if (isDevelopment && process.platform === "darwin" && ext === "png") {
1090− const developmentDockIconPath = Path.join(
1091− ROOT_DIR,
1092− "assets",
1093− "dev",
1094− "blueprint-macos-1024.png",
15+import type { DesktopSettings as DesktopSettingsValue } from "./settings/DesktopAppSettings.ts";
16+import * as DesktopIpc from "./ipc/DesktopIpc.ts";
17+import * as ElectronApp from "./electron/ElectronApp.ts";
18+import * as ElectronDialog from "./electron/ElectronDialog.ts";
19+import * as ElectronMenu from "./electron/ElectronMenu.ts";
20+import * as ElectronProtocol from "./electron/ElectronProtocol.ts";
21+import * as DesktopSecretStorage from "./electron/ElectronSafeStorage.ts";
22+import * as ElectronShell from "./electron/ElectronShell.ts";
23+import * as ElectronTheme from "./electron/ElectronTheme.ts";
24+import * as ElectronUpdater from "./electron/ElectronUpdater.ts";
25+import * as ElectronWindow from "./electron/ElectronWindow.ts";
26+import * as DesktopApp from "./app/DesktopApp.ts";
27+import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts";
28+import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts";
29+import * as DesktopAssets from "./app/DesktopAssets.ts";
30+import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts";
31+import * as DesktopBackendManager from "./backend/DesktopBackendManager.ts";
32+import * as DesktopEnvironment from "./app/DesktopEnvironment.ts";
33+import * as DesktopLifecycle from "./app/DesktopLifecycle.ts";
34+import * as DesktopObservability from "./app/DesktopObservability.ts";
35+import * as DesktopServerExposure from "./backend/DesktopServerExposure.ts";
36+import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts";
37+import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts";
38+import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts";
39+import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts";
40+import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts";
41+import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts";
42+import * as DesktopSshRemoteApi from "./ssh/DesktopSshRemoteApi.ts";
43+import * as DesktopState from "./app/DesktopState.ts";
44+import * as DesktopUpdates from "./updates/DesktopUpdates.ts";
45+import * as DesktopWindow from "./window/DesktopWindow.ts";
46+
47+const desktopEnvironmentLayer = Layer.unwrap(
48+ Effect.gen(function* () {
49+ const metadata = yield* Effect.service(ElectronApp.ElectronApp).pipe(
50+ Effect.flatMap((app) => app.metadata),
109551 );
1096− if (FS.existsSync(developmentDockIconPath)) {
1097− return developmentDockIconPath;
1098− }
1099− }
1100−
1101− return resolveResourcePath(`icon.${ext}`);
1102−}
1103−
1104−/**
1105− * Resolve the Electron userData directory path.
1106− *
1107− * Electron derives the default userData path from `productName` in
1108− * package.json, which currently produces directories with spaces and
1109− * parentheses (e.g. `~/.config/T3 Code (Alpha)` on Linux). This is
1110− * unfriendly for shell usage and violates Linux naming conventions.
1111− *
1112− * We override it to a clean lowercase name (`t3code`). If the legacy
1113− * directory already exists we keep using it so existing users don't
1114− * lose their Chromium profile data (localStorage, cookies, sessions).
1115− */
1116−function resolveUserDataPath(): string {
1117− const appDataBase =
1118− process.platform === "win32"
1119− ? process.env.APPDATA || Path.join(OS.homedir(), "AppData", "Roaming")
1120− : process.platform === "darwin"
1121− ? Path.join(OS.homedir(), "Library", "Application Support")
1122− : process.env.XDG_CONFIG_HOME || Path.join(OS.homedir(), ".config");
1123−
1124− const legacyPath = Path.join(appDataBase, LEGACY_USER_DATA_DIR_NAME);
1125− if (FS.existsSync(legacyPath)) {
1126− return legacyPath;
1127− }
1128−
1129− return Path.join(appDataBase, USER_DATA_DIR_NAME);
1130−}
1131−
1132−function configureAppIdentity(): void {
1133− app.setName(APP_DISPLAY_NAME);
1134− const commitHash = resolveAboutCommitHash();
1135− app.setAboutPanelOptions({
1136− applicationName: APP_DISPLAY_NAME,
1137− applicationVersion: app.getVersion(),
1138− version: commitHash ?? "unknown",
1139− });
1140−
1141− if (process.platform === "win32") {
1142− app.setAppUserModelId(APP_USER_MODEL_ID);
1143− }
1144−
1145− if (process.platform === "linux") {
1146− (app as LinuxDesktopNamedApp).setDesktopName?.(LINUX_DESKTOP_ENTRY_NAME);
1147− }
1148−
1149− if (process.platform === "darwin" && app.dock) {
1150− const iconPath = resolveIconPath("png");
1151− if (iconPath) {
1152− app.dock.setIcon(iconPath);
1153− }
1154− }
1155−}
1156−
1157−function clearUpdatePollTimer(): void {
1158− if (updateStartupTimer) {
1159− clearTimeout(updateStartupTimer);
1160− updateStartupTimer = null;
1161− }
1162− if (updatePollTimer) {
1163− clearInterval(updatePollTimer);
1164− updatePollTimer = null;
1165− }
1166−}
1167−
1168−function revealWindow(window: BrowserWindow): void {
1169− if (window.isDestroyed()) {
1170− return;
1171− }
1172−
1173− if (window.isMinimized()) {
1174− window.restore();
1175− }
1176−
1177− if (!window.isVisible()) {
1178− window.show();
1179− }
1180−
1181− if (process.platform === "darwin") {
1182− app.focus({ steal: true });
1183− }
1184−
1185− window.focus();
1186−}
1187−
1188−function emitUpdateState(): void {
1189− for (const window of BrowserWindow.getAllWindows()) {
1190− if (window.isDestroyed()) continue;
1191− window.webContents.send(UPDATE_STATE_CHANNEL, updateState);
1192− }
1193−}
1194−
1195−function setUpdateState(patch: Partial<DesktopUpdateState>): void {
1196− updateState = { ...updateState, ...patch };
1197− emitUpdateState();
1198−}
1199−
1200−function createBaseUpdateState(
1201− channel: DesktopUpdateChannel,
1202− enabled: boolean,
1203−): DesktopUpdateState {
1204− return {
1205− ...createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo, channel),
1206− enabled,
1207− status: enabled ? "idle" : "disabled",
1208− };
1209−}
1210−
1211−function applyAutoUpdaterChannel(channel: DesktopUpdateChannel): void {
1212− autoUpdater.channel = channel;
1213− autoUpdater.allowPrerelease = channel === "nightly";
1214− autoUpdater.allowDowngrade = channel === "nightly";
1215− console.info(
1216− `[desktop-updater] Using update channel '${channel}' (allowPrerelease=${channel === "nightly"}, allowDowngrade=${channel === "nightly"}).`,
1217− );
1218−}
1219−
1220−function shouldEnableAutoUpdates(): boolean {
1221− const hasUpdateFeedConfig =
1222− readAppUpdateYml() !== null || Boolean(process.env.T3CODE_DESKTOP_MOCK_UPDATES);
1223− return (
1224− getAutoUpdateDisabledReason({
1225− isDevelopment,
1226− isPackaged: app.isPackaged,
52+ return DesktopEnvironment.layer({
53+ dirname: __dirname,
54+ homeDirectory: NodeOS.homedir(),
122755 platform: process.platform,
1228− appImage: process.env.APPIMAGE,
1229− disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1",
1230− hasUpdateFeedConfig,
1231− }) === null
1232− );
1233−}
1234−
1235−async function checkForUpdates(reason: string): Promise<boolean> {
1236− if (isQuitting || !updaterConfigured || updateCheckInFlight) return false;
1237− if (updateState.status === "downloading" || updateState.status === "downloaded") {
1238− console.info(
1239− `[desktop-updater] Skipping update check (${reason}) while status=${updateState.status}.`,
1240− );
1241− return false;
1242− }
1243− updateCheckInFlight = true;
1244− setUpdateState(reduceDesktopUpdateStateOnCheckStart(updateState, new Date().toISOString()));
1245− console.info(`[desktop-updater] Checking for updates (${reason})...`);
1246−
1247− try {
1248− await autoUpdater.checkForUpdates();
1249− return true;
1250− } catch (error: unknown) {
1251− const message = error instanceof Error ? error.message : String(error);
1252− setUpdateState(
1253− reduceDesktopUpdateStateOnCheckFailure(updateState, message, new Date().toISOString()),
1254− );
1255− console.error(`[desktop-updater] Failed to check for updates: ${message}`);
1256− return true;
1257− } finally {
1258− updateCheckInFlight = false;
1259− }
1260−}
1261−
1262−async function downloadAvailableUpdate(): Promise<{
1263− accepted: boolean;
1264− completed: boolean;
1265−}> {
1266− if (!updaterConfigured || updateDownloadInFlight || updateState.status !== "available") {
1267− return { accepted: false, completed: false };
1268− }
1269− updateDownloadInFlight = true;
1270− setUpdateState(reduceDesktopUpdateStateOnDownloadStart(updateState));
1271− autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(desktopRuntimeInfo);
1272− console.info("[desktop-updater] Downloading update...");
1273−
1274− try {
1275− await autoUpdater.downloadUpdate();
1276− return { accepted: true, completed: true };
1277− } catch (error: unknown) {
1278− const message = error instanceof Error ? error.message : String(error);
1279− setUpdateState(reduceDesktopUpdateStateOnDownloadFailure(updateState, message));
1280− console.error(`[desktop-updater] Failed to download update: ${message}`);
1281− return { accepted: true, completed: false };
1282− } finally {
1283− updateDownloadInFlight = false;
1284− }
1285−}
1286−
1287−async function installDownloadedUpdate(): Promise<{
1288− accepted: boolean;
1289− completed: boolean;
1290−}> {
1291− if (isQuitting || !updaterConfigured || updateState.status !== "downloaded") {
1292− return { accepted: false, completed: false };
1293− }
1294−
1295− isQuitting = true;
1296− updateInstallInFlight = true;
1297− clearUpdatePollTimer();
1298− try {
1299− await stopBackendAndWaitForExit();
1300− // Destroy all windows before launching the NSIS installer to avoid the installer finding live windows it needs to close.
1301− for (const win of BrowserWindow.getAllWindows()) {
1302− win.destroy();
1303− }
1304− // `quitAndInstall()` only starts the handoff to the updater. The actual
1305− // install may still fail asynchronously, so keep the action incomplete
1306− // until we either quit or receive an updater error.
1307− autoUpdater.quitAndInstall(true, true);
1308− return { accepted: true, completed: false };
1309− } catch (error: unknown) {
1310− const message = formatErrorMessage(error);
1311− updateInstallInFlight = false;
1312− isQuitting = false;
1313− setUpdateState(reduceDesktopUpdateStateOnInstallFailure(updateState, message));
1314− console.error(`[desktop-updater] Failed to install update: ${message}`);
1315− return { accepted: true, completed: false };
1316− }
1317−}
1318−
1319−function configureAutoUpdater(): void {
1320− const githubToken =
1321− process.env.T3CODE_DESKTOP_UPDATE_GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim() || "";
1322− if (githubToken) {
1323− // When a token is provided, re-configure the feed with `private: true` so
1324− // electron-updater uses the GitHub API (api.github.com) instead of the
1325− // public Atom feed (github.com/…/releases.atom) which rejects Bearer auth.
1326− const appUpdateYml = readAppUpdateYml();
1327− if (appUpdateYml?.provider === "github") {
1328− autoUpdater.setFeedURL({
1329− ...appUpdateYml,
1330− provider: "github" as const,
1331− private: true,
1332− token: githubToken,
1333− });
1334− }
1335− }
1336−
1337− if (process.env.T3CODE_DESKTOP_MOCK_UPDATES) {
1338− autoUpdater.setFeedURL({
1339− provider: "generic",
1340− url: `http://localhost:${process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT ?? 3000}`,
56+ processArch: process.arch,
57+ ...metadata,
134158 });
1342− }
1343−
1344− const enabled = shouldEnableAutoUpdates();
1345− setUpdateState(createBaseUpdateState(desktopSettings.updateChannel, enabled));
1346− if (!enabled) {
1347− return;
1348− }
1349− updaterConfigured = true;
1350−
1351− autoUpdater.autoDownload = false;
1352− autoUpdater.autoInstallOnAppQuit = false;
1353− applyAutoUpdaterChannel(desktopSettings.updateChannel);
1354− autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(desktopRuntimeInfo);
1355− let lastLoggedDownloadMilestone = -1;
1356−
1357− if (isArm64HostRunningIntelBuild(desktopRuntimeInfo)) {
1358− console.info(
1359− "[desktop-updater] Apple Silicon host detected while running Intel build; updates will switch to arm64 packages.",
1360− );
1361− }
1362−
1363− autoUpdater.on("checking-for-update", () => {
1364− console.info("[desktop-updater] Looking for updates...");
1365− });
1366− autoUpdater.on("update-available", (info) => {
1367− if (!doesVersionMatchDesktopUpdateChannel(info.version, updateState.channel)) {
1368− console.info(
1369− `[desktop-updater] Ignoring ${info.version} because it does not match the selected '${updateState.channel}' channel.`,
1370− );
1371− setUpdateState(reduceDesktopUpdateStateOnNoUpdate(updateState, new Date().toISOString()));
1372− lastLoggedDownloadMilestone = -1;
1373− return;
1374− }
1375−
1376− setUpdateState(
1377− reduceDesktopUpdateStateOnUpdateAvailable(
1378− updateState,
1379− info.version,
1380− new Date().toISOString(),
1381− ),
1382− );
1383− lastLoggedDownloadMilestone = -1;
1384− console.info(`[desktop-updater] Update available: ${info.version}`);
1385− });
1386− autoUpdater.on("update-not-available", () => {
1387− setUpdateState(reduceDesktopUpdateStateOnNoUpdate(updateState, new Date().toISOString()));
1388− lastLoggedDownloadMilestone = -1;
1389− console.info("[desktop-updater] No updates available.");
1390− });
1391− autoUpdater.on("error", (error) => {
1392− const message = formatErrorMessage(error);
1393− if (updateInstallInFlight) {
1394− updateInstallInFlight = false;
1395− isQuitting = false;
1396− setUpdateState(reduceDesktopUpdateStateOnInstallFailure(updateState, message));
1397− console.error(`[desktop-updater] Updater error: ${message}`);
1398− return;
1399− }
1400− if (!updateCheckInFlight && !updateDownloadInFlight) {
1401− setUpdateState({
1402− status: "error",
1403− message,
1404− checkedAt: new Date().toISOString(),
1405− downloadPercent: null,
1406− errorContext: resolveUpdaterErrorContext(),
1407− canRetry: updateState.availableVersion !== null || updateState.downloadedVersion !== null,
1408− });
1409− }
1410− console.error(`[desktop-updater] Updater error: ${message}`);
1411− });
1412− autoUpdater.on("download-progress", (progress) => {
1413− const percent = Math.floor(progress.percent);
1414− if (
1415− shouldBroadcastDownloadProgress(updateState, progress.percent) ||
1416− updateState.message !== null
1417− ) {
1418− setUpdateState(reduceDesktopUpdateStateOnDownloadProgress(updateState, progress.percent));
1419− }
1420− const milestone = percent - (percent % 10);
1421− if (milestone > lastLoggedDownloadMilestone) {
1422− lastLoggedDownloadMilestone = milestone;
1423− console.info(`[desktop-updater] Download progress: ${percent}%`);
1424− }
1425− });
1426− autoUpdater.on("update-downloaded", (info) => {
1427− setUpdateState(reduceDesktopUpdateStateOnDownloadComplete(updateState, info.version));
1428− console.info(`[desktop-updater] Update downloaded: ${info.version}`);
1429− });
1430−
1431− clearUpdatePollTimer();
59+ }),
60+);
143261
1433− updateStartupTimer = setTimeout(() => {
1434− updateStartupTimer = null;
1435− void checkForUpdates("startup");
1436− }, AUTO_UPDATE_STARTUP_DELAY_MS);
1437− updateStartupTimer.unref();
1438−
1439− updatePollTimer = setInterval(() => {
1440− void checkForUpdates("poll");
1441− }, AUTO_UPDATE_POLL_INTERVAL_MS);
1442− updatePollTimer.unref();
1443−}
1444−function scheduleBackendRestart(reason: string): void {
1445− if (isQuitting || restartTimer) return;
1446−
1447− const delayMs = Math.min(500 * 2 ** restartAttempt, 10_000);
1448− restartAttempt += 1;
1449− console.error(`[desktop] backend exited unexpectedly (${reason}); restarting in ${delayMs}ms`);
1450−
1451− restartTimer = setTimeout(() => {
1452− restartTimer = null;
1453− startBackend();
1454− }, delayMs);
1455−}
1456−
1457−function startBackend(): void {
1458− if (isQuitting || backendProcess) return;
1459−
1460− backendObservabilitySettings = readPersistedBackendObservabilitySettings();
1461− const backendEntry = resolveBackendEntry();
1462− if (!FS.existsSync(backendEntry)) {
1463− scheduleBackendRestart(`missing server entry at ${backendEntry}`);
1464− return;
1465− }
1466−
1467− const captureBackendLogs = !isDevelopment;
1468− const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], {
1469− cwd: resolveBackendCwd(),
1470− // In Electron main, process.execPath points to the Electron binary.
1471− // Run the child in Node mode so this backend process does not become a GUI app instance.
1472− env: {
1473− ...backendChildEnv(),
1474− ELECTRON_RUN_AS_NODE: "1",
1475− },
1476− stdio: captureBackendLogs
1477− ? ["ignore", "pipe", "pipe", "pipe"]
1478− : ["ignore", "inherit", "inherit", "pipe"],
1479− });
1480− const bootstrapStream = child.stdio[3];
1481− if (bootstrapStream && "write" in bootstrapStream) {
1482− bootstrapStream.write(
1483− `${JSON.stringify({
1484− mode: "desktop",
1485− noBrowser: true,
1486− port: backendPort,
1487− t3Home: BASE_DIR,
1488− host: backendBindHost,
1489− desktopBootstrapToken: backendBootstrapToken,
1490− tailscaleServeEnabled: desktopSettings.tailscaleServeEnabled,
1491− tailscaleServePort: desktopSettings.tailscaleServePort,
1492− ...(backendObservabilitySettings.otlpTracesUrl
1493− ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl }
1494− : {}),
1495− ...(backendObservabilitySettings.otlpMetricsUrl
1496− ? { otlpMetricsUrl: backendObservabilitySettings.otlpMetricsUrl }
1497− : {}),
1498− })}\n`,
1499− );
1500− bootstrapStream.end();
1501− } else {
1502− child.kill("SIGTERM");
1503− scheduleBackendRestart("missing desktop bootstrap pipe");
1504− return;
62+const resolveDesktopSshCliRunner = (
63+ environment: DesktopEnvironment.DesktopEnvironmentShape,
64+ settings: DesktopSettingsValue,
65+): RemoteT3RunnerOptions => {
66+ const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath);
67+ if (environment.isDevelopment && devRemoteEntryPath !== undefined) {
68+ return { nodeScriptPath: devRemoteEntryPath };
150569 }
1506− const listeningDetector = new ServerListeningDetector();
1507− backendListeningDetector = listeningDetector;
1508− backendProcess = child;
1509− let backendSessionClosed = false;
1510− const closeBackendSession = (details: string) => {
1511− if (backendSessionClosed) return;
1512− backendSessionClosed = true;
1513− writeBackendSessionBoundary("END", details);
1514− };
1515− writeBackendSessionBoundary(
1516− "START",
1517− `pid=${child.pid ?? "unknown"} port=${backendPort} cwd=${resolveBackendCwd()}`,
1518− );
1519− captureBackendOutput(child);
1520−
1521− child.once("spawn", () => {
1522− restartAttempt = 0;
1523− });
1524−
1525− child.on("error", (error) => {
1526− if (backendListeningDetector === listeningDetector) {
1527− listeningDetector.fail(error);
1528− backendListeningDetector = null;
1529− }
1530− const wasExpected = expectedBackendExitChildren.has(child);
1531− if (backendProcess === child) {
1532− backendProcess = null;
1533− }
1534− closeBackendSession(`pid=${child.pid ?? "unknown"} error=${error.message}`);
1535− if (wasExpected) {
1536− return;
1537− }
1538− scheduleBackendRestart(error.message);
1539− });
1540−
1541− child.on("exit", (code, signal) => {
1542− if (backendListeningDetector === listeningDetector) {
1543− listeningDetector.fail(
1544− new Error(
1545− `backend exited before logging readiness (code=${code ?? "null"} signal=${signal ?? "null"})`,
1546− ),
1547− );
1548− backendListeningDetector = null;
1549− }
1550− const wasExpected = expectedBackendExitChildren.has(child);
1551− if (backendProcess === child) {
1552− backendProcess = null;
1553− }
1554− closeBackendSession(
1555− `pid=${child.pid ?? "unknown"} code=${code ?? "null"} signal=${signal ?? "null"}`,
1556− );
1557− if (isQuitting || wasExpected) return;
1558− const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`;
1559− scheduleBackendRestart(reason);
1560− });
1561−
1562− ensureInitialBackendWindowOpen();
1563−}
1564−
1565−function stopBackend(): void {
1566− cancelBackendReadinessWait();
1567− backendListeningDetector = null;
1568− if (restartTimer) {
1569− clearTimeout(restartTimer);
1570− restartTimer = null;
1571− }
1572−
1573− const child = backendProcess;
1574− backendProcess = null;
1575− if (!child) return;
1576−
1577− if (child.exitCode === null && child.signalCode === null) {
1578− expectedBackendExitChildren.add(child);
1579− child.kill("SIGTERM");
1580− setTimeout(() => {
1581− if (child.exitCode === null && child.signalCode === null) {
1582− child.kill("SIGKILL");
1583− }
1584− }, 2_000).unref();
1585− }
1586−}
1587−
1588−async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> {
1589− cancelBackendReadinessWait();
1590− if (restartTimer) {
1591− clearTimeout(restartTimer);
1592− restartTimer = null;
1593− }
1594−
1595− const child = backendProcess;
1596− backendProcess = null;
1597− if (!child) return;
1598− const backendChild = child;
1599− if (backendChild.exitCode !== null || backendChild.signalCode !== null) return;
1600− expectedBackendExitChildren.add(backendChild);
1601−
1602− await new Promise<void>((resolve) => {
1603− let settled = false;
1604− let forceKillTimer: ReturnType<typeof setTimeout> | null = null;
1605− let exitTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
1606−
1607− function settle(): void {
1608− if (settled) return;
1609− settled = true;
1610− backendChild.off("exit", onExit);
1611− if (forceKillTimer) {
1612− clearTimeout(forceKillTimer);
1613− }
1614− if (exitTimeoutTimer) {
1615− clearTimeout(exitTimeoutTimer);
1616− }
1617− resolve();
1618− }
1619−
1620− function onExit(): void {
1621− settle();
1622− }
1623−
1624− backendChild.once("exit", onExit);
1625− backendChild.kill("SIGTERM");
1626−
1627− forceKillTimer = setTimeout(() => {
1628− if (backendChild.exitCode === null && backendChild.signalCode === null) {
1629− backendChild.kill("SIGKILL");
1630− }
1631− }, 2_000);
1632− forceKillTimer.unref();
1633−
1634− exitTimeoutTimer = setTimeout(() => {
1635− settle();
1636− }, timeoutMs);
1637− exitTimeoutTimer.unref();
1638− });
1639−}
1640−
1641−function registerIpcHandlers(): void {
1642− ipcMain.removeAllListeners(GET_APP_BRANDING_CHANNEL);
1643− ipcMain.on(GET_APP_BRANDING_CHANNEL, (event) => {
1644− event.returnValue = desktopAppBranding;
1645− });
1646−
1647− ipcMain.removeAllListeners(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL);
1648− ipcMain.on(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, (event) => {
1649− event.returnValue = {
1650− label: "Local environment",
1651− httpBaseUrl: backendHttpUrl || null,
1652− wsBaseUrl: backendWsUrl || null,
1653− bootstrapToken: backendBootstrapToken || undefined,
1654− } as const;
1655− });
1656−
1657− ipcMain.removeHandler(GET_CLIENT_SETTINGS_CHANNEL);
1658− ipcMain.handle(GET_CLIENT_SETTINGS_CHANNEL, async () => readClientSettings(CLIENT_SETTINGS_PATH));
1659−
1660− ipcMain.removeHandler(SET_CLIENT_SETTINGS_CHANNEL);
1661− ipcMain.handle(SET_CLIENT_SETTINGS_CHANNEL, async (_event, rawSettings: unknown) => {
1662− if (typeof rawSettings !== "object" || rawSettings === null) {
1663− throw new Error("Invalid client settings payload.");
1664− }
1665−
1666− writeClientSettings(CLIENT_SETTINGS_PATH, rawSettings as ClientSettings);
1667− });
1668−
1669− ipcMain.removeHandler(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL);
1670− ipcMain.handle(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, async () =>
1671− readSavedEnvironmentRegistry(SAVED_ENVIRONMENT_REGISTRY_PATH),
1672− );
1673−
1674− ipcMain.removeHandler(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL);
1675− ipcMain.handle(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, async (_event, rawRecords: unknown) => {
1676− if (!Array.isArray(rawRecords)) {
1677− throw new Error("Invalid saved environment registry payload.");
1678− }
1679−
1680− writeSavedEnvironmentRegistry(
1681− SAVED_ENVIRONMENT_REGISTRY_PATH,
1682− rawRecords as readonly PersistedSavedEnvironmentRecord[],
1683− );
1684− });
1685−
1686− ipcMain.removeHandler(GET_SAVED_ENVIRONMENT_SECRET_CHANNEL);
1687− ipcMain.handle(
1688− GET_SAVED_ENVIRONMENT_SECRET_CHANNEL,
1689− async (_event, rawEnvironmentId: unknown) => {
1690− if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) {
1691− return null;
1692− }
1693−
1694− return readSavedEnvironmentSecret({
1695− registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH,
1696− environmentId: rawEnvironmentId,
1697− secretStorage: getDesktopSecretStorage(),
1698− });
1699− },
1700− );
1701−
1702− ipcMain.removeHandler(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL);
1703− ipcMain.handle(
1704− SET_SAVED_ENVIRONMENT_SECRET_CHANNEL,
1705− async (_event, rawEnvironmentId: unknown, rawSecret: unknown) => {
1706− if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) {
1707− throw new Error("Invalid saved environment id.");
1708− }
1709− if (typeof rawSecret !== "string" || rawSecret.trim().length === 0) {
1710− throw new Error("Invalid saved environment secret.");
1711− }
1712−
1713− return writeSavedEnvironmentSecret({
1714− registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH,
1715− environmentId: rawEnvironmentId,
1716− secret: rawSecret,
1717− secretStorage: getDesktopSecretStorage(),
1718− });
1719− },
1720− );
1721−
1722− ipcMain.removeHandler(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL);
1723− ipcMain.handle(
1724− REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL,
1725− async (_event, rawEnvironmentId: unknown) => {
1726− if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) {
1727− return;
1728− }
1729−
1730− removeSavedEnvironmentSecret({
1731− registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH,
1732− environmentId: rawEnvironmentId,
1733− });
1734− },
1735− );
1736−
1737− desktopSshEnvironmentBridge.registerIpcHandlers(ipcMain);
1738−
1739− ipcMain.removeHandler(GET_SERVER_EXPOSURE_STATE_CHANNEL);
1740− ipcMain.handle(GET_SERVER_EXPOSURE_STATE_CHANNEL, async () => getDesktopServerExposureState());
1741−
1742− ipcMain.removeHandler(SET_SERVER_EXPOSURE_MODE_CHANNEL);
1743− ipcMain.handle(SET_SERVER_EXPOSURE_MODE_CHANNEL, async (_event, rawMode: unknown) => {
1744− if (rawMode !== "local-only" && rawMode !== "network-accessible") {
1745− throw new Error("Invalid desktop server exposure input.");
1746− }
1747−
1748− const nextMode = rawMode as DesktopServerExposureMode;
1749− if (nextMode === desktopServerExposureMode) {
1750− return getDesktopServerExposureState();
1751− }
1752−
1753− const nextState = await applyDesktopServerExposureMode(nextMode, {
1754− persist: true,
1755− rejectIfUnavailable: true,
1756− });
1757− relaunchDesktopApp(`serverExposureMode=${nextMode}`);
1758− return nextState;
1759− });
1760−
1761− ipcMain.removeHandler(SET_TAILSCALE_SERVE_ENABLED_CHANNEL);
1762− ipcMain.handle(SET_TAILSCALE_SERVE_ENABLED_CHANNEL, async (_event, rawInput: unknown) => {
1763− if (typeof rawInput !== "object" || rawInput === null) {
1764− throw new Error("Invalid Tailscale Serve input.");
1765− }
1766− const input = rawInput as {
1767− readonly enabled?: unknown;
1768− readonly port?: unknown;
1769− };
1770− if (typeof input.enabled !== "boolean") {
1771− throw new Error("Invalid Tailscale Serve input.");
1772− }
1773− const nextSettings = setDesktopTailscaleServePreference(desktopSettings, {
1774− enabled: input.enabled,
1775− ...(typeof input.port === "number" ? { port: input.port } : {}),
1776− });
1777− if (nextSettings === desktopSettings) {
1778− return getDesktopServerExposureState();
1779− }
1780− return applyDesktopTailscaleServeEnabled(nextSettings);
1781− });
1782−
1783− ipcMain.removeHandler(GET_ADVERTISED_ENDPOINTS_CHANNEL);
1784− ipcMain.handle(GET_ADVERTISED_ENDPOINTS_CHANNEL, async () => getDesktopAdvertisedEndpoints());
1785−
1786− ipcMain.removeHandler(PICK_FOLDER_CHANNEL);
1787− ipcMain.handle(PICK_FOLDER_CHANNEL, async (_event, rawOptions: unknown) => {
1788− const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;
1789− const defaultPath = resolvePickFolderDefaultPath(rawOptions);
1790− const openDialogOptions: OpenDialogOptions = {
1791− properties: ["openDirectory", "createDirectory"],
1792− ...(defaultPath ? { defaultPath } : {}),
1793− };
1794− const result = owner
1795− ? await dialog.showOpenDialog(owner, openDialogOptions)
1796− : await dialog.showOpenDialog(openDialogOptions);
1797− if (result.canceled) return null;
1798− return result.filePaths[0] ?? null;
1799− });
1800−
1801− ipcMain.removeHandler(CONFIRM_CHANNEL);
1802− ipcMain.handle(CONFIRM_CHANNEL, async (_event, message: unknown) => {
1803− if (typeof message !== "string") {
1804− return false;
1805− }
1806−
1807− const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;
1808− return showDesktopConfirmDialog(message, owner);
1809− });
1810−
1811− ipcMain.removeHandler(SET_THEME_CHANNEL);
1812− ipcMain.handle(SET_THEME_CHANNEL, async (_event, rawTheme: unknown) => {
1813− const theme = getSafeTheme(rawTheme);
1814− if (!theme) {
1815− return;
1816− }
1817−
1818− nativeTheme.themeSource = theme;
1819− });
1820−
1821− ipcMain.removeHandler(CONTEXT_MENU_CHANNEL);
1822− ipcMain.handle(
1823− CONTEXT_MENU_CHANNEL,
1824− async (_event, items: ContextMenuItem[], position?: { x: number; y: number }) => {
1825− const normalizedItems = normalizeContextMenuItems(items);
1826− if (normalizedItems.length === 0) {
1827− return null;
1828− }
1829−
1830− const popupPosition =
1831− position &&
1832− Number.isFinite(position.x) &&
1833− Number.isFinite(position.y) &&
1834− position.x >= 0 &&
1835− position.y >= 0
1836− ? {
1837− x: Math.floor(position.x),
1838− y: Math.floor(position.y),
1839− }
1840− : null;
1841−
1842− const window = BrowserWindow.getFocusedWindow() ?? mainWindow;
1843− if (!window) return null;
1844−
1845− return new Promise<string | null>((resolve) => {
1846− const buildTemplate = (
1847− entries: readonly ContextMenuItem[],
1848− ): MenuItemConstructorOptions[] => {
1849− const template: MenuItemConstructorOptions[] = [];
1850− let hasInsertedDestructiveSeparator = false;
1851− for (const item of entries) {
1852− if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) {
1853− template.push({ type: "separator" });
1854− hasInsertedDestructiveSeparator = true;
1855− }
1856− const itemOption: MenuItemConstructorOptions = {
1857− label: item.label,
1858− enabled: !item.disabled,
1859− };
1860− if (item.children && item.children.length > 0) {
1861− itemOption.submenu = buildTemplate(item.children);
1862− } else {
1863− itemOption.click = () => resolve(item.id);
1864− }
1865− if (item.destructive && (!item.children || item.children.length === 0)) {
1866− const destructiveIcon = getDestructiveMenuIcon();
1867− if (destructiveIcon) {
1868− itemOption.icon = destructiveIcon;
1869− }
1870− }
1871− template.push(itemOption);
1872− }
1873− return template;
1874− };
1875−
1876− const menu = Menu.buildFromTemplate(buildTemplate(normalizedItems));
1877− menu.popup({
1878− window,
1879− ...popupPosition,
1880− callback: () => resolve(null),
1881− });
1882− });
1883− },
1884− );
1885−
1886− ipcMain.removeHandler(OPEN_EXTERNAL_CHANNEL);
1887− ipcMain.handle(OPEN_EXTERNAL_CHANNEL, async (_event, rawUrl: unknown) => {
1888− const externalUrl = getSafeExternalUrl(rawUrl);
1889− if (!externalUrl) {
1890− return false;
1891− }
1892−
1893− try {
1894− await shell.openExternal(externalUrl);
1895− return true;
1896− } catch {
1897− return false;
1898− }
1899− });
1900−
1901− ipcMain.removeHandler(UPDATE_GET_STATE_CHANNEL);
1902− ipcMain.handle(UPDATE_GET_STATE_CHANNEL, async () => updateState);
1903−
1904− ipcMain.removeHandler(UPDATE_SET_CHANNEL_CHANNEL);
1905− ipcMain.handle(UPDATE_SET_CHANNEL_CHANNEL, async (_event, rawChannel: unknown) => {
1906− if (rawChannel !== "latest" && rawChannel !== "nightly") {
1907− throw new Error("Invalid desktop update channel input.");
1908− }
1909− if (updateCheckInFlight || updateDownloadInFlight || updateInstallInFlight) {
1910− throw new Error("Cannot change update tracks while an update action is in progress.");
1911− }
1912−
1913− const nextChannel = rawChannel as DesktopUpdateChannel;
1914−
1915− desktopSettings = setDesktopUpdateChannelPreference(desktopSettings, nextChannel);
1916− writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings);
1917−
1918− if (nextChannel === updateState.channel) {
1919− return updateState;
1920− }
1921−
1922− const enabled = shouldEnableAutoUpdates();
1923− setUpdateState(createBaseUpdateState(nextChannel, enabled));
1924−
1925− if (!enabled || !updaterConfigured) {
1926− return updateState;
1927− }
1928−
1929− applyAutoUpdaterChannel(nextChannel);
1930− const allowDowngrade = autoUpdater.allowDowngrade;
1931− // An explicit channel switch should allow the immediate nightly->stable rollback path.
1932− autoUpdater.allowDowngrade = true;
1933− try {
1934− await checkForUpdates("channel-change");
1935− } finally {
1936− autoUpdater.allowDowngrade = allowDowngrade;
1937− }
1938− return updateState;
1939− });
1940−
1941− ipcMain.removeHandler(UPDATE_DOWNLOAD_CHANNEL);
1942− ipcMain.handle(UPDATE_DOWNLOAD_CHANNEL, async () => {
1943− const result = await downloadAvailableUpdate();
1944− return {
1945− accepted: result.accepted,
1946− completed: result.completed,
1947− state: updateState,
1948− } satisfies DesktopUpdateActionResult;
1949− });
1950−
1951− ipcMain.removeHandler(UPDATE_INSTALL_CHANNEL);
1952− ipcMain.handle(UPDATE_INSTALL_CHANNEL, async () => {
1953− if (isQuitting) {
1954− return {
1955− accepted: false,
1956− completed: false,
1957− state: updateState,
1958− } satisfies DesktopUpdateActionResult;
1959− }
1960− const result = await installDownloadedUpdate();
1961− return {
1962− accepted: result.accepted,
1963− completed: result.completed,
1964− state: updateState,
1965− } satisfies DesktopUpdateActionResult;
1966− });
1967−
1968− ipcMain.removeHandler(UPDATE_CHECK_CHANNEL);
1969− ipcMain.handle(UPDATE_CHECK_CHANNEL, async () => {
1970− if (!updaterConfigured) {
1971− return {
1972− checked: false,
1973− state: updateState,
1974− } satisfies DesktopUpdateCheckResult;
1975− }
1976− const checked = await checkForUpdates("web-ui");
1977− return {
1978− checked,
1979− state: updateState,
1980− } satisfies DesktopUpdateCheckResult;
1981− });
1982−}
1983−
1984−function getIconOption(): { icon: string } | Record<string, never> {
1985− if (process.platform === "darwin") return {}; // macOS uses .icns from app bundle
1986− const ext = process.platform === "win32" ? "ico" : "png";
1987− const iconPath = resolveIconPath(ext);
1988− return iconPath ? { icon: iconPath } : {};
1989−}
1990−
1991−function getInitialWindowBackgroundColor(): string {
1992− return nativeTheme.shouldUseDarkColors ? "#0a0a0a" : "#ffffff";
1993−}
1994−
1995−function getWindowTitleBarOptions(): WindowTitleBarOptions {
1996− if (process.platform === "darwin") {
1997− return {
1998− titleBarStyle: "hiddenInset",
1999− trafficLightPosition: { x: 16, y: 18 },
2000− };
2001− }
2002−
200370 return {
2004− titleBarStyle: "hidden",
2005− titleBarOverlay: {
2006− color: TITLEBAR_COLOR,
2007− height: TITLEBAR_HEIGHT,
2008− symbolColor: nativeTheme.shouldUseDarkColors
2009− ? TITLEBAR_DARK_SYMBOL_COLOR
2010− : TITLEBAR_LIGHT_SYMBOL_COLOR,
2011− },
71+ packageSpec: resolveRemoteT3CliPackageSpec({
72+ appVersion: environment.appVersion,
73+ updateChannel: settings.updateChannel,
74+ isDevelopment: environment.isDevelopment,
75+ }),
201276 };
2013−}
2014−
2015−function syncWindowAppearance(window: BrowserWindow): void {
2016− if (window.isDestroyed()) {
2017− return;
2018− }
2019−
2020− window.setBackgroundColor(getInitialWindowBackgroundColor());
2021− const { titleBarOverlay } = getWindowTitleBarOptions();
2022− if (typeof titleBarOverlay === "object") {
2023− window.setTitleBarOverlay(titleBarOverlay);
2024− }
2025−}
2026−
2027−function syncAllWindowAppearance(): void {
2028− for (const window of BrowserWindow.getAllWindows()) {
2029− syncWindowAppearance(window);
2030− }
2031−}
2032−
2033−nativeTheme.on("updated", syncAllWindowAppearance);
2034−
2035−function createWindow(): BrowserWindow {
2036− const window = new BrowserWindow({
2037− width: 1100,
2038− height: 780,
2039− minWidth: 840,
2040− minHeight: 620,
2041− show: false,
2042− autoHideMenuBar: true,
2043− backgroundColor: getInitialWindowBackgroundColor(),
2044− ...getIconOption(),
2045− title: APP_DISPLAY_NAME,
2046− ...getWindowTitleBarOptions(),
2047− webPreferences: {
2048− preload: Path.join(__dirname, "preload.cjs"),
2049− contextIsolation: true,
2050− nodeIntegration: false,
2051− sandbox: true,
2052− },
2053− });
2054−
2055− window.webContents.on("context-menu", (event, params) => {
2056− event.preventDefault();
2057−
2058− const menuTemplate: MenuItemConstructorOptions[] = [];
2059−
2060− if (params.misspelledWord) {
2061− for (const suggestion of params.dictionarySuggestions.slice(0, 5)) {
2062− menuTemplate.push({
2063− label: suggestion,
2064− click: () => window.webContents.replaceMisspelling(suggestion),
2065− });
2066− }
2067− if (params.dictionarySuggestions.length === 0) {
2068− menuTemplate.push({ label: "No suggestions", enabled: false });
2069− }
2070− menuTemplate.push({ type: "separator" });
2071− }
2072−
2073− const externalUrl = getSafeExternalUrl(params.linkURL);
2074− if (externalUrl) {
2075− menuTemplate.push(
2076− {
2077− label: "Copy Link",
2078− click: () => clipboard.writeText(params.linkURL),
2079− },
2080− { type: "separator" },
2081− );
2082− }
2083−
2084− if (params.mediaType === "image") {
2085− menuTemplate.push({
2086− label: "Copy Image",
2087− click: () => window.webContents.copyImageAt(params.x, params.y),
2088− });
2089− menuTemplate.push({ type: "separator" });
2090− }
2091−
2092− menuTemplate.push(
2093− { role: "cut", enabled: params.editFlags.canCut },
2094− { role: "copy", enabled: params.editFlags.canCopy },
2095− { role: "paste", enabled: params.editFlags.canPaste },
2096− { role: "selectAll", enabled: params.editFlags.canSelectAll },
2097− );
2098−
2099− Menu.buildFromTemplate(menuTemplate).popup({ window });
2100− });
2101−
2102− window.webContents.setWindowOpenHandler(({ url }) => {
2103− const externalUrl = getSafeExternalUrl(url);
2104− if (externalUrl) {
2105− void shell.openExternal(externalUrl);
2106− }
2107− return { action: "deny" };
2108− });
2109−
2110− window.on("page-title-updated", (event) => {
2111− event.preventDefault();
2112− window.setTitle(APP_DISPLAY_NAME);
2113− });
2114− window.webContents.on("did-finish-load", () => {
2115− window.setTitle(APP_DISPLAY_NAME);
2116− emitUpdateState();
2117− });
2118−
2119− // On Linux/Wayland with `show: false`, Electron's `ready-to-show` only
2120− // fires after `show()` is called, deadlocking the standard "wait for
2121− // ready, then show" pattern. Add `did-finish-load` as a Linux-only
2122− // fallback so the window still surfaces once the renderer has loaded
2123− // the page. Other platforms keep the no-flash `ready-to-show` path,
2124− // since `did-finish-load` typically fires before the first paint there.
2125− const revealSubscribers: RevealSubscription[] = [(fire) => window.once("ready-to-show", fire)];
2126− if (process.platform === "linux") {
2127− revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire));
2128− }
2129− bindFirstRevealTrigger(revealSubscribers, () => revealWindow(window));
2130−
2131− if (isDevelopment) {
2132− void window.loadURL(resolveDesktopDevServerUrl());
2133− window.webContents.openDevTools({ mode: "detach" });
2134− } else {
2135− void window.loadURL(backendHttpUrl);
2136− }
2137−
2138− window.on("closed", () => {
2139− desktopSshEnvironmentBridge.cancelPendingPasswordPrompts(
2140− "SSH authentication was cancelled because the app window closed.",
2141− );
2142− if (mainWindow === window) {
2143− mainWindow = null;
2144− }
2145− });
2146−
2147− return window;
2148−}
2149−
2150−// Override Electron's userData path before the `ready` event so that
2151−// Chromium session data uses a filesystem-friendly directory name.
2152−// Must be called synchronously at the top level — before `app.whenReady()`.
2153−app.setPath("userData", resolveUserDataPath());
2154−
2155−configureAppIdentity();
2156−
2157−async function bootstrap(): Promise<void> {
2158− writeDesktopLogHeader("bootstrap start");
2159− const configuredBackendPort = resolveConfiguredDesktopBackendPort(process.env.T3CODE_PORT);
2160− if (isDevelopment && configuredBackendPort === undefined) {
2161− throw new Error("T3CODE_PORT is required in desktop development.");
2162− }
2163−
2164− backendPort =
2165− configuredBackendPort ??
2166− (await resolveDesktopBackendPort({
2167− host: DESKTOP_LOOPBACK_HOST,
2168− startPort: DEFAULT_DESKTOP_BACKEND_PORT,
2169− requiredHosts: DESKTOP_REQUIRED_PORT_PROBE_HOSTS,
2170− }));
2171− writeDesktopLogHeader(
2172− configuredBackendPort === undefined
2173− ? `selected backend port via sequential scan startPort=${DEFAULT_DESKTOP_BACKEND_PORT} port=${backendPort}`
2174− : `using configured backend port port=${backendPort}`,
2175− );
2176− backendBootstrapToken = Crypto.randomBytes(24).toString("hex");
2177− if (desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode) {
2178− writeDesktopLogHeader(
2179− `bootstrap restoring persisted server exposure mode mode=${desktopSettings.serverExposureMode}`,
2180− );
2181− }
2182− const serverExposureState = await applyDesktopServerExposureMode(
2183− desktopSettings.serverExposureMode,
2184− {
2185− persist: desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode,
2186− },
2187− );
2188− writeDesktopLogHeader(`bootstrap resolved backend endpoint baseUrl=${backendHttpUrl}`);
2189− if (serverExposureState.endpointUrl) {
2190− writeDesktopLogHeader(
2191− `bootstrap enabled network access endpointUrl=${serverExposureState.endpointUrl}`,
2192− );
2193− } else if (desktopSettings.serverExposureMode === "network-accessible") {
2194− writeDesktopLogHeader(
2195− "bootstrap fell back to local-only because no advertised network host was available",
2196− );
2197− }
2198−
2199− registerIpcHandlers();
2200− writeDesktopLogHeader("bootstrap ipc handlers registered");
2201− startBackend();
2202− writeDesktopLogHeader("bootstrap backend start requested");
2203−
2204− if (isDevelopment) {
2205− mainWindow = createWindow();
2206− writeDesktopLogHeader("bootstrap main window created");
2207− void waitForBackendWindowReady(backendHttpUrl)
2208− .then((source) => {
2209− writeDesktopLogHeader(`bootstrap backend ready source=${source}`);
2210− })
2211− .catch((error) => {
2212− if (isBackendReadinessAborted(error)) {
2213− return;
2214− }
2215− writeDesktopLogHeader(
2216− `bootstrap backend readiness warning message=${formatErrorMessage(error)}`,
2217− );
2218− console.warn("[desktop] backend readiness check timed out during dev bootstrap", error);
2219− });
2220− return;
2221− }
2222−
2223− ensureInitialBackendWindowOpen();
2224−}
2225−
2226−app.on("before-quit", () => {
2227− isQuitting = true;
2228− updateInstallInFlight = false;
2229− writeDesktopLogHeader("before-quit received");
2230− clearUpdatePollTimer();
2231− cancelBackendReadinessWait();
2232− stopBackend();
2233− void desktopSshEnvironmentBridge.dispose().catch(() => undefined);
2234− restoreStdIoCapture?.();
2235−});
2236−
2237−app
2238− .whenReady()
2239− .then(() => {
2240− writeDesktopLogHeader("app ready");
2241− configureAppIdentity();
2242− configureApplicationMenu();
2243− registerDesktopProtocol();
2244− configureAutoUpdater();
2245− void bootstrap().catch((error) => {
2246− if (isBackendReadinessAborted(error) && isQuitting) {
2247− return;
2248− }
2249− handleFatalStartupError("bootstrap", error);
2250− });
77+};
225178
2252− app.on("activate", () => {
2253− const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0];
2254− if (existingWindow) {
2255− revealWindow(existingWindow);
2256− return;
2257− }
2258− if (isDevelopment) {
2259− mainWindow = createWindow();
2260− return;
2261− }
2262− ensureInitialBackendWindowOpen();
79+const desktopSshEnvironmentLayer = Layer.unwrap(
80+ Effect.gen(function* () {
81+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
82+ const settings = yield* DesktopAppSettings.DesktopAppSettings;
83+ return DesktopSshEnvironment.layer({
84+ resolveCliRunner: settings.get.pipe(
85+ Effect.map((currentSettings) => resolveDesktopSshCliRunner(environment, currentSettings)),
86+ ),
226387 });
2264− })
2265− .catch((error) => {
2266− handleFatalStartupError("whenReady", error);
2267− });
2268−
2269−app.on("window-all-closed", () => {
2270− if (process.platform !== "darwin" && !isQuitting) {
2271− app.quit();
2272− }
2273−});
2274−
2275−if (process.platform !== "win32") {
2276− process.on("SIGINT", () => {
2277− if (isQuitting) return;
2278− isQuitting = true;
2279− writeDesktopLogHeader("SIGINT received");
2280− clearUpdatePollTimer();
2281− cancelBackendReadinessWait();
2282− stopBackend();
2283− void desktopSshEnvironmentBridge.dispose().catch(() => undefined);
2284− restoreStdIoCapture?.();
2285− app.quit();
2286− });
2287−
2288− process.on("SIGTERM", () => {
2289− if (isQuitting) return;
2290− isQuitting = true;
2291− writeDesktopLogHeader("SIGTERM received");
2292− clearUpdatePollTimer();
2293− stopBackend();
2294− void desktopSshEnvironmentBridge.dispose().catch(() => undefined);
2295− restoreStdIoCapture?.();
2296− app.quit();
2297− });
2298−}
88+ }),
89+);
90+
91+const electronLayer = Layer.mergeAll(
92+ ElectronApp.layer,
93+ ElectronDialog.layer,
94+ ElectronMenu.layer,
95+ ElectronProtocol.layer,
96+ DesktopSecretStorage.layer,
97+ ElectronShell.layer,
98+ ElectronTheme.layer,
99+ ElectronUpdater.layer,
100+ ElectronWindow.layer,
101+ Layer.succeed(DesktopIpc.DesktopIpc, DesktopIpc.make(Electron.ipcMain)),
102+);
103+
104+const desktopFoundationLayer = Layer.mergeAll(
105+ DesktopState.layer,
106+ DesktopLifecycle.layerShutdown,
107+ DesktopAppSettings.layer,
108+ DesktopClientSettings.layer,
109+ DesktopSavedEnvironments.layer,
110+ DesktopAssets.layer,
111+ DesktopObservability.layer,
112+).pipe(Layer.provideMerge(desktopEnvironmentLayer));
113+
114+const desktopSshLayer = Layer.mergeAll(desktopSshEnvironmentLayer, DesktopSshRemoteApi.layer).pipe(
115+ Layer.provideMerge(DesktopSshPasswordPrompts.layer()),
116+);
117+
118+const desktopServerExposureLayer = DesktopServerExposure.layer.pipe(
119+ Layer.provideMerge(DesktopServerExposure.networkInterfacesLayer),
120+ Layer.provideMerge(desktopFoundationLayer),
121+);
122+
123+const desktopWindowLayer = DesktopWindow.layer.pipe(Layer.provideMerge(desktopServerExposureLayer));
124+
125+const desktopBackendLayer = DesktopBackendManager.layer.pipe(
126+ Layer.provideMerge(DesktopAppIdentity.layer),
127+ Layer.provideMerge(DesktopBackendConfiguration.layer),
128+ Layer.provideMerge(desktopWindowLayer),
129+);
130+
131+const desktopApplicationLayer = Layer.mergeAll(
132+ DesktopLifecycle.layer,
133+ DesktopApplicationMenu.layer,
134+ DesktopShellEnvironment.layer,
135+ desktopSshLayer,
136+).pipe(Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopBackendLayer));
137+
138+const desktopRuntimeLayer = ElectronProtocol.layerSchemePrivileges.pipe(
139+ Layer.flatMap(() =>
140+ desktopApplicationLayer.pipe(
141+ Layer.provideMerge(NodeServices.layer),
142+ Layer.provideMerge(NodeHttpClient.layerUndici),
143+ Layer.provideMerge(NetService.layer),
144+ Layer.provideMerge(electronLayer),
145+ ),
146+ ),
147+);
148+
149+DesktopApp.program.pipe(Effect.provide(desktopRuntimeLayer), NodeRuntime.runMain);
apps/desktop/src/preload.ts+54−74
@@ -1,48 +1,14 @@
1−import { contextBridge, ipcRenderer } from "electron";
21 import type { DesktopBridge } from "@t3tools/contracts";
2+import { contextBridge, ipcRenderer } from "electron";
33
4−const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
5−const CONFIRM_CHANNEL = "desktop:confirm";
6−const SET_THEME_CHANNEL = "desktop:set-theme";
7−const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
8−const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
9−const MENU_ACTION_CHANNEL = "desktop:menu-action";
10−const UPDATE_STATE_CHANNEL = "desktop:update-state";
11−const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
12−const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel";
13−const UPDATE_CHECK_CHANNEL = "desktop:update-check";
14−const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
15−const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
16−const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";
17−const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";
18−const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings";
19−const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings";
20−const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry";
21−const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry";
22−const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret";
23−const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret";
24−const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret";
25−const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts";
26−const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment";
27−const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment";
28−const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor";
29−const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session";
30−const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state";
31−const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-token";
32−const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt";
33−const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt";
34−const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state";
35−const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode";
36−const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled";
37−const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints";
38−const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled";
4+import * as IpcChannels from "./ipc/channels.ts";
395
406 function unwrapEnsureSshEnvironmentResult(result: unknown) {
417 if (
428 typeof result === "object" &&
439 result !== null &&
4410 "type" in result &&
45− result.type === SSH_PASSWORD_PROMPT_CANCELLED_RESULT
11+ result.type === IpcChannels.SSH_PASSWORD_PROMPT_CANCELLED_RESULT
4612 ) {
4713 const message =
4814 "message" in result && typeof result.message === "string"
@@ -55,93 +21,107 @@ function unwrapEnsureSshEnvironmentResult(result: unknown) { · 6 unmodified lines
5521
5622 contextBridge.exposeInMainWorld("desktopBridge", {
5723 getAppBranding: () => {
58− const result = ipcRenderer.sendSync(GET_APP_BRANDING_CHANNEL);
24+ const result = ipcRenderer.sendSync(IpcChannels.GET_APP_BRANDING_CHANNEL);
5925 if (typeof result !== "object" || result === null) {
6026 return null;
6127 }
6228 return result as ReturnType<DesktopBridge["getAppBranding"]>;
6329 },
6430 getLocalEnvironmentBootstrap: () => {
65− const result = ipcRenderer.sendSync(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL);
31+ const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL);
6632 if (typeof result !== "object" || result === null) {
6733 return null;
6834 }
6935 return result as ReturnType<DesktopBridge["getLocalEnvironmentBootstrap"]>;
7036 },
71− getClientSettings: () => ipcRenderer.invoke(GET_CLIENT_SETTINGS_CHANNEL),
72− setClientSettings: (settings) => ipcRenderer.invoke(SET_CLIENT_SETTINGS_CHANNEL, settings),
73− getSavedEnvironmentRegistry: () => ipcRenderer.invoke(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL),
37+ getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL),
38+ setClientSettings: (settings) =>
39+ ipcRenderer.invoke(IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, settings),
40+ getSavedEnvironmentRegistry: () =>
41+ ipcRenderer.invoke(IpcChannels.GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL),
7442 setSavedEnvironmentRegistry: (records) =>
75− ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, records),
43+ ipcRenderer.invoke(IpcChannels.SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, records),
7644 getSavedEnvironmentSecret: (environmentId) =>
77− ipcRenderer.invoke(GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId),
45+ ipcRenderer.invoke(IpcChannels.GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId),
7846 setSavedEnvironmentSecret: (environmentId, secret) =>
79− ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId, secret),
47+ ipcRenderer.invoke(IpcChannels.SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, { environmentId, secret }),
8048 removeSavedEnvironmentSecret: (environmentId) =>
81− ipcRenderer.invoke(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId),
82− discoverSshHosts: () => ipcRenderer.invoke(DISCOVER_SSH_HOSTS_CHANNEL),
49+ ipcRenderer.invoke(IpcChannels.REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId),
50+ discoverSshHosts: () => ipcRenderer.invoke(IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL),
8351 ensureSshEnvironment: async (target, options) =>
8452 unwrapEnsureSshEnvironmentResult(
85− await ipcRenderer.invoke(ENSURE_SSH_ENVIRONMENT_CHANNEL, target, options),
53+ await ipcRenderer.invoke(IpcChannels.ENSURE_SSH_ENVIRONMENT_CHANNEL, {
54+ target,
55+ ...(options === undefined ? {} : { options }),
56+ }),
8657 ),
8758 disconnectSshEnvironment: (target) =>
88− ipcRenderer.invoke(DISCONNECT_SSH_ENVIRONMENT_CHANNEL, target),
59+ ipcRenderer.invoke(IpcChannels.DISCONNECT_SSH_ENVIRONMENT_CHANNEL, target),
8960 fetchSshEnvironmentDescriptor: (httpBaseUrl) =>
90− ipcRenderer.invoke(FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, httpBaseUrl),
61+ ipcRenderer.invoke(IpcChannels.FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, { httpBaseUrl }),
9162 bootstrapSshBearerSession: (httpBaseUrl, credential) =>
92− ipcRenderer.invoke(BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, httpBaseUrl, credential),
63+ ipcRenderer.invoke(IpcChannels.BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, {
64+ httpBaseUrl,
65+ credential,
66+ }),
9367 fetchSshSessionState: (httpBaseUrl, bearerToken) =>
94− ipcRenderer.invoke(FETCH_SSH_SESSION_STATE_CHANNEL, httpBaseUrl, bearerToken),
68+ ipcRenderer.invoke(IpcChannels.FETCH_SSH_SESSION_STATE_CHANNEL, { httpBaseUrl, bearerToken }),
9569 issueSshWebSocketToken: (httpBaseUrl, bearerToken) =>
96− ipcRenderer.invoke(ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, httpBaseUrl, bearerToken),
70+ ipcRenderer.invoke(IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, { httpBaseUrl, bearerToken }),
9771 onSshPasswordPrompt: (listener) => {
9872 const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => {
9973 if (typeof request !== "object" || request === null) return;
10074 listener(request as Parameters<typeof listener>[0]);
10175 };
10276
103− ipcRenderer.on(SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener);
77+ ipcRenderer.on(IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener);
10478 return () => {
105− ipcRenderer.removeListener(SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener);
79+ ipcRenderer.removeListener(IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener);
10680 };
10781 },
10882 resolveSshPasswordPrompt: (requestId, password) =>
109− ipcRenderer.invoke(RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, requestId, password),
110− getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL),
111− setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode),
83+ ipcRenderer.invoke(IpcChannels.RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, { requestId, password }),
84+ getServerExposureState: () => ipcRenderer.invoke(IpcChannels.GET_SERVER_EXPOSURE_STATE_CHANNEL),
85+ setServerExposureMode: (mode) =>
86+ ipcRenderer.invoke(IpcChannels.SET_SERVER_EXPOSURE_MODE_CHANNEL, mode),
11287 setTailscaleServeEnabled: (input) =>
113− ipcRenderer.invoke(SET_TAILSCALE_SERVE_ENABLED_CHANNEL, input),
114− getAdvertisedEndpoints: () => ipcRenderer.invoke(GET_ADVERTISED_ENDPOINTS_CHANNEL),
115− pickFolder: (options) => ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options),
116− confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),
117− setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme),
118− showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position),
119− openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url),
88+ ipcRenderer.invoke(IpcChannels.SET_TAILSCALE_SERVE_ENABLED_CHANNEL, input),
89+ getAdvertisedEndpoints: () => ipcRenderer.invoke(IpcChannels.GET_ADVERTISED_ENDPOINTS_CHANNEL),
90+ pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options),
91+ confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message),
92+ setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme),
93+ showContextMenu: (items, position) =>
94+ ipcRenderer.invoke(IpcChannels.CONTEXT_MENU_CHANNEL, {
95+ items,
96+ ...(position === undefined ? {} : { position }),
97+ }),
98+ openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url),
12099 onMenuAction: (listener) => {
121100 const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => {
122101 if (typeof action !== "string") return;
123102 listener(action);
124103 };
125104
126− ipcRenderer.on(MENU_ACTION_CHANNEL, wrappedListener);
105+ ipcRenderer.on(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener);
127106 return () => {
128− ipcRenderer.removeListener(MENU_ACTION_CHANNEL, wrappedListener);
107+ ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener);
129108 };
130109 },
131− getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL),
132− setUpdateChannel: (channel) => ipcRenderer.invoke(UPDATE_SET_CHANNEL_CHANNEL, channel),
133− checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL),
134− downloadUpdate: () => ipcRenderer.invoke(UPDATE_DOWNLOAD_CHANNEL),
135− installUpdate: () => ipcRenderer.invoke(UPDATE_INSTALL_CHANNEL),
110+ getUpdateState: () => ipcRenderer.invoke(IpcChannels.UPDATE_GET_STATE_CHANNEL),
111+ setUpdateChannel: (channel) =>
112+ ipcRenderer.invoke(IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, channel),
113+ checkForUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_CHECK_CHANNEL),
114+ downloadUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_DOWNLOAD_CHANNEL),
115+ installUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_INSTALL_CHANNEL),
136116 onUpdateState: (listener) => {
137117 const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => {
138118 if (typeof state !== "object" || state === null) return;
139119 listener(state as Parameters<typeof listener>[0]);
140120 };
141121
142− ipcRenderer.on(UPDATE_STATE_CHANNEL, wrappedListener);
122+ ipcRenderer.on(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener);
143123 return () => {
144− ipcRenderer.removeListener(UPDATE_STATE_CHANNEL, wrappedListener);
124+ ipcRenderer.removeListener(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener);
145125 };
146126 },
147127 } satisfies DesktopBridge);
apps/desktop/src/rotatingFileSink.test.ts+0−74
@@ -1,74 +0,0 @@
1−import fs from "node:fs";
2−import os from "node:os";
3−import path from "node:path";
4−
5−import { RotatingFileSink } from "@t3tools/shared/logging";
6−import { afterEach, describe, expect, it } from "vitest";
7−
8−const tempRoots: string[] = [];
9−
10−function makeTempDir(): string {
11− const dir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-rotating-log-"));
12− tempRoots.push(dir);
13− return dir;
14−}
15−
16−afterEach(() => {
17− for (const dir of tempRoots.splice(0)) {
18− fs.rmSync(dir, { recursive: true, force: true });
19− }
20−});
21−
22−describe("RotatingFileSink", () => {
23− it("rotates when writes exceed max bytes", () => {
24− const dir = makeTempDir();
25− const logPath = path.join(dir, "desktop-main.log");
26− const sink = new RotatingFileSink({
27− filePath: logPath,
28− maxBytes: 10,
29− maxFiles: 3,
30− });
31−
32− sink.write("12345");
33− sink.write("67890");
34− sink.write("abc");
35−
36− expect(fs.readFileSync(path.join(dir, "desktop-main.log"), "utf8")).toBe("abc");
37− expect(fs.readFileSync(path.join(dir, "desktop-main.log.1"), "utf8")).toBe("1234567890");
38− });
39−
40− it("retains only maxFiles backups", () => {
41− const dir = makeTempDir();
42− const logPath = path.join(dir, "server-child.log");
43− const sink = new RotatingFileSink({
44− filePath: logPath,
45− maxBytes: 4,
46− maxFiles: 2,
47− });
48−
49− sink.write("aaaa");
50− sink.write("bbbb");
51− sink.write("cccc");
52− sink.write("dddd");
53−
54− expect(fs.existsSync(path.join(dir, "server-child.log.1"))).toBe(true);
55− expect(fs.existsSync(path.join(dir, "server-child.log.2"))).toBe(true);
56− expect(fs.existsSync(path.join(dir, "server-child.log.3"))).toBe(false);
57− });
58−
59− it("prunes stale backups above maxFiles on startup", () => {
60− const dir = makeTempDir();
61− const logPath = path.join(dir, "desktop-main.log");
62− fs.writeFileSync(path.join(dir, "desktop-main.log.1"), "first");
63− fs.writeFileSync(path.join(dir, "desktop-main.log.4"), "stale");
64−
65− const sink = new RotatingFileSink({
66− filePath: logPath,
67− maxBytes: 16,
68− maxFiles: 2,
69− });
70− sink.write("hello");
71−
72− expect(fs.existsSync(path.join(dir, "desktop-main.log.4"))).toBe(false);
73− });
74−});
apps/desktop/src/runtimeArch.test.ts+0−49
@@ -1,49 +0,0 @@
1−import { describe, expect, it } from "vitest";
2−
3−import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts";
4−
5−describe("resolveDesktopRuntimeInfo", () => {
6− it("detects Rosetta-translated Intel builds on Apple Silicon", () => {
7− const runtimeInfo = resolveDesktopRuntimeInfo({
8− platform: "darwin",
9− processArch: "x64",
10− runningUnderArm64Translation: true,
11− });
12−
13− expect(runtimeInfo).toEqual({
14− hostArch: "arm64",
15− appArch: "x64",
16− runningUnderArm64Translation: true,
17− });
18− expect(isArm64HostRunningIntelBuild(runtimeInfo)).toBe(true);
19− });
20−
21− it("detects native Apple Silicon builds", () => {
22− const runtimeInfo = resolveDesktopRuntimeInfo({
23− platform: "darwin",
24− processArch: "arm64",
25− runningUnderArm64Translation: false,
26− });
27−
28− expect(runtimeInfo).toEqual({
29− hostArch: "arm64",
30− appArch: "arm64",
31− runningUnderArm64Translation: false,
32− });
33− expect(isArm64HostRunningIntelBuild(runtimeInfo)).toBe(false);
34− });
35−
36− it("passes through non-mac builds without translation", () => {
37− const runtimeInfo = resolveDesktopRuntimeInfo({
38− platform: "linux",
39− processArch: "x64",
40− runningUnderArm64Translation: true,
41− });
42−
43− expect(runtimeInfo).toEqual({
44− hostArch: "x64",
45− appArch: "x64",
46− runningUnderArm64Translation: false,
47− });
48− });
49−});
apps/desktop/src/runtimeArch.ts+0−39
@@ -1,39 +0,0 @@
1−import type { DesktopRuntimeArch, DesktopRuntimeInfo } from "@t3tools/contracts";
2−
3−interface ResolveDesktopRuntimeInfoInput {
4− readonly platform: NodeJS.Platform;
5− readonly processArch: string;
6− readonly runningUnderArm64Translation: boolean;
7−}
8−
9−function normalizeDesktopArch(arch: string): DesktopRuntimeArch {
10− if (arch === "arm64") return "arm64";
11− if (arch === "x64") return "x64";
12− return "other";
13−}
14−
15−export function resolveDesktopRuntimeInfo(
16− input: ResolveDesktopRuntimeInfoInput,
17−): DesktopRuntimeInfo {
18− const appArch = normalizeDesktopArch(input.processArch);
19−
20− if (input.platform !== "darwin") {
21− return {
22− hostArch: appArch,
23− appArch,
24− runningUnderArm64Translation: false,
25− };
26− }
27−
28− const hostArch = appArch === "arm64" || input.runningUnderArm64Translation ? "arm64" : appArch;
29−
30− return {
31− hostArch,
32− appArch,
33− runningUnderArm64Translation: input.runningUnderArm64Translation,
34− };
35−}
36−
37−export function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean {
38− return runtimeInfo.hostArch === "arm64" && runtimeInfo.appArch === "x64";
39−}
apps/desktop/src/serverExposure.test.ts+0−258
@@ -1,258 +0,0 @@
1−import { describe, expect, it } from "vitest";
2−
3−import {
4− resolveDesktopCoreAdvertisedEndpoints,
5− resolveDesktopServerExposure,
6− resolveLanAdvertisedHost,
7−} from "./serverExposure.ts";
8−
9−describe("resolveLanAdvertisedHost", () => {
10− it("prefers an explicit host override", () => {
11− expect(
12− resolveLanAdvertisedHost(
13− {
14− en0: [
15− {
16− address: "192.168.1.44",
17− family: "IPv4",
18− internal: false,
19− netmask: "255.255.255.0",
20− cidr: "192.168.1.44/24",
21− mac: "00:00:00:00:00:00",
22− },
23− ],
24− },
25− "10.0.0.9",
26− ),
27− ).toBe("10.0.0.9");
28− });
29−
30− it("returns the first usable non-internal IPv4 address", () => {
31− expect(
32− resolveLanAdvertisedHost(
33− {
34− lo0: [
35− {
36− address: "127.0.0.1",
37− family: "IPv4",
38− internal: true,
39− netmask: "255.0.0.0",
40− cidr: "127.0.0.1/8",
41− mac: "00:00:00:00:00:00",
42− },
43− ],
44− en0: [
45− {
46− address: "192.168.1.44",
47− family: "IPv4",
48− internal: false,
49− netmask: "255.255.255.0",
50− cidr: "192.168.1.44/24",
51− mac: "00:00:00:00:00:00",
52− },
53− ],
54− },
55− undefined,
56− ),
57− ).toBe("192.168.1.44");
58− });
59−
60− it("returns null when no usable network address is available", () => {
61− expect(
62− resolveLanAdvertisedHost(
63− {
64− lo0: [
65− {
66− address: "127.0.0.1",
67− family: "IPv4",
68− internal: true,
69− netmask: "255.0.0.0",
70− cidr: "127.0.0.1/8",
71− mac: "00:00:00:00:00:00",
72− },
73− ],
74− },
75− undefined,
76− ),
77− ).toBeNull();
78− });
79−});
80−
81−describe("resolveDesktopCoreAdvertisedEndpoints", () => {
82− it("advertises loopback and LAN endpoints without provider-specific assumptions", () => {
83− const exposure = resolveDesktopServerExposure({
84− mode: "network-accessible",
85− port: 3773,
86− networkInterfaces: {
87− en0: [
88− {
89− address: "192.168.1.44",
90− family: "IPv4",
91− internal: false,
92− netmask: "255.255.255.0",
93− cidr: "192.168.1.44/24",
94− mac: "00:00:00:00:00:00",
95− },
96− ],
97− },
98− });
99−
100− expect(
101− resolveDesktopCoreAdvertisedEndpoints({
102− port: 3773,
103− exposure,
104− customHttpsEndpointUrls: [
105− "https://desktop.example.ts.net",
106− "http://desktop.example.test:3773",
107− "not-a-url",
108− ],
109− }),
110− ).toEqual([
111− {
112− id: "desktop-loopback:3773",
113− label: "This machine",
114− provider: {
115− id: "desktop-core",
116− label: "Desktop",
117− kind: "core",
118− isAddon: false,
119− },
120− httpBaseUrl: "http://127.0.0.1:3773/",
121− wsBaseUrl: "ws://127.0.0.1:3773/",
122− reachability: "loopback",
123− compatibility: {
124− hostedHttpsApp: "mixed-content-blocked",
125− desktopApp: "compatible",
126− },
127− source: "desktop-core",
128− status: "available",
129− description: "Loopback endpoint for this desktop app.",
130− },
131− {
132− id: "desktop-lan:http://192.168.1.44:3773",
133− label: "Local network",
134− provider: {
135− id: "desktop-core",
136− label: "Desktop",
137− kind: "core",
138− isAddon: false,
139− },
140− httpBaseUrl: "http://192.168.1.44:3773/",
141− wsBaseUrl: "ws://192.168.1.44:3773/",
142− reachability: "lan",
143− compatibility: {
144− hostedHttpsApp: "mixed-content-blocked",
145− desktopApp: "compatible",
146− },
147− source: "desktop-core",
148− status: "available",
149− isDefault: true,
150− description: "Reachable from devices on the same network.",
151− },
152− {
153− id: "manual:https://desktop.example.ts.net",
154− label: "Custom HTTPS",
155− provider: {
156− id: "manual",
157− label: "Manual",
158− kind: "manual",
159− isAddon: false,
160− },
161− httpBaseUrl: "https://desktop.example.ts.net/",
162− wsBaseUrl: "wss://desktop.example.ts.net/",
163− reachability: "public",
164− compatibility: {
165− hostedHttpsApp: "compatible",
166− desktopApp: "compatible",
167− },
168− source: "user",
169− status: "unknown",
170− description: "User-configured HTTPS endpoint for this desktop backend.",
171− },
172− {
173− id: "manual:http://desktop.example.test:3773",
174− label: "Custom endpoint",
175− provider: {
176− id: "manual",
177− label: "Manual",
178− kind: "manual",
179− isAddon: false,
180− },
181− httpBaseUrl: "http://desktop.example.test:3773/",
182− wsBaseUrl: "ws://desktop.example.test:3773/",
183− reachability: "public",
184− compatibility: {
185− hostedHttpsApp: "mixed-content-blocked",
186− desktopApp: "compatible",
187− },
188− source: "user",
189− status: "unknown",
190− description: "User-configured endpoint for this desktop backend.",
191− },
192− ]);
193− });
194−});
195−
196−describe("resolveDesktopServerExposure", () => {
197− it("keeps the desktop server loopback-only when local-only mode is selected", () => {
198− expect(
199− resolveDesktopServerExposure({
200− mode: "local-only",
201− port: 3773,
202− networkInterfaces: {},
203− }),
204− ).toEqual({
205− mode: "local-only",
206− bindHost: "127.0.0.1",
207− localHttpUrl: "http://127.0.0.1:3773",
208− localWsUrl: "ws://127.0.0.1:3773",
209− endpointUrl: null,
210− advertisedHost: null,
211− });
212− });
213−
214− it("binds to all interfaces in network-accessible mode", () => {
215− expect(
216− resolveDesktopServerExposure({
217− mode: "network-accessible",
218− port: 3773,
219− networkInterfaces: {
220− en0: [
221− {
222− address: "192.168.1.44",
223− family: "IPv4",
224− internal: false,
225− netmask: "255.255.255.0",
226− cidr: "192.168.1.44/24",
227− mac: "00:00:00:00:00:00",
228− },
229− ],
230− },
231− }),
232− ).toEqual({
233− mode: "network-accessible",
234− bindHost: "0.0.0.0",
235− localHttpUrl: "http://127.0.0.1:3773",
236− localWsUrl: "ws://127.0.0.1:3773",
237− endpointUrl: "http://192.168.1.44:3773",
238− advertisedHost: "192.168.1.44",
239− });
240− });
241−
242− it("stays network-accessible even when no LAN address is currently detectable", () => {
243− expect(
244− resolveDesktopServerExposure({
245− mode: "network-accessible",
246− port: 3773,
247− networkInterfaces: {},
248− }),
249− ).toEqual({
250− mode: "network-accessible",
251− bindHost: "0.0.0.0",
252− localHttpUrl: "http://127.0.0.1:3773",
253− localWsUrl: "ws://127.0.0.1:3773",
254− endpointUrl: null,
255− advertisedHost: null,
256− });
257− });
258−});
apps/desktop/src/serverExposure.ts+0−188
@@ -1,188 +0,0 @@
1−import type { NetworkInterfaceInfo } from "node:os";
2−import {
3− createAdvertisedEndpoint,
4− type CreateAdvertisedEndpointInput,
5−} from "@t3tools/client-runtime";
6−import type {
7− AdvertisedEndpoint,
8− AdvertisedEndpointProvider,
9− DesktopServerExposureMode,
10−} from "@t3tools/contracts";
11−
12−const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
13−const DESKTOP_LAN_BIND_HOST = "0.0.0.0";
14−
15−export interface DesktopServerExposure {
16− readonly mode: DesktopServerExposureMode;
17− readonly bindHost: string;
18− readonly localHttpUrl: string;
19− readonly localWsUrl: string;
20− readonly endpointUrl: string | null;
21− readonly advertisedHost: string | null;
22−}
23−
24−export interface DesktopAdvertisedEndpointInput {
25− readonly port: number;
26− readonly exposure: DesktopServerExposure;
27− readonly customHttpsEndpointUrls?: readonly string[];
28−}
29−
30−const DESKTOP_CORE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = {
31− id: "desktop-core",
32− label: "Desktop",
33− kind: "core",
34− isAddon: false,
35−};
36−
37−const DESKTOP_MANUAL_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = {
38− id: "manual",
39− label: "Manual",
40− kind: "manual",
41− isAddon: false,
42−};
43−
44−const normalizeOptionalHost = (value: string | undefined): string | undefined => {
45− const normalized = value?.trim();
46− return normalized && normalized.length > 0 ? normalized : undefined;
47−};
48−
49−const isUsableLanIpv4Address = (address: string): boolean =>
50− !address.startsWith("127.") && !address.startsWith("169.254.");
51−
52−function isHttpsEndpointUrl(value: string): boolean {
53− try {
54− return new URL(value).protocol === "https:";
55− } catch {
56− return false;
57− }
58−}
59−
60−export function resolveLanAdvertisedHost(
61− networkInterfaces: NodeJS.Dict<NetworkInterfaceInfo[]>,
62− explicitHost: string | undefined,
63−): string | null {
64− const normalizedExplicitHost = normalizeOptionalHost(explicitHost);
65− if (normalizedExplicitHost) {
66− return normalizedExplicitHost;
67− }
68−
69− for (const interfaceAddresses of Object.values(networkInterfaces)) {
70− if (!interfaceAddresses) continue;
71−
72− for (const address of interfaceAddresses) {
73− if (address.internal) continue;
74− if (address.family !== "IPv4") continue;
75− if (!isUsableLanIpv4Address(address.address)) continue;
76− return address.address;
77− }
78− }
79−
80− return null;
81−}
82−
83−export function resolveDesktopServerExposure(input: {
84− readonly mode: DesktopServerExposureMode;
85− readonly port: number;
86− readonly networkInterfaces: NodeJS.Dict<NetworkInterfaceInfo[]>;
87− readonly advertisedHostOverride?: string;
88−}): DesktopServerExposure {
89− const localHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${input.port}`;
90− const localWsUrl = `ws://${DESKTOP_LOOPBACK_HOST}:${input.port}`;
91−
92− if (input.mode === "local-only") {
93− return {
94− mode: input.mode,
95− bindHost: DESKTOP_LOOPBACK_HOST,
96− localHttpUrl,
97− localWsUrl,
98− endpointUrl: null,
99− advertisedHost: null,
100− };
101− }
102−
103− const advertisedHost = resolveLanAdvertisedHost(
104− input.networkInterfaces,
105− input.advertisedHostOverride,
106− );
107−
108− return {
109− mode: input.mode,
110− bindHost: DESKTOP_LAN_BIND_HOST,
111− localHttpUrl,
112− localWsUrl,
113− endpointUrl: advertisedHost ? `http://${advertisedHost}:${input.port}` : null,
114− advertisedHost,
115− };
116−}
117−
118−function createDesktopEndpoint(
119− input: Omit<CreateAdvertisedEndpointInput, "provider" | "source">,
120−): AdvertisedEndpoint {
121− return createAdvertisedEndpoint({
122− ...input,
123− provider: DESKTOP_CORE_ENDPOINT_PROVIDER,
124− source: "desktop-core",
125− });
126−}
127−
128−function createManualEndpoint(
129− input: Omit<CreateAdvertisedEndpointInput, "provider" | "source">,
130−): AdvertisedEndpoint {
131− return createAdvertisedEndpoint({
132− ...input,
133− provider: DESKTOP_MANUAL_ENDPOINT_PROVIDER,
134− source: "user",
135− });
136−}
137−
138−export function resolveDesktopCoreAdvertisedEndpoints(
139− input: DesktopAdvertisedEndpointInput,
140−): readonly AdvertisedEndpoint[] {
141− const endpoints: AdvertisedEndpoint[] = [
142− createDesktopEndpoint({
143− id: `desktop-loopback:${input.port}`,
144− label: "This machine",
145− httpBaseUrl: input.exposure.localHttpUrl,
146− reachability: "loopback",
147− status: "available",
148− description: "Loopback endpoint for this desktop app.",
149− }),
150− ];
151−
152− if (input.exposure.endpointUrl) {
153− endpoints.push(
154− createDesktopEndpoint({
155− id: `desktop-lan:${input.exposure.endpointUrl}`,
156− label: "Local network",
157− httpBaseUrl: input.exposure.endpointUrl,
158− reachability: "lan",
159− status: "available",
160− isDefault: true,
161− description: "Reachable from devices on the same network.",
162− }),
163− );
164− }
165−
166− for (const customEndpointUrl of input.customHttpsEndpointUrls ?? []) {
167− try {
168− const isHttpsEndpoint = isHttpsEndpointUrl(customEndpointUrl);
169− endpoints.push(
170− createManualEndpoint({
171− id: `manual:${customEndpointUrl}`,
172− label: isHttpsEndpoint ? "Custom HTTPS" : "Custom endpoint",
173− httpBaseUrl: customEndpointUrl,
174− reachability: "public",
175− ...(isHttpsEndpoint ? ({ hostedHttpsCompatibility: "compatible" } as const) : {}),
176− status: "unknown",
177− description: isHttpsEndpoint
178− ? "User-configured HTTPS endpoint for this desktop backend."
179− : "User-configured endpoint for this desktop backend.",
180− }),
181− );
182− } catch {
183− // Ignore malformed user-configured endpoints without dropping valid endpoints.
184− }
185− }
186−
187− return endpoints;
188−}
apps/desktop/src/serverListeningDetector.test.ts+0−31
@@ -1,31 +0,0 @@
1−import { describe, expect, it } from "vitest";
2−
3−import { ServerListeningDetector } from "./serverListeningDetector.ts";
4−
5−describe("ServerListeningDetector", () => {
6− it("resolves when the server logs the listening line", async () => {
7− const detector = new ServerListeningDetector();
8−
9− detector.push("[01:23:30.571] INFO (#148): Listening on http://0.0.0.0:7011\n");
10−
11− await expect(detector.promise).resolves.toBeUndefined();
12− });
13−
14− it("resolves when the listening line arrives across multiple chunks", async () => {
15− const detector = new ServerListeningDetector();
16−
17− detector.push("[01:23:30.571] INFO (#148): Listen");
18− detector.push("ing on http://0.0.0.0:7011\n");
19−
20− await expect(detector.promise).resolves.toBeUndefined();
21− });
22−
23− it("rejects when the server exits before logging readiness", async () => {
24− const detector = new ServerListeningDetector();
25− const error = new Error("server exited");
26−
27− detector.fail(error);
28−
29− await expect(detector.promise).rejects.toBe(error);
30− });
31−});
apps/desktop/src/serverListeningDetector.ts+0−56
@@ -1,56 +0,0 @@
1−const LISTENING_LOG_FRAGMENT = "Listening on http://";
2−const MAX_BUFFER_CHARS = 8_192;
3−
4−export class ServerListeningDetector {
5− private buffer = "";
6− private settled = false;
7− private readonly resolvePromise: () => void;
8− private readonly rejectPromise: (error: unknown) => void;
9− readonly promise: Promise<void>;
10−
11− constructor() {
12− let resolvePromise: (() => void) | null = null;
13− let rejectPromise: ((error: unknown) => void) | null = null;
14−
15− this.promise = new Promise<void>((resolve, reject) => {
16− resolvePromise = resolve;
17− rejectPromise = reject;
18− });
19−
20− this.resolvePromise = () => {
21− if (this.settled) {
22− return;
23− }
24− this.settled = true;
25− resolvePromise?.();
26− };
27− this.rejectPromise = (error) => {
28− if (this.settled) {
29− return;
30− }
31− this.settled = true;
32− rejectPromise?.(error);
33− };
34− }
35−
36− push(chunk: unknown): void {
37− if (this.settled) {
38− return;
39− }
40−
41− const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
42− this.buffer = `${this.buffer}${text.replace(/\r/g, "")}`;
43− if (this.buffer.includes(LISTENING_LOG_FRAGMENT)) {
44− this.resolvePromise();
45− return;
46− }
47−
48− if (this.buffer.length > MAX_BUFFER_CHARS) {
49− this.buffer = this.buffer.slice(-MAX_BUFFER_CHARS);
50− }
51− }
52−
53− fail(error: unknown): void {
54− this.rejectPromise(error);
55− }
56−}
apps/desktop/src/settings/DesktopAppSettings.test.ts+284−0
@@ -0,0 +1,284 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { assert, describe, it } from "@effect/vitest";
3+import * as Effect from "effect/Effect";
4+import * as FileSystem from "effect/FileSystem";
5+import * as Layer from "effect/Layer";
6+import * as Option from "effect/Option";
7+import * as Schema from "effect/Schema";
8+
9+import * as DesktopConfig from "../app/DesktopConfig.ts";
10+import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
11+import {
12+ DEFAULT_DESKTOP_SETTINGS,
13+ resolveDefaultDesktopSettings,
14+ type DesktopSettings as DesktopSettingsValue,
15+} from "./DesktopAppSettings.ts";
16+import * as DesktopAppSettings from "./DesktopAppSettings.ts";
17+
18+const DesktopSettingsPatch = Schema.Struct({
19+ serverExposureMode: Schema.optionalKey(Schema.Literals(["local-only", "network-accessible"])),
20+ tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean),
21+ tailscaleServePort: Schema.optionalKey(Schema.Number),
22+ updateChannel: Schema.optionalKey(Schema.Literals(["latest", "nightly"])),
23+ updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean),
24+});
25+
26+const decodeDesktopSettingsPatch = Schema.decodeEffect(Schema.fromJsonString(DesktopSettingsPatch));
27+const encodeDesktopSettingsPatch = Schema.encodeEffect(Schema.fromJsonString(DesktopSettingsPatch));
28+
29+function makeEnvironmentLayer(baseDir: string, appVersion = "0.0.17") {
30+ return DesktopEnvironment.layer({
31+ dirname: "/repo/apps/desktop/src",
32+ homeDirectory: baseDir,
33+ platform: "darwin",
34+ processArch: "x64",
35+ appVersion,
36+ appPath: "/repo",
37+ isPackaged: true,
38+ resourcesPath: "/missing/resources",
39+ runningUnderArm64Translation: false,
40+ }).pipe(
41+ Layer.provide(
42+ Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })),
43+ ),
44+ );
45+}
46+
47+const withSettings = <A, E, R>(
48+ effect: Effect.Effect<
49+ A,
50+ E,
51+ R | DesktopAppSettings.DesktopAppSettings | DesktopEnvironment.DesktopEnvironment
52+ >,
53+ options?: { readonly appVersion?: string },
54+) =>
55+ Effect.gen(function* () {
56+ const fileSystem = yield* FileSystem.FileSystem;
57+ const baseDir = yield* fileSystem.makeTempDirectoryScoped({
58+ prefix: "t3-desktop-settings-test-",
59+ });
60+ return yield* effect.pipe(
61+ Effect.provide(
62+ DesktopAppSettings.layer.pipe(
63+ Layer.provideMerge(makeEnvironmentLayer(baseDir, options?.appVersion)),
64+ Layer.provideMerge(NodeServices.layer),
65+ ),
66+ ),
67+ );
68+ }).pipe(Effect.provide(NodeServices.layer), Effect.scoped);
69+
70+function writeSettingsPatch(patch: typeof DesktopSettingsPatch.Type) {
71+ return Effect.gen(function* () {
72+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
73+ const fileSystem = yield* FileSystem.FileSystem;
74+ const encoded = yield* encodeDesktopSettingsPatch(patch);
75+ yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
76+ yield* fileSystem.writeFileString(environment.desktopSettingsPath, `${encoded}\n`);
77+ });
78+}
79+
80+describe("DesktopSettings", () => {
81+ it.effect("loads defaults when no settings file exists", () =>
82+ withSettings(
83+ Effect.gen(function* () {
84+ const settings = yield* DesktopAppSettings.DesktopAppSettings;
85+ assert.deepEqual(yield* settings.load, DEFAULT_DESKTOP_SETTINGS);
86+ assert.deepEqual(yield* settings.get, DEFAULT_DESKTOP_SETTINGS);
87+ }),
88+ ),
89+ );
90+
91+ it("defaults packaged nightly builds to the nightly update channel", () => {
92+ assert.deepEqual(resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), {
93+ serverExposureMode: "local-only",
94+ tailscaleServeEnabled: false,
95+ tailscaleServePort: 443,
96+ updateChannel: "nightly",
97+ updateChannelConfiguredByUser: false,
98+ } satisfies DesktopSettingsValue);
99+ });
100+
101+ it.effect("loads persisted settings and applies semantic updates", () =>
102+ withSettings(
103+ Effect.gen(function* () {
104+ const settings = yield* DesktopAppSettings.DesktopAppSettings;
105+ yield* writeSettingsPatch({
106+ serverExposureMode: "network-accessible",
107+ tailscaleServeEnabled: true,
108+ tailscaleServePort: 8443,
109+ updateChannel: "latest",
110+ updateChannelConfiguredByUser: true,
111+ });
112+
113+ assert.deepEqual(yield* settings.load, {
114+ serverExposureMode: "network-accessible",
115+ tailscaleServeEnabled: true,
116+ tailscaleServePort: 8443,
117+ updateChannel: "latest",
118+ updateChannelConfiguredByUser: true,
119+ } satisfies DesktopSettingsValue);
120+
121+ const exposure = yield* settings.setServerExposureMode("local-only");
122+ assert.isTrue(exposure.changed);
123+ assert.equal(exposure.settings.serverExposureMode, "local-only");
124+
125+ const tailscale = yield* settings.setTailscaleServe({
126+ enabled: true,
127+ port: Option.some(9443),
128+ });
129+ assert.isTrue(tailscale.changed);
130+ assert.equal(tailscale.settings.tailscaleServePort, 9443);
131+
132+ const updateChannel = yield* settings.setUpdateChannel("nightly");
133+ assert.isTrue(updateChannel.changed);
134+ assert.equal(updateChannel.settings.updateChannel, "nightly");
135+ assert.equal(updateChannel.settings.updateChannelConfiguredByUser, true);
136+ }),
137+ ),
138+ );
139+
140+ it.effect("does not persist no-op semantic updates", () =>
141+ withSettings(
142+ Effect.gen(function* () {
143+ const settings = yield* DesktopAppSettings.DesktopAppSettings;
144+
145+ const exposure = yield* settings.setServerExposureMode("local-only");
146+ assert.isFalse(exposure.changed);
147+
148+ const tailscale = yield* settings.setTailscaleServe({
149+ enabled: false,
150+ port: Option.none(),
151+ });
152+ assert.isFalse(tailscale.changed);
153+
154+ const updateChannel = yield* settings.setUpdateChannel("latest");
155+ assert.isFalse(updateChannel.changed);
156+ assert.equal(updateChannel.settings.updateChannelConfiguredByUser, false);
157+ }),
158+ ),
159+ );
160+
161+ it.effect("falls back to defaults when the settings file is malformed", () =>
162+ withSettings(
163+ Effect.gen(function* () {
164+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
165+ const fileSystem = yield* FileSystem.FileSystem;
166+ const settings = yield* DesktopAppSettings.DesktopAppSettings;
167+ yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
168+ yield* fileSystem.writeFileString(environment.desktopSettingsPath, "{not-json");
169+
170+ assert.deepEqual(yield* settings.load, DEFAULT_DESKTOP_SETTINGS);
171+ }),
172+ ),
173+ );
174+
175+ it.effect("loads lenient persisted desktop settings JSON", () =>
176+ withSettings(
177+ Effect.gen(function* () {
178+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
179+ const fileSystem = yield* FileSystem.FileSystem;
180+ const settings = yield* DesktopAppSettings.DesktopAppSettings;
181+ yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
182+ yield* fileSystem.writeFileString(
183+ environment.desktopSettingsPath,
184+ `{
185+ // JSONC-style comments and trailing commas match server settings parsing.
186+ "serverExposureMode": "network-accessible",
187+ "tailscaleServeEnabled": true,
188+ "tailscaleServePort": 8443,
189+ }\n`,
190+ );
191+
192+ assert.deepEqual(yield* settings.load, {
193+ serverExposureMode: "network-accessible",
194+ tailscaleServeEnabled: true,
195+ tailscaleServePort: 8443,
196+ updateChannel: "latest",
197+ updateChannelConfiguredByUser: false,
198+ } satisfies DesktopSettingsValue);
199+ }),
200+ ),
201+ );
202+
203+ it.effect("persists sparse desktop settings documents", () =>
204+ withSettings(
205+ Effect.gen(function* () {
206+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
207+ const fileSystem = yield* FileSystem.FileSystem;
208+ const settings = yield* DesktopAppSettings.DesktopAppSettings;
209+
210+ yield* settings.setServerExposureMode("network-accessible");
211+
212+ const persisted = yield* decodeDesktopSettingsPatch(
213+ yield* fileSystem.readFileString(environment.desktopSettingsPath),
214+ );
215+ assert.deepEqual(persisted, {
216+ serverExposureMode: "network-accessible",
217+ } satisfies typeof DesktopSettingsPatch.Type);
218+ }),
219+ ),
220+ );
221+
222+ it.effect("migrates legacy implicit update channels to the runtime default", () =>
223+ withSettings(
224+ Effect.gen(function* () {
225+ const settings = yield* DesktopAppSettings.DesktopAppSettings;
226+ yield* writeSettingsPatch({
227+ serverExposureMode: "local-only",
228+ updateChannel: "latest",
229+ });
230+
231+ assert.deepEqual(yield* settings.load, {
232+ serverExposureMode: "local-only",
233+ tailscaleServeEnabled: false,
234+ tailscaleServePort: 443,
235+ updateChannel: "nightly",
236+ updateChannelConfiguredByUser: false,
237+ } satisfies DesktopSettingsValue);
238+ }),
239+ { appVersion: "0.0.17-nightly.20260415.1" },
240+ ),
241+ );
242+
243+ it.effect("preserves explicit stable update channel on nightly builds", () =>
244+ withSettings(
245+ Effect.gen(function* () {
246+ const settings = yield* DesktopAppSettings.DesktopAppSettings;
247+ yield* writeSettingsPatch({
248+ serverExposureMode: "local-only",
249+ updateChannel: "latest",
250+ updateChannelConfiguredByUser: true,
251+ });
252+
253+ assert.deepEqual(yield* settings.load, {
254+ serverExposureMode: "local-only",
255+ tailscaleServeEnabled: false,
256+ tailscaleServePort: 443,
257+ updateChannel: "latest",
258+ updateChannelConfiguredByUser: true,
259+ } satisfies DesktopSettingsValue);
260+ }),
261+ { appVersion: "0.0.17-nightly.20260415.1" },
262+ ),
263+ );
264+
265+ it.effect("normalizes invalid persisted Tailscale Serve ports", () =>
266+ withSettings(
267+ Effect.gen(function* () {
268+ const settings = yield* DesktopAppSettings.DesktopAppSettings;
269+ yield* writeSettingsPatch({
270+ tailscaleServeEnabled: true,
271+ tailscaleServePort: 0,
272+ });
273+
274+ assert.deepEqual(yield* settings.load, {
275+ serverExposureMode: "local-only",
276+ tailscaleServeEnabled: true,
277+ tailscaleServePort: 443,
278+ updateChannel: "latest",
279+ updateChannelConfiguredByUser: false,
280+ } satisfies DesktopSettingsValue);
281+ }),
282+ ),
283+ );
284+});
apps/desktop/src/settings/DesktopAppSettings.ts+318−0
@@ -0,0 +1,318 @@
1+import {
2+ DesktopServerExposureModeSchema,
3+ DesktopUpdateChannelSchema,
4+ type DesktopServerExposureMode,
5+ type DesktopUpdateChannel,
6+} from "@t3tools/contracts";
7+import { fromLenientJson } from "@t3tools/shared/schemaJson";
8+import * as Context from "effect/Context";
9+import * as Data from "effect/Data";
10+import * as Effect from "effect/Effect";
11+import * as FileSystem from "effect/FileSystem";
12+import * as Layer from "effect/Layer";
13+import * as Option from "effect/Option";
14+import * as Path from "effect/Path";
15+import * as PlatformError from "effect/PlatformError";
16+import * as Random from "effect/Random";
17+import * as Schema from "effect/Schema";
18+import * as SynchronizedRef from "effect/SynchronizedRef";
19+
20+import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
21+import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts";
22+
23+export interface DesktopSettings {
24+ readonly serverExposureMode: DesktopServerExposureMode;
25+ readonly tailscaleServeEnabled: boolean;
26+ readonly tailscaleServePort: number;
27+ readonly updateChannel: DesktopUpdateChannel;
28+ readonly updateChannelConfiguredByUser: boolean;
29+}
30+
31+export interface DesktopSettingsChange {
32+ readonly settings: DesktopSettings;
33+ readonly changed: boolean;
34+}
35+
36+export const DEFAULT_TAILSCALE_SERVE_PORT = 443;
37+
38+export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
39+ serverExposureMode: "local-only",
40+ tailscaleServeEnabled: false,
41+ tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT,
42+ updateChannel: "latest",
43+ updateChannelConfiguredByUser: false,
44+};
45+
46+const DesktopSettingsDocument = Schema.Struct({
47+ serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema),
48+ tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean),
49+ tailscaleServePort: Schema.optionalKey(Schema.Number),
50+ updateChannel: Schema.optionalKey(DesktopUpdateChannelSchema),
51+ updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean),
52+});
53+
54+type DesktopSettingsDocument = typeof DesktopSettingsDocument.Type;
55+type Mutable<T> = { -readonly [K in keyof T]: T[K] };
56+
57+const DesktopSettingsJson = fromLenientJson(DesktopSettingsDocument);
58+const decodeDesktopSettingsJson = Schema.decodeEffect(DesktopSettingsJson);
59+const encodeDesktopSettingsJson = Schema.encodeEffect(DesktopSettingsJson);
60+
61+const settingsChange = (settings: DesktopSettings, changed: boolean): DesktopSettingsChange => ({
62+ settings,
63+ changed,
64+});
65+
66+export class DesktopSettingsWriteError extends Data.TaggedError("DesktopSettingsWriteError")<{
67+ readonly cause: PlatformError.PlatformError | Schema.SchemaError;
68+}> {
69+ override get message() {
70+ return `Failed to write desktop settings: ${this.cause.message}`;
71+ }
72+}
73+
74+export interface DesktopAppSettingsShape {
75+ readonly load: Effect.Effect<DesktopSettings>;
76+ readonly get: Effect.Effect<DesktopSettings>;
77+ readonly setServerExposureMode: (
78+ mode: DesktopServerExposureMode,
79+ ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;
80+ readonly setTailscaleServe: (input: {
81+ readonly enabled: boolean;
82+ readonly port: Option.Option<number>;
83+ }) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;
84+ readonly setUpdateChannel: (
85+ channel: DesktopUpdateChannel,
86+ ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;
87+}
88+
89+export class DesktopAppSettings extends Context.Service<
90+ DesktopAppSettings,
91+ DesktopAppSettingsShape
92+>()("t3/desktop/AppSettings") {}
93+
94+export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings {
95+ return {
96+ ...DEFAULT_DESKTOP_SETTINGS,
97+ updateChannel: resolveDefaultDesktopUpdateChannel(appVersion),
98+ };
99+}
100+
101+function normalizeTailscaleServePort(value: unknown): number {
102+ return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65_535
103+ ? value
104+ : DEFAULT_TAILSCALE_SERVE_PORT;
105+}
106+
107+function normalizeDesktopSettingsDocument(
108+ parsed: DesktopSettingsDocument,
109+ appVersion: string,
110+): DesktopSettings {
111+ const defaultSettings = resolveDefaultDesktopSettings(appVersion);
112+ const parsedUpdateChannel = Option.fromNullishOr(parsed.updateChannel);
113+ const isLegacySettings = parsed.updateChannelConfiguredByUser === undefined;
114+ const updateChannelConfiguredByUser =
115+ parsed.updateChannelConfiguredByUser === true ||
116+ (isLegacySettings && Option.contains(parsedUpdateChannel, "nightly"));
117+
118+ return {
119+ serverExposureMode:
120+ parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only",
121+ tailscaleServeEnabled: parsed.tailscaleServeEnabled === true,
122+ tailscaleServePort: normalizeTailscaleServePort(parsed.tailscaleServePort),
123+ updateChannel: updateChannelConfiguredByUser
124+ ? Option.getOrElse(parsedUpdateChannel, () => defaultSettings.updateChannel)
125+ : defaultSettings.updateChannel,
126+ updateChannelConfiguredByUser,
127+ };
128+}
129+
130+function toDesktopSettingsDocument(
131+ settings: DesktopSettings,
132+ defaults: DesktopSettings,
133+): DesktopSettingsDocument {
134+ const document: Mutable<DesktopSettingsDocument> = {};
135+
136+ if (settings.serverExposureMode !== defaults.serverExposureMode) {
137+ document.serverExposureMode = settings.serverExposureMode;
138+ }
139+ if (settings.tailscaleServeEnabled !== defaults.tailscaleServeEnabled) {
140+ document.tailscaleServeEnabled = settings.tailscaleServeEnabled;
141+ }
142+ if (settings.tailscaleServePort !== defaults.tailscaleServePort) {
143+ document.tailscaleServePort = settings.tailscaleServePort;
144+ }
145+ if (settings.updateChannel !== defaults.updateChannel) {
146+ document.updateChannel = settings.updateChannel;
147+ }
148+ if (settings.updateChannelConfiguredByUser !== defaults.updateChannelConfiguredByUser) {
149+ document.updateChannelConfiguredByUser = settings.updateChannelConfiguredByUser;
150+ }
151+
152+ return document;
153+}
154+
155+function setServerExposureMode(
156+ settings: DesktopSettings,
157+ requestedMode: DesktopServerExposureMode,
158+): DesktopSettings {
159+ return settings.serverExposureMode === requestedMode
160+ ? settings
161+ : {
162+ ...settings,
163+ serverExposureMode: requestedMode,
164+ };
165+}
166+
167+function setTailscaleServe(
168+ settings: DesktopSettings,
169+ input: { readonly enabled: boolean; readonly port: Option.Option<number> },
170+): DesktopSettings {
171+ const port = Option.match(input.port, {
172+ onNone: () => settings.tailscaleServePort,
173+ onSome: normalizeTailscaleServePort,
174+ });
175+ return settings.tailscaleServeEnabled === input.enabled && settings.tailscaleServePort === port
176+ ? settings
177+ : {
178+ ...settings,
179+ tailscaleServeEnabled: input.enabled,
180+ tailscaleServePort: port,
181+ };
182+}
183+
184+function setUpdateChannel(
185+ settings: DesktopSettings,
186+ requestedChannel: DesktopUpdateChannel,
187+): DesktopSettings {
188+ return settings.updateChannel === requestedChannel
189+ ? settings
190+ : {
191+ ...settings,
192+ updateChannel: requestedChannel,
193+ updateChannelConfiguredByUser: true,
194+ };
195+}
196+
197+function readSettings(
198+ fileSystem: FileSystem.FileSystem,
199+ settingsPath: string,
200+ appVersion: string,
201+): Effect.Effect<DesktopSettings> {
202+ const defaultSettings = resolveDefaultDesktopSettings(appVersion);
203+
204+ return fileSystem.readFileString(settingsPath).pipe(
205+ Effect.option,
206+ Effect.flatMap(
207+ Option.match({
208+ onNone: () => Effect.succeed(defaultSettings),
209+ onSome: (raw) =>
210+ decodeDesktopSettingsJson(raw).pipe(
211+ Effect.map((parsed) => normalizeDesktopSettingsDocument(parsed, appVersion)),
212+ Effect.catch(() => Effect.succeed(defaultSettings)),
213+ ),
214+ }),
215+ ),
216+ );
217+}
218+
219+const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (input: {
220+ readonly fileSystem: FileSystem.FileSystem;
221+ readonly path: Path.Path;
222+ readonly settingsPath: string;
223+ readonly settings: DesktopSettings;
224+ readonly defaultSettings: DesktopSettings;
225+}): Effect.fn.Return<void, PlatformError.PlatformError | Schema.SchemaError> {
226+ const directory = input.path.dirname(input.settingsPath);
227+ const suffix = (yield* Random.nextUUIDv4).replace(/-/g, "");
228+ const tempPath = `${input.settingsPath}.${process.pid}.${suffix}.tmp`;
229+ const encoded = yield* encodeDesktopSettingsJson(
230+ toDesktopSettingsDocument(input.settings, input.defaultSettings),
231+ );
232+ yield* input.fileSystem.makeDirectory(directory, { recursive: true });
233+ yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`);
234+ yield* input.fileSystem.rename(tempPath, input.settingsPath);
235+});
236+
237+export const layer = Layer.effect(
238+ DesktopAppSettings,
239+ Effect.gen(function* () {
240+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
241+ const fileSystem = yield* FileSystem.FileSystem;
242+ const path = yield* Path.Path;
243+ const settingsRef = yield* SynchronizedRef.make(environment.defaultDesktopSettings);
244+
245+ const persist = (
246+ update: (settings: DesktopSettings) => DesktopSettings,
247+ ): Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError> =>
248+ SynchronizedRef.modifyEffect(settingsRef, (settings) => {
249+ const nextSettings = update(settings);
250+ if (nextSettings === settings) {
251+ return Effect.succeed([settingsChange(settings, false), settings] as const);
252+ }
253+
254+ return writeSettings({
255+ fileSystem,
256+ path,
257+ settingsPath: environment.desktopSettingsPath,
258+ settings: nextSettings,
259+ defaultSettings: environment.defaultDesktopSettings,
260+ }).pipe(
261+ Effect.mapError((cause) => new DesktopSettingsWriteError({ cause })),
262+ Effect.as([settingsChange(nextSettings, true), nextSettings] as const),
263+ );
264+ });
265+
266+ return DesktopAppSettings.of({
267+ get: SynchronizedRef.get(settingsRef),
268+ load: Effect.gen(function* () {
269+ const settings = yield* readSettings(
270+ fileSystem,
271+ environment.desktopSettingsPath,
272+ environment.appVersion,
273+ );
274+ return yield* SynchronizedRef.setAndGet(settingsRef, settings);
275+ }).pipe(Effect.withSpan("desktop.settings.load")),
276+ setServerExposureMode: (mode) =>
277+ persist((settings) => setServerExposureMode(settings, mode)).pipe(
278+ Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }),
279+ ),
280+ setTailscaleServe: (input) =>
281+ persist((settings) => setTailscaleServe(settings, input)).pipe(
282+ Effect.withSpan("desktop.settings.setTailscaleServe", { attributes: input }),
283+ ),
284+ setUpdateChannel: (channel) =>
285+ persist((settings) => setUpdateChannel(settings, channel)).pipe(
286+ Effect.withSpan("desktop.settings.setUpdateChannel", { attributes: { channel } }),
287+ ),
288+ });
289+ }),
290+);
291+
292+export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SETTINGS) =>
293+ Layer.effect(
294+ DesktopAppSettings,
295+ Effect.gen(function* () {
296+ const settingsRef = yield* SynchronizedRef.make(initialSettings);
297+ const update = (f: (settings: DesktopSettings) => DesktopSettings) =>
298+ SynchronizedRef.modify(settingsRef, (settings) => {
299+ const nextSettings = f(settings);
300+ return [
301+ {
302+ settings: nextSettings,
303+ changed: nextSettings !== settings,
304+ },
305+ nextSettings,
306+ ] as const;
307+ });
308+
309+ return DesktopAppSettings.of({
310+ get: SynchronizedRef.get(settingsRef),
311+ load: SynchronizedRef.get(settingsRef),
312+ setServerExposureMode: (mode) =>
313+ update((settings) => setServerExposureMode(settings, mode)),
314+ setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)),
315+ setUpdateChannel: (channel) => update((settings) => setUpdateChannel(settings, channel)),
316+ });
317+ }),
318+ );
apps/desktop/src/settings/DesktopClientSettings.test.ts+185−0
@@ -0,0 +1,185 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { assert, describe, it } from "@effect/vitest";
3+import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts";
4+import * as Effect from "effect/Effect";
5+import * as FileSystem from "effect/FileSystem";
6+import * as Layer from "effect/Layer";
7+import * as Option from "effect/Option";
8+import * as Schema from "effect/Schema";
9+
10+import * as DesktopConfig from "../app/DesktopConfig.ts";
11+import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
12+import * as DesktopClientSettings from "./DesktopClientSettings.ts";
13+
14+const clientSettings: ClientSettings = {
15+ autoOpenPlanSidebar: false,
16+ confirmThreadArchive: true,
17+ confirmThreadDelete: false,
18+ dismissedProviderUpdateNotificationKeys: [],
19+ diffIgnoreWhitespace: true,
20+ diffWordWrap: true,
21+ favorites: [],
22+ providerModelPreferences: {},
23+ sidebarProjectGroupingMode: "repository_path",
24+ sidebarProjectGroupingOverrides: {
25+ "environment-1:/tmp/project-a": "separate",
26+ },
27+ sidebarProjectSortOrder: "manual",
28+ sidebarThreadSortOrder: "created_at",
29+ sidebarThreadPreviewCount: 6,
30+ timestampFormat: "24-hour",
31+};
32+
33+const decodeClientSettingsJson = Schema.decodeEffect(Schema.fromJsonString(ClientSettingsSchema));
34+const decodeRecordJson = Schema.decodeEffect(
35+ Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)),
36+);
37+
38+function makeLayer(baseDir: string) {
39+ const environmentLayer = DesktopEnvironment.layer({
40+ dirname: "/repo/apps/desktop/src",
41+ homeDirectory: baseDir,
42+ platform: "darwin",
43+ processArch: "x64",
44+ appVersion: "1.2.3",
45+ appPath: "/repo",
46+ isPackaged: true,
47+ resourcesPath: "/missing/resources",
48+ runningUnderArm64Translation: false,
49+ }).pipe(
50+ Layer.provide(
51+ Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })),
52+ ),
53+ );
54+
55+ return DesktopClientSettings.layer.pipe(
56+ Layer.provideMerge(environmentLayer),
57+ Layer.provideMerge(NodeServices.layer),
58+ );
59+}
60+
61+const withClientSettings = <A, E, R>(
62+ effect: Effect.Effect<A, E, R | DesktopClientSettings.DesktopClientSettings>,
63+) =>
64+ Effect.gen(function* () {
65+ const fileSystem = yield* FileSystem.FileSystem;
66+ const baseDir = yield* fileSystem.makeTempDirectoryScoped({
67+ prefix: "t3-desktop-client-settings-test-",
68+ });
69+ return yield* effect.pipe(Effect.provide(makeLayer(baseDir)));
70+ }).pipe(Effect.provide(NodeServices.layer), Effect.scoped);
71+
72+describe("DesktopClientSettings", () => {
73+ it.effect("returns none when no client settings file exists", () =>
74+ withClientSettings(
75+ Effect.gen(function* () {
76+ const settings = yield* DesktopClientSettings.DesktopClientSettings;
77+ assert.isTrue(Option.isNone(yield* settings.get));
78+ }),
79+ ),
80+ );
81+
82+ it.effect("persists and reloads client settings", () =>
83+ withClientSettings(
84+ Effect.gen(function* () {
85+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
86+ const fileSystem = yield* FileSystem.FileSystem;
87+ const settings = yield* DesktopClientSettings.DesktopClientSettings;
88+ yield* settings.set(clientSettings);
89+
90+ assert.deepEqual(yield* settings.get, Option.some(clientSettings));
91+ assert.deepEqual(
92+ yield* decodeClientSettingsJson(
93+ yield* fileSystem.readFileString(environment.clientSettingsPath),
94+ ),
95+ clientSettings,
96+ );
97+ assert.isFalse(
98+ Object.hasOwn(
99+ yield* decodeRecordJson(
100+ yield* fileSystem.readFileString(environment.clientSettingsPath),
101+ ),
102+ "settings",
103+ ),
104+ );
105+ }),
106+ ),
107+ );
108+
109+ it.effect("loads lenient direct client settings documents", () =>
110+ withClientSettings(
111+ Effect.gen(function* () {
112+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
113+ const fileSystem = yield* FileSystem.FileSystem;
114+ const settings = yield* DesktopClientSettings.DesktopClientSettings;
115+ yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
116+ yield* fileSystem.writeFileString(
117+ environment.clientSettingsPath,
118+ `{
119+ // Matches server settings parsing.
120+ "timestampFormat": "24-hour",
121+ }\n`,
122+ );
123+
124+ const persisted = yield* settings.get;
125+ assert.isTrue(Option.isSome(persisted));
126+ if (Option.isSome(persisted)) {
127+ assert.equal(persisted.value.timestampFormat, "24-hour");
128+ }
129+ }),
130+ ),
131+ );
132+
133+ it.effect("loads legacy wrapped client settings documents", () =>
134+ withClientSettings(
135+ Effect.gen(function* () {
136+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
137+ const fileSystem = yield* FileSystem.FileSystem;
138+ const settings = yield* DesktopClientSettings.DesktopClientSettings;
139+ yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
140+ yield* fileSystem.writeFileString(
141+ environment.clientSettingsPath,
142+ `{
143+ "settings": {
144+ "timestampFormat": "12-hour"
145+ }
146+ }\n`,
147+ );
148+
149+ const persisted = yield* settings.get;
150+ assert.isTrue(Option.isSome(persisted));
151+ if (Option.isSome(persisted)) {
152+ assert.equal(persisted.value.timestampFormat, "12-hour");
153+ }
154+ }),
155+ ),
156+ );
157+
158+ it.effect("loads defaults from empty client settings documents", () =>
159+ withClientSettings(
160+ Effect.gen(function* () {
161+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
162+ const fileSystem = yield* FileSystem.FileSystem;
163+ const settings = yield* DesktopClientSettings.DesktopClientSettings;
164+ yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
165+ yield* fileSystem.writeFileString(environment.clientSettingsPath, "{}\n");
166+
167+ assert.deepEqual(yield* settings.get, Option.some(yield* decodeClientSettingsJson("{}")));
168+ }),
169+ ),
170+ );
171+
172+ it.effect("treats malformed client settings documents as absent", () =>
173+ withClientSettings(
174+ Effect.gen(function* () {
175+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
176+ const fileSystem = yield* FileSystem.FileSystem;
177+ const settings = yield* DesktopClientSettings.DesktopClientSettings;
178+ yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
179+ yield* fileSystem.writeFileString(environment.clientSettingsPath, "{not-json");
180+
181+ assert.isTrue(Option.isNone(yield* settings.get));
182+ }),
183+ ),
184+ );
185+});
apps/desktop/src/settings/DesktopClientSettings.ts+118−0
@@ -0,0 +1,118 @@
1+import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts";
2+import { fromLenientJson } from "@t3tools/shared/schemaJson";
3+import * as Context from "effect/Context";
4+import * as Data from "effect/Data";
5+import * as Effect from "effect/Effect";
6+import * as FileSystem from "effect/FileSystem";
7+import * as Layer from "effect/Layer";
8+import * as Option from "effect/Option";
9+import * as Path from "effect/Path";
10+import * as PlatformError from "effect/PlatformError";
11+import * as Random from "effect/Random";
12+import * as Schema from "effect/Schema";
13+import * as Ref from "effect/Ref";
14+
15+import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
16+
17+const ClientSettingsDocumentSchema = Schema.Struct({
18+ settings: ClientSettingsSchema,
19+});
20+
21+const ClientSettingsJson = fromLenientJson(ClientSettingsSchema);
22+const LegacyClientSettingsDocumentJson = fromLenientJson(ClientSettingsDocumentSchema);
23+const decodeClientSettingsJson = (raw: string): Effect.Effect<ClientSettings, Schema.SchemaError> =>
24+ Schema.decodeEffect(LegacyClientSettingsDocumentJson)(raw).pipe(
25+ Effect.map((document) => document.settings),
26+ Effect.catch(() => Schema.decodeEffect(ClientSettingsJson)(raw)),
27+ );
28+const encodeClientSettingsJson = Schema.encodeEffect(ClientSettingsJson);
29+
30+export class DesktopClientSettingsWriteError extends Data.TaggedError(
31+ "DesktopClientSettingsWriteError",
32+)<{
33+ readonly cause: PlatformError.PlatformError | Schema.SchemaError;
34+}> {
35+ override get message() {
36+ return `Failed to write desktop client settings: ${this.cause.message}`;
37+ }
38+}
39+
40+export interface DesktopClientSettingsShape {
41+ readonly get: Effect.Effect<Option.Option<ClientSettings>>;
42+ readonly set: (settings: ClientSettings) => Effect.Effect<void, DesktopClientSettingsWriteError>;
43+}
44+
45+export class DesktopClientSettings extends Context.Service<
46+ DesktopClientSettings,
47+ DesktopClientSettingsShape
48+>()("t3/desktop/ClientSettings") {}
49+
50+const readClientSettings = (
51+ fileSystem: FileSystem.FileSystem,
52+ settingsPath: string,
53+): Effect.Effect<Option.Option<ClientSettings>> =>
54+ fileSystem.readFileString(settingsPath).pipe(
55+ Effect.option,
56+ Effect.flatMap(
57+ Option.match({
58+ onNone: () => Effect.succeed(Option.none<ClientSettings>()),
59+ onSome: (raw) =>
60+ decodeClientSettingsJson(raw).pipe(
61+ Effect.map((settings) => Option.some(settings)),
62+ Effect.catch(() => Effect.succeed(Option.none<ClientSettings>())),
63+ ),
64+ }),
65+ ),
66+ );
67+
68+const writeClientSettings = Effect.fnUntraced(function* (input: {
69+ readonly fileSystem: FileSystem.FileSystem;
70+ readonly path: Path.Path;
71+ readonly settingsPath: string;
72+ readonly settings: ClientSettings;
73+}): Effect.fn.Return<void, PlatformError.PlatformError | Schema.SchemaError> {
74+ const directory = input.path.dirname(input.settingsPath);
75+ const suffix = (yield* Random.nextUUIDv4).replace(/-/g, "");
76+ const tempPath = `${input.settingsPath}.${process.pid}.${suffix}.tmp`;
77+ const encoded = yield* encodeClientSettingsJson(input.settings);
78+ yield* input.fileSystem.makeDirectory(directory, { recursive: true });
79+ yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`);
80+ yield* input.fileSystem.rename(tempPath, input.settingsPath);
81+});
82+
83+export const layer = Layer.effect(
84+ DesktopClientSettings,
85+ Effect.gen(function* () {
86+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
87+ const fileSystem = yield* FileSystem.FileSystem;
88+ const path = yield* Path.Path;
89+
90+ return DesktopClientSettings.of({
91+ get: readClientSettings(fileSystem, environment.clientSettingsPath).pipe(
92+ Effect.withSpan("desktop.clientSettings.get"),
93+ ),
94+ set: (settings) =>
95+ writeClientSettings({
96+ fileSystem,
97+ path,
98+ settingsPath: environment.clientSettingsPath,
99+ settings,
100+ }).pipe(
101+ Effect.mapError((cause) => new DesktopClientSettingsWriteError({ cause })),
102+ Effect.withSpan("desktop.clientSettings.set"),
103+ ),
104+ });
105+ }),
106+);
107+
108+export const layerTest = (initialSettings: Option.Option<ClientSettings> = Option.none()) =>
109+ Layer.effect(
110+ DesktopClientSettings,
111+ Effect.gen(function* () {
112+ const settingsRef = yield* Ref.make(initialSettings);
113+ return DesktopClientSettings.of({
114+ get: Ref.get(settingsRef),
115+ set: (settings) => Ref.set(settingsRef, Option.some(settings)),
116+ });
117+ }),
118+ );
apps/desktop/src/settings/DesktopSavedEnvironments.test.ts+344−0
@@ -0,0 +1,344 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { assert, describe, it } from "@effect/vitest";
3+import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts";
4+import * as Effect from "effect/Effect";
5+import * as FileSystem from "effect/FileSystem";
6+import * as Layer from "effect/Layer";
7+import * as Option from "effect/Option";
8+import * as Schema from "effect/Schema";
9+
10+import * as DesktopConfig from "../app/DesktopConfig.ts";
11+import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
12+import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
13+import * as DesktopSavedEnvironments from "./DesktopSavedEnvironments.ts";
14+
15+const textDecoder = new TextDecoder();
16+const textEncoder = new TextEncoder();
17+
18+const savedRegistryRecord: PersistedSavedEnvironmentRecord = {
19+ environmentId: EnvironmentId.make("environment-1"),
20+ label: "Remote environment",
21+ httpBaseUrl: "https://remote.example.com/",
22+ wsBaseUrl: "wss://remote.example.com/",
23+ createdAt: "2026-04-09T00:00:00.000Z",
24+ lastConnectedAt: "2026-04-09T01:00:00.000Z",
25+ desktopSsh: {
26+ alias: "devbox",
27+ hostname: "devbox.example.com",
28+ username: "julius",
29+ port: 22,
30+ },
31+};
32+
33+const SavedEnvironmentRegistryDocumentProbe = Schema.Struct({
34+ version: Schema.Number,
35+ records: Schema.Array(Schema.Unknown),
36+});
37+const decodeSavedEnvironmentRegistryDocumentProbe = Schema.decodeEffect(
38+ Schema.fromJsonString(SavedEnvironmentRegistryDocumentProbe),
39+);
40+
41+function makeSafeStorageLayer(input: {
42+ readonly available: boolean;
43+ readonly availabilityError?: unknown;
44+ readonly encryptError?: unknown;
45+ readonly decryptError?: unknown;
46+}) {
47+ return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, {
48+ isEncryptionAvailable:
49+ input.availabilityError === undefined
50+ ? Effect.succeed(input.available)
51+ : Effect.fail(
52+ new ElectronSafeStorage.ElectronSafeStorageAvailabilityError({
53+ cause: input.availabilityError,
54+ }),
55+ ),
56+ encryptString: (value) =>
57+ input.encryptError === undefined
58+ ? Effect.succeed(textEncoder.encode(`enc:${value}`))
59+ : Effect.fail(
60+ new ElectronSafeStorage.ElectronSafeStorageEncryptError({
61+ cause: input.encryptError,
62+ }),
63+ ),
64+ decryptString: (value) => {
65+ if (input.decryptError !== undefined) {
66+ return Effect.fail(
67+ new ElectronSafeStorage.ElectronSafeStorageDecryptError({
68+ cause: input.decryptError,
69+ }),
70+ );
71+ }
72+
73+ const decoded = textDecoder.decode(value);
74+ if (!decoded.startsWith("enc:")) {
75+ return Effect.fail(
76+ new ElectronSafeStorage.ElectronSafeStorageDecryptError({
77+ cause: new Error("invalid secret"),
78+ }),
79+ );
80+ }
81+ return Effect.succeed(decoded.slice("enc:".length));
82+ },
83+ } satisfies ElectronSafeStorage.ElectronSafeStorageShape);
84+}
85+
86+function makeLayer(
87+ baseDir: string,
88+ options?: {
89+ readonly availableSecretStorage?: boolean;
90+ readonly availabilityError?: unknown;
91+ readonly encryptError?: unknown;
92+ readonly decryptError?: unknown;
93+ },
94+) {
95+ const environmentLayer = DesktopEnvironment.layer({
96+ dirname: "/repo/apps/desktop/src",
97+ homeDirectory: baseDir,
98+ platform: "darwin",
99+ processArch: "x64",
100+ appVersion: "1.2.3",
101+ appPath: "/repo",
102+ isPackaged: true,
103+ resourcesPath: "/missing/resources",
104+ runningUnderArm64Translation: false,
105+ }).pipe(
106+ Layer.provide(
107+ Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })),
108+ ),
109+ );
110+
111+ return DesktopSavedEnvironments.layer.pipe(
112+ Layer.provideMerge(environmentLayer),
113+ Layer.provideMerge(
114+ makeSafeStorageLayer({
115+ available: options?.availableSecretStorage ?? true,
116+ availabilityError: options?.availabilityError,
117+ encryptError: options?.encryptError,
118+ decryptError: options?.decryptError,
119+ }),
120+ ),
121+ Layer.provideMerge(NodeServices.layer),
122+ );
123+}
124+
125+const withSavedEnvironments = <A, E, R>(
126+ effect: Effect.Effect<A, E, R | DesktopSavedEnvironments.DesktopSavedEnvironments>,
127+ options?: {
128+ readonly availableSecretStorage?: boolean;
129+ readonly availabilityError?: unknown;
130+ readonly encryptError?: unknown;
131+ readonly decryptError?: unknown;
132+ },
133+) =>
134+ Effect.gen(function* () {
135+ const fileSystem = yield* FileSystem.FileSystem;
136+ const baseDir = yield* fileSystem.makeTempDirectoryScoped({
137+ prefix: "t3-desktop-saved-environments-test-",
138+ });
139+ return yield* effect.pipe(Effect.provide(makeLayer(baseDir, options)));
140+ }).pipe(Effect.provide(NodeServices.layer), Effect.scoped);
141+
142+describe("DesktopSavedEnvironments", () => {
143+ it.effect("persists and reloads saved environment metadata", () =>
144+ withSavedEnvironments(
145+ Effect.gen(function* () {
146+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
147+ const fileSystem = yield* FileSystem.FileSystem;
148+ const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
149+ yield* savedEnvironments.setRegistry([savedRegistryRecord]);
150+
151+ assert.deepEqual(yield* savedEnvironments.getRegistry, [savedRegistryRecord]);
152+ const persisted = yield* decodeSavedEnvironmentRegistryDocumentProbe(
153+ yield* fileSystem.readFileString(environment.savedEnvironmentRegistryPath),
154+ );
155+ assert.equal(persisted.version, 1);
156+ assert.lengthOf(persisted.records, 1);
157+ }),
158+ ),
159+ );
160+
161+ it.effect("loads lenient saved environment registry documents", () =>
162+ withSavedEnvironments(
163+ Effect.gen(function* () {
164+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
165+ const fileSystem = yield* FileSystem.FileSystem;
166+ const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
167+ yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
168+ yield* fileSystem.writeFileString(
169+ environment.savedEnvironmentRegistryPath,
170+ `{
171+ // Same optional envelope shape as browser saved environments.
172+ "version": 1,
173+ "records": [
174+ {
175+ "environmentId": "${savedRegistryRecord.environmentId}",
176+ "label": "Remote environment",
177+ "httpBaseUrl": "https://remote.example.com/",
178+ "wsBaseUrl": "wss://remote.example.com/",
179+ "createdAt": "2026-04-09T00:00:00.000Z",
180+ "lastConnectedAt": "2026-04-09T01:00:00.000Z",
181+ "desktopSsh": {
182+ "alias": "devbox",
183+ "hostname": "devbox.example.com",
184+ "username": "julius",
185+ "port": 22,
186+ },
187+ },
188+ ],
189+ }\n`,
190+ );
191+
192+ assert.deepEqual(yield* savedEnvironments.getRegistry, [savedRegistryRecord]);
193+ }),
194+ ),
195+ );
196+
197+ it.effect("persists encrypted saved environment secrets when encryption is available", () =>
198+ withSavedEnvironments(
199+ Effect.gen(function* () {
200+ const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
201+ yield* savedEnvironments.setRegistry([savedRegistryRecord]);
202+
203+ assert.isTrue(
204+ yield* savedEnvironments.setSecret({
205+ environmentId: savedRegistryRecord.environmentId,
206+ secret: "bearer-token",
207+ }),
208+ );
209+
210+ assert.deepEqual(
211+ yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId),
212+ Option.some("bearer-token"),
213+ );
214+ }),
215+ ),
216+ );
217+
218+ it.effect("returns false when writing secrets while encryption is unavailable", () =>
219+ withSavedEnvironments(
220+ Effect.gen(function* () {
221+ const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
222+ yield* savedEnvironments.setRegistry([savedRegistryRecord]);
223+
224+ assert.isFalse(
225+ yield* savedEnvironments.setSecret({
226+ environmentId: savedRegistryRecord.environmentId,
227+ secret: "next-token",
228+ }),
229+ );
230+ }),
231+ { availableSecretStorage: false },
232+ ),
233+ );
234+
235+ it.effect("surfaces typed safe storage availability failures", () => {
236+ const cause = new Error("safe storage unavailable");
237+ return withSavedEnvironments(
238+ Effect.gen(function* () {
239+ const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
240+ yield* savedEnvironments.setRegistry([savedRegistryRecord]);
241+
242+ const error = yield* savedEnvironments
243+ .setSecret({
244+ environmentId: savedRegistryRecord.environmentId,
245+ secret: "next-token",
246+ })
247+ .pipe(Effect.flip);
248+
249+ assert.instanceOf(error, ElectronSafeStorage.ElectronSafeStorageAvailabilityError);
250+ assert.equal(error.cause, cause);
251+ }),
252+ { availabilityError: cause },
253+ );
254+ });
255+
256+ it.effect("removes saved environment secrets", () =>
257+ withSavedEnvironments(
258+ Effect.gen(function* () {
259+ const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
260+ yield* savedEnvironments.setRegistry([savedRegistryRecord]);
261+ yield* savedEnvironments.setSecret({
262+ environmentId: savedRegistryRecord.environmentId,
263+ secret: "bearer-token",
264+ });
265+
266+ yield* savedEnvironments.removeSecret(savedRegistryRecord.environmentId);
267+
268+ assert.isTrue(
269+ Option.isNone(yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId)),
270+ );
271+ }),
272+ ),
273+ );
274+
275+ it.effect("treats empty saved environment documents as empty", () =>
276+ withSavedEnvironments(
277+ Effect.gen(function* () {
278+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
279+ const fileSystem = yield* FileSystem.FileSystem;
280+ const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
281+ yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
282+ yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, "{}\n");
283+
284+ assert.deepEqual(yield* savedEnvironments.getRegistry, []);
285+ assert.isTrue(
286+ Option.isNone(yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId)),
287+ );
288+ }),
289+ ),
290+ );
291+
292+ it.effect("treats malformed saved environment documents as empty", () =>
293+ withSavedEnvironments(
294+ Effect.gen(function* () {
295+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
296+ const fileSystem = yield* FileSystem.FileSystem;
297+ const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
298+ yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
299+ yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, "{not-json");
300+
301+ assert.deepEqual(yield* savedEnvironments.getRegistry, []);
302+ assert.isTrue(
303+ Option.isNone(yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId)),
304+ );
305+ }),
306+ ),
307+ );
308+
309+ it.effect("returns false when writing a secret without metadata", () =>
310+ withSavedEnvironments(
311+ Effect.gen(function* () {
312+ const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
313+
314+ assert.isFalse(
315+ yield* savedEnvironments.setSecret({
316+ environmentId: savedRegistryRecord.environmentId,
317+ secret: "bearer-token",
318+ }),
319+ );
320+ }),
321+ ),
322+ );
323+
324+ it.effect("preserves encrypted secrets when metadata is rewritten", () =>
325+ withSavedEnvironments(
326+ Effect.gen(function* () {
327+ const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
328+ yield* savedEnvironments.setRegistry([savedRegistryRecord]);
329+ yield* savedEnvironments.setSecret({
330+ environmentId: savedRegistryRecord.environmentId,
331+ secret: "bearer-token",
332+ });
333+
334+ yield* savedEnvironments.setRegistry([savedRegistryRecord]);
335+
336+ assert.deepEqual(yield* savedEnvironments.getRegistry, [savedRegistryRecord]);
337+ assert.deepEqual(
338+ yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId),
339+ Option.some("bearer-token"),
340+ );
341+ }),
342+ ),
343+ );
344+});
apps/desktop/src/settings/DesktopSavedEnvironments.ts+390−0
@@ -0,0 +1,390 @@
1+import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts";
2+import { fromLenientJson } from "@t3tools/shared/schemaJson";
3+import * as Context from "effect/Context";
4+import * as Data from "effect/Data";
5+import * as Effect from "effect/Effect";
6+import * as Encoding from "effect/Encoding";
7+import * as FileSystem from "effect/FileSystem";
8+import * as Layer from "effect/Layer";
9+import * as Option from "effect/Option";
10+import * as Path from "effect/Path";
11+import * as PlatformError from "effect/PlatformError";
12+import * as Random from "effect/Random";
13+import * as Schema from "effect/Schema";
14+import * as Ref from "effect/Ref";
15+
16+import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
17+import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
18+
19+type PersistedSavedEnvironmentDesktopSsh = NonNullable<
20+ PersistedSavedEnvironmentRecord["desktopSsh"]
21+>;
22+
23+interface PersistedSavedEnvironmentStorageRecord extends Omit<
24+ PersistedSavedEnvironmentRecord,
25+ "desktopSsh"
26+> {
27+ readonly desktopSsh?: PersistedSavedEnvironmentDesktopSsh;
28+ readonly encryptedBearerToken?: string;
29+}
30+
31+interface SavedEnvironmentRegistryDocument {
32+ readonly version: number;
33+ readonly records: readonly PersistedSavedEnvironmentStorageRecord[];
34+}
35+
36+interface SavedEnvironmentRegistryStorageDocument {
37+ readonly version?: number;
38+ readonly records?: readonly PersistedSavedEnvironmentStorageRecord[];
39+}
40+
41+const DesktopSshTargetSchema = Schema.Struct({
42+ alias: Schema.String,
43+ hostname: Schema.String,
44+ username: Schema.NullOr(Schema.String),
45+ port: Schema.NullOr(Schema.Number),
46+});
47+
48+const PersistedSavedEnvironmentStorageRecordSchema = Schema.Struct({
49+ environmentId: EnvironmentId,
50+ label: Schema.String,
51+ httpBaseUrl: Schema.String,
52+ wsBaseUrl: Schema.String,
53+ createdAt: Schema.String,
54+ lastConnectedAt: Schema.NullOr(Schema.String),
55+ desktopSsh: Schema.optionalKey(DesktopSshTargetSchema),
56+ encryptedBearerToken: Schema.optionalKey(Schema.String),
57+});
58+
59+const SavedEnvironmentRegistryDocumentSchema = Schema.Struct({
60+ version: Schema.optionalKey(Schema.Number),
61+ records: Schema.optionalKey(Schema.Array(PersistedSavedEnvironmentStorageRecordSchema)),
62+});
63+
64+const SavedEnvironmentRegistryDocumentJson = fromLenientJson(
65+ SavedEnvironmentRegistryDocumentSchema,
66+);
67+const decodeSavedEnvironmentRegistryDocumentJson = Schema.decodeEffect(
68+ SavedEnvironmentRegistryDocumentJson,
69+);
70+const encodeSavedEnvironmentRegistryDocumentJson = Schema.encodeEffect(
71+ SavedEnvironmentRegistryDocumentJson,
72+);
73+
74+export class DesktopSavedEnvironmentsWriteError extends Data.TaggedError(
75+ "DesktopSavedEnvironmentsWriteError",
76+)<{
77+ readonly cause: PlatformError.PlatformError | Schema.SchemaError;
78+}> {
79+ override get message() {
80+ return `Failed to write desktop saved environments: ${this.cause.message}`;
81+ }
82+}
83+
84+export class DesktopSavedEnvironmentSecretDecodeError extends Data.TaggedError(
85+ "DesktopSavedEnvironmentSecretDecodeError",
86+)<{
87+ readonly cause: Encoding.EncodingError;
88+}> {
89+ override get message() {
90+ return "Failed to decode desktop saved environment secret.";
91+ }
92+}
93+
94+export type DesktopSavedEnvironmentsGetSecretError =
95+ | DesktopSavedEnvironmentSecretDecodeError
96+ | ElectronSafeStorage.ElectronSafeStorageAvailabilityError
97+ | ElectronSafeStorage.ElectronSafeStorageDecryptError;
98+
99+export type DesktopSavedEnvironmentsSetSecretError =
100+ | DesktopSavedEnvironmentsWriteError
101+ | ElectronSafeStorage.ElectronSafeStorageAvailabilityError
102+ | ElectronSafeStorage.ElectronSafeStorageEncryptError;
103+
104+export interface DesktopSavedEnvironmentsShape {
105+ readonly getRegistry: Effect.Effect<readonly PersistedSavedEnvironmentRecord[]>;
106+ readonly setRegistry: (
107+ records: readonly PersistedSavedEnvironmentRecord[],
108+ ) => Effect.Effect<void, DesktopSavedEnvironmentsWriteError>;
109+ readonly getSecret: (
110+ environmentId: string,
111+ ) => Effect.Effect<Option.Option<string>, DesktopSavedEnvironmentsGetSecretError>;
112+ readonly setSecret: (input: {
113+ readonly environmentId: string;
114+ readonly secret: string;
115+ }) => Effect.Effect<boolean, DesktopSavedEnvironmentsSetSecretError>;
116+ readonly removeSecret: (
117+ environmentId: string,
118+ ) => Effect.Effect<void, DesktopSavedEnvironmentsWriteError>;
119+}
120+
121+export class DesktopSavedEnvironments extends Context.Service<
122+ DesktopSavedEnvironments,
123+ DesktopSavedEnvironmentsShape
124+>()("t3/desktop/SavedEnvironments") {}
125+
126+function toPersistedSavedEnvironmentRecord(
127+ record: PersistedSavedEnvironmentStorageRecord,
128+): PersistedSavedEnvironmentRecord {
129+ const nextRecord = {
130+ environmentId: record.environmentId,
131+ label: record.label,
132+ httpBaseUrl: record.httpBaseUrl,
133+ wsBaseUrl: record.wsBaseUrl,
134+ createdAt: record.createdAt,
135+ lastConnectedAt: record.lastConnectedAt,
136+ };
137+ return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord;
138+}
139+
140+function toSavedEnvironmentStorageRecord(
141+ record: PersistedSavedEnvironmentRecord | PersistedSavedEnvironmentStorageRecord,
142+ encryptedBearerToken: Option.Option<string>,
143+): PersistedSavedEnvironmentStorageRecord {
144+ const nextRecord = {
145+ environmentId: record.environmentId,
146+ label: record.label,
147+ httpBaseUrl: record.httpBaseUrl,
148+ wsBaseUrl: record.wsBaseUrl,
149+ createdAt: record.createdAt,
150+ lastConnectedAt: record.lastConnectedAt,
151+ };
152+ const desktopSsh = record.desktopSsh;
153+ if (desktopSsh) {
154+ return Option.match(encryptedBearerToken, {
155+ onNone: () => ({ ...nextRecord, desktopSsh }),
156+ onSome: (value) => ({
157+ ...nextRecord,
158+ desktopSsh,
159+ encryptedBearerToken: value,
160+ }),
161+ });
162+ }
163+ return Option.match(encryptedBearerToken, {
164+ onNone: () => nextRecord,
165+ onSome: (value) => ({ ...nextRecord, encryptedBearerToken: value }),
166+ });
167+}
168+
169+function normalizeSavedEnvironmentRegistryDocument(
170+ document: SavedEnvironmentRegistryStorageDocument,
171+): SavedEnvironmentRegistryDocument {
172+ return {
173+ version: document.version ?? 1,
174+ records: document.records ?? [],
175+ };
176+}
177+
178+function readRegistryDocument(
179+ fileSystem: FileSystem.FileSystem,
180+ registryPath: string,
181+): Effect.Effect<SavedEnvironmentRegistryDocument> {
182+ return fileSystem.readFileString(registryPath).pipe(
183+ Effect.option,
184+ Effect.flatMap(
185+ Option.match({
186+ onNone: () => Effect.succeed({ version: 1, records: [] }),
187+ onSome: (raw) =>
188+ decodeSavedEnvironmentRegistryDocumentJson(raw).pipe(
189+ Effect.map(normalizeSavedEnvironmentRegistryDocument),
190+ Effect.catch(() => Effect.succeed({ version: 1, records: [] })),
191+ ),
192+ }),
193+ ),
194+ );
195+}
196+
197+const writeRegistryDocument = Effect.fn("desktop.savedEnvironments.writeRegistryDocument")(
198+ function* (input: {
199+ readonly fileSystem: FileSystem.FileSystem;
200+ readonly path: Path.Path;
201+ readonly registryPath: string;
202+ readonly document: SavedEnvironmentRegistryDocument;
203+ }): Effect.fn.Return<void, PlatformError.PlatformError | Schema.SchemaError> {
204+ const directory = input.path.dirname(input.registryPath);
205+ const suffix = (yield* Random.nextUUIDv4).replace(/-/g, "");
206+ const tempPath = `${input.registryPath}.${process.pid}.${suffix}.tmp`;
207+ const encoded = yield* encodeSavedEnvironmentRegistryDocumentJson(input.document);
208+ yield* input.fileSystem.makeDirectory(directory, { recursive: true });
209+ yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`);
210+ yield* input.fileSystem.rename(tempPath, input.registryPath);
211+ },
212+);
213+
214+function preserveExistingSecrets(
215+ currentDocument: SavedEnvironmentRegistryDocument,
216+ records: readonly PersistedSavedEnvironmentRecord[],
217+): SavedEnvironmentRegistryDocument {
218+ const encryptedBearerTokenById = new Map(
219+ currentDocument.records.flatMap((record) =>
220+ record.encryptedBearerToken
221+ ? [[record.environmentId, record.encryptedBearerToken] as const]
222+ : [],
223+ ),
224+ );
225+
226+ return {
227+ version: currentDocument.version,
228+ records: records.map((record) => {
229+ const encryptedBearerToken = encryptedBearerTokenById.get(record.environmentId);
230+ return toSavedEnvironmentStorageRecord(record, Option.fromNullishOr(encryptedBearerToken));
231+ }),
232+ };
233+}
234+
235+function decodeSecretBytes(
236+ encoded: string,
237+): Effect.Effect<Uint8Array, DesktopSavedEnvironmentSecretDecodeError> {
238+ return Effect.fromResult(Encoding.decodeBase64(encoded)).pipe(
239+ Effect.mapError((cause) => new DesktopSavedEnvironmentSecretDecodeError({ cause })),
240+ );
241+}
242+
243+export const layer = Layer.effect(
244+ DesktopSavedEnvironments,
245+ Effect.gen(function* () {
246+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
247+ const fileSystem = yield* FileSystem.FileSystem;
248+ const path = yield* Path.Path;
249+ const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage;
250+
251+ const writeDocument = (document: SavedEnvironmentRegistryDocument) =>
252+ writeRegistryDocument({
253+ fileSystem,
254+ path,
255+ registryPath: environment.savedEnvironmentRegistryPath,
256+ document,
257+ }).pipe(Effect.mapError((cause) => new DesktopSavedEnvironmentsWriteError({ cause })));
258+
259+ return DesktopSavedEnvironments.of({
260+ getRegistry: readRegistryDocument(fileSystem, environment.savedEnvironmentRegistryPath).pipe(
261+ Effect.map((document) =>
262+ document.records.map((record) => toPersistedSavedEnvironmentRecord(record)),
263+ ),
264+ Effect.withSpan("desktop.savedEnvironments.getRegistry"),
265+ ),
266+ setRegistry: Effect.fn("desktop.savedEnvironments.setRegistry")(function* (records) {
267+ const currentDocument = yield* readRegistryDocument(
268+ fileSystem,
269+ environment.savedEnvironmentRegistryPath,
270+ );
271+ yield* writeDocument(preserveExistingSecrets(currentDocument, records));
272+ }),
273+ getSecret: Effect.fn("desktop.savedEnvironments.getSecret")(function* (environmentId) {
274+ yield* Effect.annotateCurrentSpan({ environmentId });
275+ const document = yield* readRegistryDocument(
276+ fileSystem,
277+ environment.savedEnvironmentRegistryPath,
278+ );
279+ const encoded = Option.fromNullishOr(
280+ document.records.find((record) => record.environmentId === environmentId)
281+ ?.encryptedBearerToken,
282+ );
283+ if (Option.isNone(encoded) || !(yield* safeStorage.isEncryptionAvailable)) {
284+ return Option.none<string>();
285+ }
286+
287+ const secretBytes = yield* decodeSecretBytes(encoded.value);
288+ return Option.some(yield* safeStorage.decryptString(secretBytes));
289+ }),
290+ setSecret: Effect.fn("desktop.savedEnvironments.setSecret")(function* (input) {
291+ const { environmentId, secret } = input;
292+ yield* Effect.annotateCurrentSpan({ environmentId });
293+ const document = yield* readRegistryDocument(
294+ fileSystem,
295+ environment.savedEnvironmentRegistryPath,
296+ );
297+
298+ if (!(yield* safeStorage.isEncryptionAvailable)) {
299+ return false;
300+ }
301+
302+ const encryptedBearerToken = Encoding.encodeBase64(
303+ yield* safeStorage.encryptString(secret),
304+ );
305+ let found = false;
306+ const nextDocument: SavedEnvironmentRegistryDocument = {
307+ version: document.version,
308+ records: document.records.map((record) => {
309+ if (record.environmentId !== environmentId) {
310+ return record;
311+ }
312+
313+ found = true;
314+ return toSavedEnvironmentStorageRecord(record, Option.some(encryptedBearerToken));
315+ }),
316+ };
317+
318+ if (found) {
319+ yield* writeDocument(nextDocument);
320+ }
321+ return found;
322+ }),
323+ removeSecret: Effect.fn("desktop.savedEnvironments.removeSecret")(function* (environmentId) {
324+ yield* Effect.annotateCurrentSpan({ environmentId });
325+ const document = yield* readRegistryDocument(
326+ fileSystem,
327+ environment.savedEnvironmentRegistryPath,
328+ );
329+ if (
330+ !document.records.some(
331+ (record) =>
332+ record.environmentId === environmentId && record.encryptedBearerToken !== undefined,
333+ )
334+ ) {
335+ return;
336+ }
337+
338+ yield* writeDocument({
339+ version: document.version,
340+ records: document.records.map((record) => {
341+ if (record.environmentId !== environmentId) {
342+ return record;
343+ }
344+ return toPersistedSavedEnvironmentRecord(record);
345+ }),
346+ });
347+ }),
348+ });
349+ }),
350+);
351+
352+export const layerTest = (input?: {
353+ readonly records?: readonly PersistedSavedEnvironmentRecord[];
354+ readonly secrets?: ReadonlyMap<string, string>;
355+}) =>
356+ Layer.effect(
357+ DesktopSavedEnvironments,
358+ Effect.gen(function* () {
359+ const recordsRef = yield* Ref.make(input?.records ?? []);
360+ const secretsRef = yield* Ref.make(new Map(input?.secrets ?? []));
361+
362+ return DesktopSavedEnvironments.of({
363+ getRegistry: Ref.get(recordsRef),
364+ setRegistry: (records) => Ref.set(recordsRef, records),
365+ getSecret: (environmentId) =>
366+ Ref.get(secretsRef).pipe(
367+ Effect.map((secrets) => Option.fromNullishOr(secrets.get(environmentId))),
368+ ),
369+ setSecret: ({ environmentId, secret }) =>
370+ Ref.get(recordsRef).pipe(
371+ Effect.flatMap((records) => {
372+ if (!records.some((record) => record.environmentId === environmentId)) {
373+ return Effect.succeed(false);
374+ }
375+ return Ref.update(secretsRef, (secrets) => {
376+ const nextSecrets = new Map(secrets);
377+ nextSecrets.set(environmentId, secret);
378+ return nextSecrets;
379+ }).pipe(Effect.as(true));
380+ }),
381+ ),
382+ removeSecret: (environmentId) =>
383+ Ref.update(secretsRef, (secrets) => {
384+ const nextSecrets = new Map(secrets);
385+ nextSecrets.delete(environmentId);
386+ return nextSecrets;
387+ }),
388+ });
389+ }),
390+ );
apps/desktop/src/shell/DesktopShellEnvironment.test.ts+232−0
@@ -0,0 +1,232 @@
1+import { assert, describe, it } from "@effect/vitest";
2+import * as Effect from "effect/Effect";
3+import * as Layer from "effect/Layer";
4+import * as Sink from "effect/Sink";
5+import * as Stream from "effect/Stream";
6+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
7+
8+import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
9+import * as DesktopShellEnvironment from "./DesktopShellEnvironment.ts";
10+
11+const textEncoder = new TextEncoder();
12+
13+function envOutput(values: Readonly<Record<string, string>>): string {
14+ return Object.entries(values)
15+ .flatMap(([name, value]) => [
16+ `__T3CODE_ENV_${name}_START__`,
17+ value,
18+ `__T3CODE_ENV_${name}_END__`,
19+ ])
20+ .join("\n");
21+}
22+
23+function makeProcess(output: string): ChildProcessSpawner.ChildProcessHandle {
24+ const stdout = output.length === 0 ? Stream.empty : Stream.make(textEncoder.encode(output));
25+ return ChildProcessSpawner.makeHandle({
26+ pid: ChildProcessSpawner.ProcessId(123),
27+ stdout,
28+ stderr: Stream.empty,
29+ all: stdout,
30+ exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
31+ isRunning: Effect.succeed(false),
32+ kill: () => Effect.void,
33+ stdin: Sink.drain,
34+ getInputFd: () => Sink.drain,
35+ getOutputFd: () => Stream.empty,
36+ unref: Effect.succeed(Effect.void),
37+ });
38+}
39+
40+function withProcessEnv<A, E, R>(
41+ env: NodeJS.ProcessEnv,
42+ effect: Effect.Effect<A, E, R>,
43+): Effect.Effect<A, E, R> {
44+ return Effect.acquireUseRelease(
45+ Effect.sync(() => {
46+ const previous = process.env;
47+ process.env = env;
48+ return previous;
49+ }),
50+ () => effect,
51+ (previous) =>
52+ Effect.sync(() => {
53+ process.env = previous;
54+ }),
55+ );
56+}
57+
58+function runShellEnvironment(input: {
59+ readonly env: NodeJS.ProcessEnv;
60+ readonly platform: NodeJS.Platform;
61+ readonly handler: (command: ChildProcess.Command) => string;
62+}) {
63+ const environmentLayer = Layer.succeed(
64+ DesktopEnvironment.DesktopEnvironment,
65+ DesktopEnvironment.DesktopEnvironment.of({
66+ platform: input.platform,
67+ } as DesktopEnvironment.DesktopEnvironmentShape),
68+ );
69+ const spawnerLayer = Layer.succeed(
70+ ChildProcessSpawner.ChildProcessSpawner,
71+ ChildProcessSpawner.make((command) => Effect.succeed(makeProcess(input.handler(command)))),
72+ );
73+
74+ const program = Effect.gen(function* () {
75+ const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment;
76+ yield* shellEnvironment.installIntoProcess;
77+ }).pipe(
78+ Effect.provide(
79+ DesktopShellEnvironment.layer.pipe(
80+ Layer.provide(Layer.mergeAll(environmentLayer, spawnerLayer)),
81+ ),
82+ ),
83+ );
84+
85+ return withProcessEnv(input.env, program);
86+}
87+
88+describe("DesktopShellEnvironment", () => {
89+ it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () =>
90+ Effect.gen(function* () {
91+ const env: NodeJS.ProcessEnv = {
92+ SHELL: "/bin/zsh",
93+ PATH: "/Users/test/.local/bin:/usr/bin",
94+ };
95+ const commands: ChildProcess.Command[] = [];
96+
97+ yield* runShellEnvironment({
98+ env,
99+ platform: "darwin",
100+ handler: (command) => {
101+ commands.push(command);
102+ return envOutput({
103+ PATH: "/opt/homebrew/bin:/usr/bin",
104+ SSH_AUTH_SOCK: "/tmp/secretive.sock",
105+ HOMEBREW_PREFIX: "/opt/homebrew",
106+ });
107+ },
108+ });
109+
110+ assert.equal(commands.length, 1);
111+ assert.equal(commands[0]?._tag === "StandardCommand" ? commands[0].command : "", "/bin/zsh");
112+ assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin");
113+ assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock");
114+ assert.equal(env.HOMEBREW_PREFIX, "/opt/homebrew");
115+ }),
116+ );
117+
118+ it.effect("preserves inherited POSIX values when present", () =>
119+ Effect.gen(function* () {
120+ const env: NodeJS.ProcessEnv = {
121+ SHELL: "/bin/zsh",
122+ PATH: "/usr/bin",
123+ SSH_AUTH_SOCK: "/tmp/inherited.sock",
124+ };
125+
126+ yield* runShellEnvironment({
127+ env,
128+ platform: "darwin",
129+ handler: () =>
130+ envOutput({
131+ PATH: "/opt/homebrew/bin:/usr/bin",
132+ SSH_AUTH_SOCK: "/tmp/login-shell.sock",
133+ }),
134+ });
135+
136+ assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin");
137+ assert.equal(env.SSH_AUTH_SOCK, "/tmp/inherited.sock");
138+ }),
139+ );
140+
141+ it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on linux", () =>
142+ Effect.gen(function* () {
143+ const env: NodeJS.ProcessEnv = {
144+ SHELL: "/bin/zsh",
145+ PATH: "/usr/bin",
146+ };
147+
148+ yield* runShellEnvironment({
149+ env,
150+ platform: "linux",
151+ handler: () =>
152+ envOutput({
153+ PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin",
154+ SSH_AUTH_SOCK: "/tmp/secretive.sock",
155+ }),
156+ });
157+
158+ assert.equal(env.PATH, "/home/linuxbrew/.linuxbrew/bin:/usr/bin");
159+ assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock");
160+ }),
161+ );
162+
163+ it.effect("falls back to launchctl PATH on macOS when shell probing does not return one", () =>
164+ Effect.gen(function* () {
165+ const env: NodeJS.ProcessEnv = {
166+ SHELL: "/opt/homebrew/bin/nu",
167+ PATH: "/usr/bin",
168+ };
169+ const commands: string[] = [];
170+
171+ yield* runShellEnvironment({
172+ env,
173+ platform: "darwin",
174+ handler: (command) => {
175+ if (command._tag !== "StandardCommand") return "";
176+ commands.push(command.command);
177+ return command.command === "/bin/launchctl" ? "/opt/homebrew/bin:/usr/bin" : "";
178+ },
179+ });
180+
181+ assert.deepEqual(commands, ["/opt/homebrew/bin/nu", "/bin/zsh", "/bin/launchctl"]);
182+ assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin");
183+ }),
184+ );
185+
186+ it.effect("loads PowerShell profile environment on Windows", () =>
187+ Effect.gen(function* () {
188+ const env: NodeJS.ProcessEnv = {
189+ PATH: "C:\\Windows\\System32",
190+ APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
191+ LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local",
192+ USERPROFILE: "C:\\Users\\testuser",
193+ };
194+
195+ yield* runShellEnvironment({
196+ env,
197+ platform: "win32",
198+ handler: (command) => {
199+ if (command._tag !== "StandardCommand") return "";
200+ const loadProfile = !command.args.includes("-NoProfile");
201+ return loadProfile
202+ ? envOutput({
203+ PATH: "C:\\Profile\\Node;C:\\Windows\\System32",
204+ FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
205+ FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
206+ })
207+ : envOutput({ PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" });
208+ },
209+ });
210+
211+ assert.equal(
212+ env.PATH,
213+ [
214+ "C:\\Profile\\Node",
215+ "C:\\Windows\\System32",
216+ "C:\\Users\\testuser\\AppData\\Roaming\\npm",
217+ "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
218+ "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
219+ "C:\\Users\\testuser\\AppData\\Local\\pnpm",
220+ "C:\\Users\\testuser\\.bun\\bin",
221+ "C:\\Users\\testuser\\scoop\\shims",
222+ "C:\\Custom\\Bin",
223+ ].join(";"),
224+ );
225+ assert.equal(env.FNM_DIR, "C:\\Users\\testuser\\AppData\\Roaming\\fnm");
226+ assert.equal(
227+ env.FNM_MULTISHELL_PATH,
228+ "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
229+ );
230+ }),
231+ );
232+});
apps/desktop/src/shell/DesktopShellEnvironment.ts+356−0
@@ -0,0 +1,356 @@
1+import * as Context from "effect/Context";
2+import * as Duration from "effect/Duration";
3+import * as Effect from "effect/Effect";
4+import * as Layer from "effect/Layer";
5+import * as Option from "effect/Option";
6+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
7+
8+import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
9+
10+type EnvironmentPatch = Record<string, string>;
11+
12+interface ShellEnvironmentConfig {
13+ readonly env: NodeJS.ProcessEnv;
14+ readonly platform: NodeJS.Platform;
15+ readonly userShell: Option.Option<string>;
16+}
17+
18+interface WindowsProbeOptions {
19+ readonly loadProfile: boolean;
20+}
21+
22+export interface DesktopShellEnvironmentShape {
23+ readonly installIntoProcess: Effect.Effect<void, never>;
24+}
25+
26+export class DesktopShellEnvironment extends Context.Service<
27+ DesktopShellEnvironment,
28+ DesktopShellEnvironmentShape
29+>()("t3/desktop/ShellEnvironment") {}
30+
31+const LOGIN_SHELL_ENV_NAMES = [
32+ "PATH",
33+ "SSH_AUTH_SOCK",
34+ "HOMEBREW_PREFIX",
35+ "HOMEBREW_CELLAR",
36+ "HOMEBREW_REPOSITORY",
37+ "XDG_CONFIG_HOME",
38+ "XDG_DATA_HOME",
39+] as const;
40+const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const;
41+const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const;
42+const LOGIN_SHELL_TIMEOUT = Duration.seconds(5);
43+const LAUNCHCTL_TIMEOUT = Duration.seconds(2);
44+const PROCESS_TERMINATE_GRACE = Duration.seconds(1);
45+
46+const trimNonEmpty = (value: string | null | undefined): Option.Option<string> =>
47+ Option.fromNullishOr(value).pipe(
48+ Option.map((entry) => entry.trim()),
49+ Option.filter((entry) => entry.length > 0),
50+ );
51+
52+const pathDelimiter = (platform: NodeJS.Platform) => (platform === "win32" ? ";" : ":");
53+
54+const readEnvPath = (env: NodeJS.ProcessEnv): Option.Option<string> =>
55+ trimNonEmpty(env.PATH ?? env.Path ?? env.path);
56+
57+const pathComparisonKey = (entry: string, platform: NodeJS.Platform) => {
58+ const normalized = entry.trim().replace(/^"+|"+$/g, "");
59+ return platform === "win32" ? normalized.toLowerCase() : normalized;
60+};
61+
62+const mergePaths = (
63+ platform: NodeJS.Platform,
64+ values: ReadonlyArray<Option.Option<string>>,
65+): Option.Option<string> => {
66+ const delimiter = pathDelimiter(platform);
67+ const entries: string[] = [];
68+ const seen = new Set<string>();
69+
70+ for (const value of values) {
71+ if (Option.isNone(value)) continue;
72+
73+ for (const entry of value.value.split(delimiter)) {
74+ const trimmed = entry.trim();
75+ if (trimmed.length === 0) continue;
76+
77+ const key = pathComparisonKey(trimmed, platform);
78+ if (key.length === 0 || seen.has(key)) continue;
79+
80+ seen.add(key);
81+ entries.push(trimmed);
82+ }
83+ }
84+
85+ return entries.length > 0 ? Option.some(entries.join(delimiter)) : Option.none();
86+};
87+
88+const listLoginShellCandidates = (config: ShellEnvironmentConfig): ReadonlyArray<string> => {
89+ const fallback =
90+ config.platform === "darwin" ? "/bin/zsh" : config.platform === "linux" ? "/bin/bash" : "";
91+ const seen = new Set<string>();
92+ const candidates: string[] = [];
93+
94+ for (const candidate of [
95+ trimNonEmpty(config.env.SHELL),
96+ config.userShell,
97+ trimNonEmpty(fallback),
98+ ]) {
99+ if (Option.isNone(candidate) || seen.has(candidate.value)) continue;
100+ seen.add(candidate.value);
101+ candidates.push(candidate.value);
102+ }
103+
104+ return candidates;
105+};
106+
107+const knownWindowsCliDirs = (env: NodeJS.ProcessEnv): ReadonlyArray<string> => [
108+ ...trimNonEmpty(env.APPDATA).pipe(
109+ Option.match({
110+ onNone: () => [],
111+ onSome: (value) => [`${value}\\npm`],
112+ }),
113+ ),
114+ ...trimNonEmpty(env.LOCALAPPDATA).pipe(
115+ Option.match({
116+ onNone: () => [],
117+ onSome: (value) => [`${value}\\Programs\\nodejs`, `${value}\\Volta\\bin`, `${value}\\pnpm`],
118+ }),
119+ ),
120+ ...trimNonEmpty(env.USERPROFILE).pipe(
121+ Option.match({
122+ onNone: () => [],
123+ onSome: (value) => [`${value}\\.bun\\bin`, `${value}\\scoop\\shims`],
124+ }),
125+ ),
126+];
127+
128+const startMarker = (name: string) => `__T3CODE_ENV_${name}_START__`;
129+const endMarker = (name: string) => `__T3CODE_ENV_${name}_END__`;
130+
131+const capturePosixEnvironmentCommand = (names: ReadonlyArray<string>) =>
132+ names
133+ .map((name) => {
134+ return [
135+ `printf '%s\\n' '${startMarker(name)}'`,
136+ `printenv ${name} || true`,
137+ `printf '%s\\n' '${endMarker(name)}'`,
138+ ].join("; ");
139+ })
140+ .join("; ");
141+
142+const captureWindowsEnvironmentCommand = (names: ReadonlyArray<string>) =>
143+ [
144+ "$ErrorActionPreference = 'Stop'",
145+ ...names.flatMap((name) => {
146+ return [
147+ `Write-Output '${startMarker(name)}'`,
148+ `$value = [Environment]::GetEnvironmentVariable('${name}')`,
149+ "if ($null -ne $value -and $value.Length -gt 0) { Write-Output $value }",
150+ `Write-Output '${endMarker(name)}'`,
151+ ];
152+ }),
153+ ].join("; ");
154+
155+const extractEnvironment = (output: string, names: ReadonlyArray<string>): EnvironmentPatch => {
156+ const environment: EnvironmentPatch = {};
157+
158+ for (const name of names) {
159+ const start = output.indexOf(startMarker(name));
160+ if (start === -1) continue;
161+
162+ const valueStart = start + startMarker(name).length;
163+ const end = output.indexOf(endMarker(name), valueStart);
164+ if (end === -1) continue;
165+
166+ const value = output
167+ .slice(valueStart, end)
168+ .replace(/^\r?\n/, "")
169+ .replace(/\r?\n$/, "");
170+ if (value.length > 0) {
171+ environment[name] = value;
172+ }
173+ }
174+
175+ return environment;
176+};
177+
178+const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")(function* (input: {
179+ readonly command: string;
180+ readonly args: ReadonlyArray<string>;
181+ readonly timeout: Duration.Duration;
182+ readonly shell?: boolean;
183+}): Effect.fn.Return<string, never, ChildProcessSpawner.ChildProcessSpawner> {
184+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
185+ return yield* spawner
186+ .string(
187+ ChildProcess.make(input.command, input.args, {
188+ shell: input.shell ?? false,
189+ stdin: "ignore",
190+ stdout: "pipe",
191+ stderr: "pipe",
192+ killSignal: "SIGTERM",
193+ forceKillAfter: PROCESS_TERMINATE_GRACE,
194+ }),
195+ )
196+ .pipe(
197+ Effect.timeoutOption(input.timeout),
198+ Effect.map(Option.getOrElse(() => "")),
199+ Effect.catch(() => Effect.succeed("")),
200+ );
201+});
202+
203+const readLoginShellEnvironment = (
204+ shell: string,
205+ names: ReadonlyArray<string>,
206+): Effect.Effect<EnvironmentPatch, never, ChildProcessSpawner.ChildProcessSpawner> =>
207+ names.length === 0
208+ ? Effect.succeed({})
209+ : runCommandOutput({
210+ command: shell,
211+ args: ["-ilc", capturePosixEnvironmentCommand(names)],
212+ timeout: LOGIN_SHELL_TIMEOUT,
213+ }).pipe(Effect.map((output) => extractEnvironment(output, names)));
214+
215+const readLaunchctlPath: Effect.Effect<
216+ Option.Option<string>,
217+ never,
218+ ChildProcessSpawner.ChildProcessSpawner
219+> = runCommandOutput({
220+ command: "/bin/launchctl",
221+ args: ["getenv", "PATH"],
222+ timeout: LAUNCHCTL_TIMEOUT,
223+}).pipe(Effect.map(trimNonEmpty));
224+
225+const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEnvironment")(
226+ function* (
227+ names: ReadonlyArray<string>,
228+ options: WindowsProbeOptions,
229+ ): Effect.fn.Return<EnvironmentPatch, never, ChildProcessSpawner.ChildProcessSpawner> {
230+ if (names.length === 0) return {};
231+
232+ const args = [
233+ "-NoLogo",
234+ ...(options.loadProfile ? ([] as const) : (["-NoProfile"] as const)),
235+ "-NonInteractive",
236+ "-Command",
237+ captureWindowsEnvironmentCommand(names),
238+ ];
239+
240+ for (const command of WINDOWS_SHELL_CANDIDATES) {
241+ const output = yield* runCommandOutput({
242+ command,
243+ args,
244+ shell: true,
245+ timeout: LOGIN_SHELL_TIMEOUT,
246+ });
247+ const environment = extractEnvironment(output, names);
248+ if (Object.keys(environment).length > 0) {
249+ return environment;
250+ }
251+ }
252+
253+ return {};
254+ },
255+);
256+
257+const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWindowsEnvironment")(
258+ function* (
259+ config: ShellEnvironmentConfig,
260+ ): Effect.fn.Return<void, never, ChildProcessSpawner.ChildProcessSpawner> {
261+ const noProfile = yield* readWindowsEnvironment(["PATH"], { loadProfile: false });
262+ const profile = yield* readWindowsEnvironment(WINDOWS_PROFILE_ENV_NAMES, {
263+ loadProfile: true,
264+ });
265+ const mergedPath = mergePaths("win32", [
266+ trimNonEmpty(profile.PATH),
267+ trimNonEmpty(knownWindowsCliDirs(config.env).join(";")),
268+ trimNonEmpty(noProfile.PATH),
269+ readEnvPath(config.env),
270+ ]);
271+
272+ if (Option.isSome(mergedPath)) {
273+ config.env.PATH = mergedPath.value;
274+ }
275+ if (!config.env.FNM_DIR && profile.FNM_DIR) {
276+ config.env.FNM_DIR = profile.FNM_DIR;
277+ }
278+ if (!config.env.FNM_MULTISHELL_PATH && profile.FNM_MULTISHELL_PATH) {
279+ config.env.FNM_MULTISHELL_PATH = profile.FNM_MULTISHELL_PATH;
280+ }
281+ },
282+);
283+
284+const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosixEnvironment")(
285+ function* (
286+ config: ShellEnvironmentConfig,
287+ ): Effect.fn.Return<void, never, ChildProcessSpawner.ChildProcessSpawner> {
288+ const shellEnvironment: EnvironmentPatch = {};
289+
290+ for (const shell of listLoginShellCandidates(config)) {
291+ Object.assign(
292+ shellEnvironment,
293+ yield* readLoginShellEnvironment(shell, LOGIN_SHELL_ENV_NAMES),
294+ );
295+ if (shellEnvironment.PATH) break;
296+ }
297+
298+ const launchctlPath =
299+ config.platform === "darwin" && !shellEnvironment.PATH
300+ ? yield* readLaunchctlPath
301+ : Option.none<string>();
302+ const mergedPath = mergePaths(config.platform, [
303+ trimNonEmpty(shellEnvironment.PATH).pipe(Option.orElse(() => launchctlPath)),
304+ readEnvPath(config.env),
305+ ]);
306+
307+ if (Option.isSome(mergedPath)) {
308+ config.env.PATH = mergedPath.value;
309+ }
310+ if (!config.env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) {
311+ config.env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK;
312+ }
313+
314+ for (const name of [
315+ "HOMEBREW_PREFIX",
316+ "HOMEBREW_CELLAR",
317+ "HOMEBREW_REPOSITORY",
318+ "XDG_CONFIG_HOME",
319+ "XDG_DATA_HOME",
320+ ] as const) {
321+ if (!config.env[name] && shellEnvironment[name]) {
322+ config.env[name] = shellEnvironment[name];
323+ }
324+ }
325+ },
326+);
327+
328+const installShellEnvironment = (
329+ config: ShellEnvironmentConfig,
330+): Effect.Effect<void, never, ChildProcessSpawner.ChildProcessSpawner> => {
331+ if (config.platform === "win32") {
332+ return installWindowsEnvironment(config);
333+ }
334+ if (config.platform === "darwin" || config.platform === "linux") {
335+ return installPosixEnvironment(config);
336+ }
337+ return Effect.void;
338+};
339+
340+export const layer = Layer.effect(
341+ DesktopShellEnvironment,
342+ Effect.gen(function* () {
343+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
344+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
345+ return DesktopShellEnvironment.of({
346+ installIntoProcess: installShellEnvironment({
347+ env: process.env,
348+ platform: environment.platform,
349+ userShell: Option.none(),
350+ }).pipe(
351+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
352+ Effect.withSpan("desktop.shellEnvironment.installIntoProcess"),
353+ ),
354+ });
355+ }),
356+);
apps/desktop/src/ssh/DesktopSshEnvironment.test.ts+118−0
@@ -0,0 +1,118 @@
1+import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
2+import * as NodeServices from "@effect/platform-node/NodeServices";
3+import { assert, describe, it } from "@effect/vitest";
4+import * as NetService from "@t3tools/shared/Net";
5+import { SshPasswordPromptError } from "@t3tools/ssh/errors";
6+import * as Effect from "effect/Effect";
7+import * as FileSystem from "effect/FileSystem";
8+import * as Layer from "effect/Layer";
9+import * as Path from "effect/Path";
10+
11+import * as DesktopSshEnvironment from "./DesktopSshEnvironment.ts";
12+import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts";
13+
14+function makeTempHomeDir() {
15+ return Effect.gen(function* () {
16+ const fs = yield* FileSystem.FileSystem;
17+ return yield* fs.makeTempDirectoryScoped({ prefix: "t3-ssh-env-test-" });
18+ });
19+}
20+
21+describe("sshEnvironment", () => {
22+ it("treats password prompt timeouts as cancellable authentication prompts", () => {
23+ assert.equal(
24+ DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation(
25+ new SshPasswordPromptError({
26+ message: "SSH authentication timed out for devbox.",
27+ cause: new DesktopSshPasswordPrompts.DesktopSshPromptTimedOutError({
28+ requestId: "prompt-1",
29+ destination: "devbox",
30+ }),
31+ }),
32+ ),
33+ true,
34+ );
35+ });
36+
37+ it.effect("wires desktop host discovery through the ssh package runtime", () =>
38+ Effect.gen(function* () {
39+ const fs = yield* FileSystem.FileSystem;
40+ const path = yield* Path.Path;
41+ const homeDir = yield* makeTempHomeDir();
42+ const sshDir = path.join(homeDir, ".ssh");
43+ yield* fs.makeDirectory(path.join(sshDir, "config.d"), { recursive: true });
44+ yield* fs.writeFileString(
45+ path.join(sshDir, "config"),
46+ ["Host devbox", " HostName devbox.example.com", "Include config.d/*.conf", ""].join("\n"),
47+ );
48+ yield* fs.writeFileString(
49+ path.join(sshDir, "config.d", "team.conf"),
50+ [
51+ "Host staging",
52+ " HostName staging.example.com",
53+ "Host *",
54+ " ServerAliveInterval 30",
55+ "",
56+ ].join("\n"),
57+ );
58+ yield* fs.writeFileString(
59+ path.join(sshDir, "known_hosts"),
60+ [
61+ "known.example.com ssh-ed25519 AAAA",
62+ "|1|hashed|entry ssh-ed25519 AAAA",
63+ "[bastion.example.com]:2222 ssh-ed25519 AAAA",
64+ "",
65+ ].join("\n"),
66+ );
67+
68+ const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment;
69+ const hosts = yield* sshEnvironment.discoverHosts({ homeDir });
70+ assert.deepEqual(hosts, [
71+ {
72+ alias: "bastion.example.com",
73+ hostname: "bastion.example.com",
74+ username: null,
75+ port: null,
76+ source: "known-hosts",
77+ },
78+ {
79+ alias: "devbox",
80+ hostname: "devbox",
81+ username: null,
82+ port: null,
83+ source: "ssh-config",
84+ },
85+ {
86+ alias: "known.example.com",
87+ hostname: "known.example.com",
88+ username: null,
89+ port: null,
90+ source: "known-hosts",
91+ },
92+ {
93+ alias: "staging",
94+ hostname: "staging",
95+ username: null,
96+ port: null,
97+ source: "ssh-config",
98+ },
99+ ]);
100+ }).pipe(
101+ Effect.provide(
102+ DesktopSshEnvironment.layer().pipe(
103+ Layer.provideMerge(
104+ Layer.succeed(DesktopSshPasswordPrompts.DesktopSshPasswordPrompts, {
105+ request: () => Effect.die("unexpected password prompt request"),
106+ resolve: () => Effect.die("unexpected password prompt resolution"),
107+ cancelPending: () => Effect.void,
108+ }),
109+ ),
110+ Layer.provideMerge(NodeServices.layer),
111+ Layer.provideMerge(NodeHttpClient.layerUndici),
112+ Layer.provideMerge(NetService.layer),
113+ ),
114+ ),
115+ Effect.scoped,
116+ ),
117+ );
118+});
apps/desktop/src/ssh/DesktopSshEnvironment.ts+150−0
@@ -0,0 +1,150 @@
1+import type {
2+ DesktopDiscoveredSshHost,
3+ DesktopSshEnvironmentBootstrap,
4+ DesktopSshEnvironmentTarget,
5+} from "@t3tools/contracts";
6+import { type NetError, NetService } from "@t3tools/shared/Net";
7+import {
8+ SshPasswordPrompt,
9+ type SshPasswordPromptShape,
10+ type SshPasswordRequest,
11+} from "@t3tools/ssh/auth";
12+import { discoverSshHosts } from "@t3tools/ssh/config";
13+import {
14+ SshCommandError,
15+ SshHostDiscoveryError,
16+ SshInvalidTargetError,
17+ SshLaunchError,
18+ SshPairingError,
19+ SshPasswordPromptError,
20+ SshReadinessError,
21+} from "@t3tools/ssh/errors";
22+import { SshEnvironmentManager, type RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel";
23+import * as Context from "effect/Context";
24+import * as Effect from "effect/Effect";
25+import * as FileSystem from "effect/FileSystem";
26+import * as Layer from "effect/Layer";
27+import * as Path from "effect/Path";
28+import { HttpClient } from "effect/unstable/http";
29+import { ChildProcessSpawner } from "effect/unstable/process";
30+
31+import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts";
32+
33+export type DesktopSshEnvironmentRuntimeServices =
34+ | ChildProcessSpawner.ChildProcessSpawner
35+ | FileSystem.FileSystem
36+ | Path.Path
37+ | HttpClient.HttpClient
38+ | NetService;
39+
40+export type DesktopSshEnvironmentOperationError =
41+ | SshCommandError
42+ | SshInvalidTargetError
43+ | SshLaunchError
44+ | SshPairingError
45+ | SshReadinessError
46+ | SshPasswordPromptError
47+ | NetError;
48+
49+export type DesktopSshEnvironmentDiscoverError = SshHostDiscoveryError;
50+
51+export type DesktopSshEnvironmentError =
52+ | DesktopSshEnvironmentDiscoverError
53+ | DesktopSshEnvironmentOperationError;
54+
55+export interface DesktopSshEnvironmentShape {
56+ readonly discoverHosts: (input?: {
57+ readonly homeDir?: string;
58+ }) => Effect.Effect<readonly DesktopDiscoveredSshHost[], DesktopSshEnvironmentDiscoverError>;
59+ readonly ensureEnvironment: (
60+ target: DesktopSshEnvironmentTarget,
61+ options?: { readonly issuePairingToken?: boolean },
62+ ) => Effect.Effect<DesktopSshEnvironmentBootstrap, DesktopSshEnvironmentOperationError>;
63+ readonly disconnectEnvironment: (
64+ target: DesktopSshEnvironmentTarget,
65+ ) => Effect.Effect<void, DesktopSshEnvironmentOperationError>;
66+}
67+
68+export class DesktopSshEnvironment extends Context.Service<
69+ DesktopSshEnvironment,
70+ DesktopSshEnvironmentShape
71+>()("t3/desktop/SshEnvironment") {}
72+
73+export interface DesktopSshEnvironmentLayerOptions {
74+ readonly resolveCliPackageSpec?: () => string;
75+ readonly resolveCliRunner?: Effect.Effect<RemoteT3RunnerOptions>;
76+}
77+
78+function discoverDesktopSshHostsEffect(input?: { readonly homeDir?: string }) {
79+ return discoverSshHosts(input ?? {});
80+}
81+
82+export function isDesktopSshPasswordPromptCancellation(
83+ error: unknown,
84+): error is SshPasswordPromptError {
85+ return (
86+ error instanceof SshPasswordPromptError &&
87+ DesktopSshPasswordPrompts.isDesktopSshPasswordPromptCancellation(error.cause)
88+ );
89+}
90+
91+const makePasswordPrompt = (
92+ prompts: DesktopSshPasswordPrompts.DesktopSshPasswordPromptsShape,
93+): SshPasswordPromptShape => ({
94+ isAvailable: true,
95+ request: (request: SshPasswordRequest) =>
96+ prompts.request(request).pipe(
97+ Effect.mapError(
98+ (cause) =>
99+ new SshPasswordPromptError({
100+ message: cause.message,
101+ cause,
102+ }),
103+ ),
104+ ),
105+});
106+
107+const make = Effect.gen(function* () {
108+ const manager = yield* SshEnvironmentManager;
109+ const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts;
110+ const runtimeContext = yield* Effect.context<DesktopSshEnvironmentRuntimeServices>();
111+ const passwordPrompt = SshPasswordPrompt.of(makePasswordPrompt(prompts));
112+
113+ return DesktopSshEnvironment.of({
114+ discoverHosts: (input) =>
115+ discoverDesktopSshHostsEffect(input).pipe(
116+ Effect.provide(runtimeContext),
117+ Effect.withSpan("desktop.ssh.discoverHosts"),
118+ ),
119+ ensureEnvironment: (target, ensureOptions) =>
120+ manager
121+ .ensureEnvironment(target, ensureOptions)
122+ .pipe(
123+ Effect.provideService(SshPasswordPrompt, passwordPrompt),
124+ Effect.provide(runtimeContext),
125+ Effect.withSpan("desktop.ssh.ensureEnvironment"),
126+ ),
127+ disconnectEnvironment: (target) =>
128+ manager
129+ .disconnectEnvironment(target)
130+ .pipe(
131+ Effect.provideService(SshPasswordPrompt, passwordPrompt),
132+ Effect.provide(runtimeContext),
133+ Effect.withSpan("desktop.ssh.disconnectEnvironment"),
134+ ),
135+ });
136+});
137+
138+export const layer = (options: DesktopSshEnvironmentLayerOptions = {}) =>
139+ Layer.effect(DesktopSshEnvironment, make).pipe(
140+ Layer.provide(
141+ SshEnvironmentManager.layer({
142+ ...(options.resolveCliPackageSpec === undefined
143+ ? {}
144+ : { resolveCliPackageSpec: options.resolveCliPackageSpec }),
145+ ...(options.resolveCliRunner === undefined
146+ ? {}
147+ : { resolveCliRunner: options.resolveCliRunner }),
148+ }),
149+ ),
150+ );
apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts+144−0
@@ -0,0 +1,144 @@
1+import { assert, describe, it } from "@effect/vitest";
2+import * as Duration from "effect/Duration";
3+import * as Effect from "effect/Effect";
4+import * as Fiber from "effect/Fiber";
5+import * as Layer from "effect/Layer";
6+import * as Option from "effect/Option";
7+import { TestClock } from "effect/testing";
8+import type * as Electron from "electron";
9+
10+import * as ElectronWindow from "../electron/ElectronWindow.ts";
11+import * as IpcChannels from "../ipc/channels.ts";
12+import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts";
13+
14+interface SentMessage {
15+ readonly channel: string;
16+ readonly args: readonly unknown[];
17+}
18+
19+function makeTestWindow() {
20+ const listeners = new Map<string, Set<() => void>>();
21+ const sentMessages: SentMessage[] = [];
22+ let destroyed = false;
23+ let minimized = true;
24+ let restored = false;
25+ let focused = false;
26+
27+ const window = {
28+ isDestroyed: () => destroyed,
29+ isMinimized: () => minimized,
30+ restore: () => {
31+ restored = true;
32+ minimized = false;
33+ },
34+ focus: () => {
35+ focused = true;
36+ },
37+ once: (eventName: string, listener: () => void) => {
38+ const eventListeners = listeners.get(eventName) ?? new Set<() => void>();
39+ eventListeners.add(listener);
40+ listeners.set(eventName, eventListeners);
41+ },
42+ removeListener: (eventName: string, listener: () => void) => {
43+ listeners.get(eventName)?.delete(listener);
44+ },
45+ webContents: {
46+ send: (channel: string, ...args: readonly unknown[]) => {
47+ sentMessages.push({ channel, args });
48+ },
49+ },
50+ };
51+
52+ return {
53+ window,
54+ sentMessages,
55+ isRestored: () => restored,
56+ isFocused: () => focused,
57+ close: () => {
58+ destroyed = true;
59+ const closedListeners = [...(listeners.get("closed") ?? [])];
60+ listeners.delete("closed");
61+ for (const listener of closedListeners) {
62+ listener();
63+ }
64+ },
65+ };
66+}
67+
68+function makeElectronWindowLayer(window: ReturnType<typeof makeTestWindow>["window"]) {
69+ return Layer.succeed(
70+ ElectronWindow.ElectronWindow,
71+ ElectronWindow.ElectronWindow.of({
72+ create: () => Effect.die("unexpected BrowserWindow creation"),
73+ main: Effect.succeed(Option.some(window as Electron.BrowserWindow)),
74+ currentMainOrFirst: Effect.succeed(Option.some(window as Electron.BrowserWindow)),
75+ focusedMainOrFirst: Effect.succeed(Option.some(window as Electron.BrowserWindow)),
76+ setMain: () => Effect.void,
77+ clearMain: () => Effect.void,
78+ reveal: () => Effect.void,
79+ sendAll: () => Effect.void,
80+ destroyAll: Effect.void,
81+ syncAllAppearance: () => Effect.void,
82+ }),
83+ );
84+}
85+
86+function makeLayer(window: ReturnType<typeof makeTestWindow>["window"]) {
87+ return DesktopSshPasswordPrompts.layer({ passwordPromptTimeoutMs: 1_000 }).pipe(
88+ Layer.provide(makeElectronWindowLayer(window)),
89+ Layer.provideMerge(TestClock.layer()),
90+ );
91+}
92+
93+describe("DesktopSshPasswordPrompts", () => {
94+ it.effect("sends renderer prompts and resolves them by request id", () => {
95+ const testWindow = makeTestWindow();
96+
97+ return Effect.gen(function* () {
98+ const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts;
99+ const fiber = yield* prompts
100+ .request({
101+ destination: "devbox",
102+ username: "julius",
103+ prompt: "Enter the SSH password.",
104+ attempt: 1,
105+ })
106+ .pipe(Effect.forkScoped);
107+
108+ yield* Effect.yieldNow;
109+ assert.equal(testWindow.sentMessages.length, 1);
110+ const sent = testWindow.sentMessages[0];
111+ assert.ok(sent);
112+ assert.equal(sent.channel, IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL);
113+ const request = sent.args[0] as { readonly requestId: string; readonly destination: string };
114+ assert.equal(request.destination, "devbox");
115+ assert.equal(testWindow.isRestored(), true);
116+ assert.equal(testWindow.isFocused(), true);
117+
118+ yield* prompts.resolve({ requestId: request.requestId, password: "secret" });
119+ assert.equal(yield* Fiber.join(fiber), "secret");
120+ }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped);
121+ });
122+
123+ it.effect("times out pending renderer prompts with a typed error", () => {
124+ const testWindow = makeTestWindow();
125+
126+ return Effect.gen(function* () {
127+ const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts;
128+ const fiber = yield* prompts
129+ .request({
130+ destination: "devbox",
131+ username: null,
132+ prompt: "Enter the SSH password.",
133+ attempt: 1,
134+ })
135+ .pipe(Effect.forkScoped);
136+
137+ yield* Effect.yieldNow;
138+ yield* TestClock.adjust(Duration.millis(1_000));
139+ const error = yield* Fiber.join(fiber).pipe(Effect.flip);
140+ assert.instanceOf(error, DesktopSshPasswordPrompts.DesktopSshPromptTimedOutError);
141+ assert.equal(error.destination, "devbox");
142+ }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped);
143+ });
144+});
apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts+332−0
@@ -0,0 +1,332 @@
1+import type { DesktopSshPasswordPromptRequest } from "@t3tools/contracts";
2+import { DesktopSshPasswordPromptResolutionInputSchema } from "@t3tools/contracts";
3+import type { SshPasswordRequest } from "@t3tools/ssh/auth";
4+import * as Context from "effect/Context";
5+import * as Data from "effect/Data";
6+import * as DateTime from "effect/DateTime";
7+import * as Deferred from "effect/Deferred";
8+import * as Duration from "effect/Duration";
9+import * as Effect from "effect/Effect";
10+import * as Layer from "effect/Layer";
11+import * as Option from "effect/Option";
12+import * as Random from "effect/Random";
13+import * as Ref from "effect/Ref";
14+
15+import * as IpcChannels from "../ipc/channels.ts";
16+import * as ElectronWindow from "../electron/ElectronWindow.ts";
17+
18+const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000;
19+const WINDOW_UNAVAILABLE_MESSAGE = "T3 Code window is not available for SSH authentication.";
20+
21+type DesktopSshPasswordPromptResolutionInput =
22+ typeof DesktopSshPasswordPromptResolutionInputSchema.Type;
23+
24+export class DesktopSshPromptUnavailableError extends Data.TaggedError(
25+ "DesktopSshPromptUnavailableError",
26+)<{
27+ readonly reason: string;
28+}> {
29+ override get message() {
30+ return this.reason;
31+ }
32+}
33+
34+export class DesktopSshPromptWindowUnavailableError extends Data.TaggedError(
35+ "DesktopSshPromptWindowUnavailableError",
36+)<{
37+ readonly destination: string;
38+}> {
39+ override get message() {
40+ return WINDOW_UNAVAILABLE_MESSAGE;
41+ }
42+}
43+
44+export class DesktopSshPromptSendError extends Data.TaggedError("DesktopSshPromptSendError")<{
45+ readonly requestId: string;
46+ readonly destination: string;
47+ readonly cause: unknown;
48+}> {
49+ override get message() {
50+ return WINDOW_UNAVAILABLE_MESSAGE;
51+ }
52+}
53+
54+export class DesktopSshPromptTimedOutError extends Data.TaggedError(
55+ "DesktopSshPromptTimedOutError",
56+)<{
57+ readonly requestId: string;
58+ readonly destination: string;
59+}> {
60+ override get message() {
61+ return `SSH authentication timed out for ${this.destination}.`;
62+ }
63+}
64+
65+export class DesktopSshPromptCancelledError extends Data.TaggedError(
66+ "DesktopSshPromptCancelledError",
67+)<{
68+ readonly requestId: string;
69+ readonly destination: string;
70+ readonly reason: string;
71+}> {
72+ override get message() {
73+ return this.reason;
74+ }
75+}
76+
77+export class DesktopSshPromptInvalidRequestIdError extends Data.TaggedError(
78+ "DesktopSshPromptInvalidRequestIdError",
79+)<{
80+ readonly requestId: string;
81+}> {
82+ override get message() {
83+ return "Invalid SSH password prompt id.";
84+ }
85+}
86+
87+export class DesktopSshPromptExpiredError extends Data.TaggedError("DesktopSshPromptExpiredError")<{
88+ readonly requestId: string;
89+}> {
90+ override get message() {
91+ return "SSH password prompt expired. Try connecting again.";
92+ }
93+}
94+
95+export type DesktopSshPasswordPromptRequestError =
96+ | DesktopSshPromptUnavailableError
97+ | DesktopSshPromptWindowUnavailableError
98+ | DesktopSshPromptSendError
99+ | DesktopSshPromptTimedOutError
100+ | DesktopSshPromptCancelledError;
101+
102+export type DesktopSshPasswordPromptResolveError =
103+ | DesktopSshPromptInvalidRequestIdError
104+ | DesktopSshPromptExpiredError;
105+
106+export type DesktopSshPasswordPromptError =
107+ | DesktopSshPasswordPromptRequestError
108+ | DesktopSshPasswordPromptResolveError;
109+
110+export function isDesktopSshPasswordPromptCancellation(
111+ error: unknown,
112+): error is DesktopSshPromptCancelledError | DesktopSshPromptTimedOutError {
113+ return (
114+ error instanceof DesktopSshPromptCancelledError ||
115+ error instanceof DesktopSshPromptTimedOutError
116+ );
117+}
118+
119+export interface DesktopSshPasswordPromptsShape {
120+ readonly request: (
121+ request: SshPasswordRequest,
122+ ) => Effect.Effect<string, DesktopSshPasswordPromptRequestError>;
123+ readonly resolve: (
124+ input: DesktopSshPasswordPromptResolutionInput,
125+ ) => Effect.Effect<void, DesktopSshPasswordPromptResolveError>;
126+ readonly cancelPending: (reason: string) => Effect.Effect<void>;
127+}
128+
129+export class DesktopSshPasswordPrompts extends Context.Service<
130+ DesktopSshPasswordPrompts,
131+ DesktopSshPasswordPromptsShape
132+>()("t3/desktop/SshPasswordPrompts") {}
133+
134+interface PendingSshPasswordPrompt {
135+ readonly requestId: string;
136+ readonly destination: string;
137+ readonly deferred: Deferred.Deferred<string, DesktopSshPasswordPromptRequestError>;
138+}
139+
140+interface LayerOptions {
141+ readonly passwordPromptTimeoutMs?: number;
142+}
143+
144+const removePending = (
145+ pendingRef: Ref.Ref<Map<string, PendingSshPasswordPrompt>>,
146+ requestId: string,
147+) =>
148+ Ref.modify(pendingRef, (pending) => {
149+ const entry = pending.get(requestId);
150+ if (entry === undefined) {
151+ return [Option.none<PendingSshPasswordPrompt>(), pending] as const;
152+ }
153+
154+ const nextPending = new Map(pending);
155+ nextPending.delete(requestId);
156+ return [Option.some(entry), nextPending] as const;
157+ });
158+
159+const failPending = (
160+ pending: PendingSshPasswordPrompt,
161+ error: DesktopSshPasswordPromptRequestError,
162+) => Deferred.fail(pending.deferred, error).pipe(Effect.asVoid);
163+
164+const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: LayerOptions = {}) {
165+ const electronWindow = yield* ElectronWindow.ElectronWindow;
166+ const pendingRef = yield* Ref.make(new Map<string, PendingSshPasswordPrompt>());
167+ const passwordPromptTimeoutMs =
168+ options.passwordPromptTimeoutMs ?? DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS;
169+
170+ const cancelPending = (reason: string): Effect.Effect<void> =>
171+ Ref.getAndSet(pendingRef, new Map()).pipe(
172+ Effect.flatMap((pending) =>
173+ Effect.forEach(
174+ pending.values(),
175+ (entry) =>
176+ failPending(
177+ entry,
178+ new DesktopSshPromptCancelledError({
179+ requestId: entry.requestId,
180+ destination: entry.destination,
181+ reason,
182+ }),
183+ ),
184+ { discard: true },
185+ ),
186+ ),
187+ Effect.asVoid,
188+ );
189+
190+ yield* Effect.addFinalizer(() =>
191+ cancelPending("SSH password prompt service stopped.").pipe(Effect.ignore),
192+ );
193+
194+ const resolve = Effect.fn("desktop.sshPasswordPrompts.resolve")(function* (
195+ input: DesktopSshPasswordPromptResolutionInput,
196+ ): Effect.fn.Return<void, DesktopSshPasswordPromptResolveError> {
197+ const requestId = input.requestId.trim();
198+ if (requestId.length === 0) {
199+ return yield* new DesktopSshPromptInvalidRequestIdError({ requestId: input.requestId });
200+ }
201+
202+ const pending = yield* removePending(pendingRef, requestId);
203+ if (Option.isNone(pending)) {
204+ return yield* new DesktopSshPromptExpiredError({ requestId });
205+ }
206+
207+ const entry = pending.value;
208+ if (input.password === null) {
209+ yield* failPending(
210+ entry,
211+ new DesktopSshPromptCancelledError({
212+ requestId,
213+ destination: entry.destination,
214+ reason: `SSH authentication cancelled for ${entry.destination}.`,
215+ }),
216+ );
217+ return;
218+ }
219+
220+ yield* Deferred.succeed(entry.deferred, input.password).pipe(Effect.asVoid);
221+ });
222+
223+ const request = Effect.fn("desktop.sshPasswordPrompts.request")(function* (
224+ input: SshPasswordRequest,
225+ ): Effect.fn.Return<string, DesktopSshPasswordPromptRequestError> {
226+ const window = yield* electronWindow.main;
227+ if (Option.isNone(window) || window.value.isDestroyed()) {
228+ return yield* new DesktopSshPromptWindowUnavailableError({
229+ destination: input.destination,
230+ });
231+ }
232+
233+ const requestId = yield* Random.nextUUIDv4;
234+ const now = yield* DateTime.now;
235+ const expiresAt = DateTime.formatIso(
236+ DateTime.add(now, { milliseconds: passwordPromptTimeoutMs }),
237+ );
238+ const promptRequest: DesktopSshPasswordPromptRequest = {
239+ requestId,
240+ destination: input.destination,
241+ username: input.username,
242+ prompt: input.prompt,
243+ expiresAt,
244+ };
245+ const deferred = yield* Deferred.make<string, DesktopSshPasswordPromptRequestError>();
246+ const pending: PendingSshPasswordPrompt = {
247+ requestId,
248+ destination: input.destination,
249+ deferred,
250+ };
251+ yield* Ref.update(pendingRef, (entries) => new Map(entries).set(requestId, pending));
252+
253+ const context = yield* Effect.context();
254+ const runFork = Effect.runForkWith(context);
255+
256+ const cancelOnWindowClosed = () => {
257+ runFork(
258+ removePending(pendingRef, requestId).pipe(
259+ Effect.flatMap((entry) =>
260+ Option.match(entry, {
261+ onNone: () => Effect.void,
262+ onSome: (pending) =>
263+ failPending(
264+ pending,
265+ new DesktopSshPromptCancelledError({
266+ requestId,
267+ destination: input.destination,
268+ reason: "SSH authentication was cancelled because the app window closed.",
269+ }),
270+ ),
271+ }),
272+ ),
273+ ),
274+ );
275+ };
276+ const cleanup = Effect.sync(() => {
277+ if (!window.value.isDestroyed()) {
278+ window.value.removeListener("closed", cancelOnWindowClosed);
279+ }
280+ }).pipe(Effect.andThen(removePending(pendingRef, requestId)), Effect.asVoid);
281+ const waitForPassword = Deferred.await(deferred).pipe(
282+ Effect.timeoutOption(Duration.millis(passwordPromptTimeoutMs)),
283+ Effect.flatMap(
284+ Option.match({
285+ onNone: () =>
286+ Effect.fail(
287+ new DesktopSshPromptTimedOutError({
288+ requestId,
289+ destination: input.destination,
290+ }),
291+ ),
292+ onSome: Effect.succeed,
293+ }),
294+ ),
295+ );
296+
297+ return yield* Effect.try({
298+ try: () => {
299+ if (window.value.isDestroyed()) {
300+ throw new Error(WINDOW_UNAVAILABLE_MESSAGE);
301+ }
302+ window.value.once("closed", cancelOnWindowClosed);
303+ window.value.webContents.send(IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL, promptRequest);
304+ if (window.value.isDestroyed()) {
305+ throw new Error(WINDOW_UNAVAILABLE_MESSAGE);
306+ }
307+ if (window.value.isMinimized()) {
308+ window.value.restore();
309+ }
310+ if (window.value.isDestroyed()) {
311+ throw new Error(WINDOW_UNAVAILABLE_MESSAGE);
312+ }
313+ window.value.focus();
314+ },
315+ catch: (cause) =>
316+ new DesktopSshPromptSendError({
317+ requestId,
318+ destination: input.destination,
319+ cause,
320+ }),
321+ }).pipe(Effect.andThen(waitForPassword), Effect.ensuring(cleanup));
322+ });
323+
324+ return DesktopSshPasswordPrompts.of({
325+ request,
326+ resolve,
327+ cancelPending,
328+ });
329+});
330+
331+export const layer = (options: LayerOptions = {}) =>
332+ Layer.effect(DesktopSshPasswordPrompts, make(options));
apps/desktop/src/ssh/DesktopSshRemoteApi.test.ts+79−0
@@ -0,0 +1,79 @@
1+import { assert, describe, it } from "@effect/vitest";
2+import { SshHttpBridgeError } from "@t3tools/ssh/errors";
3+import * as Effect from "effect/Effect";
4+import * as Layer from "effect/Layer";
5+import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
6+
7+import * as DesktopSshRemoteApi from "./DesktopSshRemoteApi.ts";
8+
9+function jsonResponse(request: HttpClientRequest.HttpClientRequest, body: unknown, status = 200) {
10+ return HttpClientResponse.fromWeb(
11+ request,
12+ new Response(JSON.stringify(body), {
13+ status,
14+ headers: { "content-type": "application/json" },
15+ }),
16+ );
17+}
18+
19+function makeLayer(
20+ handler: (
21+ request: HttpClientRequest.HttpClientRequest,
22+ ) => Effect.Effect<HttpClientResponse.HttpClientResponse>,
23+) {
24+ return DesktopSshRemoteApi.layer.pipe(
25+ Layer.provide(
26+ Layer.succeed(
27+ HttpClient.HttpClient,
28+ HttpClient.make((request) => handler(request)),
29+ ),
30+ ),
31+ );
32+}
33+
34+describe("DesktopSshRemoteApi", () => {
35+ it.effect("fetches and decodes the remote environment descriptor", () => {
36+ const requestUrls: string[] = [];
37+ const layer = makeLayer((request) =>
38+ Effect.sync(() => {
39+ requestUrls.push(request.url);
40+ return jsonResponse(request, {
41+ environmentId: "remote-env",
42+ label: "Remote Devbox",
43+ platform: { os: "linux", arch: "x64" },
44+ serverVersion: "1.2.3",
45+ capabilities: { repositoryIdentity: true },
46+ });
47+ }),
48+ );
49+
50+ return Effect.gen(function* () {
51+ const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi;
52+ const descriptor = yield* remoteApi.fetchEnvironmentDescriptor({
53+ httpBaseUrl: "http://127.0.0.1:41773/",
54+ });
55+
56+ assert.equal(descriptor.label, "Remote Devbox");
57+ assert.deepEqual(requestUrls, ["http://127.0.0.1:41773/.well-known/t3/environment"]);
58+ }).pipe(Effect.provide(layer));
59+ });
60+
61+ it.effect("wraps schema decode failures in a typed remote api error", () => {
62+ const layer = makeLayer((request) =>
63+ Effect.succeed(jsonResponse(request, { environmentId: "remote-env" })),
64+ );
65+
66+ return Effect.gen(function* () {
67+ const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi;
68+ const error = yield* remoteApi
69+ .fetchEnvironmentDescriptor({
70+ httpBaseUrl: "http://127.0.0.1:41773/",
71+ })
72+ .pipe(Effect.flip);
73+
74+ assert.instanceOf(error, DesktopSshRemoteApi.DesktopSshRemoteApiError);
75+ assert.equal(error.operation, "fetch-environment-descriptor");
76+ assert.equal(error.cause instanceof SshHttpBridgeError, false);
77+ }).pipe(Effect.provide(layer));
78+ });
79+});
apps/desktop/src/ssh/DesktopSshRemoteApi.ts+124−0
@@ -0,0 +1,124 @@
1+import {
2+ AuthBearerBootstrapResult,
3+ AuthSessionState,
4+ AuthWebSocketTokenResult,
5+ type AuthBearerBootstrapResult as AuthBearerBootstrapResultType,
6+ type AuthSessionState as AuthSessionStateType,
7+ type AuthWebSocketTokenResult as AuthWebSocketTokenResultType,
8+ ExecutionEnvironmentDescriptor,
9+ type ExecutionEnvironmentDescriptor as ExecutionEnvironmentDescriptorType,
10+} from "@t3tools/contracts";
11+import { SshHttpBridgeError } from "@t3tools/ssh/errors";
12+import { fetchLoopbackSshJson } from "@t3tools/ssh/tunnel";
13+import * as Context from "effect/Context";
14+import * as Data from "effect/Data";
15+import * as Effect from "effect/Effect";
16+import * as Layer from "effect/Layer";
17+import * as Schema from "effect/Schema";
18+import { HttpClient } from "effect/unstable/http";
19+
20+export type DesktopSshRemoteApiOperation =
21+ | "fetch-environment-descriptor"
22+ | "bootstrap-bearer-session"
23+ | "fetch-session-state"
24+ | "issue-websocket-token";
25+
26+export class DesktopSshRemoteApiError extends Data.TaggedError("DesktopSshRemoteApiError")<{
27+ readonly operation: DesktopSshRemoteApiOperation;
28+ readonly cause: SshHttpBridgeError | Schema.SchemaError;
29+}> {
30+ override get message() {
31+ return `SSH remote API request failed during ${this.operation}.`;
32+ }
33+}
34+
35+export interface DesktopSshRemoteApiShape {
36+ readonly fetchEnvironmentDescriptor: (input: {
37+ readonly httpBaseUrl: string;
38+ }) => Effect.Effect<ExecutionEnvironmentDescriptorType, DesktopSshRemoteApiError>;
39+ readonly bootstrapBearerSession: (input: {
40+ readonly httpBaseUrl: string;
41+ readonly credential: string;
42+ }) => Effect.Effect<AuthBearerBootstrapResultType, DesktopSshRemoteApiError>;
43+ readonly fetchSessionState: (input: {
44+ readonly httpBaseUrl: string;
45+ readonly bearerToken: string;
46+ }) => Effect.Effect<AuthSessionStateType, DesktopSshRemoteApiError>;
47+ readonly issueWebSocketToken: (input: {
48+ readonly httpBaseUrl: string;
49+ readonly bearerToken: string;
50+ }) => Effect.Effect<AuthWebSocketTokenResultType, DesktopSshRemoteApiError>;
51+}
52+
53+export class DesktopSshRemoteApi extends Context.Service<
54+ DesktopSshRemoteApi,
55+ DesktopSshRemoteApiShape
56+>()("t3/desktop/SshRemoteApi") {}
57+
58+const decodeExecutionEnvironmentDescriptor = Schema.decodeUnknownEffect(
59+ ExecutionEnvironmentDescriptor,
60+);
61+const decodeAuthBearerBootstrapResult = Schema.decodeUnknownEffect(AuthBearerBootstrapResult);
62+const decodeAuthSessionState = Schema.decodeUnknownEffect(AuthSessionState);
63+const decodeAuthWebSocketTokenResult = Schema.decodeUnknownEffect(AuthWebSocketTokenResult);
64+
65+const mapError =
66+ (operation: DesktopSshRemoteApiOperation) =>
67+ (cause: SshHttpBridgeError | Schema.SchemaError): DesktopSshRemoteApiError =>
68+ new DesktopSshRemoteApiError({ operation, cause });
69+
70+const make = Effect.gen(function* () {
71+ const httpClient = yield* HttpClient.HttpClient;
72+ const provideHttpClient = <A, E>(effect: Effect.Effect<A, E, HttpClient.HttpClient>) =>
73+ effect.pipe(Effect.provideService(HttpClient.HttpClient, httpClient));
74+
75+ return DesktopSshRemoteApi.of({
76+ fetchEnvironmentDescriptor: ({ httpBaseUrl }) =>
77+ fetchLoopbackSshJson<unknown>({
78+ httpBaseUrl,
79+ pathname: "/.well-known/t3/environment",
80+ }).pipe(
81+ Effect.flatMap(decodeExecutionEnvironmentDescriptor),
82+ Effect.mapError(mapError("fetch-environment-descriptor")),
83+ provideHttpClient,
84+ Effect.withSpan("desktop.sshRemoteApi.fetchEnvironmentDescriptor"),
85+ ),
86+ bootstrapBearerSession: ({ httpBaseUrl, credential }) =>
87+ fetchLoopbackSshJson<unknown>({
88+ httpBaseUrl,
89+ pathname: "/api/auth/bootstrap/bearer",
90+ method: "POST",
91+ body: { credential },
92+ }).pipe(
93+ Effect.flatMap(decodeAuthBearerBootstrapResult),
94+ Effect.mapError(mapError("bootstrap-bearer-session")),
95+ provideHttpClient,
96+ Effect.withSpan("desktop.sshRemoteApi.bootstrapBearerSession"),
97+ ),
98+ fetchSessionState: ({ httpBaseUrl, bearerToken }) =>
99+ fetchLoopbackSshJson<unknown>({
100+ httpBaseUrl,
101+ pathname: "/api/auth/session",
102+ bearerToken,
103+ }).pipe(
104+ Effect.flatMap(decodeAuthSessionState),
105+ Effect.mapError(mapError("fetch-session-state")),
106+ provideHttpClient,
107+ Effect.withSpan("desktop.sshRemoteApi.fetchSessionState"),
108+ ),
109+ issueWebSocketToken: ({ httpBaseUrl, bearerToken }) =>
110+ fetchLoopbackSshJson<unknown>({
111+ httpBaseUrl,
112+ pathname: "/api/auth/ws-token",
113+ method: "POST",
114+ bearerToken,
115+ }).pipe(
116+ Effect.flatMap(decodeAuthWebSocketTokenResult),
117+ Effect.mapError(mapError("issue-websocket-token")),
118+ provideHttpClient,
119+ Effect.withSpan("desktop.sshRemoteApi.issueWebSocketToken"),
120+ ),
121+ });
122+});
123+
124+export const layer = Layer.effect(DesktopSshRemoteApi, make);
apps/desktop/src/sshEnvironment.test.ts+0−98
@@ -1,98 +0,0 @@
1−import * as FS from "node:fs";
2−import * as OS from "node:os";
3−import * as Path from "node:path";
4−
5−import { afterEach, describe, expect, it } from "vitest";
6−
7−import { SshPasswordPromptError } from "@t3tools/ssh/errors";
8−
9−import { discoverDesktopSshHosts, isSshPasswordPromptCancellation } from "./sshEnvironment.ts";
10−
11−const tempDirectories: string[] = [];
12−
13−afterEach(() => {
14− for (const directory of tempDirectories.splice(0)) {
15− FS.rmSync(directory, { recursive: true, force: true });
16− }
17−});
18−
19−function makeTempHomeDir(): string {
20− const directory = FS.mkdtempSync(Path.join(OS.tmpdir(), "t3-ssh-env-test-"));
21− tempDirectories.push(directory);
22− return directory;
23−}
24−
25−describe("sshEnvironment", () => {
26− it("treats password prompt timeouts as cancellable authentication prompts", () => {
27− expect(
28− isSshPasswordPromptCancellation(
29− new SshPasswordPromptError({
30− message: "SSH authentication timed out for devbox.",
31− }),
32− ),
33− ).toBe(true);
34− });
35−
36− it("wires desktop host discovery through the ssh package runtime", async () => {
37− const homeDir = makeTempHomeDir();
38− const sshDir = Path.join(homeDir, ".ssh");
39− FS.mkdirSync(Path.join(sshDir, "config.d"), { recursive: true });
40− FS.writeFileSync(
41− Path.join(sshDir, "config"),
42− ["Host devbox", " HostName devbox.example.com", "Include config.d/*.conf", ""].join("\n"),
43− "utf8",
44− );
45− FS.writeFileSync(
46− Path.join(sshDir, "config.d", "team.conf"),
47− [
48− "Host staging",
49− " HostName staging.example.com",
50− "Host *",
51− " ServerAliveInterval 30",
52− "",
53− ].join("\n"),
54− "utf8",
55− );
56− FS.writeFileSync(
57− Path.join(sshDir, "known_hosts"),
58− [
59− "known.example.com ssh-ed25519 AAAA",
60− "|1|hashed|entry ssh-ed25519 AAAA",
61− "[bastion.example.com]:2222 ssh-ed25519 AAAA",
62− "",
63− ].join("\n"),
64− "utf8",
65− );
66−
67− await expect(discoverDesktopSshHosts({ homeDir })).resolves.toEqual([
68− {
69− alias: "bastion.example.com",
70− hostname: "bastion.example.com",
71− username: null,
72− port: null,
73− source: "known-hosts",
74− },
75− {
76− alias: "devbox",
77− hostname: "devbox",
78− username: null,
79− port: null,
80− source: "ssh-config",
81− },
82− {
83− alias: "known.example.com",
84− hostname: "known.example.com",
85− username: null,
86− port: null,
87− source: "known-hosts",
88− },
89− {
90− alias: "staging",
91− hostname: "staging",
92− username: null,
93− port: null,
94− source: "ssh-config",
95− },
96− ]);
97− });
98−});
apps/desktop/src/sshEnvironment.ts+0−420
@@ -1,420 +0,0 @@
1−import * as Crypto from "node:crypto";
2−
3−import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
4−import * as NodeServices from "@effect/platform-node/NodeServices";
5−import { NetService } from "@t3tools/shared/Net";
6−import type {
7− AuthBearerBootstrapResult,
8− AuthSessionState,
9− AuthWebSocketTokenResult,
10− DesktopDiscoveredSshHost,
11− DesktopSshEnvironmentTarget,
12− DesktopSshPasswordPromptRequest,
13− ExecutionEnvironmentDescriptor,
14−} from "@t3tools/contracts";
15−import {
16− SshPasswordPrompt,
17− type SshPasswordPromptShape,
18− type SshPasswordRequest,
19−} from "@t3tools/ssh/auth";
20−import { discoverSshHosts } from "@t3tools/ssh/config";
21−import { SshPasswordPromptError } from "@t3tools/ssh/errors";
22−import {
23− fetchLoopbackSshJson,
24− SshEnvironmentManager,
25− type RemoteT3RunnerOptions,
26−} from "@t3tools/ssh/tunnel";
27−import { Effect, Exit, Layer, ManagedRuntime, Scope } from "effect";
28−
29−export { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command";
30−
31−const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts";
32−const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment";
33−const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment";
34−const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor";
35−const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session";
36−const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state";
37−const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-token";
38−const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt";
39−const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt";
40−const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000;
41−const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled";
42−
43−interface DesktopSshEnvironmentManagerOptions {
44− readonly passwordProvider?: (request: SshPasswordRequest) => Promise<string | null>;
45− readonly resolveCliPackageSpec?: () => string;
46− readonly resolveCliRunner?: () => RemoteT3RunnerOptions;
47−}
48−
49−const sshRuntime = ManagedRuntime.make(
50− Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, NetService.layer),
51−);
52−
53−function createDesktopSshRuntime(
54− passwordPrompt: SshPasswordPromptShape,
55− scope: Scope.Scope,
56− options: DesktopSshEnvironmentManagerOptions,
57−) {
58− return ManagedRuntime.make(
59− Layer.mergeAll(
60− NodeServices.layer,
61− NodeHttpClient.layerUndici,
62− NetService.layer,
63− Layer.succeed(Scope.Scope, scope),
64− Layer.succeed(SshPasswordPrompt, SshPasswordPrompt.of(passwordPrompt)),
65− SshEnvironmentManager.layer({
66− ...(options.resolveCliPackageSpec === undefined
67− ? {}
68− : { resolveCliPackageSpec: options.resolveCliPackageSpec }),
69− ...(options.resolveCliRunner === undefined
70− ? {}
71− : { resolveCliRunner: options.resolveCliRunner }),
72− }),
73− ),
74− );
75−}
76−
77−export async function discoverDesktopSshHosts(input?: {
78− readonly homeDir?: string;
79−}): Promise<readonly DesktopDiscoveredSshHost[]> {
80− return await sshRuntime.runPromise(discoverSshHosts(input ?? {}));
81−}
82−
83−export class DesktopSshEnvironmentManager {
84− private readonly runtime: ReturnType<typeof createDesktopSshRuntime>;
85− private readonly scope: Scope.Scope;
86−
87− constructor(options: DesktopSshEnvironmentManagerOptions = {}) {
88− const passwordPrompt: SshPasswordPromptShape = {
89− isAvailable: options.passwordProvider !== undefined,
90− request: (request) => {
91− const passwordProvider = options.passwordProvider;
92− if (!passwordProvider) {
93− return Effect.succeed(null);
94− }
95−
96− return Effect.tryPromise({
97− try: () => passwordProvider(request),
98− catch: (cause) =>
99− new SshPasswordPromptError({
100− message: cause instanceof Error ? cause.message : "SSH password prompt failed.",
101− cause,
102− }),
103− });
104− },
105− };
106− this.scope = Effect.runSync(Scope.make());
107− this.runtime = createDesktopSshRuntime(passwordPrompt, this.scope, options);
108− }
109−
110− async discoverHosts(): Promise<readonly DesktopDiscoveredSshHost[]> {
111− return await discoverDesktopSshHosts();
112− }
113−
114− async ensureEnvironment(
115− target: DesktopSshEnvironmentTarget,
116− options?: { readonly issuePairingToken?: boolean },
117− ) {
118− return await this.runtime.runPromise(
119− Effect.service(SshEnvironmentManager).pipe(
120− Effect.flatMap((manager) => manager.ensureEnvironment(target, options)),
121− ),
122− );
123− }
124−
125− async disconnectEnvironment(target: DesktopSshEnvironmentTarget): Promise<void> {
126− await this.runtime.runPromise(
127− Effect.service(SshEnvironmentManager).pipe(
128− Effect.flatMap((manager) => manager.disconnectEnvironment(target)),
129− ),
130− );
131− }
132−
133− async dispose(): Promise<void> {
134− await this.runtime.runPromise(Scope.close(this.scope, Exit.void));
135− await this.runtime.dispose();
136− }
137−}
138−
139−function getSafeDesktopSshTarget(rawTarget: unknown): DesktopSshEnvironmentTarget | null {
140− if (typeof rawTarget !== "object" || rawTarget === null) {
141− return null;
142− }
143−
144− const target = rawTarget as Partial<DesktopSshEnvironmentTarget>;
145− if (typeof target.alias !== "string" || typeof target.hostname !== "string") {
146− return null;
147− }
148− if (
149− target.username !== null &&
150− target.username !== undefined &&
151− typeof target.username !== "string"
152− ) {
153− return null;
154− }
155− if (target.port !== null && target.port !== undefined && !Number.isInteger(target.port)) {
156− return null;
157− }
158−
159− const alias = target.alias.trim();
160− const hostname = target.hostname.trim();
161− if (alias.length === 0 || hostname.length === 0) {
162− return null;
163− }
164−
165− return {
166− alias,
167− hostname,
168− username: target.username?.trim() || null,
169− port: target.port ?? null,
170− };
171−}
172−
173−/** Minimal subset of Electron's BrowserWindow used by the SSH bridge. */
174−export interface DesktopSshBridgeWindow {
175− isDestroyed(): boolean;
176− isMinimized(): boolean;
177− restore(): void;
178− focus(): void;
179− readonly webContents: {
180− send(channel: string, ...args: readonly unknown[]): void;
181− };
182−}
183−
184−/** Minimal subset of Electron's ipcMain used by the SSH bridge. */
185−export interface DesktopSshBridgeIpcMain {
186− removeHandler(channel: string): void;
187− handle(
188− channel: string,
189− listener: (event: unknown, ...args: readonly unknown[]) => unknown | Promise<unknown>,
190− ): void;
191−}
192−
193−export interface DesktopSshEnvironmentBridgeOptions {
194− readonly getMainWindow: () => DesktopSshBridgeWindow | null;
195− readonly resolveCliPackageSpec?: () => string;
196− readonly resolveCliRunner?: () => RemoteT3RunnerOptions;
197− readonly passwordPromptTimeoutMs?: number;
198−}
199−
200−interface PendingSshPasswordPrompt {
201− readonly resolve: (password: string | null) => void;
202− readonly reject: (error: Error) => void;
203− readonly timeout: ReturnType<typeof setTimeout>;
204−}
205−
206−export function isSshPasswordPromptCancellation(error: unknown): error is SshPasswordPromptError {
207− const message = error instanceof SshPasswordPromptError ? error.message.toLowerCase() : "";
208− return (
209− error instanceof SshPasswordPromptError &&
210− (message.includes("cancelled") || message.includes("timed out"))
211− );
212−}
213−
214−/**
215− * Wires the SSH environment manager to Electron IPC, owning the renderer-facing
216− * password prompt state so `main.ts` only needs to register, cancel, and dispose.
217− */
218−export class DesktopSshEnvironmentBridge {
219− private readonly options: DesktopSshEnvironmentBridgeOptions;
220− private readonly manager: DesktopSshEnvironmentManager;
221− private readonly pendingPrompts = new Map<string, PendingSshPasswordPrompt>();
222− private readonly passwordPromptTimeoutMs: number;
223−
224− constructor(options: DesktopSshEnvironmentBridgeOptions) {
225− this.options = options;
226− this.passwordPromptTimeoutMs =
227− options.passwordPromptTimeoutMs ?? DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS;
228− this.manager = new DesktopSshEnvironmentManager({
229− passwordProvider: (request) => this.requestPasswordFromRenderer(request),
230− ...(options.resolveCliPackageSpec === undefined
231− ? {}
232− : { resolveCliPackageSpec: options.resolveCliPackageSpec }),
233− ...(options.resolveCliRunner === undefined
234− ? {}
235− : { resolveCliRunner: options.resolveCliRunner }),
236− });
237− }
238−
239− registerIpcHandlers(ipcMain: DesktopSshBridgeIpcMain): void {
240− ipcMain.removeHandler(DISCOVER_SSH_HOSTS_CHANNEL);
241− ipcMain.handle(DISCOVER_SSH_HOSTS_CHANNEL, async () => this.manager.discoverHosts());
242−
243− ipcMain.removeHandler(ENSURE_SSH_ENVIRONMENT_CHANNEL);
244− ipcMain.handle(ENSURE_SSH_ENVIRONMENT_CHANNEL, async (_event, rawTarget, rawOptions) => {
245− const target = getSafeDesktopSshTarget(rawTarget);
246− if (!target) {
247− throw new Error("Invalid desktop SSH target.");
248− }
249−
250− const issuePairingToken =
251− typeof rawOptions === "object" &&
252− rawOptions !== null &&
253− "issuePairingToken" in rawOptions &&
254− (rawOptions as { issuePairingToken?: unknown }).issuePairingToken === true;
255−
256− try {
257− return await this.manager.ensureEnvironment(target, {
258− issuePairingToken,
259− });
260− } catch (error) {
261− if (isSshPasswordPromptCancellation(error)) {
262− return {
263− type: SSH_PASSWORD_PROMPT_CANCELLED_RESULT,
264− message: error.message,
265− };
266− }
267− throw error;
268− }
269− });
270−
271− ipcMain.removeHandler(DISCONNECT_SSH_ENVIRONMENT_CHANNEL);
272− ipcMain.handle(DISCONNECT_SSH_ENVIRONMENT_CHANNEL, async (_event, rawTarget) => {
273− const target = getSafeDesktopSshTarget(rawTarget);
274− if (!target) {
275− throw new Error("Invalid desktop SSH target.");
276− }
277−
278− await this.manager.disconnectEnvironment(target);
279− });
280−
281− ipcMain.removeHandler(FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL);
282− ipcMain.handle(FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, async (_event, rawHttpBaseUrl) =>
283− sshRuntime.runPromise(
284− fetchLoopbackSshJson<ExecutionEnvironmentDescriptor>({
285− httpBaseUrl: rawHttpBaseUrl,
286− pathname: "/.well-known/t3/environment",
287− }),
288− ),
289− );
290−
291− ipcMain.removeHandler(BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL);
292− ipcMain.handle(
293− BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL,
294− async (_event, rawHttpBaseUrl, rawCredential) =>
295− sshRuntime.runPromise(
296− fetchLoopbackSshJson<AuthBearerBootstrapResult>({
297− httpBaseUrl: rawHttpBaseUrl,
298− pathname: "/api/auth/bootstrap/bearer",
299− method: "POST",
300− body: { credential: rawCredential },
301− }),
302− ),
303− );
304−
305− ipcMain.removeHandler(FETCH_SSH_SESSION_STATE_CHANNEL);
306− ipcMain.handle(
307− FETCH_SSH_SESSION_STATE_CHANNEL,
308− async (_event, rawHttpBaseUrl, rawBearerToken) =>
309− sshRuntime.runPromise(
310− fetchLoopbackSshJson<AuthSessionState>({
311− httpBaseUrl: rawHttpBaseUrl,
312− pathname: "/api/auth/session",
313− bearerToken: rawBearerToken,
314− }),
315− ),
316− );
317−
318− ipcMain.removeHandler(ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL);
319− ipcMain.handle(
320− ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL,
321− async (_event, rawHttpBaseUrl, rawBearerToken) =>
322− sshRuntime.runPromise(
323− fetchLoopbackSshJson<AuthWebSocketTokenResult>({
324− httpBaseUrl: rawHttpBaseUrl,
325− pathname: "/api/auth/ws-token",
326− method: "POST",
327− bearerToken: rawBearerToken,
328− }),
329− ),
330− );
331−
332− ipcMain.removeHandler(RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL);
333− ipcMain.handle(
334− RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL,
335− async (_event, rawRequestId, rawPassword) => {
336− if (typeof rawRequestId !== "string" || rawRequestId.trim().length === 0) {
337− throw new Error("Invalid SSH password prompt id.");
338− }
339− if (rawPassword !== null && typeof rawPassword !== "string") {
340− throw new Error("Invalid SSH password prompt response.");
341− }
342−
343− const pending = this.pendingPrompts.get(rawRequestId);
344− if (!pending) {
345− throw new Error("SSH password prompt expired. Try connecting again.");
346− }
347−
348− clearTimeout(pending.timeout);
349− this.pendingPrompts.delete(rawRequestId);
350− pending.resolve(rawPassword);
351− },
352− );
353− }
354−
355− cancelPendingPasswordPrompts(reason: string): void {
356− for (const [requestId, pending] of this.pendingPrompts) {
357− clearTimeout(pending.timeout);
358− this.pendingPrompts.delete(requestId);
359− pending.reject(new Error(reason));
360− }
361− }
362−
363− async dispose(): Promise<void> {
364− this.cancelPendingPasswordPrompts("SSH environment bridge disposed.");
365− await this.manager.dispose();
366− }
367−
368− private async requestPasswordFromRenderer(input: SshPasswordRequest): Promise<string | null> {
369− const window = this.options.getMainWindow();
370− if (!window || window.isDestroyed()) {
371− throw new Error("T3 Code window is not available for SSH authentication.");
372− }
373−
374− const request: DesktopSshPasswordPromptRequest = {
375− requestId: Crypto.randomUUID(),
376− destination: input.destination,
377− username: input.username,
378− prompt: input.prompt,
379− expiresAt: new Date(Date.now() + this.passwordPromptTimeoutMs).toISOString(),
380− };
381−
382− return await new Promise<string | null>((resolve, reject) => {
383− const rejectPrompt = (error: Error) => {
384− clearTimeout(timeout);
385− this.pendingPrompts.delete(request.requestId);
386− reject(error);
387− };
388− const timeout = setTimeout(() => {
389− this.pendingPrompts.delete(request.requestId);
390− reject(new Error(`SSH authentication timed out for ${input.destination}.`));
391− }, this.passwordPromptTimeoutMs);
392− timeout.unref();
393−
394− this.pendingPrompts.set(request.requestId, { resolve, reject, timeout });
395−
396− try {
397− if (window.isDestroyed()) {
398− throw new Error("T3 Code window is not available for SSH authentication.");
399− }
400− window.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, request);
401− if (window.isDestroyed()) {
402− throw new Error("T3 Code window is not available for SSH authentication.");
403− }
404− if (window.isMinimized()) {
405− window.restore();
406− }
407− if (window.isDestroyed()) {
408− throw new Error("T3 Code window is not available for SSH authentication.");
409− }
410− window.focus();
411− } catch (error) {
412− rejectPrompt(
413− error instanceof Error
414− ? error
415− : new Error("T3 Code window is not available for SSH authentication."),
416− );
417− }
418− });
419− }
420−}
apps/desktop/src/syncShellEnvironment.test.ts+0−289
@@ -1,289 +0,0 @@
1−import { describe, expect, it, vi } from "vitest";
2−
3−import { syncShellEnvironment } from "./syncShellEnvironment.ts";
4−
5−describe("syncShellEnvironment", () => {
6− it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => {
7− const env: NodeJS.ProcessEnv = {
8− SHELL: "/bin/zsh",
9− PATH: "/Users/test/.local/bin:/usr/bin",
10− };
11− const readEnvironment = vi.fn(() => ({
12− PATH: "/opt/homebrew/bin:/usr/bin",
13− SSH_AUTH_SOCK: "/tmp/secretive.sock",
14− HOMEBREW_PREFIX: "/opt/homebrew",
15− }));
16−
17− syncShellEnvironment(env, {
18− platform: "darwin",
19− readEnvironment,
20− });
21−
22− expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", [
23− "PATH",
24− "SSH_AUTH_SOCK",
25− "HOMEBREW_PREFIX",
26− "HOMEBREW_CELLAR",
27− "HOMEBREW_REPOSITORY",
28− "XDG_CONFIG_HOME",
29− "XDG_DATA_HOME",
30− ]);
31− expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin");
32− expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock");
33− expect(env.HOMEBREW_PREFIX).toBe("/opt/homebrew");
34− });
35−
36− it("preserves an inherited SSH_AUTH_SOCK value", () => {
37− const env: NodeJS.ProcessEnv = {
38− SHELL: "/bin/zsh",
39− PATH: "/usr/bin",
40− SSH_AUTH_SOCK: "/tmp/inherited.sock",
41− };
42− const readEnvironment = vi.fn(() => ({
43− PATH: "/opt/homebrew/bin:/usr/bin",
44− SSH_AUTH_SOCK: "/tmp/login-shell.sock",
45− }));
46−
47− syncShellEnvironment(env, {
48− platform: "darwin",
49− readEnvironment,
50− });
51−
52− expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
53− expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock");
54− });
55−
56− it("preserves inherited values when the login shell omits them", () => {
57− const env: NodeJS.ProcessEnv = {
58− SHELL: "/bin/zsh",
59− PATH: "/usr/bin",
60− SSH_AUTH_SOCK: "/tmp/inherited.sock",
61− };
62− const readEnvironment = vi.fn(() => ({
63− PATH: "/opt/homebrew/bin:/usr/bin",
64− }));
65−
66− syncShellEnvironment(env, {
67− platform: "darwin",
68− readEnvironment,
69− });
70−
71− expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
72− expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock");
73− });
74−
75− it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on linux", () => {
76− const env: NodeJS.ProcessEnv = {
77− SHELL: "/bin/zsh",
78− PATH: "/usr/bin",
79− };
80− const readEnvironment = vi.fn(() => ({
81− PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin",
82− SSH_AUTH_SOCK: "/tmp/secretive.sock",
83− }));
84−
85− syncShellEnvironment(env, {
86− platform: "linux",
87− readEnvironment,
88− });
89−
90− expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", [
91− "PATH",
92− "SSH_AUTH_SOCK",
93− "HOMEBREW_PREFIX",
94− "HOMEBREW_CELLAR",
95− "HOMEBREW_REPOSITORY",
96− "XDG_CONFIG_HOME",
97− "XDG_DATA_HOME",
98− ]);
99− expect(env.PATH).toBe("/home/linuxbrew/.linuxbrew/bin:/usr/bin");
100− expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock");
101− });
102−
103− it("falls back to launchctl PATH on macOS when shell probing does not return one", () => {
104− const env: NodeJS.ProcessEnv = {
105− SHELL: "/opt/homebrew/bin/nu",
106− PATH: "/usr/bin",
107− };
108− const readEnvironment = vi
109− .fn()
110− .mockImplementationOnce(() => {
111− throw new Error("unknown flag");
112− })
113− .mockImplementationOnce(() => ({}));
114− const readLaunchctlPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin");
115− const logWarning = vi.fn();
116−
117− syncShellEnvironment(env, {
118− platform: "darwin",
119− readEnvironment,
120− readLaunchctlPath,
121− userShell: "/bin/zsh",
122− logWarning,
123− });
124−
125− expect(readEnvironment).toHaveBeenNthCalledWith(1, "/opt/homebrew/bin/nu", [
126− "PATH",
127− "SSH_AUTH_SOCK",
128− "HOMEBREW_PREFIX",
129− "HOMEBREW_CELLAR",
130− "HOMEBREW_REPOSITORY",
131− "XDG_CONFIG_HOME",
132− "XDG_DATA_HOME",
133− ]);
134− expect(readEnvironment).toHaveBeenNthCalledWith(2, "/bin/zsh", [
135− "PATH",
136− "SSH_AUTH_SOCK",
137− "HOMEBREW_PREFIX",
138− "HOMEBREW_CELLAR",
139− "HOMEBREW_REPOSITORY",
140− "XDG_CONFIG_HOME",
141− "XDG_DATA_HOME",
142− ]);
143− expect(readLaunchctlPath).toHaveBeenCalledTimes(1);
144− expect(logWarning).toHaveBeenCalledWith(
145− "Failed to read login shell environment from /opt/homebrew/bin/nu.",
146− expect.any(Error),
147− );
148− expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
149− });
150−
151− it("does nothing on unsupported platforms", () => {
152− const env: NodeJS.ProcessEnv = {
153− SHELL: "C:/Program Files/Git/bin/bash.exe",
154− PATH: "C:\\Windows\\System32",
155− SSH_AUTH_SOCK: "/tmp/inherited.sock",
156− };
157− const readEnvironment = vi.fn(() => ({
158− PATH: "/usr/local/bin:/usr/bin",
159− SSH_AUTH_SOCK: "/tmp/secretive.sock",
160− }));
161−
162− syncShellEnvironment(env, {
163− platform: "freebsd",
164− readEnvironment,
165− });
166−
167− expect(readEnvironment).not.toHaveBeenCalled();
168− expect(env.PATH).toBe("C:\\Windows\\System32");
169− expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock");
170− });
171−
172− it("hydrates PATH on Windows by merging PowerShell PATH with inherited PATH", () => {
173− const env: NodeJS.ProcessEnv = {
174− PATH: "C:\\Windows\\System32",
175− APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
176− LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local",
177− USERPROFILE: "C:\\Users\\testuser",
178− };
179− const readWindowsEnvironment = vi.fn(() => ({
180− PATH: "C:\\Custom\\Bin;C:\\Windows\\System32",
181− }));
182− const isWindowsCommandAvailable = vi.fn(() => true);
183−
184− syncShellEnvironment(env, {
185− platform: "win32",
186− readWindowsEnvironment,
187− isWindowsCommandAvailable,
188− });
189−
190− expect(readWindowsEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false });
191− expect(env.PATH).toBe(
192− [
193− "C:\\Users\\testuser\\AppData\\Roaming\\npm",
194− "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
195− "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
196− "C:\\Users\\testuser\\AppData\\Local\\pnpm",
197− "C:\\Users\\testuser\\.bun\\bin",
198− "C:\\Users\\testuser\\scoop\\shims",
199− "C:\\Custom\\Bin",
200− "C:\\Windows\\System32",
201− ].join(";"),
202− );
203− expect(isWindowsCommandAvailable).toHaveBeenCalledTimes(1);
204− });
205−
206− it("loads the PowerShell profile on Windows when node is not available", () => {
207− const env: NodeJS.ProcessEnv = {
208− PATH: "C:\\Windows\\System32",
209− APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
210− LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local",
211− USERPROFILE: "C:\\Users\\testuser",
212− };
213− const readWindowsEnvironment = vi.fn(
214− (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) =>
215− options?.loadProfile
216− ? {
217− PATH: "C:\\Profile\\Node;C:\\Windows\\System32",
218− FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
219− FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
220− }
221− : { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" },
222− );
223− const isWindowsCommandAvailable = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true);
224−
225− syncShellEnvironment(env, {
226− platform: "win32",
227− readWindowsEnvironment,
228− isWindowsCommandAvailable,
229− });
230−
231− expect(env.PATH).toBe(
232− [
233− "C:\\Profile\\Node",
234− "C:\\Windows\\System32",
235− "C:\\Users\\testuser\\AppData\\Roaming\\npm",
236− "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
237− "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
238− "C:\\Users\\testuser\\AppData\\Local\\pnpm",
239− "C:\\Users\\testuser\\.bun\\bin",
240− "C:\\Users\\testuser\\scoop\\shims",
241− "C:\\Custom\\Bin",
242− ].join(";"),
243− );
244− expect(env.FNM_DIR).toBe("C:\\Users\\testuser\\AppData\\Roaming\\fnm");
245− expect(env.FNM_MULTISHELL_PATH).toBe(
246− "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
247− );
248− expect(readWindowsEnvironment).toHaveBeenNthCalledWith(1, ["PATH"], { loadProfile: false });
249− expect(readWindowsEnvironment).toHaveBeenNthCalledWith(
250− 2,
251− ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"],
252− { loadProfile: true },
253− );
254− });
255−
256− it("preserves baseline Windows env when the profile probe fails", () => {
257− const env: NodeJS.ProcessEnv = {
258− PATH: "C:\\Windows\\System32",
259− APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
260− USERPROFILE: "C:\\Users\\testuser",
261− };
262− const readWindowsEnvironment = vi.fn(
263− (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) => {
264− if (options?.loadProfile) {
265− throw new Error("profile load failed");
266− }
267− return { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" };
268− },
269− );
270− const isWindowsCommandAvailable = vi.fn(() => false);
271−
272− syncShellEnvironment(env, {
273− platform: "win32",
274− readWindowsEnvironment,
275− isWindowsCommandAvailable,
276− });
277−
278− expect(env.PATH).toBe(
279− [
280− "C:\\Users\\testuser\\AppData\\Roaming\\npm",
281− "C:\\Users\\testuser\\.bun\\bin",
282− "C:\\Users\\testuser\\scoop\\shims",
283− "C:\\Custom\\Bin",
284− "C:\\Windows\\System32",
285− ].join(";"),
286− );
287− expect(env.SSH_AUTH_SOCK).toBeUndefined();
288− });
289−});
apps/desktop/src/syncShellEnvironment.ts+0−109
@@ -1,109 +0,0 @@
1−import {
2− listLoginShellCandidates,
3− mergePathEntries,
4− readPathFromLaunchctl,
5− readEnvironmentFromLoginShell,
6− resolveWindowsEnvironment,
7−} from "@t3tools/shared/shell";
8−import type {
9− CommandAvailabilityOptions,
10− ShellEnvironmentReader,
11− WindowsShellEnvironmentReader,
12−} from "@t3tools/shared/shell";
13−
14−type WindowsCommandAvailabilityChecker = (
15− command: string,
16− options?: CommandAvailabilityOptions,
17−) => boolean;
18−
19−const LOGIN_SHELL_ENV_NAMES = [
20− "PATH",
21− "SSH_AUTH_SOCK",
22− "HOMEBREW_PREFIX",
23− "HOMEBREW_CELLAR",
24− "HOMEBREW_REPOSITORY",
25− "XDG_CONFIG_HOME",
26− "XDG_DATA_HOME",
27−] as const;
28−
29−function logShellEnvironmentWarning(message: string, error?: unknown): void {
30− console.warn(`[desktop] ${message}`, error instanceof Error ? error.message : (error ?? ""));
31−}
32−
33−export function syncShellEnvironment(
34− env: NodeJS.ProcessEnv = process.env,
35− options: {
36− platform?: NodeJS.Platform;
37− readEnvironment?: ShellEnvironmentReader;
38− readWindowsEnvironment?: WindowsShellEnvironmentReader;
39− isWindowsCommandAvailable?: WindowsCommandAvailabilityChecker;
40− readLaunchctlPath?: typeof readPathFromLaunchctl;
41− userShell?: string;
42− logWarning?: (message: string, error?: unknown) => void;
43− } = {},
44−): void {
45− const platform = options.platform ?? process.platform;
46−
47− const logWarning = options.logWarning ?? logShellEnvironmentWarning;
48− const readEnvironment = options.readEnvironment ?? readEnvironmentFromLoginShell;
49− const shellEnvironment: Partial<Record<string, string>> = {};
50−
51− try {
52− if (platform === "win32") {
53− const repairedEnvironment = resolveWindowsEnvironment(env, {
54− ...(options.readWindowsEnvironment
55− ? { readEnvironment: options.readWindowsEnvironment }
56− : {}),
57− ...(options.isWindowsCommandAvailable
58− ? { commandAvailable: options.isWindowsCommandAvailable }
59− : {}),
60− });
61− for (const [key, value] of Object.entries(repairedEnvironment)) {
62− if (value !== undefined) {
63− env[key] = value;
64− }
65− }
66− return;
67− }
68−
69− if (platform !== "darwin" && platform !== "linux") return;
70−
71− for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) {
72− try {
73− Object.assign(shellEnvironment, readEnvironment(shell, LOGIN_SHELL_ENV_NAMES));
74− if (shellEnvironment.PATH) {
75− break;
76− }
77− } catch (error) {
78− logWarning(`Failed to read login shell environment from ${shell}.`, error);
79− }
80− }
81−
82− const launchctlPath =
83− platform === "darwin" && !shellEnvironment.PATH
84− ? (options.readLaunchctlPath ?? readPathFromLaunchctl)()
85− : undefined;
86− const mergedPath = mergePathEntries(shellEnvironment.PATH ?? launchctlPath, env.PATH, platform);
87− if (mergedPath) {
88− env.PATH = mergedPath;
89− }
90−
91− if (!env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) {
92− env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK;
93− }
94−
95− for (const name of [
96− "HOMEBREW_PREFIX",
97− "HOMEBREW_CELLAR",
98− "HOMEBREW_REPOSITORY",
99− "XDG_CONFIG_HOME",
100− "XDG_DATA_HOME",
101− ] as const) {
102− if (!env[name] && shellEnvironment[name]) {
103− env[name] = shellEnvironment[name];
104− }
105− }
106− } catch (error) {
107− logWarning("Failed to synchronize the desktop shell environment.", error);
108− }
109−}
apps/desktop/src/tailscaleEndpointProvider.test.ts+0−122
@@ -1,122 +0,0 @@
1−import { describe, expect, it } from "vitest";
2−import { Effect } from "effect";
3−
4−import {
5− isTailscaleIpv4Address,
6− parseTailscaleMagicDnsName,
7− resolveTailscaleAdvertisedEndpoints,
8−} from "./tailscaleEndpointProvider.ts";
9−
10−describe("tailscale endpoint provider", () => {
11− it("detects Tailnet IPv4 addresses", () => {
12− expect(isTailscaleIpv4Address("100.64.0.1")).toBe(true);
13− expect(isTailscaleIpv4Address("100.127.255.254")).toBe(true);
14− expect(isTailscaleIpv4Address("100.128.0.1")).toBe(false);
15− expect(isTailscaleIpv4Address("192.168.1.44")).toBe(false);
16− });
17−
18− it("parses MagicDNS names from tailscale status", async () => {
19− expect(
20− Effect.runSync(
21− parseTailscaleMagicDnsName(JSON.stringify({ Self: { DNSName: "desktop.tail.ts.net." } })),
22− ),
23− ).toBe("desktop.tail.ts.net");
24− expect(Effect.runSync(parseTailscaleMagicDnsName("{}"))).toBeNull();
25− await expect(Effect.runPromise(parseTailscaleMagicDnsName("not-json"))).rejects.toBeDefined();
26− });
27−
28− it("resolves Tailscale endpoints as add-on advertised endpoints", async () => {
29− await expect(
30− resolveTailscaleAdvertisedEndpoints({
31− port: 3773,
32− networkInterfaces: {
33− tailscale0: [
34− {
35− address: "100.100.100.100",
36− family: "IPv4",
37− internal: false,
38− netmask: "255.192.0.0",
39− cidr: "100.100.100.100/10",
40− mac: "00:00:00:00:00:00",
41− },
42− ],
43− },
44− statusJson: JSON.stringify({ Self: { DNSName: "desktop.tail.ts.net." } }),
45− }),
46− ).resolves.toEqual([
47− {
48− id: "tailscale-ip:http://100.100.100.100:3773",
49− label: "Tailscale IP",
50− provider: {
51− id: "tailscale",
52− label: "Tailscale",
53− kind: "private-network",
54− isAddon: true,
55− },
56− httpBaseUrl: "http://100.100.100.100:3773/",
57− wsBaseUrl: "ws://100.100.100.100:3773/",
58− reachability: "private-network",
59− compatibility: {
60− hostedHttpsApp: "mixed-content-blocked",
61− desktopApp: "compatible",
62− },
63− source: "desktop-addon",
64− status: "available",
65− description: "Reachable from devices on the same Tailnet.",
66− },
67− {
68− id: "tailscale-magicdns:https://desktop.tail.ts.net/",
69− label: "Tailscale HTTPS",
70− provider: {
71− id: "tailscale",
72− label: "Tailscale",
73− kind: "private-network",
74− isAddon: true,
75− },
76− httpBaseUrl: "https://desktop.tail.ts.net/",
77− wsBaseUrl: "wss://desktop.tail.ts.net/",
78− reachability: "private-network",
79− compatibility: {
80− hostedHttpsApp: "requires-configuration",
81− desktopApp: "compatible",
82− },
83− source: "desktop-addon",
84− status: "unavailable",
85− description: "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.",
86− },
87− ]);
88− });
89−
90− it("marks the Tailscale HTTPS endpoint available after Serve is enabled and reachable", async () => {
91− await expect(
92− resolveTailscaleAdvertisedEndpoints({
93− port: 3773,
94− networkInterfaces: {},
95− statusJson: JSON.stringify({ Self: { DNSName: "desktop.tail.ts.net." } }),
96− serveEnabled: true,
97− probe: async () => true,
98− }),
99− ).resolves.toEqual([
100− {
101− id: "tailscale-magicdns:https://desktop.tail.ts.net/",
102− label: "Tailscale HTTPS",
103− provider: {
104− id: "tailscale",
105− label: "Tailscale",
106− kind: "private-network",
107− isAddon: true,
108− },
109− httpBaseUrl: "https://desktop.tail.ts.net/",
110− wsBaseUrl: "wss://desktop.tail.ts.net/",
111− reachability: "private-network",
112− compatibility: {
113− hostedHttpsApp: "compatible",
114− desktopApp: "compatible",
115− },
116− source: "desktop-addon",
117− status: "available",
118− description: "HTTPS endpoint served by Tailscale Serve.",
119− },
120− ]);
121− });
122−});
apps/desktop/src/tailscaleEndpointProvider.ts+0−142
@@ -1,142 +0,0 @@
1−import type { NetworkInterfaceInfo } from "node:os";
2−
3−import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
4−import * as NodeServices from "@effect/platform-node/NodeServices";
5−import {
6− createAdvertisedEndpoint,
7− type CreateAdvertisedEndpointInput,
8−} from "@t3tools/client-runtime";
9−import type { AdvertisedEndpoint, AdvertisedEndpointProvider } from "@t3tools/contracts";
10−import {
11− buildTailscaleHttpsBaseUrl,
12− isTailscaleIpv4Address,
13− parseTailscaleMagicDnsName,
14− probeTailscaleHttpsEndpoint,
15− readTailscaleStatus,
16−} from "@t3tools/tailscale";
17−import { Effect, Layer } from "effect";
18−
19−export { isTailscaleIpv4Address, parseTailscaleMagicDnsName } from "@t3tools/tailscale";
20−
21−const TailscaleDesktopLayer = Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici);
22−
23−const TAILSCALE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = {
24− id: "tailscale",
25− label: "Tailscale",
26− kind: "private-network",
27− isAddon: true,
28−};
29−
30−function createTailscaleEndpoint(
31− input: Omit<CreateAdvertisedEndpointInput, "provider" | "source">,
32−): AdvertisedEndpoint {
33− return createAdvertisedEndpoint({
34− ...input,
35− provider: TAILSCALE_ENDPOINT_PROVIDER,
36− source: "desktop-addon",
37− });
38−}
39−
40−export function resolveTailscaleIpAdvertisedEndpoints(input: {
41− readonly port: number;
42− readonly networkInterfaces: NodeJS.Dict<NetworkInterfaceInfo[]>;
43−}): readonly AdvertisedEndpoint[] {
44− const seen = new Set<string>();
45− const endpoints: AdvertisedEndpoint[] = [];
46−
47− for (const interfaceAddresses of Object.values(input.networkInterfaces)) {
48− if (!interfaceAddresses) continue;
49−
50− for (const address of interfaceAddresses) {
51− if (address.internal) continue;
52− if (address.family !== "IPv4") continue;
53− if (!isTailscaleIpv4Address(address.address)) continue;
54− if (seen.has(address.address)) continue;
55− seen.add(address.address);
56−
57− endpoints.push(
58− createTailscaleEndpoint({
59− id: `tailscale-ip:http://${address.address}:${input.port}`,
60− label: "Tailscale IP",
61− httpBaseUrl: `http://${address.address}:${input.port}`,
62− reachability: "private-network",
63− status: "available",
64− description: "Reachable from devices on the same Tailnet.",
65− }),
66− );
67− }
68− }
69−
70− return endpoints;
71−}
72−
73−export async function resolveTailscaleMagicDnsAdvertisedEndpoint(input: {
74− readonly dnsName: string | null;
75− readonly serveEnabled: boolean;
76− readonly servePort?: number;
77− readonly probe?: (baseUrl: string) => Promise<boolean>;
78−}): Promise<AdvertisedEndpoint | null> {
79− if (!input.dnsName) {
80− return null;
81− }
82−
83− const httpBaseUrl = buildTailscaleHttpsBaseUrl({
84− magicDnsName: input.dnsName,
85− ...(input.servePort === undefined ? {} : { servePort: input.servePort }),
86− });
87− const isReachable = input.serveEnabled
88− ? await (input.probe?.(httpBaseUrl) ??
89− Effect.runPromise(
90− probeTailscaleHttpsEndpoint({ baseUrl: httpBaseUrl }).pipe(
91− Effect.provide(TailscaleDesktopLayer),
92− ),
93− ))
94− : false;
95−
96− return createTailscaleEndpoint({
97− id: `tailscale-magicdns:${httpBaseUrl}`,
98− label: "Tailscale HTTPS",
99− httpBaseUrl,
100− reachability: "private-network",
101− hostedHttpsCompatibility: isReachable ? "compatible" : "requires-configuration",
102− status: isReachable ? "available" : "unavailable",
103− description: isReachable
104− ? "HTTPS endpoint served by Tailscale Serve."
105− : "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.",
106− });
107−}
108−
109−export async function resolveTailscaleAdvertisedEndpoints(input: {
110− readonly port: number;
111− readonly serveEnabled?: boolean;
112− readonly servePort?: number;
113− readonly networkInterfaces: NodeJS.Dict<NetworkInterfaceInfo[]>;
114− readonly statusJson?: string | null;
115− readonly probe?: (baseUrl: string) => Promise<boolean>;
116−}): Promise<readonly AdvertisedEndpoint[]> {
117− const ipEndpoints = resolveTailscaleIpAdvertisedEndpoints(input);
118− const dnsName =
119− input.statusJson === undefined
120− ? await Effect.runPromise(
121− readTailscaleStatus.pipe(
122− Effect.map((status) => status.magicDnsName),
123− Effect.catch(() => Effect.succeed(null)),
124− Effect.provide(TailscaleDesktopLayer),
125− ),
126− )
127− : input.statusJson
128− ? await Effect.runPromise(
129− parseTailscaleMagicDnsName(input.statusJson).pipe(
130− Effect.catch(() => Effect.succeed(null)),
131− ),
132− )
133− : null;
134− const magicDnsEndpoint = await resolveTailscaleMagicDnsAdvertisedEndpoint({
135− dnsName,
136− serveEnabled: input.serveEnabled === true,
137− ...(input.servePort === undefined ? {} : { servePort: input.servePort }),
138− ...(input.probe === undefined ? {} : { probe: input.probe }),
139− });
140−
141− return magicDnsEndpoint ? [...ipEndpoints, magicDnsEndpoint] : ipEndpoints;
142−}
apps/desktop/src/updateChannels.test.ts+0−41
@@ -1,41 +0,0 @@
1−import { describe, expect, it } from "vitest";
2−
3−import {
4− doesVersionMatchDesktopUpdateChannel,
5− isNightlyDesktopVersion,
6− resolveDefaultDesktopUpdateChannel,
7−} from "./updateChannels.ts";
8−
9−describe("isNightlyDesktopVersion", () => {
10− it("detects packaged nightly versions", () => {
11− expect(isNightlyDesktopVersion("0.0.17-nightly.20260415.1")).toBe(true);
12− });
13−
14− it("does not flag stable versions as nightly", () => {
15− expect(isNightlyDesktopVersion("0.0.17")).toBe(false);
16− });
17−});
18−
19−describe("resolveDefaultDesktopUpdateChannel", () => {
20− it("defaults stable builds to latest", () => {
21− expect(resolveDefaultDesktopUpdateChannel("0.0.17")).toBe("latest");
22− });
23−
24− it("defaults nightly builds to nightly", () => {
25− expect(resolveDefaultDesktopUpdateChannel("0.0.17-nightly.20260415.1")).toBe("nightly");
26− });
27−});
28−
29−describe("doesVersionMatchDesktopUpdateChannel", () => {
30− it("accepts nightly releases on the nightly channel", () => {
31− expect(doesVersionMatchDesktopUpdateChannel("0.0.17-nightly.20260416.1", "nightly")).toBe(true);
32− });
33−
34− it("rejects stable releases on the nightly channel", () => {
35− expect(doesVersionMatchDesktopUpdateChannel("0.0.17", "nightly")).toBe(false);
36− });
37−
38− it("rejects nightly releases on the stable channel", () => {
39− expect(doesVersionMatchDesktopUpdateChannel("0.0.17-nightly.20260416.1", "latest")).toBe(false);
40− });
41−});
apps/desktop/src/updateState.test.ts+0−175
@@ -1,175 +0,0 @@
1−import { describe, expect, it } from "vitest";
2−import type { DesktopUpdateState } from "@t3tools/contracts";
3−
4−import {
5− getCanRetryAfterDownloadFailure,
6− getAutoUpdateDisabledReason,
7− nextStatusAfterDownloadFailure,
8− shouldBroadcastDownloadProgress,
9−} from "./updateState.ts";
10−
11−const baseState: DesktopUpdateState = {
12− enabled: true,
13− status: "idle",
14− channel: "latest",
15− currentVersion: "1.0.0",
16− hostArch: "x64",
17− appArch: "x64",
18− runningUnderArm64Translation: false,
19− availableVersion: null,
20− downloadedVersion: null,
21− downloadPercent: null,
22− checkedAt: null,
23− message: null,
24− errorContext: null,
25− canRetry: false,
26−};
27−
28−describe("shouldBroadcastDownloadProgress", () => {
29− it("broadcasts the first downloading progress update", () => {
30− expect(
31− shouldBroadcastDownloadProgress(
32− { ...baseState, status: "downloading", downloadPercent: null },
33− 1,
34− ),
35− ).toBe(true);
36− });
37−
38− it("skips progress updates within the same 10% bucket", () => {
39− expect(
40− shouldBroadcastDownloadProgress(
41− { ...baseState, status: "downloading", downloadPercent: 11.2 },
42− 18.7,
43− ),
44− ).toBe(false);
45− });
46−
47− it("broadcasts progress updates when a new 10% bucket is reached", () => {
48− expect(
49− shouldBroadcastDownloadProgress(
50− { ...baseState, status: "downloading", downloadPercent: 19.9 },
51− 20.1,
52− ),
53− ).toBe(true);
54− });
55−
56− it("broadcasts progress updates when a retry resets the download percentage", () => {
57− expect(
58− shouldBroadcastDownloadProgress(
59− { ...baseState, status: "downloading", downloadPercent: 50.4 },
60− 0.2,
61− ),
62− ).toBe(true);
63− });
64−});
65−
66−describe("getAutoUpdateDisabledReason", () => {
67− it("reports development builds as disabled", () => {
68− expect(
69− getAutoUpdateDisabledReason({
70− isDevelopment: true,
71− isPackaged: false,
72− platform: "darwin",
73− appImage: undefined,
74− disabledByEnv: false,
75− hasUpdateFeedConfig: true,
76− }),
77− ).toContain("packaged production builds");
78− });
79−
80− it("reports packaged local builds without an update feed as disabled", () => {
81− expect(
82− getAutoUpdateDisabledReason({
83− isDevelopment: false,
84− isPackaged: true,
85− platform: "darwin",
86− appImage: undefined,
87− disabledByEnv: false,
88− hasUpdateFeedConfig: false,
89− }),
90− ).toContain("no update feed");
91− });
92−
93− it("allows packaged builds with an update feed", () => {
94− expect(
95− getAutoUpdateDisabledReason({
96− isDevelopment: false,
97− isPackaged: true,
98− platform: "darwin",
99− appImage: undefined,
100− disabledByEnv: false,
101− hasUpdateFeedConfig: true,
102− }),
103− ).toBeNull();
104− });
105−
106− it("reports env-disabled auto updates", () => {
107− expect(
108− getAutoUpdateDisabledReason({
109− isDevelopment: false,
110− isPackaged: true,
111− platform: "darwin",
112− appImage: undefined,
113− disabledByEnv: true,
114− hasUpdateFeedConfig: true,
115− }),
116− ).toContain("T3CODE_DISABLE_AUTO_UPDATE");
117− });
118−
119− it("reports linux non-AppImage builds as disabled", () => {
120− expect(
121− getAutoUpdateDisabledReason({
122− isDevelopment: false,
123− isPackaged: true,
124− platform: "linux",
125− appImage: undefined,
126− disabledByEnv: false,
127− hasUpdateFeedConfig: true,
128− }),
129− ).toContain("AppImage");
130− });
131−});
132−
133−describe("nextStatusAfterDownloadFailure", () => {
134− it("returns available when an update version is still known", () => {
135− expect(
136− nextStatusAfterDownloadFailure({
137− ...baseState,
138− status: "downloading",
139− availableVersion: "1.1.0",
140− }),
141− ).toBe("available");
142− });
143−
144− it("returns error when no update version can be retried", () => {
145− expect(
146− nextStatusAfterDownloadFailure({
147− ...baseState,
148− status: "downloading",
149− availableVersion: null,
150− }),
151− ).toBe("error");
152− });
153−});
154−
155−describe("getCanRetryAfterDownloadFailure", () => {
156− it("returns true when an available version is still present", () => {
157− expect(
158− getCanRetryAfterDownloadFailure({
159− ...baseState,
160− status: "downloading",
161− availableVersion: "1.1.0",
162− }),
163− ).toBe(true);
164− });
165−
166− it("returns false when no version is available to retry", () => {
167− expect(
168− getCanRetryAfterDownloadFailure({
169− ...baseState,
170− status: "downloading",
171− availableVersion: null,
172− }),
173− ).toBe(false);
174− });
175−});
apps/desktop/src/updateState.ts+0−52
@@ -1,52 +0,0 @@
1−import type { DesktopUpdateState } from "@t3tools/contracts";
2−
3−export function shouldBroadcastDownloadProgress(
4− currentState: DesktopUpdateState,
5− nextPercent: number,
6−): boolean {
7− if (currentState.status !== "downloading") {
8− return true;
9− }
10−
11− const currentPercent = currentState.downloadPercent;
12− if (currentPercent === null) {
13− return true;
14− }
15−
16− const previousStep = Math.floor(currentPercent / 10);
17− const nextStep = Math.floor(nextPercent / 10);
18− return nextStep !== previousStep || nextPercent === 100;
19−}
20−
21−export function nextStatusAfterDownloadFailure(
22− currentState: DesktopUpdateState,
23−): DesktopUpdateState["status"] {
24− return currentState.availableVersion ? "available" : "error";
25−}
26−
27−export function getCanRetryAfterDownloadFailure(currentState: DesktopUpdateState): boolean {
28− return currentState.availableVersion !== null;
29−}
30−
31−export function getAutoUpdateDisabledReason(args: {
32− isDevelopment: boolean;
33− isPackaged: boolean;
34− platform: NodeJS.Platform;
35− appImage?: string | undefined;
36− disabledByEnv: boolean;
37− hasUpdateFeedConfig: boolean;
38−}): string | null {
39− if (!args.hasUpdateFeedConfig) {
40− return "Automatic updates are not available because no update feed is configured.";
41− }
42− if (args.isDevelopment || !args.isPackaged) {
43− return "Automatic updates are only available in packaged production builds.";
44− }
45− if (args.disabledByEnv) {
46− return "Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting.";
47− }
48− if (args.platform === "linux" && !args.appImage) {
49− return "Automatic updates on Linux require running the AppImage build.";
50− }
51− return null;
52−}
apps/desktop/src/updates/DesktopUpdates.test.ts+295−0
@@ -0,0 +1,295 @@
1+import { assert, describe, it } from "@effect/vitest";
2+import * as NodeServices from "@effect/platform-node/NodeServices";
3+import type { DesktopUpdateState } from "@t3tools/contracts";
4+import * as Cause from "effect/Cause";
5+import * as Deferred from "effect/Deferred";
6+import * as Duration from "effect/Duration";
7+import * as Effect from "effect/Effect";
8+import * as Fiber from "effect/Fiber";
9+import * as Layer from "effect/Layer";
10+import * as Option from "effect/Option";
11+import { TestClock } from "effect/testing";
12+
13+import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts";
14+import * as DesktopConfig from "../app/DesktopConfig.ts";
15+import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
16+import * as ElectronUpdater from "../electron/ElectronUpdater.ts";
17+import * as ElectronWindow from "../electron/ElectronWindow.ts";
18+import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
19+import * as DesktopState from "../app/DesktopState.ts";
20+import * as DesktopUpdates from "./DesktopUpdates.ts";
21+
22+interface UpdatesHarnessOptions {
23+ readonly checkForUpdates?: Effect.Effect<
24+ void,
25+ ElectronUpdater.ElectronUpdaterCheckForUpdatesError
26+ >;
27+ readonly env?: Record<string, string | undefined>;
28+}
29+
30+const flushCallbacks = Effect.yieldNow;
31+
32+function makeHarness(options: UpdatesHarnessOptions = {}) {
33+ let checkCount = 0;
34+ let allowDowngrade = false;
35+ const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = [];
36+ const listeners = new Map<string, Set<(...args: readonly unknown[]) => void>>();
37+ const sentStates: DesktopUpdateState[] = [];
38+
39+ const addListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => {
40+ const eventListeners = listeners.get(eventName) ?? new Set();
41+ eventListeners.add(listener);
42+ listeners.set(eventName, eventListeners);
43+ };
44+
45+ const removeListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => {
46+ const eventListeners = listeners.get(eventName);
47+ if (!eventListeners) {
48+ return;
49+ }
50+ eventListeners.delete(listener);
51+ if (eventListeners.size === 0) {
52+ listeners.delete(eventName);
53+ }
54+ };
55+
56+ const updaterLayer = Layer.succeed(ElectronUpdater.ElectronUpdater, {
57+ setFeedURL: (options) =>
58+ Effect.sync(() => {
59+ feedUrls.push(options);
60+ }),
61+ setAutoDownload: () => Effect.void,
62+ setAutoInstallOnAppQuit: () => Effect.void,
63+ setChannel: () => Effect.void,
64+ setAllowPrerelease: () => Effect.void,
65+ allowDowngrade: Effect.sync(() => allowDowngrade),
66+ setAllowDowngrade: (value) =>
67+ Effect.sync(() => {
68+ allowDowngrade = value;
69+ }),
70+ setDisableDifferentialDownload: () => Effect.void,
71+ checkForUpdates: Effect.sync(() => {
72+ checkCount += 1;
73+ }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)),
74+ downloadUpdate: Effect.void,
75+ quitAndInstall: () => Effect.void,
76+ on: (eventName, listener) =>
77+ Effect.acquireRelease(
78+ Effect.sync(() => {
79+ addListener(eventName, listener as unknown as (...args: readonly unknown[]) => void);
80+ }),
81+ () =>
82+ Effect.sync(() => {
83+ removeListener(eventName, listener as unknown as (...args: readonly unknown[]) => void);
84+ }),
85+ ).pipe(Effect.asVoid),
86+ } satisfies ElectronUpdater.ElectronUpdaterShape);
87+
88+ const windowLayer = Layer.succeed(ElectronWindow.ElectronWindow, {
89+ create: () => Effect.die("unexpected BrowserWindow creation"),
90+ main: Effect.succeed(Option.none()),
91+ currentMainOrFirst: Effect.succeed(Option.none()),
92+ focusedMainOrFirst: Effect.succeed(Option.none()),
93+ setMain: () => Effect.void,
94+ clearMain: () => Effect.void,
95+ reveal: () => Effect.void,
96+ sendAll: (_channel, state) =>
97+ Effect.sync(() => {
98+ sentStates.push(state as DesktopUpdateState);
99+ }),
100+ destroyAll: Effect.void,
101+ syncAllAppearance: () => Effect.void,
102+ } satisfies ElectronWindow.ElectronWindowShape);
103+
104+ const backendLayer = Layer.succeed(DesktopBackendManager.DesktopBackendManager, {
105+ start: Effect.void,
106+ stop: () => Effect.void,
107+ currentConfig: Effect.succeed(Option.none()),
108+ snapshot: Effect.succeed({
109+ desiredRunning: false,
110+ ready: false,
111+ activePid: Option.none(),
112+ restartAttempt: 0,
113+ restartScheduled: false,
114+ }),
115+ });
116+
117+ const environmentLayer = DesktopEnvironment.layer({
118+ dirname: "/repo/apps/desktop/src",
119+ homeDirectory: `/tmp/t3-desktop-updates-home-${process.pid}`,
120+ platform: "darwin",
121+ processArch: "x64",
122+ appVersion: "1.2.3",
123+ appPath: "/repo",
124+ isPackaged: true,
125+ resourcesPath: "/missing/resources",
126+ runningUnderArm64Translation: false,
127+ }).pipe(
128+ Layer.provide(
129+ Layer.mergeAll(
130+ NodeServices.layer,
131+ DesktopConfig.layerTest({
132+ T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`,
133+ T3CODE_DESKTOP_MOCK_UPDATES: "true",
134+ T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141",
135+ ...options.env,
136+ }),
137+ ),
138+ ),
139+ );
140+
141+ const layer = DesktopUpdates.layer.pipe(
142+ Layer.provideMerge(updaterLayer),
143+ Layer.provideMerge(windowLayer),
144+ Layer.provideMerge(backendLayer),
145+ Layer.provideMerge(DesktopState.layer),
146+ Layer.provideMerge(DesktopAppSettings.layer),
147+ Layer.provideMerge(
148+ DesktopConfig.layerTest({
149+ T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`,
150+ T3CODE_DESKTOP_MOCK_UPDATES: "true",
151+ T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141",
152+ ...options.env,
153+ }),
154+ ),
155+ Layer.provideMerge(environmentLayer),
156+ Layer.provideMerge(NodeServices.layer),
157+ );
158+
159+ return {
160+ layer,
161+ checkCount: () => checkCount,
162+ feedUrls: () => feedUrls,
163+ listenerCount: () =>
164+ Array.from(listeners.values()).reduce(
165+ (total, eventListeners) => total + eventListeners.size,
166+ 0,
167+ ),
168+ sentStates,
169+ emit: (eventName: string, payload?: unknown) => {
170+ for (const listener of listeners.get(eventName) ?? []) {
171+ listener(payload);
172+ }
173+ },
174+ };
175+}
176+
177+describe("DesktopUpdates", () => {
178+ it.effect("configures the updater and runs startup checks on the test clock", () => {
179+ const harness = makeHarness();
180+
181+ return Effect.gen(function* () {
182+ yield* Effect.scoped(
183+ Effect.gen(function* () {
184+ const updates = yield* DesktopUpdates.DesktopUpdates;
185+ yield* updates.configure;
186+
187+ const state = yield* updates.getState;
188+ assert.equal(state.enabled, true);
189+ assert.equal(state.status, "idle");
190+ assert.deepEqual(harness.feedUrls(), [
191+ { provider: "generic", url: "http://localhost:4141" },
192+ ]);
193+ assert.equal(harness.listenerCount(), 6);
194+ assert.equal(harness.checkCount(), 0);
195+
196+ yield* TestClock.adjust(Duration.millis(15_000));
197+ assert.equal(harness.checkCount(), 1);
198+ }),
199+ );
200+
201+ assert.equal(harness.listenerCount(), 0);
202+ }).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
203+ });
204+
205+ it.effect("updates and broadcasts state from updater events", () => {
206+ const harness = makeHarness();
207+
208+ return Effect.scoped(
209+ Effect.gen(function* () {
210+ const updates = yield* DesktopUpdates.DesktopUpdates;
211+ yield* updates.configure;
212+
213+ harness.emit("update-available", { version: "1.2.4" });
214+ yield* flushCallbacks;
215+
216+ const state = yield* updates.getState;
217+ assert.equal(state.status, "available");
218+ assert.equal(state.availableVersion, "1.2.4");
219+ assert.isNotNull(state.checkedAt);
220+ assert.equal(harness.sentStates.at(-1)?.status, "available");
221+ }),
222+ ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
223+ });
224+
225+ it.effect("persists channel changes through the settings service", () => {
226+ const harness = makeHarness();
227+
228+ return Effect.scoped(
229+ Effect.gen(function* () {
230+ const settings = yield* DesktopAppSettings.DesktopAppSettings;
231+ const updates = yield* DesktopUpdates.DesktopUpdates;
232+ yield* updates.configure;
233+
234+ const state = yield* updates.setChannel("nightly");
235+ const persistedSettings = yield* settings.get;
236+
237+ assert.equal(state.channel, "nightly");
238+ assert.equal(persistedSettings.updateChannel, "nightly");
239+ assert.equal(persistedSettings.updateChannelConfiguredByUser, true);
240+ }),
241+ ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
242+ });
243+
244+ it.effect("does not persist an unchanged update channel as a user preference", () => {
245+ const harness = makeHarness();
246+
247+ return Effect.scoped(
248+ Effect.gen(function* () {
249+ const settings = yield* DesktopAppSettings.DesktopAppSettings;
250+ const updates = yield* DesktopUpdates.DesktopUpdates;
251+ yield* updates.configure;
252+
253+ const state = yield* updates.setChannel("latest");
254+ const persistedSettings = yield* settings.get;
255+
256+ assert.equal(state.channel, "latest");
257+ assert.equal(persistedSettings.updateChannel, "latest");
258+ assert.equal(persistedSettings.updateChannelConfiguredByUser, false);
259+ }),
260+ ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
261+ });
262+
263+ it.effect("fails channel changes with a typed error while a check is in progress", () =>
264+ Effect.gen(function* () {
265+ const checkStarted = yield* Deferred.make<void>();
266+ const releaseCheck = yield* Deferred.make<void>();
267+ const harness = makeHarness({
268+ checkForUpdates: Deferred.succeed(checkStarted, undefined).pipe(
269+ Effect.andThen(Deferred.await(releaseCheck)),
270+ ),
271+ });
272+
273+ yield* Effect.scoped(
274+ Effect.gen(function* () {
275+ const updates = yield* DesktopUpdates.DesktopUpdates;
276+ yield* updates.configure;
277+
278+ const checkFiber = yield* updates.check("manual").pipe(Effect.forkScoped);
279+ yield* Deferred.await(checkStarted);
280+
281+ const exit = yield* Effect.exit(updates.setChannel("nightly"));
282+ assert.equal(exit._tag, "Failure");
283+ if (exit._tag === "Failure") {
284+ const error = Cause.squash(exit.cause);
285+ assert.instanceOf(error, DesktopUpdates.DesktopUpdateActionInProgressError);
286+ assert.equal(error.action, "check");
287+ }
288+
289+ yield* Deferred.succeed(releaseCheck, undefined);
290+ yield* Fiber.join(checkFiber);
291+ }),
292+ ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
293+ }),
294+ );
295+});
apps/desktop/src/updates/DesktopUpdates.ts+664−0
@@ -0,0 +1,664 @@
1+import type {
2+ DesktopRuntimeInfo,
3+ DesktopUpdateActionResult,
4+ DesktopUpdateChannel,
5+ DesktopUpdateCheckResult,
6+ DesktopUpdateState,
7+} from "@t3tools/contracts";
8+import * as Cause from "effect/Cause";
9+import * as Context from "effect/Context";
10+import * as Data from "effect/Data";
11+import * as DateTime from "effect/DateTime";
12+import * as Duration from "effect/Duration";
13+import * as Effect from "effect/Effect";
14+import * as FileSystem from "effect/FileSystem";
15+import * as Layer from "effect/Layer";
16+import * as Option from "effect/Option";
17+import * as Ref from "effect/Ref";
18+import * as Schema from "effect/Schema";
19+import * as Scope from "effect/Scope";
20+
21+import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts";
22+import * as DesktopConfig from "../app/DesktopConfig.ts";
23+import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
24+import * as DesktopObservability from "../app/DesktopObservability.ts";
25+import * as DesktopState from "../app/DesktopState.ts";
26+import * as ElectronUpdater from "../electron/ElectronUpdater.ts";
27+import * as ElectronWindow from "../electron/ElectronWindow.ts";
28+import * as IpcChannels from "../ipc/channels.ts";
29+import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
30+import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts";
31+import {
32+ createInitialDesktopUpdateState,
33+ reduceDesktopUpdateStateOnCheckFailure,
34+ reduceDesktopUpdateStateOnCheckStart,
35+ reduceDesktopUpdateStateOnDownloadComplete,
36+ reduceDesktopUpdateStateOnDownloadFailure,
37+ reduceDesktopUpdateStateOnDownloadProgress,
38+ reduceDesktopUpdateStateOnDownloadStart,
39+ reduceDesktopUpdateStateOnInstallFailure,
40+ reduceDesktopUpdateStateOnNoUpdate,
41+ reduceDesktopUpdateStateOnUpdateAvailable,
42+} from "./updateMachine.ts";
43+
44+const AUTO_UPDATE_STARTUP_DELAY = "15 seconds";
45+const AUTO_UPDATE_POLL_INTERVAL = "4 minutes";
46+
47+const AppUpdateYmlConfig = Schema.Record(Schema.String, Schema.String);
48+type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type;
49+
50+const UpdateInfo = Schema.Struct({
51+ version: Schema.String,
52+});
53+
54+const DownloadProgressInfo = Schema.Struct({
55+ percent: Schema.Number,
56+});
57+
58+const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso));
59+
60+export class DesktopUpdateActionInProgressError extends Data.TaggedError(
61+ "DesktopUpdateActionInProgressError",
62+)<{
63+ readonly action: "check" | "download" | "install";
64+}> {
65+ override get message() {
66+ return `Cannot change update tracks while an update ${this.action} action is in progress.`;
67+ }
68+}
69+
70+export class DesktopUpdatePersistenceError extends Data.TaggedError(
71+ "DesktopUpdatePersistenceError",
72+)<{
73+ readonly cause: DesktopAppSettings.DesktopSettingsWriteError;
74+}> {
75+ override get message() {
76+ return "Failed to persist desktop update settings.";
77+ }
78+}
79+
80+export type DesktopUpdateConfigureError = never;
81+
82+export type DesktopUpdateSetChannelError =
83+ | DesktopUpdateActionInProgressError
84+ | DesktopUpdatePersistenceError;
85+
86+export interface DesktopUpdatesShape {
87+ readonly getState: Effect.Effect<DesktopUpdateState>;
88+ readonly emitState: Effect.Effect<void>;
89+ readonly disabledReason: Effect.Effect<Option.Option<string>>;
90+ readonly configure: Effect.Effect<void, DesktopUpdateConfigureError, Scope.Scope>;
91+ readonly setChannel: (
92+ channel: DesktopUpdateChannel,
93+ ) => Effect.Effect<DesktopUpdateState, DesktopUpdateSetChannelError>;
94+ readonly check: (reason: string) => Effect.Effect<DesktopUpdateCheckResult>;
95+ readonly download: Effect.Effect<DesktopUpdateActionResult>;
96+ readonly install: Effect.Effect<DesktopUpdateActionResult>;
97+}
98+
99+export class DesktopUpdates extends Context.Service<DesktopUpdates, DesktopUpdatesShape>()(
100+ "t3/desktop/Updates",
101+) {}
102+
103+const {
104+ logInfo: logUpdaterInfo,
105+ logWarning: logUpdaterWarning,
106+ logError: logUpdaterError,
107+} = DesktopObservability.makeComponentLogger("desktop-updater");
108+
109+function parseAppUpdateYml(raw: string): Effect.Effect<Option.Option<AppUpdateYmlConfig>> {
110+ const entries: Record<string, string> = {};
111+ for (const line of raw.split("\n")) {
112+ const match = line.match(/^(\w+):\s*(.+)$/);
113+ if (match?.[1] && match[2]) {
114+ entries[match[1]] = match[2].trim();
115+ }
116+ }
117+
118+ return Schema.decodeUnknownEffect(AppUpdateYmlConfig)(entries).pipe(
119+ Effect.map((config) => (config.provider ? Option.some(config) : Option.none())),
120+ Effect.catch(() => Effect.succeed(Option.none<AppUpdateYmlConfig>())),
121+ );
122+}
123+
124+function createBaseUpdateState(
125+ channel: DesktopUpdateChannel,
126+ enabled: boolean,
127+ environment: DesktopEnvironment.DesktopEnvironmentShape,
128+): DesktopUpdateState {
129+ return {
130+ ...createInitialDesktopUpdateState(environment.appVersion, environment.runtimeInfo, channel),
131+ enabled,
132+ status: enabled ? "idle" : "disabled",
133+ };
134+}
135+
136+function getCanRetryFromState(state: DesktopUpdateState): boolean {
137+ return state.availableVersion !== null || state.downloadedVersion !== null;
138+}
139+
140+function shouldBroadcastDownloadProgress(
141+ currentState: DesktopUpdateState,
142+ nextPercent: number,
143+): boolean {
144+ if (currentState.status !== "downloading") {
145+ return true;
146+ }
147+
148+ const currentPercent = currentState.downloadPercent;
149+ if (currentPercent === null) {
150+ return true;
151+ }
152+
153+ const previousStep = Math.floor(currentPercent / 10);
154+ const nextStep = Math.floor(nextPercent / 10);
155+ return nextStep !== previousStep || nextPercent === 100;
156+}
157+
158+function getAutoUpdateDisabledReason(args: {
159+ isDevelopment: boolean;
160+ isPackaged: boolean;
161+ platform: NodeJS.Platform;
162+ appImage?: string | undefined;
163+ disabledByEnv: boolean;
164+ hasUpdateFeedConfig: boolean;
165+}): string | null {
166+ if (!args.hasUpdateFeedConfig) {
167+ return "Automatic updates are not available because no update feed is configured.";
168+ }
169+ if (args.isDevelopment || !args.isPackaged) {
170+ return "Automatic updates are only available in packaged production builds.";
171+ }
172+ if (args.disabledByEnv) {
173+ return "Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting.";
174+ }
175+ if (args.platform === "linux" && !args.appImage) {
176+ return "Automatic updates on Linux require running the AppImage build.";
177+ }
178+ return null;
179+}
180+
181+function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean {
182+ return runtimeInfo.hostArch === "arm64" && runtimeInfo.appArch === "x64";
183+}
184+
185+const make = Effect.gen(function* () {
186+ const config = yield* DesktopConfig.DesktopConfig;
187+ const backendManager = yield* DesktopBackendManager.DesktopBackendManager;
188+ const desktopState = yield* DesktopState.DesktopState;
189+ const electronUpdater = yield* ElectronUpdater.ElectronUpdater;
190+ const electronWindow = yield* ElectronWindow.ElectronWindow;
191+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
192+ const fileSystem = yield* FileSystem.FileSystem;
193+ const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
194+
195+ const appUpdateYmlConfigRef = yield* Ref.make<Option.Option<AppUpdateYmlConfig>>(Option.none());
196+ const updateCheckInFlightRef = yield* Ref.make(false);
197+ const updateDownloadInFlightRef = yield* Ref.make(false);
198+ const updateInstallInFlightRef = yield* Ref.make(false);
199+ const updaterConfiguredRef = yield* Ref.make(false);
200+ const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1);
201+ const updateStateRef = yield* Ref.make<DesktopUpdateState>(
202+ createInitialDesktopUpdateState(
203+ environment.appVersion,
204+ environment.runtimeInfo,
205+ environment.defaultDesktopSettings.updateChannel,
206+ ),
207+ );
208+
209+ const emitState = Ref.get(updateStateRef).pipe(
210+ Effect.flatMap((state) => electronWindow.sendAll(IpcChannels.UPDATE_STATE_CHANNEL, state)),
211+ );
212+
213+ const setState = (state: DesktopUpdateState): Effect.Effect<void> =>
214+ Ref.set(updateStateRef, state).pipe(Effect.andThen(emitState));
215+
216+ const updateState = (
217+ f: (state: DesktopUpdateState) => DesktopUpdateState,
218+ ): Effect.Effect<DesktopUpdateState> =>
219+ Ref.get(updateStateRef).pipe(
220+ Effect.flatMap((state) => {
221+ const nextState = f(state);
222+ return setState(nextState).pipe(Effect.as(nextState));
223+ }),
224+ );
225+
226+ const readAppUpdateYml = fileSystem.readFileString(environment.appUpdateYmlPath, "utf-8").pipe(
227+ Effect.option,
228+ Effect.flatMap(
229+ Option.match({
230+ onNone: () => Effect.succeed(Option.none<AppUpdateYmlConfig>()),
231+ onSome: parseAppUpdateYml,
232+ }),
233+ ),
234+ );
235+
236+ const hasUpdateFeedConfig = Ref.get(appUpdateYmlConfigRef).pipe(
237+ Effect.map((appUpdateYmlConfig) => Option.isSome(appUpdateYmlConfig) || config.mockUpdates),
238+ );
239+
240+ const resolveDisabledReason = Effect.gen(function* () {
241+ const hasFeedConfig = yield* hasUpdateFeedConfig;
242+ return Option.fromNullishOr(
243+ getAutoUpdateDisabledReason({
244+ isDevelopment: environment.isDevelopment,
245+ isPackaged: environment.isPackaged,
246+ platform: environment.platform,
247+ appImage: Option.getOrUndefined(config.appImagePath),
248+ disabledByEnv: config.disableAutoUpdate,
249+ hasUpdateFeedConfig: hasFeedConfig,
250+ }),
251+ );
252+ });
253+
254+ const resolveUpdaterErrorContext = Effect.gen(function* () {
255+ if (yield* Ref.get(updateInstallInFlightRef)) return "install" as const;
256+ if (yield* Ref.get(updateDownloadInFlightRef)) return "download" as const;
257+ if (yield* Ref.get(updateCheckInFlightRef)) return "check" as const;
258+ return (yield* Ref.get(updateStateRef)).errorContext;
259+ });
260+
261+ const activeUpdateAction = Effect.gen(function* () {
262+ if (yield* Ref.get(updateInstallInFlightRef)) return Option.some("install" as const);
263+ if (yield* Ref.get(updateDownloadInFlightRef)) return Option.some("download" as const);
264+ if (yield* Ref.get(updateCheckInFlightRef)) return Option.some("check" as const);
265+ return Option.none<"check" | "download" | "install">();
266+ });
267+
268+ const applyAutoUpdaterChannel = Effect.fn("desktop.updates.applyAutoUpdaterChannel")(function* (
269+ channel: DesktopUpdateChannel,
270+ ) {
271+ yield* Effect.annotateCurrentSpan({ channel });
272+ const allowsPrerelease = channel === "nightly";
273+ yield* electronUpdater.setChannel(channel);
274+ yield* electronUpdater.setAllowPrerelease(allowsPrerelease);
275+ yield* electronUpdater.setAllowDowngrade(allowsPrerelease);
276+ yield* logUpdaterInfo("using update channel", {
277+ channel,
278+ allowPrerelease: allowsPrerelease,
279+ allowDowngrade: allowsPrerelease,
280+ });
281+ });
282+
283+ const shouldEnableAutoUpdates = resolveDisabledReason.pipe(Effect.map(Option.isNone));
284+
285+ const checkForUpdates = Effect.fn("desktop.updates.checkForUpdates")(function* (reason: string) {
286+ yield* Effect.annotateCurrentSpan({ reason });
287+ if (yield* Ref.get(desktopState.quitting)) return false;
288+ if (!(yield* Ref.get(updaterConfiguredRef))) return false;
289+ if (yield* Ref.get(updateCheckInFlightRef)) return false;
290+
291+ const state = yield* Ref.get(updateStateRef);
292+ if (state.status === "downloading" || state.status === "downloaded") {
293+ yield* logUpdaterInfo("skipping update check while update is active", {
294+ reason,
295+ status: state.status,
296+ });
297+ return false;
298+ }
299+
300+ yield* Ref.set(updateCheckInFlightRef, true);
301+ const checkedAt = yield* currentIsoTimestamp;
302+ yield* setState(reduceDesktopUpdateStateOnCheckStart(state, checkedAt));
303+ yield* logUpdaterInfo("checking for updates", { reason });
304+
305+ return yield* electronUpdater.checkForUpdates.pipe(
306+ Effect.as(true),
307+ Effect.catch(
308+ Effect.fn("desktop.updates.handleCheckForUpdatesFailure")(function* (error) {
309+ const failedAt = yield* currentIsoTimestamp;
310+ yield* updateState((current) =>
311+ reduceDesktopUpdateStateOnCheckFailure(current, error.message, failedAt),
312+ );
313+ yield* logUpdaterError("failed to check for updates", { message: error.message });
314+ return true;
315+ }),
316+ ),
317+ Effect.ensuring(Ref.set(updateCheckInFlightRef, false)),
318+ );
319+ });
320+
321+ const downloadAvailableUpdate = Effect.gen(function* () {
322+ const state = yield* Ref.get(updateStateRef);
323+ if (
324+ !(yield* Ref.get(updaterConfiguredRef)) ||
325+ (yield* Ref.get(updateDownloadInFlightRef)) ||
326+ state.status !== "available"
327+ ) {
328+ return { accepted: false, completed: false };
329+ }
330+
331+ yield* Ref.set(updateDownloadInFlightRef, true);
332+ return yield* Effect.gen(function* () {
333+ yield* setState(reduceDesktopUpdateStateOnDownloadStart(state));
334+ yield* electronUpdater.setDisableDifferentialDownload(
335+ isArm64HostRunningIntelBuild(environment.runtimeInfo),
336+ );
337+ yield* logUpdaterInfo("downloading update");
338+ yield* electronUpdater.downloadUpdate;
339+ return { accepted: true, completed: true };
340+ }).pipe(
341+ Effect.catch(
342+ Effect.fn("desktop.updates.handleDownloadFailure")(function* (error) {
343+ yield* updateState((current) =>
344+ reduceDesktopUpdateStateOnDownloadFailure(current, error.message),
345+ );
346+ yield* logUpdaterError("failed to download update", { message: error.message });
347+ return { accepted: true, completed: false };
348+ }),
349+ ),
350+ Effect.ensuring(Ref.set(updateDownloadInFlightRef, false)),
351+ );
352+ }).pipe(Effect.withSpan("desktop.updates.downloadAvailableUpdate"));
353+
354+ const installDownloadedUpdate = Effect.gen(function* () {
355+ const state = yield* Ref.get(updateStateRef);
356+ if (
357+ (yield* Ref.get(desktopState.quitting)) ||
358+ !(yield* Ref.get(updaterConfiguredRef)) ||
359+ state.status !== "downloaded"
360+ ) {
361+ return { accepted: false, completed: false };
362+ }
363+
364+ yield* Ref.set(desktopState.quitting, true);
365+ yield* Ref.set(updateInstallInFlightRef, true);
366+
367+ return yield* Effect.gen(function* () {
368+ yield* backendManager.stop({ timeout: Duration.seconds(5) });
369+ yield* electronWindow.destroyAll;
370+ yield* electronUpdater.quitAndInstall({
371+ isSilent: true,
372+ isForceRunAfter: true,
373+ });
374+ return { accepted: true, completed: false };
375+ }).pipe(
376+ Effect.catch(
377+ Effect.fn("desktop.updates.handleInstallFailure")(function* (error) {
378+ yield* Ref.set(updateInstallInFlightRef, false);
379+ yield* updateState((current) =>
380+ reduceDesktopUpdateStateOnInstallFailure(current, error.message),
381+ );
382+ yield* Ref.set(desktopState.quitting, false);
383+ yield* logUpdaterError("failed to install update", { message: error.message });
384+ return { accepted: true, completed: false };
385+ }),
386+ ),
387+ );
388+ }).pipe(Effect.withSpan("desktop.updates.installDownloadedUpdate"));
389+
390+ const startUpdatePollers: Effect.Effect<void, never, Scope.Scope> = Effect.gen(function* () {
391+ yield* Effect.sleep(AUTO_UPDATE_STARTUP_DELAY).pipe(
392+ Effect.andThen(checkForUpdates("startup")),
393+ Effect.catchCause((cause) =>
394+ logUpdaterError("startup update check failed", { cause: Cause.pretty(cause) }),
395+ ),
396+ Effect.forkScoped,
397+ );
398+ yield* Effect.sleep(AUTO_UPDATE_POLL_INTERVAL).pipe(
399+ Effect.andThen(checkForUpdates("poll")),
400+ Effect.forever,
401+ Effect.catchCause((cause) =>
402+ logUpdaterError("poll update check failed", { cause: Cause.pretty(cause) }),
403+ ),
404+ Effect.forkScoped,
405+ );
406+ }).pipe(Effect.withSpan("desktop.updates.startPollers"));
407+
408+ const handleUpdateAvailable = Effect.fn("desktop.updates.handleUpdateAvailable")(function* (
409+ raw: unknown,
410+ ) {
411+ yield* Schema.decodeUnknownEffect(UpdateInfo)(raw).pipe(
412+ Effect.flatMap(
413+ Effect.fn("desktop.updates.applyUpdateAvailable")(function* (info) {
414+ const state = yield* Ref.get(updateStateRef);
415+ if (resolveDefaultDesktopUpdateChannel(info.version) !== state.channel) {
416+ yield* logUpdaterInfo("ignoring update that does not match selected channel", {
417+ version: info.version,
418+ channel: state.channel,
419+ });
420+ const checkedAt = yield* currentIsoTimestamp;
421+ yield* setState(reduceDesktopUpdateStateOnNoUpdate(state, checkedAt));
422+ yield* Ref.set(lastLoggedDownloadMilestoneRef, -1);
423+ return;
424+ }
425+
426+ const checkedAt = yield* currentIsoTimestamp;
427+ yield* setState(
428+ reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt),
429+ );
430+ yield* Ref.set(lastLoggedDownloadMilestoneRef, -1);
431+ yield* logUpdaterInfo("update available", { version: info.version });
432+ }),
433+ ),
434+ Effect.catchCause((cause) =>
435+ logUpdaterWarning("ignored malformed update-available event", {
436+ cause: Cause.pretty(cause),
437+ }),
438+ ),
439+ );
440+ });
441+
442+ const handleUpdateNotAvailable = Effect.gen(function* () {
443+ const checkedAt = yield* currentIsoTimestamp;
444+ const state = yield* Ref.get(updateStateRef);
445+ yield* setState(reduceDesktopUpdateStateOnNoUpdate(state, checkedAt));
446+ yield* Ref.set(lastLoggedDownloadMilestoneRef, -1);
447+ yield* logUpdaterInfo("no updates available");
448+ }).pipe(Effect.withSpan("desktop.updates.handleUpdateNotAvailable"));
449+
450+ const handleUpdaterError = Effect.fn("desktop.updates.handleUpdaterError")(function* (
451+ error: unknown,
452+ ) {
453+ const message = error instanceof Error ? error.message : String(error);
454+ if (yield* Ref.get(updateInstallInFlightRef)) {
455+ yield* Ref.set(updateInstallInFlightRef, false);
456+ yield* Ref.set(desktopState.quitting, false);
457+ yield* updateState((current) => reduceDesktopUpdateStateOnInstallFailure(current, message));
458+ yield* logUpdaterError("updater error", { message });
459+ return;
460+ }
461+
462+ if (!(yield* Ref.get(updateCheckInFlightRef)) && !(yield* Ref.get(updateDownloadInFlightRef))) {
463+ const errorContext = yield* resolveUpdaterErrorContext;
464+ const checkedAt = yield* currentIsoTimestamp;
465+ yield* updateState((current) => ({
466+ ...current,
467+ status: "error",
468+ message,
469+ checkedAt,
470+ downloadPercent: null,
471+ errorContext,
472+ canRetry: getCanRetryFromState(current),
473+ }));
474+ }
475+
476+ yield* logUpdaterError("updater error", { message });
477+ });
478+
479+ const handleDownloadProgress = Effect.fn("desktop.updates.handleDownloadProgress")(function* (
480+ raw: unknown,
481+ ) {
482+ yield* Schema.decodeUnknownEffect(DownloadProgressInfo)(raw).pipe(
483+ Effect.flatMap(
484+ Effect.fn("desktop.updates.applyDownloadProgress")(function* (progress) {
485+ const state = yield* Ref.get(updateStateRef);
486+ const percent = Math.floor(progress.percent);
487+ if (shouldBroadcastDownloadProgress(state, progress.percent) || state.message !== null) {
488+ yield* setState(reduceDesktopUpdateStateOnDownloadProgress(state, progress.percent));
489+ }
490+ const milestone = percent - (percent % 10);
491+ const lastLoggedMilestone = yield* Ref.get(lastLoggedDownloadMilestoneRef);
492+ if (milestone > lastLoggedMilestone) {
493+ yield* Ref.set(lastLoggedDownloadMilestoneRef, milestone);
494+ yield* logUpdaterInfo("download progress", { percent });
495+ }
496+ }),
497+ ),
498+ Effect.catchCause((cause) =>
499+ logUpdaterWarning("ignored malformed download-progress event", {
500+ cause: Cause.pretty(cause),
501+ }),
502+ ),
503+ );
504+ });
505+
506+ const handleUpdateDownloaded = Effect.fn("desktop.updates.handleUpdateDownloaded")(function* (
507+ raw: unknown,
508+ ) {
509+ yield* Schema.decodeUnknownEffect(UpdateInfo)(raw).pipe(
510+ Effect.flatMap(
511+ Effect.fn("desktop.updates.applyUpdateDownloaded")(function* (info) {
512+ const state = yield* Ref.get(updateStateRef);
513+ yield* setState(reduceDesktopUpdateStateOnDownloadComplete(state, info.version));
514+ yield* logUpdaterInfo("update downloaded", { version: info.version });
515+ }),
516+ ),
517+ Effect.catchCause((cause) =>
518+ logUpdaterWarning("ignored malformed update-downloaded event", {
519+ cause: Cause.pretty(cause),
520+ }),
521+ ),
522+ );
523+ });
524+
525+ return DesktopUpdates.of({
526+ getState: Ref.get(updateStateRef),
527+ emitState,
528+ disabledReason: resolveDisabledReason,
529+ configure: Effect.gen(function* () {
530+ const context = yield* Effect.context<never>();
531+ const runEffect = (effect: Effect.Effect<void>) => {
532+ void Effect.runPromiseWith(context)(effect);
533+ };
534+
535+ const appUpdateYmlConfig = yield* readAppUpdateYml;
536+ yield* Ref.set(appUpdateYmlConfigRef, appUpdateYmlConfig);
537+
538+ if (config.mockUpdates) {
539+ yield* electronUpdater.setFeedURL({
540+ provider: "generic",
541+ url: `http://localhost:${config.mockUpdateServerPort}`,
542+ } as ElectronUpdater.ElectronUpdaterFeedUrl);
543+ }
544+
545+ const settings = yield* desktopSettings.get;
546+ const enabled = yield* shouldEnableAutoUpdates;
547+ yield* setState(createBaseUpdateState(settings.updateChannel, enabled, environment));
548+ if (!enabled) {
549+ return;
550+ }
551+ yield* Ref.set(updaterConfiguredRef, true);
552+
553+ yield* electronUpdater.setAutoDownload(false);
554+ yield* electronUpdater.setAutoInstallOnAppQuit(false);
555+ yield* applyAutoUpdaterChannel(settings.updateChannel);
556+ yield* electronUpdater.setDisableDifferentialDownload(
557+ isArm64HostRunningIntelBuild(environment.runtimeInfo),
558+ );
559+
560+ if (isArm64HostRunningIntelBuild(environment.runtimeInfo)) {
561+ yield* logUpdaterInfo(
562+ "Apple Silicon host detected while running Intel build; updates will switch to arm64 packages",
563+ );
564+ }
565+
566+ yield* electronUpdater.on("checking-for-update", () => {
567+ runEffect(
568+ logUpdaterInfo("looking for updates").pipe(
569+ Effect.withSpan("desktop.updates.handleCheckingForUpdate"),
570+ ),
571+ );
572+ });
573+ yield* electronUpdater.on("update-available", (info: unknown) => {
574+ runEffect(handleUpdateAvailable(info));
575+ });
576+ yield* electronUpdater.on("update-not-available", () => {
577+ runEffect(handleUpdateNotAvailable);
578+ });
579+ yield* electronUpdater.on("error", (error: unknown) => {
580+ runEffect(handleUpdaterError(error));
581+ });
582+ yield* electronUpdater.on("download-progress", (progress: unknown) => {
583+ runEffect(handleDownloadProgress(progress));
584+ });
585+ yield* electronUpdater.on("update-downloaded", (info: unknown) => {
586+ runEffect(handleUpdateDownloaded(info));
587+ });
588+
589+ yield* startUpdatePollers;
590+ }).pipe(Effect.withSpan("desktop.updates.configure")),
591+ setChannel: Effect.fn("desktop.updates.setChannel")(function* (
592+ nextChannel: DesktopUpdateChannel,
593+ ) {
594+ yield* Effect.annotateCurrentSpan({ channel: nextChannel });
595+ const activeAction = yield* activeUpdateAction;
596+ if (Option.isSome(activeAction)) {
597+ return yield* new DesktopUpdateActionInProgressError({ action: activeAction.value });
598+ }
599+
600+ const state = yield* Ref.get(updateStateRef);
601+ if (nextChannel === state.channel) {
602+ return state;
603+ }
604+
605+ yield* desktopSettings
606+ .setUpdateChannel(nextChannel)
607+ .pipe(Effect.mapError((cause) => new DesktopUpdatePersistenceError({ cause })));
608+
609+ const enabled = yield* shouldEnableAutoUpdates;
610+ yield* setState(createBaseUpdateState(nextChannel, enabled, environment));
611+
612+ if (!enabled || !(yield* Ref.get(updaterConfiguredRef))) {
613+ return yield* Ref.get(updateStateRef);
614+ }
615+
616+ yield* applyAutoUpdaterChannel(nextChannel);
617+ const allowDowngrade = yield* electronUpdater.allowDowngrade;
618+ yield* electronUpdater.setAllowDowngrade(true);
619+ yield* checkForUpdates("channel-change").pipe(
620+ Effect.ensuring(electronUpdater.setAllowDowngrade(allowDowngrade).pipe(Effect.ignore)),
621+ );
622+ return yield* Ref.get(updateStateRef);
623+ }),
624+ check: Effect.fn("desktop.updates.check")(function* (reason: string) {
625+ yield* Effect.annotateCurrentSpan({ reason });
626+ if (!(yield* Ref.get(updaterConfiguredRef))) {
627+ return {
628+ checked: false,
629+ state: yield* Ref.get(updateStateRef),
630+ };
631+ }
632+ const checked = yield* checkForUpdates(reason);
633+ return {
634+ checked,
635+ state: yield* Ref.get(updateStateRef),
636+ };
637+ }),
638+ download: Effect.gen(function* () {
639+ const result = yield* downloadAvailableUpdate;
640+ return {
641+ accepted: result.accepted,
642+ completed: result.completed,
643+ state: yield* Ref.get(updateStateRef),
644+ };
645+ }).pipe(Effect.withSpan("desktop.updates.download")),
646+ install: Effect.gen(function* () {
647+ if (yield* Ref.get(desktopState.quitting)) {
648+ return {
649+ accepted: false,
650+ completed: false,
651+ state: yield* Ref.get(updateStateRef),
652+ };
653+ }
654+ const result = yield* installDownloadedUpdate;
655+ return {
656+ accepted: result.accepted,
657+ completed: result.completed,
658+ state: yield* Ref.get(updateStateRef),
659+ };
660+ }).pipe(Effect.withSpan("desktop.updates.install")),
661+ });
662+});
663+
664+export const layer = Layer.effect(DesktopUpdates, make);
apps/desktop/src/updates/updateChannels.tsrenamed from apps/desktop/src/updateChannels.ts+0−7
@@ -9,10 +9,3 @@ export function isNightlyDesktopVersion(version: string): boolean { · 8 unmodified lines
99 export function resolveDefaultDesktopUpdateChannel(appVersion: string): DesktopUpdateChannel {
1010 return isNightlyDesktopVersion(appVersion) ? "nightly" : "latest";
1111 }
12−
13−export function doesVersionMatchDesktopUpdateChannel(
14− version: string,
15− channel: DesktopUpdateChannel,
16−): boolean {
17− return resolveDefaultDesktopUpdateChannel(version) === channel;
18−}
apps/desktop/src/updates/updateMachine.test.tsrenamed from apps/desktop/src/updateMachine.test.ts+0−0

File renamed without changes.

apps/desktop/src/updates/updateMachine.tsrenamed from apps/desktop/src/updateMachine.ts+9−1
@@ -4,7 +4,15 @@ import type { · 3 unmodified lines
44 DesktopUpdateState,
55 } from "@t3tools/contracts";
66
7−import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState.ts";
7+export function nextStatusAfterDownloadFailure(
8+ currentState: DesktopUpdateState,
9+): DesktopUpdateState["status"] {
10+ return currentState.availableVersion ? "available" : "error";
11+}
12+
13+export function getCanRetryAfterDownloadFailure(currentState: DesktopUpdateState): boolean {
14+ return currentState.availableVersion !== null;
15+}
816
917 export function createInitialDesktopUpdateState(
1018 currentVersion: string,
apps/desktop/src/window/DesktopApplicationMenu.test.ts+132−0
@@ -0,0 +1,132 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { assert, describe, it } from "@effect/vitest";
3+import * as Deferred from "effect/Deferred";
4+import * as Effect from "effect/Effect";
5+import * as Layer from "effect/Layer";
6+import * as Option from "effect/Option";
7+
8+import type * as Electron from "electron";
9+
10+import * as ElectronApp from "../electron/ElectronApp.ts";
11+import * as ElectronDialog from "../electron/ElectronDialog.ts";
12+import * as ElectronMenu from "../electron/ElectronMenu.ts";
13+import * as DesktopApplicationMenu from "./DesktopApplicationMenu.ts";
14+import * as DesktopConfig from "../app/DesktopConfig.ts";
15+import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
16+import * as DesktopUpdates from "../updates/DesktopUpdates.ts";
17+import * as DesktopWindow from "./DesktopWindow.ts";
18+
19+const environmentInput = {
20+ dirname: "/repo/apps/desktop/dist-electron",
21+ homeDirectory: "/Users/alice",
22+ platform: "linux",
23+ processArch: "arm64",
24+ appVersion: "1.2.3",
25+ appPath: "/repo",
26+ isPackaged: false,
27+ resourcesPath: "/repo/resources",
28+ runningUnderArm64Translation: false,
29+} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput;
30+
31+const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
32+ metadata: Effect.die("unexpected metadata read"),
33+ name: Effect.succeed("T3 Code"),
34+ whenReady: Effect.void,
35+ quit: Effect.void,
36+ exit: () => Effect.void,
37+ relaunch: () => Effect.void,
38+ setPath: () => Effect.void,
39+ setName: () => Effect.void,
40+ setAboutPanelOptions: () => Effect.void,
41+ setAppUserModelId: () => Effect.void,
42+ setDesktopName: () => Effect.void,
43+ setDockIcon: () => Effect.void,
44+ appendCommandLineSwitch: () => Effect.void,
45+ on: () => Effect.void,
46+} satisfies ElectronApp.ElectronAppShape);
47+
48+const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, {
49+ pickFolder: () => Effect.succeed(Option.none()),
50+ confirm: () => Effect.succeed(false),
51+ showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }),
52+ showErrorBox: () => Effect.void,
53+} satisfies ElectronDialog.ElectronDialogShape);
54+
55+const desktopUpdatesLayer = Layer.succeed(DesktopUpdates.DesktopUpdates, {
56+ getState: Effect.die("unexpected getState"),
57+ emitState: Effect.void,
58+ disabledReason: Effect.succeed(Option.none()),
59+ configure: Effect.void,
60+ setChannel: () => Effect.die("unexpected setChannel"),
61+ check: () => Effect.die("unexpected check"),
62+ download: Effect.die("unexpected download"),
63+ install: Effect.die("unexpected install"),
64+} satisfies DesktopUpdates.DesktopUpdatesShape);
65+
66+const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred<string>) =>
67+ Layer.succeed(DesktopWindow.DesktopWindow, {
68+ createMain: Effect.die("unexpected createMain"),
69+ ensureMain: Effect.die("unexpected ensureMain"),
70+ revealOrCreateMain: Effect.die("unexpected revealOrCreateMain"),
71+ activate: Effect.void,
72+ createMainIfBackendReady: Effect.void,
73+ handleBackendReady: Effect.void,
74+ dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid),
75+ syncAppearance: Effect.void,
76+ } satisfies DesktopWindow.DesktopWindowShape);
77+
78+const makeElectronMenuLayer = (
79+ applicationMenuTemplate: Deferred.Deferred<readonly Electron.MenuItemConstructorOptions[]>,
80+) =>
81+ Layer.succeed(ElectronMenu.ElectronMenu, {
82+ setApplicationMenu: (template) =>
83+ Deferred.succeed(applicationMenuTemplate, template).pipe(Effect.asVoid),
84+ popupTemplate: () => Effect.void,
85+ showContextMenu: () => Effect.succeed(Option.none()),
86+ } satisfies ElectronMenu.ElectronMenuShape);
87+
88+describe("DesktopApplicationMenu", () => {
89+ it.effect("installs the native menu and routes Settings through DesktopWindow", () =>
90+ Effect.gen(function* () {
91+ const selectedAction = yield* Deferred.make<string>();
92+ const applicationMenuTemplate =
93+ yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();
94+
95+ yield* Effect.gen(function* () {
96+ const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu;
97+ yield* menu.configure;
98+ }).pipe(
99+ Effect.provide(
100+ DesktopApplicationMenu.layer.pipe(
101+ Layer.provideMerge(makeElectronMenuLayer(applicationMenuTemplate)),
102+ Layer.provideMerge(makeDesktopWindowLayer(selectedAction)),
103+ Layer.provideMerge(desktopUpdatesLayer),
104+ Layer.provideMerge(electronDialogLayer),
105+ Layer.provideMerge(electronAppLayer),
106+ Layer.provideMerge(
107+ DesktopEnvironment.layer(environmentInput).pipe(
108+ Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))),
109+ ),
110+ ),
111+ ),
112+ ),
113+ );
114+
115+ const template = yield* Deferred.await(applicationMenuTemplate);
116+ const fileMenu = template.find((item) => item.label === "File");
117+ assert.isDefined(fileMenu);
118+ if (!Array.isArray(fileMenu.submenu)) {
119+ throw new Error("Expected File menu submenu to be an array.");
120+ }
121+ const settingsItem = fileMenu.submenu.find((item) => item.label === "Settings...");
122+ assert.isDefined(settingsItem);
123+ const settingsClick = settingsItem.click;
124+ if (typeof settingsClick !== "function") {
125+ throw new Error("Expected Settings menu item to have a click handler.");
126+ }
127+
128+ settingsClick({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent);
129+ assert.equal(yield* Deferred.await(selectedAction), "open-settings");
130+ }),
131+ );
132+});
apps/desktop/src/window/DesktopApplicationMenu.ts+212−0
@@ -0,0 +1,212 @@
1+import * as Cause from "effect/Cause";
2+import * as Context from "effect/Context";
3+import * as Effect from "effect/Effect";
4+import * as Layer from "effect/Layer";
5+import * as Option from "effect/Option";
6+
7+import type * as Electron from "electron";
8+
9+import * as DesktopObservability from "../app/DesktopObservability.ts";
10+import * as ElectronApp from "../electron/ElectronApp.ts";
11+import * as ElectronDialog from "../electron/ElectronDialog.ts";
12+import * as ElectronMenu from "../electron/ElectronMenu.ts";
13+import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
14+import * as DesktopUpdates from "../updates/DesktopUpdates.ts";
15+import * as DesktopWindow from "./DesktopWindow.ts";
16+
17+export interface DesktopApplicationMenuShape {
18+ readonly configure: Effect.Effect<void>;
19+}
20+
21+export class DesktopApplicationMenu extends Context.Service<
22+ DesktopApplicationMenu,
23+ DesktopApplicationMenuShape
24+>()("t3/desktop/ApplicationMenu") {}
25+
26+type DesktopApplicationMenuRuntimeServices =
27+ | DesktopUpdates.DesktopUpdates
28+ | DesktopWindow.DesktopWindow
29+ | ElectronDialog.ElectronDialog;
30+
31+const { logInfo: logUpdaterInfo } = DesktopObservability.makeComponentLogger("desktop-updater");
32+
33+const { logError: logMenuError } = DesktopObservability.makeComponentLogger("desktop-menu");
34+
35+const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function* (
36+ action: string,
37+): Effect.fn.Return<void, DesktopWindow.DesktopWindowError, DesktopWindow.DesktopWindow> {
38+ const desktopWindow = yield* DesktopWindow.DesktopWindow;
39+ yield* desktopWindow.dispatchMenuAction(action);
40+});
41+
42+const checkForUpdatesFromMenu: Effect.Effect<
43+ void,
44+ never,
45+ DesktopUpdates.DesktopUpdates | ElectronDialog.ElectronDialog
46+> = Effect.gen(function* () {
47+ const updates = yield* DesktopUpdates.DesktopUpdates;
48+ const electronDialog = yield* ElectronDialog.ElectronDialog;
49+ const result = yield* updates.check("menu");
50+ const updateState = result.state;
51+
52+ if (updateState.status === "up-to-date") {
53+ yield* electronDialog.showMessageBox({
54+ type: "info",
55+ title: "You're up to date!",
56+ message: `T3 Code ${updateState.currentVersion} is currently the newest version available.`,
57+ buttons: ["OK"],
58+ });
59+ } else if (updateState.status === "error") {
60+ yield* electronDialog.showMessageBox({
61+ type: "warning",
62+ title: "Update check failed",
63+ message: "Could not check for updates.",
64+ detail: updateState.message ?? "An unknown error occurred. Please try again later.",
65+ buttons: ["OK"],
66+ });
67+ }
68+}).pipe(Effect.withSpan("desktop.menu.checkForUpdates"));
69+
70+const handleCheckForUpdatesMenuClick: Effect.Effect<
71+ void,
72+ DesktopWindow.DesktopWindowError,
73+ DesktopUpdates.DesktopUpdates | ElectronDialog.ElectronDialog | DesktopWindow.DesktopWindow
74+> = Effect.gen(function* () {
75+ const updates = yield* DesktopUpdates.DesktopUpdates;
76+ const electronDialog = yield* ElectronDialog.ElectronDialog;
77+ const disabledReason = yield* updates.disabledReason;
78+ if (Option.isSome(disabledReason)) {
79+ yield* logUpdaterInfo("manual update check requested, but updates are disabled", {
80+ disabledReason: disabledReason.value,
81+ });
82+ yield* electronDialog.showMessageBox({
83+ type: "info",
84+ title: "Updates unavailable",
85+ message: "Automatic updates are not available right now.",
86+ detail: disabledReason.value,
87+ buttons: ["OK"],
88+ });
89+ return;
90+ }
91+
92+ const desktopWindow = yield* DesktopWindow.DesktopWindow;
93+ yield* desktopWindow.ensureMain;
94+ yield* checkForUpdatesFromMenu;
95+}).pipe(Effect.withSpan("desktop.menu.handleCheckForUpdatesClick"));
96+
97+const make = Effect.gen(function* () {
98+ const electronApp = yield* ElectronApp.ElectronApp;
99+ const electronMenu = yield* ElectronMenu.ElectronMenu;
100+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
101+ const appName = yield* electronApp.name;
102+ const context = yield* Effect.context<DesktopApplicationMenuRuntimeServices>();
103+ const runPromise = Effect.runPromiseWith(context);
104+
105+ const runMenuEffect = <E>(
106+ action: string,
107+ effect: Effect.Effect<void, E, DesktopApplicationMenuRuntimeServices>,
108+ ) => {
109+ void runPromise(
110+ effect.pipe(
111+ Effect.annotateLogs({ action }),
112+ Effect.withSpan("desktop.menu.action"),
113+ Effect.catchCause((cause) =>
114+ logMenuError("desktop menu action failed", {
115+ action,
116+ cause: Cause.pretty(cause),
117+ }),
118+ ),
119+ ),
120+ );
121+ };
122+
123+ const configure = Effect.gen(function* () {
124+ const checkForUpdatesClick = () => {
125+ runMenuEffect("check-for-updates", handleCheckForUpdatesMenuClick);
126+ };
127+ const settingsClick = () => {
128+ runMenuEffect("open-settings", dispatchMenuAction("open-settings"));
129+ };
130+ const template: Electron.MenuItemConstructorOptions[] = [];
131+
132+ if (environment.platform === "darwin") {
133+ template.push({
134+ label: appName,
135+ submenu: [
136+ { role: "about" },
137+ {
138+ label: "Check for Updates...",
139+ click: checkForUpdatesClick,
140+ },
141+ { type: "separator" },
142+ {
143+ label: "Settings...",
144+ accelerator: "CmdOrCtrl+,",
145+ click: settingsClick,
146+ },
147+ { type: "separator" },
148+ { role: "services" },
149+ { type: "separator" },
150+ { role: "hide" },
151+ { role: "hideOthers" },
152+ { role: "unhide" },
153+ { type: "separator" },
154+ { role: "quit" },
155+ ],
156+ });
157+ }
158+
159+ template.push(
160+ {
161+ label: "File",
162+ submenu: [
163+ ...(environment.platform === "darwin"
164+ ? []
165+ : [
166+ {
167+ label: "Settings...",
168+ accelerator: "CmdOrCtrl+,",
169+ click: settingsClick,
170+ },
171+ { type: "separator" as const },
172+ ]),
173+ { role: environment.platform === "darwin" ? "close" : "quit" },
174+ ],
175+ },
176+ { role: "editMenu" },
177+ {
178+ label: "View",
179+ submenu: [
180+ { role: "reload" },
181+ { role: "forceReload" },
182+ { role: "toggleDevTools" },
183+ { type: "separator" },
184+ { role: "resetZoom" },
185+ { role: "zoomIn", accelerator: "CmdOrCtrl+=" },
186+ { role: "zoomIn", accelerator: "CmdOrCtrl+Plus", visible: false },
187+ { role: "zoomOut" },
188+ { type: "separator" },
189+ { role: "togglefullscreen" },
190+ ],
191+ },
192+ { role: "windowMenu" },
193+ {
194+ role: "help",
195+ submenu: [
196+ {
197+ label: "Check for Updates...",
198+ click: checkForUpdatesClick,
199+ },
200+ ],
201+ },
202+ );
203+
204+ yield* electronMenu.setApplicationMenu(template);
205+ }).pipe(Effect.withSpan("desktop.menu.configure"));
206+
207+ return DesktopApplicationMenu.of({
208+ configure,
209+ });
210+});
211+
212+export const layer = Layer.effect(DesktopApplicationMenu, make);
apps/desktop/src/window/DesktopWindow.test.ts+180−0
@@ -0,0 +1,180 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { assert, describe, it } from "@effect/vitest";
3+import * as Effect from "effect/Effect";
4+import * as Layer from "effect/Layer";
5+import * as Option from "effect/Option";
6+import * as Ref from "effect/Ref";
7+
8+import type * as Electron from "electron";
9+import { vi } from "vitest";
10+
11+import * as DesktopAssets from "../app/DesktopAssets.ts";
12+import * as DesktopConfig from "../app/DesktopConfig.ts";
13+import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
14+import * as DesktopState from "../app/DesktopState.ts";
15+import * as ElectronMenu from "../electron/ElectronMenu.ts";
16+import * as ElectronShell from "../electron/ElectronShell.ts";
17+import * as ElectronTheme from "../electron/ElectronTheme.ts";
18+import * as ElectronWindow from "../electron/ElectronWindow.ts";
19+import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";
20+import * as DesktopWindow from "./DesktopWindow.ts";
21+
22+const environmentInput = {
23+ dirname: "/repo/apps/desktop/dist-electron",
24+ homeDirectory: "/Users/alice",
25+ platform: "darwin",
26+ processArch: "arm64",
27+ appVersion: "1.2.3",
28+ appPath: "/repo",
29+ isPackaged: false,
30+ resourcesPath: "/repo/resources",
31+ runningUnderArm64Translation: false,
32+} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput;
33+
34+function makeFakeBrowserWindow() {
35+ const webContents = {
36+ copyImageAt: vi.fn(),
37+ isLoadingMainFrame: vi.fn(() => false),
38+ on: vi.fn(),
39+ once: vi.fn(),
40+ openDevTools: vi.fn(),
41+ replaceMisspelling: vi.fn(),
42+ send: vi.fn(),
43+ setWindowOpenHandler: vi.fn(),
44+ };
45+
46+ const window = {
47+ focus: vi.fn(),
48+ isDestroyed: vi.fn(() => false),
49+ isMinimized: vi.fn(() => false),
50+ isVisible: vi.fn(() => true),
51+ loadURL: vi.fn(() => Promise.resolve()),
52+ on: vi.fn(),
53+ once: vi.fn(),
54+ restore: vi.fn(),
55+ setBackgroundColor: vi.fn(),
56+ setTitle: vi.fn(),
57+ setTitleBarOverlay: vi.fn(),
58+ show: vi.fn(),
59+ webContents,
60+ };
61+
62+ return {
63+ window: window as unknown as Electron.BrowserWindow,
64+ loadURL: window.loadURL,
65+ openDevTools: webContents.openDevTools,
66+ };
67+}
68+
69+const desktopAssetsLayer = Layer.succeed(DesktopAssets.DesktopAssets, {
70+ iconPaths: Effect.succeed({
71+ ico: Option.none<string>(),
72+ icns: Option.none<string>(),
73+ png: Option.none<string>(),
74+ }),
75+ resolveResourcePath: () => Effect.succeed(Option.none<string>()),
76+} satisfies DesktopAssets.DesktopAssetsShape);
77+
78+const desktopServerExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExposure, {
79+ getState: Effect.die("unexpected getState"),
80+ backendConfig: Effect.succeed({
81+ port: 3773,
82+ bindHost: "127.0.0.1",
83+ httpBaseUrl: new URL("http://127.0.0.1:3773"),
84+ tailscaleServeEnabled: false,
85+ tailscaleServePort: 443,
86+ }),
87+ configureFromSettings: () => Effect.die("unexpected configureFromSettings"),
88+ setMode: () => Effect.die("unexpected setMode"),
89+ setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"),
90+ getAdvertisedEndpoints: Effect.die("unexpected getAdvertisedEndpoints"),
91+} satisfies DesktopServerExposure.DesktopServerExposureShape);
92+
93+const electronMenuLayer = Layer.succeed(ElectronMenu.ElectronMenu, {
94+ setApplicationMenu: () => Effect.void,
95+ popupTemplate: () => Effect.void,
96+ showContextMenu: () => Effect.succeed(Option.none()),
97+} satisfies ElectronMenu.ElectronMenuShape);
98+
99+const electronShellLayer = Layer.succeed(ElectronShell.ElectronShell, {
100+ openExternal: () => Effect.succeed(true),
101+ copyText: () => Effect.void,
102+} satisfies ElectronShell.ElectronShellShape);
103+
104+const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, {
105+ shouldUseDarkColors: Effect.succeed(false),
106+ setSource: () => Effect.void,
107+ onUpdated: () => Effect.void,
108+} satisfies ElectronTheme.ElectronThemeShape);
109+
110+const desktopEnvironmentLayer = DesktopEnvironment.layer(environmentInput).pipe(
111+ Layer.provide(
112+ Layer.mergeAll(
113+ NodeServices.layer,
114+ DesktopConfig.layerTest({
115+ T3CODE_PORT: "3773",
116+ VITE_DEV_SERVER_URL: "http://127.0.0.1:5733",
117+ }),
118+ ),
119+ ),
120+);
121+
122+function makeTestLayer(input: {
123+ readonly window: Electron.BrowserWindow;
124+ readonly createCount: Ref.Ref<number>;
125+ readonly mainWindow: Ref.Ref<Option.Option<Electron.BrowserWindow>>;
126+}) {
127+ const electronWindowLayer = Layer.succeed(ElectronWindow.ElectronWindow, {
128+ create: () => Ref.update(input.createCount, (count) => count + 1).pipe(Effect.as(input.window)),
129+ main: Ref.get(input.mainWindow),
130+ currentMainOrFirst: Ref.get(input.mainWindow),
131+ focusedMainOrFirst: Ref.get(input.mainWindow),
132+ setMain: (window) => Ref.set(input.mainWindow, Option.some(window)),
133+ clearMain: () => Ref.set(input.mainWindow, Option.none()),
134+ reveal: () => Effect.void,
135+ sendAll: () => Effect.void,
136+ destroyAll: Effect.void,
137+ syncAllAppearance: (sync) => sync(input.window),
138+ } satisfies ElectronWindow.ElectronWindowShape);
139+
140+ return DesktopWindow.layer.pipe(
141+ Layer.provide(
142+ Layer.mergeAll(
143+ desktopAssetsLayer,
144+ desktopEnvironmentLayer,
145+ desktopServerExposureLayer,
146+ DesktopState.layer,
147+ electronMenuLayer,
148+ electronShellLayer,
149+ electronThemeLayer,
150+ electronWindowLayer,
151+ ),
152+ ),
153+ );
154+}
155+
156+describe("DesktopWindow", () => {
157+ it.effect("does not open a development window until the backend is ready", () =>
158+ Effect.gen(function* () {
159+ const fakeWindow = makeFakeBrowserWindow();
160+ const createCount = yield* Ref.make(0);
161+ const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
162+ const layer = makeTestLayer({
163+ window: fakeWindow.window,
164+ createCount,
165+ mainWindow,
166+ });
167+
168+ yield* Effect.gen(function* () {
169+ const desktopWindow = yield* DesktopWindow.DesktopWindow;
170+ yield* desktopWindow.activate;
171+ assert.equal(yield* Ref.get(createCount), 0);
172+
173+ yield* desktopWindow.handleBackendReady;
174+ assert.equal(yield* Ref.get(createCount), 1);
175+ assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["http://127.0.0.1:5733/"]);
176+ assert.equal(fakeWindow.openDevTools.mock.calls.length, 1);
177+ }).pipe(Effect.provide(layer));
178+ }),
179+ );
180+});
apps/desktop/src/window/DesktopWindow.ts+368−0
@@ -0,0 +1,368 @@
1+import * as Context from "effect/Context";
2+import * as Data from "effect/Data";
3+import * as Effect from "effect/Effect";
4+import * as Layer from "effect/Layer";
5+import * as Option from "effect/Option";
6+import * as Ref from "effect/Ref";
7+
8+import type * as Electron from "electron";
9+
10+import * as DesktopAssets from "../app/DesktopAssets.ts";
11+import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
12+import * as DesktopObservability from "../app/DesktopObservability.ts";
13+import * as DesktopState from "../app/DesktopState.ts";
14+import * as ElectronMenu from "../electron/ElectronMenu.ts";
15+import * as ElectronShell from "../electron/ElectronShell.ts";
16+import * as ElectronTheme from "../electron/ElectronTheme.ts";
17+import * as ElectronWindow from "../electron/ElectronWindow.ts";
18+import * as IpcChannels from "../ipc/channels.ts";
19+import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";
20+
21+const TITLEBAR_HEIGHT = 40;
22+const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux
23+const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937";
24+const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc";
25+
26+type WindowTitleBarOptions = Pick<
27+ Electron.BrowserWindowConstructorOptions,
28+ "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition"
29+>;
30+
31+type DesktopWindowRuntimeServices =
32+ | DesktopEnvironment.DesktopEnvironment
33+ | DesktopAssets.DesktopAssets
34+ | DesktopServerExposure.DesktopServerExposure
35+ | DesktopState.DesktopState
36+ | ElectronMenu.ElectronMenu
37+ | ElectronShell.ElectronShell
38+ | ElectronTheme.ElectronTheme
39+ | ElectronWindow.ElectronWindow;
40+
41+export class DesktopWindowDevServerUrlMissingError extends Data.TaggedError(
42+ "DesktopWindowDevServerUrlMissingError",
43+)<{}> {
44+ override get message() {
45+ return "VITE_DEV_SERVER_URL is required in desktop development.";
46+ }
47+}
48+
49+export type DesktopWindowError =
50+ | DesktopWindowDevServerUrlMissingError
51+ | ElectronWindow.ElectronWindowCreateError;
52+
53+export interface DesktopWindowShape {
54+ readonly createMain: Effect.Effect<Electron.BrowserWindow, DesktopWindowError>;
55+ readonly ensureMain: Effect.Effect<Electron.BrowserWindow, DesktopWindowError>;
56+ readonly revealOrCreateMain: Effect.Effect<Electron.BrowserWindow, DesktopWindowError>;
57+ readonly activate: Effect.Effect<void, DesktopWindowError>;
58+ readonly createMainIfBackendReady: Effect.Effect<void, DesktopWindowError>;
59+ readonly handleBackendReady: Effect.Effect<void, DesktopWindowError>;
60+ readonly dispatchMenuAction: (action: string) => Effect.Effect<void, DesktopWindowError>;
61+ readonly syncAppearance: Effect.Effect<void>;
62+}
63+
64+export class DesktopWindow extends Context.Service<DesktopWindow, DesktopWindowShape>()(
65+ "t3/desktop/Window",
66+) {}
67+
68+const { logInfo: logWindowInfo, logWarning: logWindowWarning } =
69+ DesktopObservability.makeComponentLogger("desktop-window");
70+
71+function resolveDesktopDevServerUrl(
72+ environment: DesktopEnvironment.DesktopEnvironmentShape,
73+): Effect.Effect<string, DesktopWindowDevServerUrlMissingError> {
74+ return Option.match(environment.devServerUrl, {
75+ onNone: () => Effect.fail(new DesktopWindowDevServerUrlMissingError()),
76+ onSome: (url) => Effect.succeed(url.href),
77+ });
78+}
79+
80+function getIconOption(
81+ iconPaths: DesktopAssets.DesktopIconPaths,
82+): { icon: string } | Record<string, never> {
83+ if (process.platform === "darwin") return {}; // macOS uses .icns from app bundle
84+ const ext = process.platform === "win32" ? "ico" : "png";
85+ return Option.match(iconPaths[ext], {
86+ onNone: () => ({}),
87+ onSome: (icon) => ({ icon }),
88+ });
89+}
90+
91+function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string {
92+ return shouldUseDarkColors ? "#0a0a0a" : "#ffffff";
93+}
94+
95+function getWindowTitleBarOptions(shouldUseDarkColors: boolean): WindowTitleBarOptions {
96+ if (process.platform === "darwin") {
97+ return {
98+ titleBarStyle: "hiddenInset",
99+ trafficLightPosition: { x: 16, y: 18 },
100+ };
101+ }
102+
103+ return {
104+ titleBarStyle: "hidden",
105+ titleBarOverlay: {
106+ color: TITLEBAR_COLOR,
107+ height: TITLEBAR_HEIGHT,
108+ symbolColor: shouldUseDarkColors ? TITLEBAR_DARK_SYMBOL_COLOR : TITLEBAR_LIGHT_SYMBOL_COLOR,
109+ },
110+ };
111+}
112+
113+function syncWindowAppearance(
114+ window: Electron.BrowserWindow,
115+ shouldUseDarkColors: boolean,
116+): Effect.Effect<void> {
117+ return Effect.sync(() => {
118+ if (window.isDestroyed()) {
119+ return;
120+ }
121+
122+ window.setBackgroundColor(getInitialWindowBackgroundColor(shouldUseDarkColors));
123+ const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors);
124+ if (typeof titleBarOverlay === "object") {
125+ window.setTitleBarOverlay(titleBarOverlay);
126+ }
127+ });
128+}
129+
130+type RevealSubscription = (listener: () => void) => void;
131+
132+function bindFirstRevealTrigger(
133+ subscribers: readonly RevealSubscription[],
134+ reveal: () => void,
135+): void {
136+ let revealed = false;
137+ const fire = () => {
138+ if (revealed) return;
139+ revealed = true;
140+ reveal();
141+ };
142+ for (const subscribe of subscribers) {
143+ subscribe(fire);
144+ }
145+}
146+
147+const make = Effect.gen(function* () {
148+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
149+ const assets = yield* DesktopAssets.DesktopAssets;
150+ const electronMenu = yield* ElectronMenu.ElectronMenu;
151+ const electronShell = yield* ElectronShell.ElectronShell;
152+ const electronTheme = yield* ElectronTheme.ElectronTheme;
153+ const electronWindow = yield* ElectronWindow.ElectronWindow;
154+ const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
155+ const state = yield* DesktopState.DesktopState;
156+ const context = yield* Effect.context<DesktopWindowRuntimeServices>();
157+ const runPromise = Effect.runPromiseWith(context);
158+
159+ const createWindow = Effect.fn("desktop.window.createWindow")(function* (
160+ backendHttpUrl: URL,
161+ ): Effect.fn.Return<Electron.BrowserWindow, DesktopWindowError> {
162+ const iconPaths = yield* assets.iconPaths;
163+ const iconOption = getIconOption(iconPaths);
164+ const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors;
165+ const window = yield* electronWindow.create({
166+ width: 1100,
167+ height: 780,
168+ minWidth: 840,
169+ minHeight: 620,
170+ show: false,
171+ autoHideMenuBar: true,
172+ backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors),
173+ ...iconOption,
174+ title: environment.displayName,
175+ ...getWindowTitleBarOptions(shouldUseDarkColors),
176+ webPreferences: {
177+ preload: environment.preloadPath,
178+ contextIsolation: true,
179+ nodeIntegration: false,
180+ sandbox: true,
181+ },
182+ });
183+
184+ window.webContents.on("context-menu", (event, params) => {
185+ event.preventDefault();
186+
187+ const menuTemplate: Electron.MenuItemConstructorOptions[] = [];
188+
189+ if (params.misspelledWord) {
190+ for (const suggestion of params.dictionarySuggestions.slice(0, 5)) {
191+ menuTemplate.push({
192+ label: suggestion,
193+ click: () => window.webContents.replaceMisspelling(suggestion),
194+ });
195+ }
196+ if (params.dictionarySuggestions.length === 0) {
197+ menuTemplate.push({ label: "No suggestions", enabled: false });
198+ }
199+ menuTemplate.push({ type: "separator" });
200+ }
201+
202+ if (Option.isSome(ElectronShell.parseSafeExternalUrl(params.linkURL))) {
203+ menuTemplate.push(
204+ {
205+ label: "Copy Link",
206+ click: () => {
207+ void runPromise(electronShell.copyText(params.linkURL));
208+ },
209+ },
210+ { type: "separator" },
211+ );
212+ }
213+
214+ if (params.mediaType === "image") {
215+ menuTemplate.push({
216+ label: "Copy Image",
217+ click: () => window.webContents.copyImageAt(params.x, params.y),
218+ });
219+ menuTemplate.push({ type: "separator" });
220+ }
221+
222+ menuTemplate.push(
223+ { role: "cut", enabled: params.editFlags.canCut },
224+ { role: "copy", enabled: params.editFlags.canCopy },
225+ { role: "paste", enabled: params.editFlags.canPaste },
226+ { role: "selectAll", enabled: params.editFlags.canSelectAll },
227+ );
228+
229+ void runPromise(electronMenu.popupTemplate({ window, template: menuTemplate }));
230+ });
231+
232+ window.webContents.setWindowOpenHandler(({ url }) => {
233+ if (Option.isSome(ElectronShell.parseSafeExternalUrl(url))) {
234+ void runPromise(electronShell.openExternal(url));
235+ }
236+ return { action: "deny" };
237+ });
238+
239+ window.on("page-title-updated", (event) => {
240+ event.preventDefault();
241+ window.setTitle(environment.displayName);
242+ });
243+ window.webContents.on("did-finish-load", () => {
244+ window.setTitle(environment.displayName);
245+ });
246+ window.webContents.on(
247+ "did-fail-load",
248+ (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
249+ if (!isMainFrame) {
250+ return;
251+ }
252+ void runPromise(
253+ logWindowWarning("main window failed to load", {
254+ errorCode,
255+ errorDescription,
256+ url: validatedURL,
257+ }),
258+ );
259+ },
260+ );
261+ window.webContents.on("render-process-gone", (_event, details) => {
262+ void runPromise(
263+ logWindowWarning("main window render process gone", {
264+ reason: details.reason,
265+ exitCode: details.exitCode,
266+ }),
267+ );
268+ });
269+
270+ const revealSubscribers: RevealSubscription[] = [(fire) => window.once("ready-to-show", fire)];
271+ if (process.platform === "linux") {
272+ revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire));
273+ }
274+ bindFirstRevealTrigger(revealSubscribers, () => {
275+ void runPromise(electronWindow.reveal(window));
276+ });
277+
278+ if (environment.isDevelopment) {
279+ const devServerUrl = yield* resolveDesktopDevServerUrl(environment);
280+ void window.loadURL(devServerUrl);
281+ window.webContents.openDevTools({ mode: "detach" });
282+ } else {
283+ void window.loadURL(backendHttpUrl.href);
284+ }
285+
286+ window.on("closed", () => {
287+ void runPromise(electronWindow.clearMain(Option.some(window)));
288+ });
289+
290+ return window;
291+ });
292+
293+ const createMain = Effect.gen(function* () {
294+ const backendConfig = yield* serverExposure.backendConfig;
295+ const window = yield* createWindow(backendConfig.httpBaseUrl);
296+ yield* electronWindow.setMain(window);
297+ yield* logWindowInfo("main window created");
298+ return window;
299+ }).pipe(Effect.withSpan("desktop.window.createMain"));
300+
301+ const ensureMain = Effect.gen(function* () {
302+ const existingWindow = yield* electronWindow.currentMainOrFirst;
303+ if (Option.isSome(existingWindow)) {
304+ return existingWindow.value;
305+ }
306+ return yield* createMain;
307+ }).pipe(Effect.withSpan("desktop.window.ensureMain"));
308+
309+ const revealOrCreateMain = Effect.gen(function* () {
310+ const window = yield* ensureMain;
311+ yield* electronWindow.reveal(window);
312+ return window;
313+ }).pipe(Effect.withSpan("desktop.window.revealOrCreateMain"));
314+
315+ const createMainIfBackendReady = Effect.gen(function* () {
316+ const backendReady = yield* Ref.get(state.backendReady);
317+ if (!backendReady) return;
318+ const existingWindow = yield* electronWindow.currentMainOrFirst;
319+ if (Option.isSome(existingWindow)) return;
320+ yield* createMain;
321+ }).pipe(Effect.withSpan("desktop.window.createMainIfBackendReady"));
322+
323+ return DesktopWindow.of({
324+ createMain,
325+ ensureMain,
326+ revealOrCreateMain,
327+ activate: Effect.gen(function* () {
328+ const existingWindow = yield* electronWindow.currentMainOrFirst;
329+ if (Option.isSome(existingWindow)) {
330+ yield* electronWindow.reveal(existingWindow.value);
331+ } else {
332+ yield* createMainIfBackendReady;
333+ }
334+ }).pipe(Effect.withSpan("desktop.window.activate")),
335+ createMainIfBackendReady,
336+ handleBackendReady: Effect.gen(function* () {
337+ yield* Ref.set(state.backendReady, true);
338+ yield* logWindowInfo("backend ready", { source: "http" });
339+ yield* createMainIfBackendReady;
340+ }).pipe(Effect.withSpan("desktop.window.handleBackendReady")),
341+ dispatchMenuAction: Effect.fn("desktop.window.dispatchMenuAction")(function* (action) {
342+ yield* Effect.annotateCurrentSpan({ action });
343+ const existingWindow = yield* electronWindow.focusedMainOrFirst;
344+ const targetWindow = Option.isSome(existingWindow) ? existingWindow.value : yield* createMain;
345+
346+ const send = () => {
347+ if (targetWindow.isDestroyed()) return;
348+ targetWindow.webContents.send(IpcChannels.MENU_ACTION_CHANNEL, action);
349+ void runPromise(electronWindow.reveal(targetWindow));
350+ };
351+
352+ if (targetWindow.webContents.isLoadingMainFrame()) {
353+ targetWindow.webContents.once("did-finish-load", send);
354+ return;
355+ }
356+
357+ send();
358+ }),
359+ syncAppearance: Effect.gen(function* () {
360+ const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors;
361+ yield* electronWindow.syncAllAppearance((window) =>
362+ syncWindowAppearance(window, shouldUseDarkColors),
363+ );
364+ }).pipe(Effect.withSpan("desktop.window.syncAppearance")),
365+ });
366+});
367+
368+export const layer = Layer.effect(DesktopWindow, make);
apps/desktop/src/windowReveal.test.ts+0−74
@@ -1,74 +0,0 @@
1−import { EventEmitter } from "node:events";
2−
3−import { describe, expect, it, vi } from "vitest";
4−
5−import { bindFirstRevealTrigger } from "./windowReveal.ts";
6−
7−describe("bindFirstRevealTrigger", () => {
8− it("reveals when the first trigger fires", () => {
9− const window = new EventEmitter();
10− const webContents = new EventEmitter();
11− const reveal = vi.fn();
12−
13− bindFirstRevealTrigger(
14− [
15− (fire) => window.once("ready-to-show", fire),
16− (fire) => webContents.once("did-finish-load", fire),
17− ],
18− reveal,
19− );
20−
21− window.emit("ready-to-show");
22−
23− expect(reveal).toHaveBeenCalledTimes(1);
24− });
25−
26− it("reveals when only the fallback trigger fires (Wayland deadlock case)", () => {
27− const window = new EventEmitter();
28− const webContents = new EventEmitter();
29− const reveal = vi.fn();
30−
31− bindFirstRevealTrigger(
32− [
33− (fire) => window.once("ready-to-show", fire),
34− (fire) => webContents.once("did-finish-load", fire),
35− ],
36− reveal,
37− );
38−
39− webContents.emit("did-finish-load");
40−
41− expect(reveal).toHaveBeenCalledTimes(1);
42− });
43−
44− it("only reveals once when multiple triggers fire", () => {
45− const window = new EventEmitter();
46− const webContents = new EventEmitter();
47− const reveal = vi.fn();
48−
49− bindFirstRevealTrigger(
50− [
51− (fire) => window.once("ready-to-show", fire),
52− (fire) => webContents.once("did-finish-load", fire),
53− ],
54− reveal,
55− );
56−
57− webContents.emit("did-finish-load");
58− window.emit("ready-to-show");
59−
60− expect(reveal).toHaveBeenCalledTimes(1);
61− });
62−
63− it("subscribers using `once` ignore re-emitted events after reveal", () => {
64− const window = new EventEmitter();
65− const reveal = vi.fn();
66−
67− bindFirstRevealTrigger([(fire) => window.once("ready-to-show", fire)], reveal);
68−
69− window.emit("ready-to-show");
70− window.emit("ready-to-show");
71−
72− expect(reveal).toHaveBeenCalledTimes(1);
73− });
74−});
apps/desktop/src/windowReveal.ts+0−28
@@ -1,28 +0,0 @@
1−export type RevealSubscription = (listener: () => void) => void;
2−
3−/**
4− * Wire a reveal callback to fire exactly once, on whichever of the provided
5− * event subscribers fires first. Each subscriber is responsible for binding
6− * its own event source.
7− *
8− * Used by the desktop main window's first-paint reveal logic. The standard
9− * Electron pattern is to wait for `ready-to-show` before calling `show()`,
10− * but on Linux/Wayland with `show: false`, `ready-to-show` only fires after
11− * `show()` is called, deadlocking that pattern. Subscribing to both
12− * `ready-to-show` and `did-finish-load` (or any other "renderer is alive"
13− * signal) lets the window surface reliably across platforms.
14− */
15−export function bindFirstRevealTrigger(
16− subscribers: readonly RevealSubscription[],
17− reveal: () => void,
18−): void {
19− let revealed = false;
20− const fire = () => {
21− if (revealed) return;
22− revealed = true;
23− reveal();
24− };
25− for (const subscribe of subscribers) {
26− subscribe(fire);
27− }
28−}
apps/desktop/tsconfig.json+13−1
@@ -3,7 +3,19 @@ · 2 unmodified lines
33 "compilerOptions": {
44 "composite": true,
55 "types": ["node", "electron"],
6− "lib": ["ESNext", "DOM", "esnext.disposable"]
6+ "lib": ["ESNext", "DOM", "esnext.disposable"],
7+ "plugins": [
8+ {
9+ "name": "@effect/language-service",
10+ "namespaceImportPackages": ["@effect/platform-node", "effect"],
11+ "diagnosticSeverity": {
12+ "importFromBarrel": "error",
13+ "anyUnknownInErrorContext": "error",
14+ "instanceOfSchema": "warning",
15+ "deterministicKeys": "warning"
16+ }
17+ }
18+ ]
719 },
820 "include": ["src", "tsdown.config.ts"]
921 }
apps/server/src/cli/config.test.ts+63−44
@@ -1,13 +1,33 @@
11 import os from "node:os";
22
33 import { assert, expect, it } from "@effect/vitest";
4−import { ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect";
4+import { ConfigProvider, Effect, FileSystem, Layer, Option, Path, Schema } from "effect";
55
6+import {
7+ DesktopBackendBootstrap,
8+ type DesktopBackendBootstrap as DesktopBackendBootstrapValue,
9+} from "@t3tools/contracts";
610 import { NetService } from "@t3tools/shared/Net";
711 import * as NodeServices from "@effect/platform-node/NodeServices";
812 import { deriveServerPaths } from "../config.ts";
913 import { resolveServerConfig } from "./config.ts";
1014
15+const encodeDesktopBootstrap = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap));
16+
17+const makeDesktopBootstrap = (
18+ overrides: Partial<DesktopBackendBootstrapValue> = {},
19+): DesktopBackendBootstrapValue => ({
20+ mode: "desktop",
21+ noBrowser: true,
22+ port: 4888,
23+ t3Home: "/tmp/t3-bootstrap-home",
24+ host: "127.0.0.1",
25+ desktopBootstrapToken: "desktop-bootstrap-token",
26+ tailscaleServeEnabled: false,
27+ tailscaleServePort: 443,
28+ ...overrides,
29+});
30+
1131 it.layer(NodeServices.layer)("cli config resolution", (it) => {
1232 const defaultObservabilityConfig = {
1333 traceMinLevel: "Info",
@@ -21,10 +41,11 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 7 unmodified lines
2141 otlpServiceName: "t3-server",
2242 } as const;
2343
24− const openBootstrapFd = Effect.fn(function* (payload: Record<string, unknown>) {
44+ const openBootstrapFd = Effect.fn(function* (payload: DesktopBackendBootstrapValue) {
2545 const fs = yield* FileSystem.FileSystem;
2646 const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" });
27− yield* fs.writeFileString(filePath, `${JSON.stringify(payload)}\n`);
47+ const encoded = yield* encodeDesktopBootstrap(payload);
48+ yield* fs.writeFileString(filePath, `${encoded}\n`);
2849 const { fd } = yield* fs.open(filePath, { flag: "r" });
2950 return fd;
3051 });
@@ -165,13 +186,13 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 134 unmodified lines
165186 Effect.gen(function* () {
166187 const { join } = yield* Path.Path;
167188 const baseDir = join(os.tmpdir(), "t3-cli-config-false-flags");
168− const fd = yield* openBootstrapFd({
169− noBrowser: true,
170− autoBootstrapProjectFromCwd: true,
171− logWebSocketEvents: true,
172− tailscaleServeEnabled: false,
173− tailscaleServePort: 443,
174− });
189+ const fd = yield* openBootstrapFd(
190+ makeDesktopBootstrap({
191+ noBrowser: true,
192+ tailscaleServeEnabled: false,
193+ tailscaleServePort: 443,
194+ }),
195+ );
175196 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173"));
176197
177198 const resolved = yield* resolveServerConfig(
@@ -221,7 +242,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 43 unmodified lines
221242 devUrl: new URL("http://127.0.0.1:4173"),
222243 noBrowser: false,
223244 startupPresentation: "browser",
224− desktopBootstrapToken: undefined,
245+ desktopBootstrapToken: "desktop-bootstrap-token",
225246 autoBootstrapProjectFromCwd: false,
226247 logWebSocketEvents: false,
227248 tailscaleServeEnabled: false,
@@ -234,21 +255,20 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 6 unmodified lines
234255 Effect.gen(function* () {
235256 const { join } = yield* Path.Path;
236257 const baseDir = "/tmp/t3-bootstrap-home";
237− const fd = yield* openBootstrapFd({
238− mode: "desktop",
239− port: 4888,
240− host: "127.0.0.2",
241− t3Home: baseDir,
242− devUrl: "http://127.0.0.1:5173",
243− noBrowser: true,
244− autoBootstrapProjectFromCwd: false,
245− logWebSocketEvents: true,
246− tailscaleServeEnabled: false,
247− tailscaleServePort: 443,
248− otlpTracesUrl: "http://localhost:4318/v1/traces",
249− otlpMetricsUrl: "http://localhost:4318/v1/metrics",
250− });
251− const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173"));
258+ const fd = yield* openBootstrapFd(
259+ makeDesktopBootstrap({
260+ port: 4888,
261+ host: "127.0.0.2",
262+ t3Home: baseDir,
263+ noBrowser: true,
264+ desktopBootstrapToken: "desktop-token",
265+ tailscaleServeEnabled: false,
266+ tailscaleServePort: 443,
267+ otlpTracesUrl: "http://localhost:4318/v1/traces",
268+ otlpMetricsUrl: "http://localhost:4318/v1/metrics",
269+ }),
270+ );
271+ const derivedPaths = yield* deriveServerPaths(baseDir, undefined);
252272
253273 const resolved = yield* resolveServerConfig(
254274 {
@@ -292,17 +312,17 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 37 unmodified lines
292312 baseDir,
293313 ...derivedPaths,
294314 host: "127.0.0.2",
295− staticDir: undefined,
296− devUrl: new URL("http://127.0.0.1:5173"),
315+ staticDir: resolved.staticDir,
316+ devUrl: undefined,
297317 noBrowser: true,
298318 startupPresentation: "browser",
299− desktopBootstrapToken: undefined,
319+ desktopBootstrapToken: "desktop-token",
300320 autoBootstrapProjectFromCwd: false,
301− logWebSocketEvents: true,
321+ logWebSocketEvents: false,
302322 tailscaleServeEnabled: false,
303323 tailscaleServePort: 443,
304324 });
305− assert.equal(join(baseDir, "dev"), resolved.stateDir);
325+ assert.equal(join(baseDir, "userdata"), resolved.stateDir);
306326 }),
307327 );
308328
@@ -359,18 +379,17 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 50 unmodified lines
359379 Effect.gen(function* () {
360380 const { join } = yield* Path.Path;
361381 const baseDir = join(os.tmpdir(), "t3-cli-config-env-wins");
362− const fd = yield* openBootstrapFd({
363− mode: "desktop",
364− port: 4888,
365− host: "127.0.0.2",
366− t3Home: "/tmp/t3-bootstrap-home",
367− devUrl: "http://127.0.0.1:5173",
368− noBrowser: false,
369− autoBootstrapProjectFromCwd: false,
370− logWebSocketEvents: false,
371− tailscaleServeEnabled: false,
372− tailscaleServePort: 443,
373− });
382+ const fd = yield* openBootstrapFd(
383+ makeDesktopBootstrap({
384+ port: 4888,
385+ host: "127.0.0.2",
386+ t3Home: "/tmp/t3-bootstrap-home",
387+ noBrowser: false,
388+ desktopBootstrapToken: "desktop-token",
389+ tailscaleServeEnabled: false,
390+ tailscaleServePort: 443,
391+ }),
392+ );
374393 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173"));
375394
376395 const resolved = yield* resolveServerConfig(
@@ -422,7 +441,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 45 unmodified lines
422441 devUrl: new URL("http://127.0.0.1:4173"),
423442 noBrowser: true,
424443 startupPresentation: "browser",
425− desktopBootstrapToken: undefined,
444+ desktopBootstrapToken: "desktop-token",
426445 autoBootstrapProjectFromCwd: true,
427446 logWebSocketEvents: true,
428447 tailscaleServeEnabled: false,
apps/server/src/cli/config.ts+3−26
@@ -1,5 +1,6 @@
11 import { NetService } from "@t3tools/shared/Net";
22 import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
3+import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts";
34 import {
45 Config,
56 Duration,
@@ -26,24 +27,6 @@ import { · 20 unmodified lines
2627 } from "../config.ts";
2728 import { expandHomePath, resolveBaseDir } from "../os-jank.ts";
2829
29−export const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }));
30−
31−const BootstrapEnvelopeSchema = Schema.Struct({
32− mode: Schema.optional(RuntimeMode),
33− port: Schema.optional(PortSchema),
34− host: Schema.optional(Schema.String),
35− t3Home: Schema.optional(Schema.String),
36− devUrl: Schema.optional(Schema.URLFromString),
37− noBrowser: Schema.optional(Schema.Boolean),
38− desktopBootstrapToken: Schema.optional(Schema.String),
39− autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean),
40− logWebSocketEvents: Schema.optional(Schema.Boolean),
41− tailscaleServeEnabled: Schema.optional(Schema.Boolean),
42− tailscaleServePort: Schema.optional(PortSchema),
43− otlpTracesUrl: Schema.optional(Schema.String),
44− otlpMetricsUrl: Schema.optional(Schema.String),
45−});
46−
4730 export const modeFlag = Flag.choice("mode", RuntimeMode.literals).pipe(
4831 Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."),
4932 Flag.optional,
@@ -253,7 +236,7 @@ export const resolveServerConfig = ( · 203 unmodified lines
253236 const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd;
254237 const bootstrapEnvelope =
255238 bootstrapFd !== undefined
256− ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd)
239+ ? yield* readBootstrapEnvelope(DesktopBackendBootstrap, bootstrapFd)
257240 : Option.none();
258241 const bootstrap = Option.getOrUndefined(bootstrapEnvelope);
259242
@@ -283,11 +266,7 @@ export const resolveServerConfig = ( · 23 unmodified lines
283266 },
284267 );
285268 const devUrl = Option.getOrElse(
286− resolveOptionPrecedence(
287− normalizedFlags.devUrl,
288− Option.fromUndefinedOr(env.devUrl),
289− Option.fromUndefinedOr(bootstrap?.devUrl),
290− ),
269+ resolveOptionPrecedence(normalizedFlags.devUrl, Option.fromUndefinedOr(env.devUrl)),
291270 () => undefined,
292271 );
293272 const baseDir = yield* resolveBaseDir(
@@ -327,7 +306,6 @@ export const resolveServerConfig = ( · 33 unmodified lines
327306 isHeadlessStartup ? Option.some(false) : Option.none(),
328307 normalizedFlags.autoBootstrapProjectFromCwd,
329308 Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd),
330− Option.fromUndefinedOr(bootstrap?.autoBootstrapProjectFromCwd),
331309 ),
332310 () => mode === "web",
333311 );
@@ -335,7 +313,6 @@ export const resolveServerConfig = ( · 1 unmodified line
335313 resolveOptionPrecedence(
336314 normalizedFlags.logWebSocketEvents,
337315 Option.fromUndefinedOr(env.logWebSocketEvents),
338− Option.fromUndefinedOr(bootstrap?.logWebSocketEvents),
339316 ),
340317 () => Boolean(devUrl),
341318 );
apps/server/src/http.ts+1−1
@@ -1,4 +1,5 @@
11 import Mime from "@effect/platform-node/Mime";
2+import { decodeOtlpTraceRecords } from "@t3tools/shared/observability";
23 import { Data, Effect, FileSystem, Option, Path } from "effect";
34 import { cast } from "effect/Function";
45 import {
@@ -18,7 +19,6 @@ import { · 13 unmodified lines
1819 } from "./attachmentPaths.ts";
1920 import { resolveAttachmentPathById } from "./attachmentStore.ts";
2021 import { resolveStaticDir, ServerConfig } from "./config.ts";
21−import { decodeOtlpTraceRecords } from "./observability/TraceRecord.ts";
2222 import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts";
2323 import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts";
2424 import { ServerAuth } from "./auth/Services/ServerAuth.ts";
apps/server/src/observability/Attributes.test.ts+1−36
@@ -1,43 +1,8 @@
11 import { assert, describe, it } from "@effect/vitest";
22
3−import { compactTraceAttributes, normalizeModelMetricLabel } from "./Attributes.ts";
3+import { normalizeModelMetricLabel } from "./Attributes.ts";
44
55 describe("Attributes", () => {
6− it("normalizes circular arrays, maps, and sets without recursing forever", () => {
7− const array: Array<unknown> = ["alpha"];
8− array.push(array);
9−
10− const map = new Map<string, unknown>();
11− map.set("self", map);
12−
13− const set = new Set<unknown>();
14− set.add(set);
15−
16− assert.deepStrictEqual(
17− compactTraceAttributes({
18− array,
19− map,
20− set,
21− }),
22− {
23− array: ["alpha", "[Circular]"],
24− map: { self: "[Circular]" },
25− set: ["[Circular]"],
26− },
27− );
28− });
29−
30− it("normalizes invalid dates without throwing", () => {
31− assert.deepStrictEqual(
32− compactTraceAttributes({
33− invalidDate: new Date("not-a-real-date"),
34− }),
35− {
36− invalidDate: "Invalid Date",
37− },
38− );
39− });
40−
416 it("groups GPT-family models under a shared metric label", () => {
427 assert.strictEqual(normalizeModelMetricLabel("gpt-4o"), "gpt");
438 assert.strictEqual(normalizeModelMetricLabel("gpt-5.4"), "gpt");
apps/server/src/observability/Attributes.ts+0−80
@@ -2,88 +2,8 @@ import { Cause, Exit } from "effect"; · 1 unmodified line
22
33 export type MetricAttributeValue = string;
44 export type MetricAttributes = Readonly<Record<string, MetricAttributeValue>>;
5−export type TraceAttributes = Readonly<Record<string, unknown>>;
65 export type ObservabilityOutcome = "success" | "failure" | "interrupt";
76
8−function isPlainObject(value: unknown): value is Record<string, unknown> {
9− return typeof value === "object" && value !== null && !Array.isArray(value);
10−}
11−
12−function markSeen(value: object, seen: WeakSet<object>): boolean {
13− if (seen.has(value)) {
14− return true;
15− }
16− seen.add(value);
17− return false;
18−}
19−
20−function normalizeJsonValue(value: unknown, seen: WeakSet<object> = new WeakSet()): unknown {
21− if (
22− value === null ||
23− value === undefined ||
24− typeof value === "string" ||
25− typeof value === "number" ||
26− typeof value === "boolean"
27− ) {
28− return value ?? null;
29− }
30− if (typeof value === "bigint") {
31− return value.toString();
32− }
33− if (value instanceof Date) {
34− return Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString();
35− }
36− if (value instanceof Error) {
37− return {
38− name: value.name,
39− message: value.message,
40− ...(value.stack ? { stack: value.stack } : {}),
41− };
42− }
43− if (Array.isArray(value)) {
44− if (markSeen(value, seen)) {
45− return "[Circular]";
46− }
47− return value.map((entry) => normalizeJsonValue(entry, seen));
48− }
49− if (value instanceof Map) {
50− if (markSeen(value, seen)) {
51− return "[Circular]";
52− }
53− return Object.fromEntries(
54− Array.from(value.entries(), ([key, entryValue]) => [
55− String(key),
56− normalizeJsonValue(entryValue, seen),
57− ]),
58− );
59− }
60− if (value instanceof Set) {
61− if (markSeen(value, seen)) {
62− return "[Circular]";
63− }
64− return Array.from(value.values(), (entry) => normalizeJsonValue(entry, seen));
65− }
66− if (!isPlainObject(value)) {
67− return String(value);
68− }
69− if (markSeen(value, seen)) {
70− return "[Circular]";
71− }
72− return Object.fromEntries(
73− Object.entries(value).map(([key, entryValue]) => [key, normalizeJsonValue(entryValue, seen)]),
74− );
75−}
76−
77−export function compactTraceAttributes(
78− attributes: Readonly<Record<string, unknown>>,
79−): TraceAttributes {
80− return Object.fromEntries(
81− Object.entries(attributes)
82− .filter(([, value]) => value !== undefined)
83− .map(([key, value]) => [key, normalizeJsonValue(value)]),
84− );
85−}
86−
877 export function compactMetricAttributes(
888 attributes: Readonly<Record<string, unknown>>,
899 ): MetricAttributes {
apps/server/src/observability/Layers/Observability.ts+1−2
@@ -1,11 +1,10 @@
1+import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability";
12 import { Effect, Layer, References, Tracer } from "effect";
23 import { OtlpMetrics, OtlpSerialization, OtlpTracer } from "effect/unstable/observability";
34
45 import { ServerConfig } from "../../config.ts";
56 import { ServerLoggerLive } from "../../serverLogger.ts";
6−import { makeLocalFileTracer } from "../LocalFileTracer.ts";
77 import { BrowserTraceCollector } from "../Services/BrowserTraceCollector.ts";
8−import { makeTraceSink } from "../TraceSink.ts";
98
109 const otlpSerializationLayer = OtlpSerialization.layerJson;
1110
apps/server/src/observability/LocalFileTracer.test.ts+0−117
@@ -1,117 +0,0 @@
1−import fs from "node:fs";
2−import os from "node:os";
3−import path from "node:path";
4−
5−import { assert, describe, it } from "@effect/vitest";
6−import { Effect, Layer, Logger, References, Tracer } from "effect";
7−
8−import type { EffectTraceRecord } from "./TraceRecord.ts";
9−import { makeLocalFileTracer } from "./LocalFileTracer.ts";
10−
11−const makeTestLayer = (tracePath: string) =>
12− Layer.mergeAll(
13− Layer.effect(
14− Tracer.Tracer,
15− makeLocalFileTracer({
16− filePath: tracePath,
17− maxBytes: 1024 * 1024,
18− maxFiles: 2,
19− batchWindowMs: 10_000,
20− }),
21− ),
22− Logger.layer([Logger.tracerLogger], { mergeWithExisting: false }),
23− Layer.succeed(References.MinimumLogLevel, "Info"),
24− );
25−
26−const readTraceRecords = (tracePath: string): Array<EffectTraceRecord> =>
27− fs
28− .readFileSync(tracePath, "utf8")
29− .trim()
30− .split("\n")
31− .filter((line) => line.length > 0)
32− .map((line) => JSON.parse(line) as EffectTraceRecord);
33−
34−describe("LocalFileTracer", () => {
35− it.effect("writes nested spans to disk and captures log messages as span events", () =>
36− Effect.gen(function* () {
37− const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-local-tracer-"));
38− const tracePath = path.join(tempDir, "server.trace.ndjson");
39−
40− try {
41− yield* Effect.scoped(
42− Effect.gen(function* () {
43− const program = Effect.gen(function* () {
44− yield* Effect.annotateCurrentSpan({
45− "demo.parent": true,
46− });
47− yield* Effect.logInfo("parent event");
48− yield* Effect.gen(function* () {
49− yield* Effect.annotateCurrentSpan({
50− "demo.child": true,
51− });
52− yield* Effect.logInfo("child event");
53− }).pipe(Effect.withSpan("child-span"));
54− }).pipe(Effect.withSpan("parent-span"));
55−
56− yield* program.pipe(Effect.provide(makeTestLayer(tracePath)));
57− }),
58− );
59−
60− const records = readTraceRecords(tracePath);
61− assert.equal(records.length, 2);
62−
63− const parent = records.find((record) => record.name === "parent-span");
64− const child = records.find((record) => record.name === "child-span");
65−
66− assert.notEqual(parent, undefined);
67− assert.notEqual(child, undefined);
68− if (!parent || !child) {
69− return;
70− }
71−
72− assert.equal(child.parentSpanId, parent.spanId);
73− assert.equal(parent.attributes["demo.parent"], true);
74− assert.equal(child.attributes["demo.child"], true);
75− assert.equal(
76− parent.events.some((event) => event.name === "parent event"),
77− true,
78− );
79− assert.equal(
80− child.events.some((event) => event.name === "child event"),
81− true,
82− );
83− assert.equal(
84− child.events.some((event) => event.attributes["effect.logLevel"] === "INFO"),
85− true,
86− );
87− } finally {
88− fs.rmSync(tempDir, { recursive: true, force: true });
89− }
90− }),
91− );
92−
93− it.effect("serializes interrupted spans with an interrupted exit status", () =>
94− Effect.gen(function* () {
95− const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-local-tracer-"));
96− const tracePath = path.join(tempDir, "server.trace.ndjson");
97−
98− try {
99− yield* Effect.scoped(
100− Effect.exit(
101− Effect.interrupt.pipe(
102− Effect.withSpan("interrupt-span"),
103− Effect.provide(makeTestLayer(tracePath)),
104− ),
105− ),
106− );
107−
108− const records = readTraceRecords(tracePath);
109− assert.equal(records.length, 1);
110− assert.equal(records[0]?.name, "interrupt-span");
111− assert.equal(records[0]?.exit._tag, "Interrupted");
112− } finally {
113− fs.rmSync(tempDir, { recursive: true, force: true });
114− }
115− }),
116− );
117−});
apps/server/src/observability/LocalFileTracer.ts+0−112
@@ -1,112 +0,0 @@
1−import type * as Exit from "effect/Exit";
2−import { Effect, Option, Tracer } from "effect";
3−
4−import { spanToTraceRecord } from "./TraceRecord.ts";
5−import type { EffectTraceRecord } from "./TraceRecord.ts";
6−import { makeTraceSink, type TraceSink } from "./TraceSink.ts";
7−
8−export interface LocalFileTracerOptions {
9− readonly filePath: string;
10− readonly maxBytes: number;
11− readonly maxFiles: number;
12− readonly batchWindowMs: number;
13− readonly delegate?: Tracer.Tracer;
14− readonly sink?: TraceSink;
15−}
16−
17−class LocalFileSpan implements Tracer.Span {
18− readonly _tag = "Span";
19− readonly name: string;
20− readonly spanId: string;
21− readonly traceId: string;
22− readonly parent: Option.Option<Tracer.AnySpan>;
23− readonly annotations: Tracer.Span["annotations"];
24− readonly links: Array<Tracer.SpanLink>;
25− readonly sampled: boolean;
26− readonly kind: Tracer.SpanKind;
27−
28− status: Tracer.SpanStatus;
29− attributes: Map<string, unknown>;
30− events: Array<[name: string, startTime: bigint, attributes: Record<string, unknown>]>;
31− private readonly delegate: Tracer.Span;
32− private readonly push: (record: EffectTraceRecord) => void;
33−
34− constructor(
35− options: Parameters<Tracer.Tracer["span"]>[0],
36− delegate: Tracer.Span,
37− push: (record: EffectTraceRecord) => void,
38− ) {
39− this.delegate = delegate;
40− this.push = push;
41− this.name = delegate.name;
42− this.spanId = delegate.spanId;
43− this.traceId = delegate.traceId;
44− this.parent = options.parent;
45− this.annotations = options.annotations;
46− this.links = [...options.links];
47− this.sampled = delegate.sampled;
48− this.kind = delegate.kind;
49− this.status = {
50− _tag: "Started",
51− startTime: options.startTime,
52− };
53− this.attributes = new Map();
54− this.events = [];
55− }
56−
57− end(endTime: bigint, exit: Exit.Exit<unknown, unknown>): void {
58− this.status = {
59− _tag: "Ended",
60− startTime: this.status.startTime,
61− endTime,
62− exit,
63− };
64− this.delegate.end(endTime, exit);
65−
66− if (this.sampled) {
67− this.push(spanToTraceRecord(this));
68− }
69− }
70−
71− attribute(key: string, value: unknown): void {
72− this.attributes.set(key, value);
73− this.delegate.attribute(key, value);
74− }
75−
76− event(name: string, startTime: bigint, attributes?: Record<string, unknown>): void {
77− const nextAttributes = attributes ?? {};
78− this.events.push([name, startTime, nextAttributes]);
79− this.delegate.event(name, startTime, nextAttributes);
80− }
81−
82− addLinks(links: ReadonlyArray<Tracer.SpanLink>): void {
83− this.links.push(...links);
84− this.delegate.addLinks(links);
85− }
86−}
87−
88−export const makeLocalFileTracer = Effect.fn("makeLocalFileTracer")(function* (
89− options: LocalFileTracerOptions,
90−) {
91− const sink =
92− options.sink ??
93− (yield* makeTraceSink({
94− filePath: options.filePath,
95− maxBytes: options.maxBytes,
96− maxFiles: options.maxFiles,
97− batchWindowMs: options.batchWindowMs,
98− }));
99−
100− const delegate =
101− options.delegate ??
102− Tracer.make({
103− span: (spanOptions) => new Tracer.NativeSpan(spanOptions),
104− });
105−
106− return Tracer.make({
107− span(spanOptions) {
108− return new LocalFileSpan(spanOptions, delegate.span(spanOptions), sink.push);
109− },
110− ...(delegate.context ? { context: delegate.context } : {}),
111− });
112−});
apps/server/src/observability/Services/BrowserTraceCollector.ts+1−2
@@ -1,8 +1,7 @@
1+import type { TraceRecord } from "@t3tools/shared/observability";
12 import { Context } from "effect";
23 import type { Effect } from "effect";
34
4−import type { TraceRecord } from "../TraceRecord.ts";
5−
65 export interface BrowserTraceCollectorShape {
76 readonly record: (records: ReadonlyArray<TraceRecord>) => Effect.Effect<void>;
87 }
apps/server/src/observability/TraceSink.test.ts+0−152
@@ -1,152 +0,0 @@
1−import fs from "node:fs";
2−import os from "node:os";
3−import path from "node:path";
4−
5−import { assert, describe, it } from "@effect/vitest";
6−import { Effect } from "effect";
7−
8−import type { TraceRecord } from "./TraceRecord.ts";
9−import { makeTraceSink } from "./TraceSink.ts";
10−
11−const makeRecord = (name: string, suffix = ""): TraceRecord => ({
12− type: "effect-span",
13− name,
14− traceId: `trace-${name}-${suffix}`,
15− spanId: `span-${name}-${suffix}`,
16− sampled: true,
17− kind: "internal",
18− startTimeUnixNano: "1",
19− endTimeUnixNano: "2",
20− durationMs: 1,
21− attributes: {
22− payload: suffix,
23− },
24− events: [],
25− links: [],
26− exit: {
27− _tag: "Success",
28− },
29−});
30−
31−describe("TraceSink", () => {
32− it.effect("flushes buffered records on close", () =>
33− Effect.scoped(
34− Effect.gen(function* () {
35− const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-trace-sink-"));
36− const tracePath = path.join(tempDir, "server.trace.ndjson");
37−
38− try {
39− const sink = yield* makeTraceSink({
40− filePath: tracePath,
41− maxBytes: 1024,
42− maxFiles: 2,
43− batchWindowMs: 10_000,
44− });
45−
46− sink.push(makeRecord("alpha"));
47− sink.push(makeRecord("beta"));
48− yield* sink.close();
49−
50− const lines = fs
51− .readFileSync(tracePath, "utf8")
52− .trim()
53− .split("\n")
54− .map((line) => JSON.parse(line) as TraceRecord);
55−
56− assert.equal(lines.length, 2);
57− assert.equal(lines[0]?.name, "alpha");
58− assert.equal(lines[1]?.name, "beta");
59− } finally {
60− fs.rmSync(tempDir, { recursive: true, force: true });
61− }
62− }),
63− ),
64− );
65−
66− it.effect("rotates the trace file when the configured max size is exceeded", () =>
67− Effect.scoped(
68− Effect.gen(function* () {
69− const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-trace-sink-"));
70− const tracePath = path.join(tempDir, "server.trace.ndjson");
71−
72− try {
73− const sink = yield* makeTraceSink({
74− filePath: tracePath,
75− maxBytes: 180,
76− maxFiles: 2,
77− batchWindowMs: 10_000,
78− });
79−
80− for (let index = 0; index < 8; index += 1) {
81− sink.push(makeRecord("rotate", `${index}-${"x".repeat(48)}`));
82− yield* sink.flush;
83− }
84− yield* sink.close();
85−
86− const matchingFiles = fs
87− .readdirSync(tempDir)
88− .filter(
89− (entry) =>
90− entry === "server.trace.ndjson" || entry.startsWith("server.trace.ndjson."),
91− )
92− .toSorted();
93−
94− assert.equal(
95− matchingFiles.some((entry) => entry === "server.trace.ndjson.1"),
96− true,
97− );
98− assert.equal(
99− matchingFiles.some((entry) => entry === "server.trace.ndjson.3"),
100− false,
101− );
102− } finally {
103− fs.rmSync(tempDir, { recursive: true, force: true });
104− }
105− }),
106− ),
107− );
108−
109− it.effect("drops only the invalid record when serialization fails", () =>
110− Effect.scoped(
111− Effect.gen(function* () {
112− const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-trace-sink-"));
113− const tracePath = path.join(tempDir, "server.trace.ndjson");
114−
115− try {
116− const sink = yield* makeTraceSink({
117− filePath: tracePath,
118− maxBytes: 1024,
119− maxFiles: 2,
120− batchWindowMs: 10_000,
121− });
122−
123− const circular: Array<unknown> = [];
124− circular.push(circular);
125−
126− sink.push(makeRecord("alpha"));
127− sink.push({
128− ...makeRecord("invalid"),
129− attributes: {
130− circular,
131− },
132− } as TraceRecord);
133− sink.push(makeRecord("beta"));
134− yield* sink.close();
135−
136− const lines = fs
137− .readFileSync(tracePath, "utf8")
138− .trim()
139− .split("\n")
140− .map((line) => JSON.parse(line) as TraceRecord);
141−
142− assert.deepStrictEqual(
143− lines.map((line) => line.name),
144− ["alpha", "beta"],
145− );
146− } finally {
147− fs.rmSync(tempDir, { recursive: true, force: true });
148− }
149− }),
150− ),
151− );
152−});
apps/server/src/observability/TraceSink.ts+0−68
@@ -1,68 +0,0 @@
1−import { RotatingFileSink } from "@t3tools/shared/logging";
2−import { Effect } from "effect";
3−
4−import type { TraceRecord } from "./TraceRecord.ts";
5−
6−const FLUSH_BUFFER_THRESHOLD = 32;
7−
8−export interface TraceSinkOptions {
9− readonly filePath: string;
10− readonly maxBytes: number;
11− readonly maxFiles: number;
12− readonly batchWindowMs: number;
13−}
14−
15−export interface TraceSink {
16− readonly filePath: string;
17− push: (record: TraceRecord) => void;
18− flush: Effect.Effect<void>;
19− close: () => Effect.Effect<void>;
20−}
21−
22−export const makeTraceSink = Effect.fn("makeTraceSink")(function* (options: TraceSinkOptions) {
23− const sink = new RotatingFileSink({
24− filePath: options.filePath,
25− maxBytes: options.maxBytes,
26− maxFiles: options.maxFiles,
27− });
28−
29− let buffer: Array<string> = [];
30−
31− const flushUnsafe = () => {
32− if (buffer.length === 0) {
33− return;
34− }
35−
36− const chunk = buffer.join("");
37− buffer = [];
38−
39− try {
40− sink.write(chunk);
41− } catch {
42− buffer.unshift(chunk);
43− }
44− };
45−
46− const flush = Effect.sync(flushUnsafe).pipe(Effect.withTracerEnabled(false));
47−
48− yield* Effect.addFinalizer(() => flush.pipe(Effect.ignore));
49− yield* Effect.forkScoped(
50− Effect.sleep(`${options.batchWindowMs} millis`).pipe(Effect.andThen(flush), Effect.forever),
51− );
52−
53− return {
54− filePath: options.filePath,
55− push(record) {
56− try {
57− buffer.push(`${JSON.stringify(record)}\n`);
58− if (buffer.length >= FLUSH_BUFFER_THRESHOLD) {
59− flushUnsafe();
60− }
61− } catch {
62− return;
63− }
64− },
65− flush,
66− close: () => flush,
67− } satisfies TraceSink;
68−});
apps/server/src/orchestration/decider.ts+11−21
@@ -3,7 +3,7 @@ import type { · 2 unmodified lines
33 OrchestrationEvent,
44 OrchestrationReadModel,
55 } from "@t3tools/contracts";
6−import { Effect } from "effect";
6+import { DateTime, Effect } from "effect";
77
88 import { OrchestrationCommandInvariantError } from "./Errors.ts";
99 import {
@@ -17,17 +17,7 @@ import { · 7 unmodified lines
1717 } from "./commandInvariants.ts";
1818 import { projectEvent } from "./projector.ts";
1919
20−const nowIso = () => new Date().toISOString();
21−const defaultMetadata: Omit<OrchestrationEvent, "sequence" | "type" | "payload"> = {
22− eventId: crypto.randomUUID() as OrchestrationEvent["eventId"],
23− aggregateKind: "thread",
24− aggregateId: "" as OrchestrationEvent["aggregateId"],
25− occurredAt: nowIso(),
26− commandId: null,
27− causationEventId: null,
28− correlationId: null,
29− metadata: {},
30−};
20+const currentIso = DateTime.now.pipe(Effect.map(DateTime.formatIso));
3121
3222 function withEventBase(
3323 input: Pick<OrchestrationCommand, "commandId"> & {
@@ -38,12 +28,12 @@ function withEventBase( · 4 unmodified lines
3828 },
3929 ): Omit<OrchestrationEvent, "sequence" | "type" | "payload"> {
4030 return {
41− ...defaultMetadata,
4231 eventId: crypto.randomUUID() as OrchestrationEvent["eventId"],
4332 aggregateKind: input.aggregateKind,
4433 aggregateId: input.aggregateId,
4534 occurredAt: input.occurredAt,
4635 commandId: input.commandId,
36+ causationEventId: null,
4737 correlationId: input.commandId,
4838 metadata: input.metadata ?? {},
4939 };
@@ -126,7 +116,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 76 unmodified lines
126116 command,
127117 projectId: command.projectId,
128118 });
129− const occurredAt = nowIso();
119+ const occurredAt = yield* currentIso;
130120 return {
131121 ...withEventBase({
132122 aggregateKind: "project",
@@ -183,7 +173,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 50 unmodified lines
183173 });
184174 }
185175
186− const occurredAt = nowIso();
176+ const occurredAt = yield* currentIso;
187177 return {
188178 ...withEventBase({
189179 aggregateKind: "project",
@@ -239,7 +229,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 49 unmodified lines
239229 command,
240230 threadId: command.threadId,
241231 });
242− const occurredAt = nowIso();
232+ const occurredAt = yield* currentIso;
243233 return {
244234 ...withEventBase({
245235 aggregateKind: "thread",
@@ -261,7 +251,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 15 unmodified lines
261251 command,
262252 threadId: command.threadId,
263253 });
264− const occurredAt = nowIso();
254+ const occurredAt = yield* currentIso;
265255 return {
266256 ...withEventBase({
267257 aggregateKind: "thread",
@@ -284,7 +274,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 16 unmodified lines
284274 command,
285275 threadId: command.threadId,
286276 });
287− const occurredAt = nowIso();
277+ const occurredAt = yield* currentIso;
288278 return {
289279 ...withEventBase({
290280 aggregateKind: "thread",
@@ -306,7 +296,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 15 unmodified lines
306296 command,
307297 threadId: command.threadId,
308298 });
309− const occurredAt = nowIso();
299+ const occurredAt = yield* currentIso;
310300 return {
311301 ...withEventBase({
312302 aggregateKind: "thread",
@@ -334,7 +324,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 21 unmodified lines
334324 command,
335325 threadId: command.threadId,
336326 });
337− const occurredAt = nowIso();
327+ const occurredAt = yield* currentIso;
338328 return {
339329 ...withEventBase({
340330 aggregateKind: "thread",
@@ -357,7 +347,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 16 unmodified lines
357347 command,
358348 threadId: command.threadId,
359349 });
360− const occurredAt = nowIso();
350+ const occurredAt = yield* currentIso;
361351 return {
362352 ...withEventBase({
363353 aggregateKind: "thread",
apps/server/src/provider/Layers/CodexAdapter.test.ts+36−0
@@ -452,6 +452,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { · 451 unmodified lines
452452 turnId: asTurnId("turn-1"),
453453 itemId: asItemId("msg_1"),
454454 payload: {
455+ completedAtMs: 1_778_000_000_000,
455456 threadId: "thread-1",
456457 turnId: "turn-1",
457458 item: {
@@ -494,6 +495,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { · 36 unmodified lines
494495 turnId: asTurnId("turn-1"),
495496 itemId: asItemId("plan_1"),
496497 payload: {
498+ completedAtMs: 1_778_000_000_000,
497499 threadId: "thread-1",
498500 turnId: "turn-1",
499501 item: {
@@ -660,6 +662,40 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { · 160 unmodified lines
660662 }),
661663 );
662664
665+ it.effect("maps realtime started notifications with upstream realtime session ids", () =>
666+ Effect.gen(function* () {
667+ const { adapter, runtime } = yield* startLifecycleRuntime();
668+ const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild);
669+
670+ yield* runtime.emit({
671+ id: asEventId("evt-realtime-started"),
672+ kind: "notification",
673+ provider: ProviderDriverKind.make("codex"),
674+ threadId: asThreadId("thread-1"),
675+ createdAt: new Date().toISOString(),
676+ method: "thread/realtime/started",
677+ payload: {
678+ threadId: "thread-1",
679+ realtimeSessionId: "realtime-session-1",
680+ version: "v2",
681+ },
682+ } satisfies ProviderEvent);
683+
684+ const firstEvent = yield* Fiber.join(firstEventFiber);
685+
686+ assert.equal(firstEvent._tag, "Some");
687+ if (firstEvent._tag !== "Some") {
688+ return;
689+ }
690+ assert.equal(firstEvent.value.type, "thread.realtime.started");
691+ if (firstEvent.value.type !== "thread.realtime.started") {
692+ return;
693+ }
694+ assert.equal(firstEvent.value.threadId, "thread-1");
695+ assert.equal(firstEvent.value.payload.realtimeSessionId, "realtime-session-1");
696+ }),
697+ );
698+
663699 it.effect("maps fatal websocket stderr notifications to runtime.error", () =>
664700 Effect.gen(function* () {
665701 const { adapter, runtime } = yield* startLifecycleRuntime();
apps/server/src/provider/Layers/CodexAdapter.ts+1−1
@@ -1151,7 +1151,7 @@ function mapToRuntimeEvents( · 1150 unmodified lines
11511151 type: "thread.realtime.started",
11521152 ...runtimeEventBase(event, canonicalThreadId),
11531153 payload: {
1154− realtimeSessionId: payload.sessionId ?? undefined,
1154+ realtimeSessionId: payload.realtimeSessionId ?? undefined,
11551155 },
11561156 },
11571157 ];
apps/server/src/provider/Layers/CodexProvider.ts+2−0
@@ -45,6 +45,8 @@ const REASONING_EFFORT_LABELS: Record<CodexSchema.V2ModelListResponse__Reasoning · 44 unmodified lines
4545 function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["account"]) {
4646 if (!account) return undefined;
4747 if (account.type === "apiKey") return "OpenAI API Key";
48+ if (account.type === "amazonBedrock") return "Amazon Bedrock";
49+ if (account.type !== "chatgpt") return undefined;
4850
4951 switch (account.planType) {
5052 case "free":
apps/server/src/provider/Layers/CodexSessionRuntime.ts+6−5
@@ -70,6 +70,7 @@ export type CodexTurnStartParamsWithCollaborationMode = · 69 unmodified lines
7070 const formatSchemaIssue = SchemaIssue.makeFormatterDefault();
7171
7272 export type CodexResumeCursor = typeof CodexResumeCursorSchema.Type;
73+type CodexServiceTier = NonNullable<EffectCodexSchema.V2ThreadStartParams["serviceTier"]>;
7374 type CodexThreadItem =
7475 | EffectCodexSchema.V2ThreadReadResponse["thread"]["turns"][number]["items"][number]
7576 | EffectCodexSchema.V2ThreadRollbackResponse["thread"]["turns"][number]["items"][number];
@@ -83,7 +84,7 @@ export interface CodexSessionRuntimeOptions { · 7 unmodified lines
8384 readonly cwd: string;
8485 readonly runtimeMode: RuntimeMode;
8586 readonly model?: string;
86− readonly serviceTier?: EffectCodexSchema.V2ThreadStartParams__ServiceTier | undefined;
87+ readonly serviceTier?: CodexServiceTier | undefined;
8788 readonly resumeCursor?: CodexResumeCursor;
8889 }
8990
@@ -94,7 +95,7 @@ export interface CodexSessionRuntimeSendTurnInput { · 4 unmodified lines
9495 readonly url: string;
9596 }>;
9697 readonly model?: string;
97− readonly serviceTier?: EffectCodexSchema.V2TurnStartParams__ServiceTier | undefined;
98+ readonly serviceTier?: CodexServiceTier | undefined;
9899 readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort | undefined;
99100 readonly interactionMode?: ProviderInteractionMode;
100101 }
@@ -268,7 +269,7 @@ function buildThreadStartParams(input: { · 167 unmodified lines
268269 readonly cwd: string;
269270 readonly runtimeMode: RuntimeMode;
270271 readonly model: string | undefined;
271− readonly serviceTier: EffectCodexSchema.V2ThreadStartParams__ServiceTier | undefined;
272+ readonly serviceTier: CodexServiceTier | undefined;
272273 }): EffectCodexSchema.V2ThreadStartParams {
273274 const config = runtimeModeToThreadConfig(input.runtimeMode);
274275 return {
@@ -331,7 +332,7 @@ export function buildTurnStartParams(input: { · 56 unmodified lines
331332 readonly url: string;
332333 }>;
333334 readonly model?: string;
334− readonly serviceTier?: EffectCodexSchema.V2TurnStartParams__ServiceTier;
335+ readonly serviceTier?: CodexServiceTier;
335336 readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort;
336337 readonly interactionMode?: ProviderInteractionMode;
337338 }): Effect.Effect<
@@ -417,7 +418,7 @@ export const openCodexThread = (input: { · 79 unmodified lines
417418 readonly runtimeMode: RuntimeMode;
418419 readonly cwd: string;
419420 readonly requestedModel: string | undefined;
420− readonly serviceTier: EffectCodexSchema.V2ThreadStartParams__ServiceTier | undefined;
421+ readonly serviceTier: CodexServiceTier | undefined;
421422 readonly resumeThreadId: string | undefined;
422423 }): Effect.Effect<CodexThreadOpenResponse, CodexErrors.CodexAppServerError> => {
423424 const resumeThreadId = input.resumeThreadId;
apps/server/src/provider/Layers/CursorAdapter.test.ts+1−0
@@ -113,6 +113,7 @@ async function waitForFileContent(filePath: string, attempts = 40) { · 112 unmodified lines
113113 // tests assumed.
114114 const makeResolveCursorSettings = Effect.gen(function* () {
115115 const serverSettings = yield* ServerSettingsService;
116+ // @effect-diagnostics effect/returnEffectInGen:off
116117 return serverSettings.getSettings.pipe(
117118 Effect.map((snapshot) => snapshot.providers.cursor),
118119 Effect.orDie,
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+9−25
@@ -193,9 +193,7 @@ const openCodeAdapterTestSettings = Schema.decodeSync(OpenCodeSettings)({ · 192 unmodified lines
193193
194194 const OpenCodeAdapterTestLayer = Layer.effect(
195195 OpenCodeAdapter,
196− Effect.gen(function* () {
197− return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings);
198− }),
196+ makeOpenCodeAdapter(openCodeAdapterTestSettings),
199197 ).pipe(
200198 Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),
201199 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
@@ -390,14 +388,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { · 188 unmodified lines
390388 );
391389
392390 it.effect("passes agent and variant options for the adapter's bound custom instance id", () => {
393− const customInstanceId = ProviderInstanceId.make("opencode_zen");
391+ const instanceId = ProviderInstanceId.make("opencode_zen");
394392 const adapterLayer = Layer.effect(
395393 OpenCodeAdapter,
396− Effect.gen(function* () {
397− return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings, {
398− instanceId: customInstanceId,
399− });
400− }),
394+ makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }),
401395 ).pipe(
402396 Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),
403397 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
@@ -441,14 +435,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { · 37 unmodified lines
441435 });
442436
443437 it.effect("uses the bound custom instance id for fallback sendTurn model selection", () => {
444− const customInstanceId = ProviderInstanceId.make("opencode_zen");
438+ const instanceId = ProviderInstanceId.make("opencode_zen");
445439 const adapterLayer = Layer.effect(
446440 OpenCodeAdapter,
447− Effect.gen(function* () {
448− return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings, {
449− instanceId: customInstanceId,
450− });
451− }),
441+ makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }),
452442 ).pipe(
453443 Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),
454444 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
@@ -487,14 +477,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { · 32 unmodified lines
487477 });
488478
489479 it.effect("rejects sendTurn model selections for another instance id", () => {
490− const customInstanceId = ProviderInstanceId.make("opencode_zen");
480+ const instanceId = ProviderInstanceId.make("opencode_zen");
491481 const adapterLayer = Layer.effect(
492482 OpenCodeAdapter,
493− Effect.gen(function* () {
494− return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings, {
495− instanceId: customInstanceId,
496− });
497− }),
483+ makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }),
498484 ).pipe(
499485 Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),
500486 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
@@ -634,10 +620,8 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { · 133 unmodified lines
634620
635621 const adapterLayer = Layer.effect(
636622 OpenCodeAdapter,
637− Effect.gen(function* () {
638− return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings, {
639− nativeEventLogger,
640− });
623+ makeOpenCodeAdapter(openCodeAdapterTestSettings, {
624+ nativeEventLogger,
641625 }),
642626 ).pipe(
643627 Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),
apps/server/src/provider/Layers/ProviderRegistry.test.ts+20−0
@@ -368,6 +368,26 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T · 367 unmodified lines
368368 }),
369369 );
370370
371+ it.effect("returns an Amazon Bedrock label for codex Bedrock auth", () =>
372+ Effect.gen(function* () {
373+ const status = yield* checkCodexProviderStatus(defaultCodexSettings, () =>
374+ Effect.succeed(
375+ makeCodexProbeSnapshot({
376+ account: {
377+ account: { type: "amazonBedrock" },
378+ requiresOpenaiAuth: false,
379+ },
380+ }),
381+ ),
382+ );
383+
384+ assert.strictEqual(status.status, "ready");
385+ assert.strictEqual(status.auth.status, "authenticated");
386+ assert.strictEqual(status.auth.type, "amazonBedrock");
387+ assert.strictEqual(status.auth.label, "Amazon Bedrock");
388+ }),
389+ );
390+
371391 it.effect("returns unavailable when codex is missing", () =>
372392 Effect.gen(function* () {
373393 const status = yield* checkCodexProviderStatus(defaultCodexSettings, () =>
apps/server/src/sourceControl/SourceControlRepositoryService.ts+15−21
@@ -153,13 +153,11 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* () · 152 unmodified lines
153153 function* (destinationPath: string) {
154154 const trimmed = destinationPath.trim();
155155 if (trimmed.length === 0) {
156− return yield* Effect.fail(
157− repositoryError({
158− operation: "cloneRepository",
159− provider: "unknown",
160− detail: "Choose a destination path before cloning.",
161− }),
162− );
156+ return yield* repositoryError({
157+ operation: "cloneRepository",
158+ provider: "unknown",
159+ detail: "Choose a destination path before cloning.",
160+ });
163161 }
164162
165163 return path.resolve(expandHomePath(trimmed, path));
@@ -183,13 +181,11 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* () · 17 unmodified lines
183181 ),
184182 );
185183 if (entries.length > 0) {
186− return yield* Effect.fail(
187− repositoryError({
188− operation: "cloneRepository",
189− provider: "unknown",
190− detail: "Destination path already exists and is not empty.",
191− }),
192− );
184+ return yield* repositoryError({
185+ operation: "cloneRepository",
186+ provider: "unknown",
187+ detail: "Destination path already exists and is not empty.",
188+ });
193189 }
194190 } else {
195191 yield* fileSystem.makeDirectory(path.dirname(normalizedDestination), { recursive: true });
@@ -222,13 +218,11 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* () · 26 unmodified lines
222218 }
223219
224220 if (!remoteUrl) {
225− return yield* Effect.fail(
226− repositoryError({
227− operation: "cloneRepository",
228− provider,
229− detail: "Enter a repository path or clone URL before cloning.",
230− }),
231− );
221+ return yield* repositoryError({
222+ operation: "cloneRepository",
223+ provider,
224+ detail: "Enter a repository path or clone URL before cloning.",
225+ });
232226 }
233227
234228 yield* git.execute({
apps/server/src/vcs/GitVcsDriverCore.ts+2−2
@@ -26,7 +26,8 @@ import { · 25 unmodified lines
2626 type VcsRef,
2727 } from "@t3tools/contracts";
2828 import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git";
29−import { compactTraceAttributes } from "../observability/Attributes.ts";
29+import { compactTraceAttributes } from "@t3tools/shared/observability";
30+import { decodeJsonResult } from "@t3tools/shared/schemaJson";
3031 import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts";
3132 import * as GitVcsDriver from "./GitVcsDriver.ts";
3233 import {
@@ -35,7 +36,6 @@ import { · 2 unmodified lines
3536 parseRemoteRefWithRemoteNames,
3637 } from "../git/remoteRefs.ts";
3738 import { ServerConfig } from "../config.ts";
38−import { decodeJsonResult } from "@t3tools/shared/schemaJson";
3939
4040 const DEFAULT_TIMEOUT_MS = 30_000;

1 more lines aren’t shown here.

apps/web/package.json+1−1

This diff is large: its 9 lines aren’t shown here.

apps/web/src/components/ChatMarkdown.tsx+18−1

This diff is large: its 62 lines aren’t shown here.

apps/web/src/components/ChatView.tsx+2−0

This diff is large: its 16 lines aren’t shown here.

apps/web/src/components/ComposerPromptEditor.tsx+1−2

This diff is large: its 17 lines aren’t shown here.

apps/web/src/components/Sidebar.tsx+94−7

This diff is large: its 249 lines aren’t shown here.

apps/web/src/components/chat/ChatComposer.tsx+1−1

This diff is large: its 9 lines aren’t shown here.

apps/web/src/components/chat/MessagesTimeline.tsx+71−18

This diff is large: its 221 lines aren’t shown here.

apps/web/src/components/chat/SkillInlineText.tsx+87−0

This diff is large: its 88 lines aren’t shown here.

apps/web/src/components/composerInlineChip.ts+2−0

This diff is large: its 8 lines aren’t shown here.

apps/web/src/components/settings/SettingsPanels.tsx+5−0

This diff is large: its 26 lines aren’t shown here.

apps/web/src/components/ui/input.tsx+26−15

This diff is large: its 55 lines aren’t shown here.

apps/web/src/components/ui/number-field.tsx+156−0

This diff is large: its 157 lines aren’t shown here.

apps/web/src/localApi.test.ts+2−0

This diff is large: its 16 lines aren’t shown here.

apps/web/tsconfig.json+1−1

This diff is large: its 9 lines aren’t shown here.

apps/web/vite.config.ts+0−2

This diff is large: its 16 lines aren’t shown here.

bun.lock+11−5

This diff is large: its 55 lines aren’t shown here.

packages/client-runtime/src/gitStatusState.ts+1−1

This diff is large: its 7 lines aren’t shown here.

packages/client-runtime/src/wsRpcClient.ts+2−1

This diff is large: its 10 lines aren’t shown here.

packages/client-runtime/src/wsRpcProtocol.ts+4−1

This diff is large: its 10 lines aren’t shown here.

packages/client-runtime/src/wsTransport.test.ts+1−1

This diff is large: its 7 lines aren’t shown here.

packages/client-runtime/src/wsTransport.ts+8−1

This diff is large: its 13 lines aren’t shown here.

packages/client-runtime/tsconfig.json+14−0

This diff is large: its 19 lines aren’t shown here.

packages/contracts/src/auth.ts+1−1

This diff is large: its 6 lines aren’t shown here.

packages/contracts/src/baseSchemas.ts+2−1

This diff is large: its 13 lines aren’t shown here.

packages/contracts/src/desktopBootstrap.ts+18−0

This diff is large: its 19 lines aren’t shown here.

packages/contracts/src/editor.ts+1−1

This diff is large: its 6 lines aren’t shown here.

packages/contracts/src/environment.ts+2−1

This diff is large: its 7 lines aren’t shown here.

packages/contracts/src/filesystem.ts+1−1

This diff is large: its 6 lines aren’t shown here.

packages/contracts/src/git.test.ts+1−1

This diff is large: its 7 lines aren’t shown here.

packages/contracts/src/git.ts+1−1

This diff is large: its 6 lines aren’t shown here.

packages/contracts/src/index.ts+1−0

This diff is large: its 8 lines aren’t shown here.

packages/contracts/src/ipc.ts+187−23

This diff is large: its 311 lines aren’t shown here.

packages/contracts/src/keybindings.test.ts+2−2

This diff is large: its 9 lines aren’t shown here.

packages/contracts/src/keybindings.ts+1−1

This diff is large: its 6 lines aren’t shown here.

packages/contracts/src/model.ts+3−1

This diff is large: its 8 lines aren’t shown here.

packages/contracts/src/orchestration.test.ts+2−1

This diff is large: its 9 lines aren’t shown here.

packages/contracts/src/orchestration.ts+6−1

This diff is large: its 11 lines aren’t shown here.

packages/contracts/src/project.ts+1−1

This diff is large: its 6 lines aren’t shown here.

packages/contracts/src/provider.test.ts+1−1

This diff is large: its 7 lines aren’t shown here.

packages/contracts/src/provider.ts+1−1

This diff is large: its 6 lines aren’t shown here.

packages/contracts/src/providerInstance.test.ts+1−1

This diff is large: its 7 lines aren’t shown here.

packages/contracts/src/providerInstance.ts+2−1

This diff is large: its 10 lines aren’t shown here.

packages/contracts/src/providerRuntime.test.ts+1−1

This diff is large: its 7 lines aren’t shown here.

packages/contracts/src/providerRuntime.ts+2−1

This diff is large: its 7 lines aren’t shown here.

packages/contracts/src/remoteAccess.ts+1−1

This diff is large: its 6 lines aren’t shown here.

packages/contracts/src/review.ts+1−1

This diff is large: its 6 lines aren’t shown here.

packages/contracts/src/rpc.ts+1−1

This diff is large: its 6 lines aren’t shown here.

packages/contracts/src/server.test.ts+1−1

This diff is large: its 6 lines aren’t shown here.

packages/contracts/src/server.ts+2−1

This diff is large: its 7 lines aren’t shown here.

packages/contracts/src/settings.test.ts+1−1

This diff is large: its 7 lines aren’t shown here.

packages/contracts/src/settings.ts+15−1

This diff is large: its 41 lines aren’t shown here.

packages/contracts/src/sourceControl.ts+1−1

This diff is large: its 6 lines aren’t shown here.

packages/contracts/src/terminal.test.ts+1−1

This diff is large: its 6 lines aren’t shown here.

packages/contracts/src/terminal.ts+1−1

This diff is large: its 6 lines aren’t shown here.

packages/contracts/src/vcs.ts+1−1

This diff is large: its 6 lines aren’t shown here.

packages/contracts/tsconfig.json+2−2

This diff is large: its 13 lines aren’t shown here.

packages/effect-acp/scripts/generate.ts+6−1

This diff is large: its 14 lines aren’t shown here.

packages/effect-acp/tsconfig.json+2−2

This diff is large: its 13 lines aren’t shown here.

packages/effect-codex-app-server/scripts/generate.ts+41−40

This diff is large: its 117 lines aren’t shown here.

packages/effect-codex-app-server/src/_generated/meta.gen.ts+72−21

This diff is large: its 345 lines aren’t shown here.

packages/effect-codex-app-server/src/_generated/namespaces.gen.ts+28−9

This diff is large: its 113 lines aren’t shown here.

packages/effect-codex-app-server/src/_generated/schema.gen.ts+4149−2647

This diff is large: its 10522 lines aren’t shown here.

packages/effect-codex-app-server/tsconfig.json+2−2

This diff is large: its 13 lines aren’t shown here.

packages/shared/package.json+4−0

This diff is large: its 11 lines aren’t shown here.

packages/shared/src/DrainableWorker.test.ts+2−1

This diff is large: its 9 lines aren’t shown here.

packages/shared/src/DrainableWorker.ts+4−2

This diff is large: its 13 lines aren’t shown here.

packages/shared/src/KeyedCoalescingWorker.test.ts+3−2

This diff is large: its 18 lines aren’t shown here.

packages/shared/src/KeyedCoalescingWorker.ts+4−2

This diff is large: its 13 lines aren’t shown here.

packages/shared/src/Net.test.ts+15−15

This diff is large: its 88 lines aren’t shown here.

packages/shared/src/Net.ts+95−88

This diff is large: its 218 lines aren’t shown here.

packages/shared/src/observability.test.ts+314−0

This diff is large: its 315 lines aren’t shown here.

packages/shared/src/observability.tsrenamed from apps/server/src/observability/TraceRecord.ts+259−7

This diff is large: its 306 lines aren’t shown here.

packages/shared/src/schemaJson.ts+9−11

This diff is large: its 24 lines aren’t shown here.

packages/shared/src/serverSettings.ts+1−1

This diff is large: its 7 lines aren’t shown here.

packages/shared/src/shell.ts+2−2

This diff is large: its 15 lines aren’t shown here.

packages/shared/tsconfig.json+14−0

This diff is large: its 19 lines aren’t shown here.

packages/ssh/src/auth.test.ts+4−1

This diff is large: its 11 lines aren’t shown here.

packages/ssh/src/auth.ts+6−1

This diff is large: its 11 lines aren’t shown here.

packages/ssh/src/command.test.ts+7−1

This diff is large: its 14 lines aren’t shown here.

packages/ssh/src/command.ts+7−1

This diff is large: its 15 lines aren’t shown here.

packages/ssh/src/config.test.ts+3−1

This diff is large: its 10 lines aren’t shown here.

packages/ssh/src/config.ts+4−1

This diff is large: its 11 lines aren’t shown here.

packages/ssh/src/errors.ts+1−1

This diff is large: its 6 lines aren’t shown here.

packages/ssh/src/tunnel.test.ts+7−1

This diff is large: its 15 lines aren’t shown here.

packages/ssh/src/tunnel.ts+20−18

This diff is large: its 59 lines aren’t shown here.

packages/ssh/tsconfig.json+2−1

This diff is large: its 12 lines aren’t shown here.

packages/tailscale/src/tailscale.test.ts+4−1

This diff is large: its 10 lines aren’t shown here.

packages/tailscale/src/tailscale.ts+5−1

This diff is large: its 10 lines aren’t shown here.