From aa9dd25b62a6832f4682ba384108e717f6b5575d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 5 May 2026 22:51:25 -0700 Subject: [PATCH 01/43] Port desktop backend readiness checks to Effect - Replace promise-based readiness polling with Effect layers and services - Update desktop startup flow to use URL-backed readiness state - Add Effect-based tests for HTTP and startup readiness --- apps/desktop/package.json | 1 + apps/desktop/src/backendReadiness.test.ts | 206 +++++++++--------- apps/desktop/src/backendReadiness.ts | 160 ++++++-------- .../src/backendStartupReadiness.test.ts | 161 ++++++++++---- apps/desktop/src/backendStartupReadiness.ts | 121 ++++++---- apps/desktop/src/main.ts | 45 ++-- bun.lock | 1 + 7 files changed, 398 insertions(+), 297 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index cd20211bfef..478688182b9 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -21,6 +21,7 @@ "electron-updater": "^6.6.2" }, "devDependencies": { + "@effect/vitest": "catalog:", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", diff --git a/apps/desktop/src/backendReadiness.test.ts b/apps/desktop/src/backendReadiness.test.ts index 0d49842acba..772f42360a6 100644 --- a/apps/desktop/src/backendReadiness.test.ts +++ b/apps/desktop/src/backendReadiness.test.ts @@ -1,114 +1,120 @@ -import { describe, expect, it, vi } from "vitest"; - -import { - BackendReadinessAbortedError, - isBackendReadinessAborted, - waitForHttpReady, -} from "./backendReadiness.ts"; - -describe("waitForHttpReady", () => { - it("returns once the backend serves the requested readiness path", async () => { - const fetchImpl = vi - .fn() - .mockResolvedValueOnce(new Response(null, { status: 503 })) - .mockResolvedValueOnce(new Response(null, { status: 200 })); - - await waitForHttpReady("http://127.0.0.1:3773", { - fetchImpl, - timeoutMs: 1_000, - intervalMs: 0, - }); - - expect(fetchImpl).toHaveBeenCalledTimes(2); - expect(fetchImpl).toHaveBeenNthCalledWith( - 1, - "http://127.0.0.1:3773/", - expect.objectContaining({ redirect: "manual" }), +import { assert, describe, it } from "@effect/vitest"; +import { Duration, Effect, Fiber, Layer, Result } from "effect"; +import { TestClock } from "effect/testing"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import { BackendReadinessAbortedError, waitForHttpReadyEffect } from "./backendReadiness.ts"; + +function responseForRequest( + request: HttpClientRequest.HttpClientRequest, + status: number, +): HttpClientResponse.HttpClientResponse { + return HttpClientResponse.fromWeb(request, new Response(null, { status })); +} + +function httpClientLayer( + handler: ( + request: HttpClientRequest.HttpClientRequest, + ) => Effect.Effect, +) { + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => handler(request)), + ); +} + +describe("waitForHttpReadyEffect", () => { + it.effect("returns once the backend serves the requested readiness path", () => { + const requestUrls: Array = []; + const statuses = [503, 200]; + const layer = Layer.merge( + TestClock.layer(), + httpClientLayer((request) => + Effect.sync(() => { + const status = statuses.shift(); + assert.isDefined(status); + requestUrls.push(request.url); + return responseForRequest(request, status); + }), + ), ); - }); - it("retries after a readiness request stalls past the per-request timeout", async () => { - const fetchImpl = vi - .fn() - .mockImplementationOnce( - (_input, init) => - new Promise((_resolve, reject) => { - init?.signal?.addEventListener( - "abort", - () => { - reject(new Error("request timed out")); - }, - { once: true }, - ); - }) as ReturnType, - ) - .mockResolvedValueOnce(new Response(null, { status: 200 })); - - await waitForHttpReady("http://127.0.0.1:3773", { - fetchImpl, - timeoutMs: 100, - intervalMs: 0, - requestTimeoutMs: 1, - }); - - expect(fetchImpl).toHaveBeenCalledTimes(2); + return Effect.gen(function* () { + const fiber = yield* waitForHttpReadyEffect(new URL("http://127.0.0.1:3773"), { + timeout: Duration.seconds(1), + interval: Duration.millis(100), + }).pipe(Effect.forkChild); + + yield* Effect.yieldNow; + assert.deepEqual(requestUrls, ["http://127.0.0.1:3773/"]); + + yield* TestClock.adjust(Duration.millis(100)); + yield* Fiber.join(fiber); + + assert.deepEqual(requestUrls, ["http://127.0.0.1:3773/", "http://127.0.0.1:3773/"]); + }).pipe(Effect.provide(layer)); }); - it("aborts an in-flight readiness wait", async () => { - const controller = new AbortController(); - const fetchImpl = vi.fn().mockImplementation( - () => - new Promise((_resolve, reject) => { - controller.signal.addEventListener( - "abort", - () => { - reject(new BackendReadinessAbortedError()); - }, - { once: true }, - ); - }) as ReturnType, + it.effect("retries after a readiness request stalls past the per-request timeout", () => { + let calls = 0; + const layer = Layer.merge( + TestClock.layer(), + httpClientLayer((request) => { + calls += 1; + return calls === 1 ? Effect.never : Effect.succeed(responseForRequest(request, 200)); + }), ); - const waitPromise = waitForHttpReady("http://127.0.0.1:3773", { - fetchImpl, - timeoutMs: 1_000, - intervalMs: 0, - signal: controller.signal, - }); + return Effect.gen(function* () { + const fiber = yield* waitForHttpReadyEffect(new URL("http://127.0.0.1:3773"), { + timeout: Duration.seconds(1), + interval: Duration.millis(100), + requestTimeout: Duration.millis(250), + }).pipe(Effect.forkChild); - controller.abort(); + yield* Effect.yieldNow; + assert.equal(calls, 1); - await expect(waitPromise).rejects.toBeInstanceOf(BackendReadinessAbortedError); - }); + yield* TestClock.adjust(Duration.millis(350)); + yield* Fiber.join(fiber); - it("recognizes aborted readiness errors", () => { - expect(isBackendReadinessAborted(new BackendReadinessAbortedError())).toBe(true); - expect(isBackendReadinessAborted(new Error("nope"))).toBe(false); + assert.equal(calls, 2); + }).pipe(Effect.provide(layer)); }); - it("supports custom readiness predicates", async () => { - const fetchImpl = vi - .fn() - .mockResolvedValueOnce(new Response(null, { status: 200 })) - .mockResolvedValueOnce(new Response(null, { status: 204 })); - - await waitForHttpReady("http://127.0.0.1:3773", { - fetchImpl, - timeoutMs: 1_000, - intervalMs: 0, - path: "/api/healthz", - isReady: (response) => response.status === 204, - }); - - expect(fetchImpl).toHaveBeenNthCalledWith( - 1, - "http://127.0.0.1:3773/api/healthz", - expect.objectContaining({ redirect: "manual" }), - ); - expect(fetchImpl).toHaveBeenNthCalledWith( - 2, - "http://127.0.0.1:3773/api/healthz", - expect.objectContaining({ redirect: "manual" }), + it.effect("times out using the Effect clock when readiness never succeeds", () => { + const layer = Layer.merge( + TestClock.layer(), + httpClientLayer(() => Effect.never), ); + + return Effect.gen(function* () { + const fiber = yield* Effect.result( + waitForHttpReadyEffect(new URL("http://127.0.0.1:3773"), { + timeout: Duration.seconds(1), + interval: Duration.millis(100), + requestTimeout: Duration.millis(250), + }), + ).pipe(Effect.forkChild); + + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(1_000)); + const result = yield* Fiber.join(fiber); + + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.include( + result.failure.message, + "Timed out waiting for backend readiness at http://127.0.0.1:3773/.", + ); + } + }).pipe(Effect.provide(layer)); }); + + it.effect("recognizes aborted readiness errors", () => + Effect.sync(() => { + assert.equal(BackendReadinessAbortedError.is(new BackendReadinessAbortedError()), true); + assert.equal(BackendReadinessAbortedError.is(new Error("nope")), false); + }), + ); }); diff --git a/apps/desktop/src/backendReadiness.ts b/apps/desktop/src/backendReadiness.ts index 71c28929ebe..33d2320970c 100644 --- a/apps/desktop/src/backendReadiness.ts +++ b/apps/desktop/src/backendReadiness.ts @@ -1,107 +1,87 @@ -export interface WaitForHttpReadyOptions { - readonly timeoutMs?: number; - readonly intervalMs?: number; - readonly requestTimeoutMs?: number; - readonly fetchImpl?: typeof fetch; - readonly signal?: AbortSignal; +import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; +import * as Cause from "effect/Cause"; +import * as Data from "effect/Data"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Predicate from "effect/Predicate"; +import * as Schedule from "effect/Schedule"; +import { HttpClient } from "effect/unstable/http"; + +export interface WaitForHttpReadyEffectOptions { + readonly timeout?: Duration.Duration; + readonly interval?: Duration.Duration; + readonly requestTimeout?: Duration.Duration; readonly path?: string; - readonly isReady?: (response: Response) => boolean; } -const DEFAULT_TIMEOUT_MS = 30_000; -const DEFAULT_INTERVAL_MS = 100; -const DEFAULT_REQUEST_TIMEOUT_MS = 1_000; - -export class BackendReadinessAbortedError extends Error { - constructor() { - super("Backend readiness wait was aborted."); - this.name = "BackendReadinessAbortedError"; - } +export interface WaitForHttpReadyOptions extends WaitForHttpReadyEffectOptions { + readonly signal?: AbortSignal; } -function delay(ms: number, signal: AbortSignal | undefined): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - resolve(); - }, ms); +const DEFAULT_TIMEOUT = Duration.seconds(30); +const DEFAULT_INTERVAL = Duration.millis(100); +const DEFAULT_REQUEST_TIMEOUT = Duration.seconds(1); - const onAbort = () => { - cleanup(); - reject(new BackendReadinessAbortedError()); - }; +export class BackendReadinessAbortedError extends Data.TaggedError( + "BackendReadinessAbortedError", +)<{}> { + static is = (u: unknown): u is BackendReadinessAbortedError => + Predicate.isTagged(u, "BackendReadinessAbortedError"); - const cleanup = () => { - clearTimeout(timer); - signal?.removeEventListener("abort", onAbort); - }; - - if (signal?.aborted) { - cleanup(); - reject(new BackendReadinessAbortedError()); - return; - } - - signal?.addEventListener("abort", onAbort, { once: true }); - }); + override get message() { + return "Backend readiness wait was aborted."; + } } -export function isBackendReadinessAborted(error: unknown): error is BackendReadinessAbortedError { - return error instanceof BackendReadinessAbortedError; +export class BackendTimeoutError extends Data.TaggedError("BackendTimeoutError")<{ + readonly url: URL; +}> { + override get message() { + return `Timed out waiting for backend readiness at ${this.url.href}.`; + } } -export async function waitForHttpReady( - baseUrl: string, - options?: WaitForHttpReadyOptions, -): Promise { - const fetchImpl = options?.fetchImpl ?? fetch; - const signal = options?.signal; - const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const intervalMs = options?.intervalMs ?? DEFAULT_INTERVAL_MS; - const requestTimeoutMs = options?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; +export const waitForHttpReadyEffect = Effect.fn("waitForHttpReadyEffect")(function* ( + baseUrl: URL, + options?: WaitForHttpReadyEffectOptions, +): Effect.fn.Return { + const timeout = options?.timeout ?? DEFAULT_TIMEOUT; + const interval = options?.interval ?? DEFAULT_INTERVAL; + const requestTimeout = options?.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT; const readinessPath = options?.path ?? "/"; - const isReady = options?.isReady ?? ((response: Response) => response.ok); - const deadline = Date.now() + timeoutMs; + const requestUrl = new URL(readinessPath, baseUrl); - for (;;) { - if (signal?.aborted) { - throw new BackendReadinessAbortedError(); - } + const client = (yield* HttpClient.HttpClient).pipe( + HttpClient.filterStatusOk, + HttpClient.transformResponse(Effect.timeout(requestTimeout)), + HttpClient.retry(Schedule.spaced(interval)), + ); - const requestController = new AbortController(); - const requestTimeout = setTimeout(() => { - requestController.abort(); - }, requestTimeoutMs); - const abortRequest = () => { - requestController.abort(); - }; - signal?.addEventListener("abort", abortRequest, { once: true }); + yield* client.get(requestUrl).pipe( + Effect.asVoid, + Effect.timeoutOption(timeout), + Effect.catchTags({ + TimeoutError: () => Effect.fail(new BackendTimeoutError({ url: baseUrl })), + // Maybe map this to different error kind? + HttpClientError: () => Effect.fail(new BackendTimeoutError({ url: baseUrl })), + }), + ); +}); - try { - const response = await fetchImpl(new URL(readinessPath, baseUrl).toString(), { - redirect: "manual", - signal: requestController.signal, - }); - if (isReady(response)) { - return; - } - } catch (error) { - if (isBackendReadinessAborted(error)) { - throw error; - } - if (signal?.aborted) { - throw new BackendReadinessAbortedError(); - } - // Retry until the backend becomes reachable or the deadline expires. - } finally { - clearTimeout(requestTimeout); - signal?.removeEventListener("abort", abortRequest); - } - - if (Date.now() >= deadline) { - throw new Error(`Timed out waiting for backend readiness at ${baseUrl}.`); - } +/** + * @deprecated - Temporary promise shim until remaining desktop entrypoint is ported to Effect + */ +export async function waitForHttpReady( + baseUrl: URL, + options?: WaitForHttpReadyOptions, +): Promise { + const signal = options?.signal; - await delay(intervalMs, signal); - } + const exit = await Effect.runPromiseExit( + waitForHttpReadyEffect(baseUrl, options).pipe(Effect.provide(NodeHttpClient.layerUndici)), + { signal }, + ); + if (exit._tag === "Success") return; + if (Cause.hasInterrupts(exit.cause)) throw new BackendReadinessAbortedError(); + throw Cause.squash(exit.cause); } diff --git a/apps/desktop/src/backendStartupReadiness.test.ts b/apps/desktop/src/backendStartupReadiness.test.ts index 6d1df3d3ecd..34fe126eb08 100644 --- a/apps/desktop/src/backendStartupReadiness.test.ts +++ b/apps/desktop/src/backendStartupReadiness.test.ts @@ -1,58 +1,123 @@ -import { describe, expect, it, vi } from "vitest"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; -import { BackendReadinessAbortedError } from "./backendReadiness.ts"; -import { waitForBackendStartupReady } from "./backendStartupReadiness.ts"; +import { + BackendStartupReadiness, + type BackendStartupReadinessService, + waitForBackendStartupReady, + waitForBackendStartupReadyEffect, +} from "./backendStartupReadiness.ts"; -describe("waitForBackendStartupReady", () => { - it("falls back to the HTTP probe when no listening signal exists", async () => { - const waitForHttpReady = vi.fn<() => Promise>().mockResolvedValue(undefined); - const cancelHttpWait = vi.fn(); +function runWithStartupReadiness(service: BackendStartupReadinessService) { + return waitForBackendStartupReadyEffect().pipe( + Effect.provideService(BackendStartupReadiness, service), + ); +} - await expect( - waitForBackendStartupReady({ - waitForHttpReady, - cancelHttpWait, - }), - ).resolves.toBe("http"); - - expect(waitForHttpReady).toHaveBeenCalledTimes(1); - expect(cancelHttpWait).not.toHaveBeenCalled(); - }); - - it("uses the listening signal and cancels the HTTP probe", async () => { - let rejectHttpWait: ((error: unknown) => void) | null = null; - const waitForHttpReady = vi.fn( - () => - new Promise((_resolve, reject) => { - rejectHttpWait = reject; +describe("waitForBackendStartupReadyEffect", () => { + it.effect("falls back to the HTTP probe when no listening signal exists", () => + Effect.gen(function* () { + let httpCalls = 0; + let cancelCalls = 0; + + const source = yield* runWithStartupReadiness({ + listening: Option.none(), + httpReady: Effect.sync(() => { + httpCalls += 1; + }), + cancelHttpWait: Effect.sync(() => { + cancelCalls += 1; }), - ); - const cancelHttpWait = vi.fn(() => { - rejectHttpWait?.(new BackendReadinessAbortedError()); - }); + }); - await expect( - waitForBackendStartupReady({ - listeningPromise: Promise.resolve(), - waitForHttpReady, - cancelHttpWait, - }), - ).resolves.toBe("listening"); + assert.equal(source, "http"); + assert.equal(httpCalls, 1); + assert.equal(cancelCalls, 0); + }), + ); - expect(waitForHttpReady).toHaveBeenCalledTimes(1); - expect(cancelHttpWait).toHaveBeenCalledTimes(1); - }); + it.effect("uses the listening signal and cancels the HTTP probe", () => + Effect.gen(function* () { + let cancelCalls = 0; - it("rejects when the listening signal fails before HTTP readiness", async () => { - const error = new Error("backend exited"); - const waitForHttpReady = vi.fn(() => new Promise(() => {})); + const source = yield* runWithStartupReadiness({ + listening: Option.some(Effect.void), + httpReady: Effect.never, + cancelHttpWait: Effect.sync(() => { + cancelCalls += 1; + }), + }); + + assert.equal(source, "listening"); + assert.equal(cancelCalls, 1); + }), + ); + + it.effect("returns HTTP when the HTTP probe wins before listening", () => + Effect.gen(function* () { + let cancelCalls = 0; + + const source = yield* runWithStartupReadiness({ + listening: Option.some(Effect.never), + httpReady: Effect.void, + cancelHttpWait: Effect.sync(() => { + cancelCalls += 1; + }), + }); + + assert.equal(source, "http"); + assert.equal(cancelCalls, 0); + }), + ); + + it.effect("fails when the listening signal fails before HTTP readiness", () => + Effect.gen(function* () { + const error = new Error("backend exited"); + let cancelCalls = 0; + + const result = yield* Effect.result( + runWithStartupReadiness({ + listening: Option.some(Effect.fail(error)), + httpReady: Effect.never, + cancelHttpWait: Effect.sync(() => { + cancelCalls += 1; + }), + }), + ); + + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.strictEqual(result.failure, error); + } + assert.equal(cancelCalls, 1); + }), + ); + + it.effect("keeps the promise shim compatible with existing callers", () => + Effect.callback((resume) => { + let cancelCalls = 0; + let rejectHttpWait: ((error: unknown) => void) | undefined; - await expect( waitForBackendStartupReady({ - listeningPromise: Promise.reject(error), - waitForHttpReady, - cancelHttpWait: vi.fn(), - }), - ).rejects.toBe(error); - }); + listeningPromise: Promise.resolve(), + waitForHttpReady: () => + new Promise((_resolve, reject) => { + rejectHttpWait = reject; + }), + cancelHttpWait: () => { + cancelCalls += 1; + rejectHttpWait?.(new Error("cancelled")); + }, + }).then( + (source) => { + assert.equal(source, "listening"); + assert.equal(cancelCalls, 1); + resume(Effect.void); + }, + (error) => resume(Effect.fail(error)), + ); + }), + ); }); diff --git a/apps/desktop/src/backendStartupReadiness.ts b/apps/desktop/src/backendStartupReadiness.ts index 37a977431d0..c467657408e 100644 --- a/apps/desktop/src/backendStartupReadiness.ts +++ b/apps/desktop/src/backendStartupReadiness.ts @@ -1,4 +1,19 @@ -import { isBackendReadinessAborted } from "./backendReadiness.ts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +export type BackendStartupReadySource = "listening" | "http"; + +export interface BackendStartupReadinessService { + readonly listening: Option.Option>; + readonly httpReady: Effect.Effect; + readonly cancelHttpWait: Effect.Effect; +} + +export class BackendStartupReadiness extends Context.Service< + BackendStartupReadiness, + BackendStartupReadinessService +>()("@t3tools/desktop/BackendStartupReadiness") {} export interface WaitForBackendStartupReadyOptions { readonly listeningPromise?: Promise | null; @@ -6,51 +21,67 @@ export interface WaitForBackendStartupReadyOptions { readonly cancelHttpWait: () => void; } -export async function waitForBackendStartupReady( - options: WaitForBackendStartupReadyOptions, -): Promise<"listening" | "http"> { - const httpReadyPromise = options.waitForHttpReady(); - const listeningPromise = options.listeningPromise; - - if (!listeningPromise) { - await httpReadyPromise; - return "http"; - } - - return await new Promise<"listening" | "http">((resolve, reject) => { - let settled = false; - - const settleResolve = (source: "listening" | "http") => { - if (settled) { - return; - } - settled = true; - if (source === "listening") { - options.cancelHttpWait(); - } - resolve(source); - }; - - const settleReject = (error: unknown) => { - if (settled) { - return; - } - settled = true; - reject(error); - }; - - listeningPromise.then( - () => settleResolve("listening"), - (error) => settleReject(error), +function fromPromise(promise: Promise): Effect.Effect { + return Effect.callback((resume) => { + promise.then( + () => resume(Effect.void), + (error) => resume(Effect.fail(error)), + ); + }); +} + +function fromPromiseThunk(thunk: () => Promise): Effect.Effect { + return Effect.callback((resume) => { + try { + thunk().then( + () => resume(Effect.void), + (error) => resume(Effect.fail(error)), + ); + } catch (error) { + resume(Effect.fail(error)); + } + }); +} + +export function waitForBackendStartupReadyEffect(): Effect.Effect< + BackendStartupReadySource, + unknown, + BackendStartupReadiness +> { + return Effect.gen(function* () { + const readiness = yield* BackendStartupReadiness; + const httpReady = readiness.httpReady.pipe(Effect.as("http" as const)); + + if (Option.isNone(readiness.listening)) { + return yield* httpReady.pipe(Effect.onInterrupt(() => readiness.cancelHttpWait)); + } + + const listeningReady = readiness.listening.value.pipe( + Effect.matchEffect({ + onFailure: (error) => readiness.cancelHttpWait.pipe(Effect.andThen(Effect.fail(error))), + onSuccess: () => readiness.cancelHttpWait.pipe(Effect.as("listening" as const)), + }), ); - httpReadyPromise.then( - () => settleResolve("http"), - (error) => { - if (settled && isBackendReadinessAborted(error)) { - return; - } - settleReject(error); - }, + + return yield* Effect.raceFirst(listeningReady, httpReady).pipe( + Effect.onInterrupt(() => readiness.cancelHttpWait), ); }); } + +/** + * @deprecated - Temporary promise shim until remaining desktop entrypoint is ported to Effect. + */ +export function waitForBackendStartupReady( + options: WaitForBackendStartupReadyOptions, +): Promise { + return Effect.runPromise( + waitForBackendStartupReadyEffect().pipe( + Effect.provideService(BackendStartupReadiness, { + listening: Option.fromNullishOr(options.listeningPromise).pipe(Option.map(fromPromise)), + httpReady: fromPromiseThunk(options.waitForHttpReady), + cancelHttpWait: Effect.sync(options.cancelHttpWait), + }), + ), + ); +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9c097fc9bd1..2c36a780897 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -3,6 +3,8 @@ import * as Crypto from "node:crypto"; import * as FS from "node:fs"; import * as OS from "node:os"; import * as Path from "node:path"; +import * as Option from "effect/Option"; +import * as Duration from "effect/Duration"; import { app, @@ -56,7 +58,7 @@ import { writeSavedEnvironmentRegistry, writeSavedEnvironmentSecret, } from "./clientPersistence.ts"; -import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness.ts"; +import { BackendReadinessAbortedError, waitForHttpReady } from "./backendReadiness.ts"; import { showDesktopConfirmDialog } from "./confirmDialog.ts"; import { resolveDesktopCoreAdvertisedEndpoints, @@ -221,7 +223,7 @@ let backendProcess: ChildProcess.ChildProcess | null = null; let backendPort = 0; let backendBindHost = DESKTOP_LOOPBACK_HOST; let backendBootstrapToken = ""; -let backendHttpUrl = ""; +let backendHttpUrl: Option.Option = Option.none(); let backendWsUrl = ""; let backendEndpointUrl: string | null = null; let backendAdvertisedHost: string | null = null; @@ -242,6 +244,21 @@ let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serve let destructiveMenuIconCache: Electron.NativeImage | null | undefined; const expectedBackendExitChildren = new WeakSet(); + +function requireBackendHttpUrl(): URL { + return Option.getOrThrowWith( + backendHttpUrl, + () => new Error("Desktop backend HTTP URL has not been resolved."), + ); +} + +function getBackendHttpUrlHref(): string | null { + return Option.match(backendHttpUrl, { + onNone: () => null, + onSome: (url) => url.href, + }); +} + const desktopRuntimeInfo = resolveDesktopRuntimeInfo({ platform: process.platform, processArch: process.arch, @@ -400,7 +417,7 @@ async function applyDesktopServerExposureMode( desktopServerExposureMode = exposure.mode; desktopSettings = setDesktopServerExposurePreference(desktopSettings, requestedMode); backendBindHost = exposure.bindHost; - backendHttpUrl = exposure.localHttpUrl; + backendHttpUrl = Option.some(new URL(exposure.localHttpUrl)); backendWsUrl = exposure.localWsUrl; backendEndpointUrl = exposure.endpointUrl; backendAdvertisedHost = exposure.advertisedHost; @@ -499,7 +516,7 @@ function getSafeTheme(rawTheme: unknown): DesktopTheme | null { } async function waitForBackendHttpReady( - baseUrl: string, + baseUrl: URL, options?: Parameters[1], ): Promise { cancelBackendReadinessWait(); @@ -523,12 +540,12 @@ function cancelBackendReadinessWait(): void { backendReadinessAbortController = null; } -async function waitForBackendWindowReady(baseUrl: string): Promise<"listening" | "http"> { +async function waitForBackendWindowReady(baseUrl: URL): Promise<"listening" | "http"> { return await waitForBackendStartupReady({ listeningPromise: backendListeningDetector?.promise ?? null, waitForHttpReady: () => waitForBackendHttpReady(baseUrl, { - timeoutMs: 60_000, + timeout: Duration.minutes(1), }), cancelHttpWait: cancelBackendReadinessWait, }); @@ -540,7 +557,7 @@ function ensureInitialBackendWindowOpen(): void { return; } - const nextOpen = waitForBackendWindowReady(backendHttpUrl) + const nextOpen = waitForBackendWindowReady(requireBackendHttpUrl()) .then((source) => { writeDesktopLogHeader(`bootstrap backend ready source=${source}`); if (mainWindow ?? BrowserWindow.getAllWindows()[0]) { @@ -550,7 +567,7 @@ function ensureInitialBackendWindowOpen(): void { writeDesktopLogHeader("bootstrap main window created"); }) .catch((error) => { - if (isBackendReadinessAborted(error)) { + if (BackendReadinessAbortedError.is(error)) { return; } writeDesktopLogHeader( @@ -1648,7 +1665,7 @@ function registerIpcHandlers(): void { ipcMain.on(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, (event) => { event.returnValue = { label: "Local environment", - httpBaseUrl: backendHttpUrl || null, + httpBaseUrl: getBackendHttpUrlHref(), wsBaseUrl: backendWsUrl || null, bootstrapToken: backendBootstrapToken || undefined, } as const; @@ -2132,7 +2149,7 @@ function createWindow(): BrowserWindow { void window.loadURL(resolveDesktopDevServerUrl()); window.webContents.openDevTools({ mode: "detach" }); } else { - void window.loadURL(backendHttpUrl); + void window.loadURL(requireBackendHttpUrl().href); } window.on("closed", () => { @@ -2185,7 +2202,7 @@ async function bootstrap(): Promise { persist: desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode, }, ); - writeDesktopLogHeader(`bootstrap resolved backend endpoint baseUrl=${backendHttpUrl}`); + writeDesktopLogHeader(`bootstrap resolved backend endpoint baseUrl=${getBackendHttpUrlHref()}`); if (serverExposureState.endpointUrl) { writeDesktopLogHeader( `bootstrap enabled network access endpointUrl=${serverExposureState.endpointUrl}`, @@ -2204,12 +2221,12 @@ async function bootstrap(): Promise { if (isDevelopment) { mainWindow = createWindow(); writeDesktopLogHeader("bootstrap main window created"); - void waitForBackendWindowReady(backendHttpUrl) + void waitForBackendWindowReady(requireBackendHttpUrl()) .then((source) => { writeDesktopLogHeader(`bootstrap backend ready source=${source}`); }) .catch((error) => { - if (isBackendReadinessAborted(error)) { + if (BackendReadinessAbortedError.is(error)) { return; } writeDesktopLogHeader( @@ -2243,7 +2260,7 @@ app registerDesktopProtocol(); configureAutoUpdater(); void bootstrap().catch((error) => { - if (isBackendReadinessAborted(error) && isQuitting) { + if (BackendReadinessAbortedError.is(error) && isQuitting) { return; } handleFatalStartupError("bootstrap", error); diff --git a/bun.lock b/bun.lock index a87ac77094b..c621a8f140f 100644 --- a/bun.lock +++ b/bun.lock @@ -24,6 +24,7 @@ "electron-updater": "^6.6.2", }, "devDependencies": { + "@effect/vitest": "catalog:", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", From 4d3358e8176031ad2161b0ee0df552e931a56bb2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 6 May 2026 06:46:12 +0000 Subject: [PATCH 02/43] Fix timeout silently succeeding in waitForHttpReadyEffect Effect.timeoutOption returns Option.None on timeout (on the success channel), not a TimeoutError on the error channel. The previous code discarded the Option result, so a timeout was treated as success. Now we explicitly match the Option: None maps to BackendTimeoutError, Some maps to Effect.void. The now-unreachable TimeoutError catchTag is removed. Applied via @cursor push command --- apps/desktop/src/backendReadiness.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/backendReadiness.ts b/apps/desktop/src/backendReadiness.ts index 33d2320970c..5175695b3fe 100644 --- a/apps/desktop/src/backendReadiness.ts +++ b/apps/desktop/src/backendReadiness.ts @@ -3,6 +3,7 @@ import * as Cause from "effect/Cause"; import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Predicate from "effect/Predicate"; import * as Schedule from "effect/Schedule"; import { HttpClient } from "effect/unstable/http"; @@ -60,9 +61,13 @@ export const waitForHttpReadyEffect = Effect.fn("waitForHttpReadyEffect")(functi yield* client.get(requestUrl).pipe( Effect.asVoid, Effect.timeoutOption(timeout), + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(new BackendTimeoutError({ url: baseUrl })), + onSome: () => Effect.void, + }), + ), Effect.catchTags({ - TimeoutError: () => Effect.fail(new BackendTimeoutError({ url: baseUrl })), - // Maybe map this to different error kind? HttpClientError: () => Effect.fail(new BackendTimeoutError({ url: baseUrl })), }), ); From 167afc1d5165b0e2519f7076e09d44fc683495f5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 00:20:16 -0700 Subject: [PATCH 03/43] nit --- apps/desktop/src/backendReadiness.ts | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/backendReadiness.ts b/apps/desktop/src/backendReadiness.ts index 5175695b3fe..5e82f70ad2d 100644 --- a/apps/desktop/src/backendReadiness.ts +++ b/apps/desktop/src/backendReadiness.ts @@ -3,7 +3,6 @@ import * as Cause from "effect/Cause"; import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; import * as Predicate from "effect/Predicate"; import * as Schedule from "effect/Schedule"; import { HttpClient } from "effect/unstable/http"; @@ -45,7 +44,7 @@ export class BackendTimeoutError extends Data.TaggedError("BackendTimeoutError") export const waitForHttpReadyEffect = Effect.fn("waitForHttpReadyEffect")(function* ( baseUrl: URL, options?: WaitForHttpReadyEffectOptions, -): Effect.fn.Return { +): Effect.fn.Return { const timeout = options?.timeout ?? DEFAULT_TIMEOUT; const interval = options?.interval ?? DEFAULT_INTERVAL; const requestTimeout = options?.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT; @@ -60,16 +59,8 @@ export const waitForHttpReadyEffect = Effect.fn("waitForHttpReadyEffect")(functi yield* client.get(requestUrl).pipe( Effect.asVoid, - Effect.timeoutOption(timeout), - Effect.flatMap( - Option.match({ - onNone: () => Effect.fail(new BackendTimeoutError({ url: baseUrl })), - onSome: () => Effect.void, - }), - ), - Effect.catchTags({ - HttpClientError: () => Effect.fail(new BackendTimeoutError({ url: baseUrl })), - }), + Effect.timeout(timeout), + Effect.mapError(() => new BackendTimeoutError({ url: baseUrl })), ); }); From f5a6173133f8b2ed2a3407a9a747fa1d08973748 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 00:59:27 -0700 Subject: [PATCH 04/43] Refactor desktop backend startup into Effect process - Spawn the backend through a shared Effect runner with fd3 bootstrap - Replace ad hoc readiness/listening plumbing with HTTP readiness hooks - Add contract support for desktop bootstrap payloads --- apps/desktop/src/backendProcess.test.ts | 166 ++++++++ apps/desktop/src/backendProcess.ts | 122 ++++++ apps/desktop/src/backendReadiness.test.ts | 9 +- apps/desktop/src/backendReadiness.ts | 36 -- .../src/backendStartupReadiness.test.ts | 123 ------ apps/desktop/src/backendStartupReadiness.ts | 87 ---- apps/desktop/src/main.ts | 393 +++++++----------- .../src/serverListeningDetector.test.ts | 31 -- apps/desktop/src/serverListeningDetector.ts | 56 --- apps/server/src/cli/config.test.ts | 107 +++-- packages/contracts/src/desktopBootstrap.ts | 16 + packages/contracts/src/index.ts | 1 + 12 files changed, 508 insertions(+), 639 deletions(-) create mode 100644 apps/desktop/src/backendProcess.test.ts create mode 100644 apps/desktop/src/backendProcess.ts delete mode 100644 apps/desktop/src/backendStartupReadiness.test.ts delete mode 100644 apps/desktop/src/backendStartupReadiness.ts delete mode 100644 apps/desktop/src/serverListeningDetector.test.ts delete mode 100644 apps/desktop/src/serverListeningDetector.ts create mode 100644 packages/contracts/src/desktopBootstrap.ts diff --git a/apps/desktop/src/backendProcess.test.ts b/apps/desktop/src/backendProcess.test.ts new file mode 100644 index 00000000000..279068cdcec --- /dev/null +++ b/apps/desktop/src/backendProcess.test.ts @@ -0,0 +1,166 @@ +import { assert, describe, it } from "@effect/vitest"; +import { Duration, Effect, Layer, Schema, Sink, Stream } from "effect"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + DesktopBackendBootstrap, + type DesktopBackendBootstrap as DesktopBackendBootstrapValue, +} from "@t3tools/contracts"; +import { runBackendProcess } from "./backendProcess.ts"; + +const bootstrap: DesktopBackendBootstrapValue = { + mode: "desktop", + noBrowser: true, + port: 3773, + t3Home: "/tmp/t3", + host: "127.0.0.1", + desktopBootstrapToken: "token", + tailscaleServeEnabled: true, + tailscaleServePort: 443, + otlpTracesUrl: "http://127.0.0.1:4318/v1/traces", +}; + +function makeProcess(options?: { + readonly stdout?: Stream.Stream; + readonly stderr?: Stream.Stream; + readonly exitCode?: Effect.Effect; + readonly kill?: ChildProcessSpawner.ChildProcessHandle["kill"]; +}): ChildProcessSpawner.ChildProcessHandle { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + stdout: options?.stdout ?? Stream.empty, + stderr: options?.stderr ?? Stream.empty, + all: Stream.merge(options?.stdout ?? Stream.empty, options?.stderr ?? Stream.empty), + exitCode: options?.exitCode ?? Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: options?.kill ?? (() => Effect.void), + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }); +} + +function httpClientLayer(status: number) { + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request: HttpClientRequest.HttpClientRequest) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status }))), + ), + ); +} + +function decodeBootstrap(raw: string) { + return Schema.decodeEffect(Schema.fromJsonString(DesktopBackendBootstrap))(raw); +} + +describe("runBackendProcess", () => { + it.effect("spawns the backend with fd3 bootstrap JSON and reports HTTP readiness", () => + Effect.gen(function* () { + let spawnedCommand: ChildProcess.Command | undefined; + let bootstrapJson = ""; + let finishExit: (() => void) | undefined; + let readyCount = 0; + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + spawnedCommand = command; + if (command._tag === "StandardCommand") { + const fd3 = command.options.additionalFds?.fd3; + if (fd3?.type === "input" && fd3.stream) { + bootstrapJson = yield* fd3.stream.pipe(Stream.decodeText(), Stream.mkString); + } + } + + return makeProcess({ + exitCode: Effect.callback((resume) => { + finishExit = () => resume(Effect.succeed(ChildProcessSpawner.ExitCode(0))); + }), + }); + }), + ), + ); + + const exit = yield* runBackendProcess({ + executablePath: "/electron", + entryPath: "/server/bin.mjs", + cwd: "/server", + env: { ELECTRON_RUN_AS_NODE: "1" }, + bootstrap, + httpBaseUrl: new URL("http://127.0.0.1:3773"), + captureOutput: true, + onReady: () => + Effect.sync(() => { + readyCount += 1; + finishExit?.(); + }), + }).pipe(Effect.scoped, Effect.provide(Layer.merge(spawnerLayer, httpClientLayer(200)))); + + assert.equal(exit.code, 0); + assert.equal(readyCount, 1); + assert.isDefined(spawnedCommand); + if (spawnedCommand?._tag === "StandardCommand") { + assert.equal(spawnedCommand.command, "/electron"); + assert.deepEqual(spawnedCommand.args, ["/server/bin.mjs", "--bootstrap-fd", "3"]); + assert.equal(spawnedCommand.options.cwd, "/server"); + assert.equal(spawnedCommand.options.stdout, "pipe"); + assert.equal(spawnedCommand.options.stderr, "pipe"); + assert.equal(spawnedCommand.options.killSignal, "SIGTERM"); + assert.isDefined(spawnedCommand.options.forceKillAfter); + assert.equal( + Duration.toMillis(Duration.fromInputUnsafe(spawnedCommand.options.forceKillAfter)), + 2_000, + ); + } + assert.deepEqual(yield* decodeBootstrap(bootstrapJson), bootstrap); + }), + ); + + it.effect("inherits child output when captureOutput is false", () => + Effect.gen(function* () { + let spawnedCommand: ChildProcess.Command | undefined; + let finishExit: (() => void) | undefined; + let readyCount = 0; + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.sync(() => { + spawnedCommand = command; + return makeProcess({ + exitCode: Effect.callback((resume) => { + finishExit = () => resume(Effect.succeed(ChildProcessSpawner.ExitCode(0))); + }), + }); + }), + ), + ); + + const exit = yield* runBackendProcess({ + executablePath: "/electron", + entryPath: "/server/bin.mjs", + cwd: "/server", + env: { ELECTRON_RUN_AS_NODE: "1" }, + bootstrap, + httpBaseUrl: new URL("http://127.0.0.1:3773"), + captureOutput: false, + onReady: () => + Effect.sync(() => { + readyCount += 1; + finishExit?.(); + }), + }).pipe(Effect.scoped, Effect.provide(Layer.merge(spawnerLayer, httpClientLayer(200)))); + + assert.equal(exit.code, 0); + assert.equal(readyCount, 1); + assert.isDefined(spawnedCommand); + if (spawnedCommand?._tag === "StandardCommand") { + assert.equal(spawnedCommand.options.stdout, "inherit"); + assert.equal(spawnedCommand.options.stderr, "inherit"); + } + }), + ); +}); diff --git a/apps/desktop/src/backendProcess.ts b/apps/desktop/src/backendProcess.ts new file mode 100644 index 00000000000..fe59a19b06f --- /dev/null +++ b/apps/desktop/src/backendProcess.ts @@ -0,0 +1,122 @@ +import { + DesktopBackendBootstrap, + type DesktopBackendBootstrap as DesktopBackendBootstrapValue, +} from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as PlatformError from "effect/PlatformError"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { BackendTimeoutError, waitForHttpReadyEffect } from "./backendReadiness.ts"; + +const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1); +const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(2); + +export interface BackendProcessExit { + readonly code: number | null; + readonly reason: string; + readonly cause: unknown; +} + +export interface RunBackendProcessOptions { + readonly executablePath: string; + readonly entryPath: string; + readonly cwd: string; + readonly env: Record; + readonly bootstrap: DesktopBackendBootstrapValue; + readonly httpBaseUrl: URL; + readonly captureOutput: boolean; + readonly readinessTimeout?: Duration.Duration; + readonly onStarted?: (pid: number) => Effect.Effect; + readonly onReady?: () => Effect.Effect; + readonly onReadinessFailure?: (error: BackendTimeoutError) => Effect.Effect; + readonly onOutput?: (streamName: "stdout" | "stderr", chunk: Uint8Array) => Effect.Effect; +} + +const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); + +function describeProcessExit( + result: Result.Result, +): BackendProcessExit { + if (Result.isSuccess(result)) { + const code = Number(result.success); + return { + code, + reason: `code=${code}`, + cause: result.success, + }; + } + + return { + code: null, + reason: result.failure.message, + cause: result.failure, + }; +} + +function drainBackendOutput( + streamName: "stdout" | "stderr", + stream: Stream.Stream, + onOutput: (streamName: "stdout" | "stderr", chunk: Uint8Array) => Effect.Effect, +): Effect.Effect { + return stream.pipe( + Stream.runForEach((chunk) => onOutput(streamName, chunk)), + Effect.ignore, + ); +} + +export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( + options: RunBackendProcessOptions, +): Effect.fn.Return< + BackendProcessExit, + unknown, + ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient | Scope.Scope +> { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const bootstrapJson = yield* encodeBootstrapJson(options.bootstrap); + const onOutput = options.onOutput ?? (() => Effect.void); + const command = ChildProcess.make( + options.executablePath, + [options.entryPath, "--bootstrap-fd", "3"], + { + cwd: options.cwd, + env: options.env, + extendEnv: false, + // In Electron main, process.execPath points to the Electron binary. + // Run the child in Node mode so this backend process does not become a GUI app instance. + stdin: "ignore", + stdout: options.captureOutput ? "pipe" : "inherit", + stderr: options.captureOutput ? "pipe" : "inherit", + killSignal: "SIGTERM", + forceKillAfter: DEFAULT_BACKEND_TERMINATE_GRACE, + additionalFds: { + fd3: { + type: "input", + stream: Stream.encodeText(Stream.make(`${bootstrapJson}\n`)), + }, + }, + }, + ); + + const handle = yield* spawner.spawn(command); + + yield* options.onStarted?.(Number(handle.pid)) ?? Effect.void; + if (options.captureOutput) { + yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped); + yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped); + } + yield* waitForHttpReadyEffect(options.httpBaseUrl, { + timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, + }).pipe( + Effect.tap(() => options.onReady?.() ?? Effect.void), + Effect.catch((error) => options.onReadinessFailure?.(error) ?? Effect.void), + Effect.forkScoped, + ); + + return describeProcessExit(yield* Effect.result(handle.exitCode)); +}); diff --git a/apps/desktop/src/backendReadiness.test.ts b/apps/desktop/src/backendReadiness.test.ts index 772f42360a6..34ad8a5f1f2 100644 --- a/apps/desktop/src/backendReadiness.test.ts +++ b/apps/desktop/src/backendReadiness.test.ts @@ -3,7 +3,7 @@ import { Duration, Effect, Fiber, Layer, Result } from "effect"; import { TestClock } from "effect/testing"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; -import { BackendReadinessAbortedError, waitForHttpReadyEffect } from "./backendReadiness.ts"; +import { waitForHttpReadyEffect } from "./backendReadiness.ts"; function responseForRequest( request: HttpClientRequest.HttpClientRequest, @@ -110,11 +110,4 @@ describe("waitForHttpReadyEffect", () => { } }).pipe(Effect.provide(layer)); }); - - it.effect("recognizes aborted readiness errors", () => - Effect.sync(() => { - assert.equal(BackendReadinessAbortedError.is(new BackendReadinessAbortedError()), true); - assert.equal(BackendReadinessAbortedError.is(new Error("nope")), false); - }), - ); }); diff --git a/apps/desktop/src/backendReadiness.ts b/apps/desktop/src/backendReadiness.ts index 5e82f70ad2d..813597304cf 100644 --- a/apps/desktop/src/backendReadiness.ts +++ b/apps/desktop/src/backendReadiness.ts @@ -1,9 +1,6 @@ -import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; -import * as Cause from "effect/Cause"; import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; -import * as Predicate from "effect/Predicate"; import * as Schedule from "effect/Schedule"; import { HttpClient } from "effect/unstable/http"; @@ -14,25 +11,10 @@ export interface WaitForHttpReadyEffectOptions { readonly path?: string; } -export interface WaitForHttpReadyOptions extends WaitForHttpReadyEffectOptions { - readonly signal?: AbortSignal; -} - const DEFAULT_TIMEOUT = Duration.seconds(30); const DEFAULT_INTERVAL = Duration.millis(100); const DEFAULT_REQUEST_TIMEOUT = Duration.seconds(1); -export class BackendReadinessAbortedError extends Data.TaggedError( - "BackendReadinessAbortedError", -)<{}> { - static is = (u: unknown): u is BackendReadinessAbortedError => - Predicate.isTagged(u, "BackendReadinessAbortedError"); - - override get message() { - return "Backend readiness wait was aborted."; - } -} - export class BackendTimeoutError extends Data.TaggedError("BackendTimeoutError")<{ readonly url: URL; }> { @@ -63,21 +45,3 @@ export const waitForHttpReadyEffect = Effect.fn("waitForHttpReadyEffect")(functi Effect.mapError(() => new BackendTimeoutError({ url: baseUrl })), ); }); - -/** - * @deprecated - Temporary promise shim until remaining desktop entrypoint is ported to Effect - */ -export async function waitForHttpReady( - baseUrl: URL, - options?: WaitForHttpReadyOptions, -): Promise { - const signal = options?.signal; - - const exit = await Effect.runPromiseExit( - waitForHttpReadyEffect(baseUrl, options).pipe(Effect.provide(NodeHttpClient.layerUndici)), - { signal }, - ); - if (exit._tag === "Success") return; - if (Cause.hasInterrupts(exit.cause)) throw new BackendReadinessAbortedError(); - throw Cause.squash(exit.cause); -} diff --git a/apps/desktop/src/backendStartupReadiness.test.ts b/apps/desktop/src/backendStartupReadiness.test.ts deleted file mode 100644 index 34fe126eb08..00000000000 --- a/apps/desktop/src/backendStartupReadiness.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as Result from "effect/Result"; - -import { - BackendStartupReadiness, - type BackendStartupReadinessService, - waitForBackendStartupReady, - waitForBackendStartupReadyEffect, -} from "./backendStartupReadiness.ts"; - -function runWithStartupReadiness(service: BackendStartupReadinessService) { - return waitForBackendStartupReadyEffect().pipe( - Effect.provideService(BackendStartupReadiness, service), - ); -} - -describe("waitForBackendStartupReadyEffect", () => { - it.effect("falls back to the HTTP probe when no listening signal exists", () => - Effect.gen(function* () { - let httpCalls = 0; - let cancelCalls = 0; - - const source = yield* runWithStartupReadiness({ - listening: Option.none(), - httpReady: Effect.sync(() => { - httpCalls += 1; - }), - cancelHttpWait: Effect.sync(() => { - cancelCalls += 1; - }), - }); - - assert.equal(source, "http"); - assert.equal(httpCalls, 1); - assert.equal(cancelCalls, 0); - }), - ); - - it.effect("uses the listening signal and cancels the HTTP probe", () => - Effect.gen(function* () { - let cancelCalls = 0; - - const source = yield* runWithStartupReadiness({ - listening: Option.some(Effect.void), - httpReady: Effect.never, - cancelHttpWait: Effect.sync(() => { - cancelCalls += 1; - }), - }); - - assert.equal(source, "listening"); - assert.equal(cancelCalls, 1); - }), - ); - - it.effect("returns HTTP when the HTTP probe wins before listening", () => - Effect.gen(function* () { - let cancelCalls = 0; - - const source = yield* runWithStartupReadiness({ - listening: Option.some(Effect.never), - httpReady: Effect.void, - cancelHttpWait: Effect.sync(() => { - cancelCalls += 1; - }), - }); - - assert.equal(source, "http"); - assert.equal(cancelCalls, 0); - }), - ); - - it.effect("fails when the listening signal fails before HTTP readiness", () => - Effect.gen(function* () { - const error = new Error("backend exited"); - let cancelCalls = 0; - - const result = yield* Effect.result( - runWithStartupReadiness({ - listening: Option.some(Effect.fail(error)), - httpReady: Effect.never, - cancelHttpWait: Effect.sync(() => { - cancelCalls += 1; - }), - }), - ); - - assert.isTrue(Result.isFailure(result)); - if (Result.isFailure(result)) { - assert.strictEqual(result.failure, error); - } - assert.equal(cancelCalls, 1); - }), - ); - - it.effect("keeps the promise shim compatible with existing callers", () => - Effect.callback((resume) => { - let cancelCalls = 0; - let rejectHttpWait: ((error: unknown) => void) | undefined; - - waitForBackendStartupReady({ - listeningPromise: Promise.resolve(), - waitForHttpReady: () => - new Promise((_resolve, reject) => { - rejectHttpWait = reject; - }), - cancelHttpWait: () => { - cancelCalls += 1; - rejectHttpWait?.(new Error("cancelled")); - }, - }).then( - (source) => { - assert.equal(source, "listening"); - assert.equal(cancelCalls, 1); - resume(Effect.void); - }, - (error) => resume(Effect.fail(error)), - ); - }), - ); -}); diff --git a/apps/desktop/src/backendStartupReadiness.ts b/apps/desktop/src/backendStartupReadiness.ts deleted file mode 100644 index c467657408e..00000000000 --- a/apps/desktop/src/backendStartupReadiness.ts +++ /dev/null @@ -1,87 +0,0 @@ -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; - -export type BackendStartupReadySource = "listening" | "http"; - -export interface BackendStartupReadinessService { - readonly listening: Option.Option>; - readonly httpReady: Effect.Effect; - readonly cancelHttpWait: Effect.Effect; -} - -export class BackendStartupReadiness extends Context.Service< - BackendStartupReadiness, - BackendStartupReadinessService ->()("@t3tools/desktop/BackendStartupReadiness") {} - -export interface WaitForBackendStartupReadyOptions { - readonly listeningPromise?: Promise | null; - readonly waitForHttpReady: () => Promise; - readonly cancelHttpWait: () => void; -} - -function fromPromise(promise: Promise): Effect.Effect { - return Effect.callback((resume) => { - promise.then( - () => resume(Effect.void), - (error) => resume(Effect.fail(error)), - ); - }); -} - -function fromPromiseThunk(thunk: () => Promise): Effect.Effect { - return Effect.callback((resume) => { - try { - thunk().then( - () => resume(Effect.void), - (error) => resume(Effect.fail(error)), - ); - } catch (error) { - resume(Effect.fail(error)); - } - }); -} - -export function waitForBackendStartupReadyEffect(): Effect.Effect< - BackendStartupReadySource, - unknown, - BackendStartupReadiness -> { - return Effect.gen(function* () { - const readiness = yield* BackendStartupReadiness; - const httpReady = readiness.httpReady.pipe(Effect.as("http" as const)); - - if (Option.isNone(readiness.listening)) { - return yield* httpReady.pipe(Effect.onInterrupt(() => readiness.cancelHttpWait)); - } - - const listeningReady = readiness.listening.value.pipe( - Effect.matchEffect({ - onFailure: (error) => readiness.cancelHttpWait.pipe(Effect.andThen(Effect.fail(error))), - onSuccess: () => readiness.cancelHttpWait.pipe(Effect.as("listening" as const)), - }), - ); - - return yield* Effect.raceFirst(listeningReady, httpReady).pipe( - Effect.onInterrupt(() => readiness.cancelHttpWait), - ); - }); -} - -/** - * @deprecated - Temporary promise shim until remaining desktop entrypoint is ported to Effect. - */ -export function waitForBackendStartupReady( - options: WaitForBackendStartupReadyOptions, -): Promise { - return Effect.runPromise( - waitForBackendStartupReadyEffect().pipe( - Effect.provideService(BackendStartupReadiness, { - listening: Option.fromNullishOr(options.listeningPromise).pipe(Option.map(fromPromise)), - httpReady: fromPromiseThunk(options.waitForHttpReady), - cancelHttpWait: Effect.sync(options.cancelHttpWait), - }), - ), - ); -} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 2c36a780897..d2c80608d2f 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,10 +1,14 @@ -import * as ChildProcess from "node:child_process"; +import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Crypto from "node:crypto"; import * as FS from "node:fs"; import * as OS from "node:os"; import * as Path from "node:path"; -import * as Option from "effect/Option"; import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import { app, @@ -58,7 +62,7 @@ import { writeSavedEnvironmentRegistry, writeSavedEnvironmentSecret, } from "./clientPersistence.ts"; -import { BackendReadinessAbortedError, waitForHttpReady } from "./backendReadiness.ts"; +import { runBackendProcess } from "./backendProcess.ts"; import { showDesktopConfirmDialog } from "./confirmDialog.ts"; import { resolveDesktopCoreAdvertisedEndpoints, @@ -66,10 +70,8 @@ import { } from "./serverExposure.ts"; import { DesktopSshEnvironmentBridge, resolveRemoteT3CliPackageSpec } from "./sshEnvironment.ts"; import { syncShellEnvironment } from "./syncShellEnvironment.ts"; -import { waitForBackendStartupReady } from "./backendStartupReadiness.ts"; import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts"; import { doesVersionMatchDesktopUpdateChannel } from "./updateChannels.ts"; -import { ServerListeningDetector } from "./serverListeningDetector.ts"; import { createInitialDesktopUpdateState, reduceDesktopUpdateStateOnCheckFailure, @@ -217,9 +219,9 @@ type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; type LinuxDesktopNamedApp = Electron.App & { setDesktopName?: (desktopName: string) => void; }; - let mainWindow: BrowserWindow | null = null; -let backendProcess: ChildProcess.ChildProcess | null = null; +let backendProcessFiber: Fiber.Fiber | null = null; +let backendReady = false; let backendPort = 0; let backendBindHost = DESKTOP_LOOPBACK_HOST; let backendBootstrapToken = ""; @@ -227,9 +229,6 @@ let backendHttpUrl: Option.Option = Option.none(); let backendWsUrl = ""; let backendEndpointUrl: string | null = null; let backendAdvertisedHost: string | null = null; -let backendReadinessAbortController: AbortController | null = null; -let backendInitialWindowOpenInFlight: Promise | null = null; -let backendListeningDetector: ServerListeningDetector | null = null; let restartAttempt = 0; let restartTimer: ReturnType | null = null; let isQuitting = false; @@ -243,7 +242,7 @@ let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH, app.getVersion( let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode; let destructiveMenuIconCache: Electron.NativeImage | null | undefined; -const expectedBackendExitChildren = new WeakSet(); +const backendProcessLayer = Layer.merge(NodeServices.layer, NodeHttpClient.layerUndici); function requireBackendHttpUrl(): URL { return Option.getOrThrowWith( @@ -445,7 +444,6 @@ function relaunchDesktopApp(reason: string): void { setImmediate(() => { isQuitting = true; clearUpdatePollTimer(); - cancelBackendReadinessWait(); void stopBackendAndWaitForExit() .catch((error) => { writeDesktopLogHeader( @@ -515,73 +513,24 @@ function getSafeTheme(rawTheme: unknown): DesktopTheme | null { return null; } -async function waitForBackendHttpReady( - baseUrl: URL, - options?: Parameters[1], -): Promise { - cancelBackendReadinessWait(); - const controller = new AbortController(); - backendReadinessAbortController = controller; - - try { - await waitForHttpReady(baseUrl, { - ...options, - signal: controller.signal, - }); - } finally { - if (backendReadinessAbortController === controller) { - backendReadinessAbortController = null; - } - } -} - -function cancelBackendReadinessWait(): void { - backendReadinessAbortController?.abort(); - backendReadinessAbortController = null; -} - -async function waitForBackendWindowReady(baseUrl: URL): Promise<"listening" | "http"> { - return await waitForBackendStartupReady({ - listeningPromise: backendListeningDetector?.promise ?? null, - waitForHttpReady: () => - waitForBackendHttpReady(baseUrl, { - timeout: Duration.minutes(1), - }), - cancelHttpWait: cancelBackendReadinessWait, - }); -} +function handleBackendReady(): void { + backendReady = true; + writeDesktopLogHeader("bootstrap backend ready source=http"); -function ensureInitialBackendWindowOpen(): void { const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null; - if (isDevelopment || existingWindow !== null || backendInitialWindowOpenInFlight !== null) { + if (isDevelopment || existingWindow !== null) { return; } - const nextOpen = waitForBackendWindowReady(requireBackendHttpUrl()) - .then((source) => { - writeDesktopLogHeader(`bootstrap backend ready source=${source}`); - if (mainWindow ?? BrowserWindow.getAllWindows()[0]) { - return; - } - mainWindow = createWindow(); - writeDesktopLogHeader("bootstrap main window created"); - }) - .catch((error) => { - if (BackendReadinessAbortedError.is(error)) { - return; - } - writeDesktopLogHeader( - `bootstrap backend readiness warning message=${formatErrorMessage(error)}`, - ); - console.warn("[desktop] backend readiness check timed out during packaged bootstrap", error); - }) - .finally(() => { - if (backendInitialWindowOpenInFlight === nextOpen) { - backendInitialWindowOpenInFlight = null; - } - }); + mainWindow = createWindow(); + writeDesktopLogHeader("bootstrap main window created"); +} - backendInitialWindowOpenInFlight = nextOpen; +function createBackendWindowIfReady(): void { + if (!backendReady) return; + const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null; + if (existingWindow !== null) return; + mainWindow = createWindow(); } function writeDesktopStreamChunk( @@ -660,17 +609,8 @@ function initializePackagedLogging(): void { } } -function captureBackendOutput(child: ChildProcess.ChildProcess): void { - const attachStream = (stream: NodeJS.ReadableStream | null | undefined): void => { - stream?.on("data", (chunk: unknown) => { - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8"); - backendLogSink?.write(buffer); - backendListeningDetector?.push(buffer); - }); - }; - - attachStream(child.stdout); - attachStream(child.stderr); +function writeBackendOutputChunk(_streamName: "stdout" | "stderr", chunk: Uint8Array): void { + backendLogSink?.write(Buffer.from(chunk)); } initializePackagedLogging(); @@ -1471,9 +1411,17 @@ function scheduleBackendRestart(reason: string): void { }, delayMs); } +function clearBackendRestartTimer(): void { + if (restartTimer) { + clearTimeout(restartTimer); + restartTimer = null; + } +} + function startBackend(): void { - if (isQuitting || backendProcess) return; + if (isQuitting || backendProcessFiber !== null) return; + backendReady = false; backendObservabilitySettings = readPersistedBackendObservabilitySettings(); const backendEntry = resolveBackendEntry(); if (!FS.existsSync(backendEntry)) { @@ -1482,22 +1430,38 @@ function startBackend(): void { } const captureBackendLogs = !isDevelopment; - const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], { - cwd: resolveBackendCwd(), - // In Electron main, process.execPath points to the Electron binary. - // Run the child in Node mode so this backend process does not become a GUI app instance. - env: { - ...backendChildEnv(), - ELECTRON_RUN_AS_NODE: "1", - }, - stdio: captureBackendLogs - ? ["ignore", "pipe", "pipe", "pipe"] - : ["ignore", "inherit", "inherit", "pipe"], - }); - const bootstrapStream = child.stdio[3]; - if (bootstrapStream && "write" in bootstrapStream) { - bootstrapStream.write( - `${JSON.stringify({ + let backendSessionClosed = false; + let backendSessionStarted = false; + let backendPid: number | null = null; + let startedFiber: Fiber.Fiber | null = null; + const closeBackendSession = (details: string) => { + if (backendSessionClosed || !backendSessionStarted) return; + backendSessionClosed = true; + writeBackendSessionBoundary("END", details); + }; + + const clearStartedBackendState = (): void => { + if (backendProcessFiber === startedFiber) { + backendProcessFiber = null; + } + backendReady = false; + }; + + const finalizeBackendSession = (details: string): void => { + clearStartedBackendState(); + closeBackendSession(details); + }; + + const program = Effect.scoped( + runBackendProcess({ + executablePath: process.execPath, + entryPath: backendEntry, + cwd: resolveBackendCwd(), + env: { + ...backendChildEnv(), + ELECTRON_RUN_AS_NODE: "1", + }, + bootstrap: { mode: "desktop", noBrowser: true, port: backendPort, @@ -1512,147 +1476,89 @@ function startBackend(): void { ...(backendObservabilitySettings.otlpMetricsUrl ? { otlpMetricsUrl: backendObservabilitySettings.otlpMetricsUrl } : {}), - })}\n`, - ); - bootstrapStream.end(); - } else { - child.kill("SIGTERM"); - scheduleBackendRestart("missing desktop bootstrap pipe"); - return; - } - const listeningDetector = new ServerListeningDetector(); - backendListeningDetector = listeningDetector; - backendProcess = child; - let backendSessionClosed = false; - const closeBackendSession = (details: string) => { - if (backendSessionClosed) return; - backendSessionClosed = true; - writeBackendSessionBoundary("END", details); - }; - writeBackendSessionBoundary( - "START", - `pid=${child.pid ?? "unknown"} port=${backendPort} cwd=${resolveBackendCwd()}`, + }, + httpBaseUrl: requireBackendHttpUrl(), + captureOutput: captureBackendLogs, + onStarted: (pid) => + Effect.sync(() => { + backendPid = pid; + backendSessionStarted = true; + restartAttempt = 0; + writeBackendSessionBoundary( + "START", + `pid=${pid} port=${backendPort} cwd=${resolveBackendCwd()}`, + ); + }), + onReady: () => Effect.sync(handleBackendReady), + onReadinessFailure: (error) => + Effect.sync(() => { + writeDesktopLogHeader( + `bootstrap backend readiness warning message=${formatErrorMessage(error)}`, + ); + console.warn("[desktop] backend readiness check failed during bootstrap", error); + }), + onOutput: (streamName, chunk) => + Effect.sync(() => { + writeBackendOutputChunk(streamName, chunk); + }), + }).pipe( + Effect.match({ + onFailure: (error) => { + const message = formatErrorMessage(error); + finalizeBackendSession(`pid=${backendPid ?? "unknown"} error=${message}`); + if (isQuitting) { + return; + } + scheduleBackendRestart(message); + }, + onSuccess: (exit) => { + finalizeBackendSession(`pid=${backendPid ?? "unknown"} ${exit.reason}`); + if (isQuitting) { + return; + } + scheduleBackendRestart(exit.reason); + }, + }), + ), + ).pipe( + Effect.ensuring( + Effect.sync(() => { + finalizeBackendSession(`pid=${backendPid ?? "unknown"} interrupted`); + }), + ), + Effect.provide(backendProcessLayer), ); - captureBackendOutput(child); - child.once("spawn", () => { - restartAttempt = 0; - }); - - child.on("error", (error) => { - if (backendListeningDetector === listeningDetector) { - listeningDetector.fail(error); - backendListeningDetector = null; - } - const wasExpected = expectedBackendExitChildren.has(child); - if (backendProcess === child) { - backendProcess = null; - } - closeBackendSession(`pid=${child.pid ?? "unknown"} error=${error.message}`); - if (wasExpected) { - return; - } - scheduleBackendRestart(error.message); - }); - - child.on("exit", (code, signal) => { - if (backendListeningDetector === listeningDetector) { - listeningDetector.fail( - new Error( - `backend exited before logging readiness (code=${code ?? "null"} signal=${signal ?? "null"})`, - ), - ); - backendListeningDetector = null; - } - const wasExpected = expectedBackendExitChildren.has(child); - if (backendProcess === child) { - backendProcess = null; - } - closeBackendSession( - `pid=${child.pid ?? "unknown"} code=${code ?? "null"} signal=${signal ?? "null"}`, - ); - if (isQuitting || wasExpected) return; - const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`; - scheduleBackendRestart(reason); - }); - - ensureInitialBackendWindowOpen(); + startedFiber = Effect.runFork(program); + backendProcessFiber = startedFiber; } function stopBackend(): void { - cancelBackendReadinessWait(); - backendListeningDetector = null; - if (restartTimer) { - clearTimeout(restartTimer); - restartTimer = null; - } - - const child = backendProcess; - backendProcess = null; - if (!child) return; + clearBackendRestartTimer(); + backendReady = false; - if (child.exitCode === null && child.signalCode === null) { - expectedBackendExitChildren.add(child); - child.kill("SIGTERM"); - setTimeout(() => { - if (child.exitCode === null && child.signalCode === null) { - child.kill("SIGKILL"); - } - }, 2_000).unref(); + const fiber = backendProcessFiber; + backendProcessFiber = null; + if (fiber !== null) { + Effect.runFork(Fiber.interrupt(fiber).pipe(Effect.ignore)); } } async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise { - cancelBackendReadinessWait(); - if (restartTimer) { - clearTimeout(restartTimer); - restartTimer = null; - } - - const child = backendProcess; - backendProcess = null; - if (!child) return; - const backendChild = child; - if (backendChild.exitCode !== null || backendChild.signalCode !== null) return; - expectedBackendExitChildren.add(backendChild); - - await new Promise((resolve) => { - let settled = false; - let forceKillTimer: ReturnType | null = null; - let exitTimeoutTimer: ReturnType | null = null; - - function settle(): void { - if (settled) return; - settled = true; - backendChild.off("exit", onExit); - if (forceKillTimer) { - clearTimeout(forceKillTimer); - } - if (exitTimeoutTimer) { - clearTimeout(exitTimeoutTimer); - } - resolve(); - } - - function onExit(): void { - settle(); - } - - backendChild.once("exit", onExit); - backendChild.kill("SIGTERM"); - - forceKillTimer = setTimeout(() => { - if (backendChild.exitCode === null && backendChild.signalCode === null) { - backendChild.kill("SIGKILL"); - } - }, 2_000); - forceKillTimer.unref(); - - exitTimeoutTimer = setTimeout(() => { - settle(); - }, timeoutMs); - exitTimeoutTimer.unref(); - }); + clearBackendRestartTimer(); + backendReady = false; + + const fiber = backendProcessFiber; + backendProcessFiber = null; + if (fiber === null) return; + + await Effect.runPromise( + Fiber.interrupt(fiber).pipe( + Effect.timeoutOption(Duration.millis(timeoutMs)), + Effect.asVoid, + Effect.ignore, + ), + ); } function registerIpcHandlers(): void { @@ -2221,23 +2127,7 @@ async function bootstrap(): Promise { if (isDevelopment) { mainWindow = createWindow(); writeDesktopLogHeader("bootstrap main window created"); - void waitForBackendWindowReady(requireBackendHttpUrl()) - .then((source) => { - writeDesktopLogHeader(`bootstrap backend ready source=${source}`); - }) - .catch((error) => { - if (BackendReadinessAbortedError.is(error)) { - return; - } - writeDesktopLogHeader( - `bootstrap backend readiness warning message=${formatErrorMessage(error)}`, - ); - console.warn("[desktop] backend readiness check timed out during dev bootstrap", error); - }); - return; } - - ensureInitialBackendWindowOpen(); } app.on("before-quit", () => { @@ -2245,7 +2135,6 @@ app.on("before-quit", () => { updateInstallInFlight = false; writeDesktopLogHeader("before-quit received"); clearUpdatePollTimer(); - cancelBackendReadinessWait(); stopBackend(); void desktopSshEnvironmentBridge.dispose().catch(() => undefined); restoreStdIoCapture?.(); @@ -2260,9 +2149,6 @@ app registerDesktopProtocol(); configureAutoUpdater(); void bootstrap().catch((error) => { - if (BackendReadinessAbortedError.is(error) && isQuitting) { - return; - } handleFatalStartupError("bootstrap", error); }); @@ -2276,7 +2162,7 @@ app mainWindow = createWindow(); return; } - ensureInitialBackendWindowOpen(); + createBackendWindowIfReady(); }); }) .catch((error) => { @@ -2295,7 +2181,6 @@ if (process.platform !== "win32") { isQuitting = true; writeDesktopLogHeader("SIGINT received"); clearUpdatePollTimer(); - cancelBackendReadinessWait(); stopBackend(); void desktopSshEnvironmentBridge.dispose().catch(() => undefined); restoreStdIoCapture?.(); diff --git a/apps/desktop/src/serverListeningDetector.test.ts b/apps/desktop/src/serverListeningDetector.test.ts deleted file mode 100644 index fcf9f50ae96..00000000000 --- a/apps/desktop/src/serverListeningDetector.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { ServerListeningDetector } from "./serverListeningDetector.ts"; - -describe("ServerListeningDetector", () => { - it("resolves when the server logs the listening line", async () => { - const detector = new ServerListeningDetector(); - - detector.push("[01:23:30.571] INFO (#148): Listening on http://0.0.0.0:7011\n"); - - await expect(detector.promise).resolves.toBeUndefined(); - }); - - it("resolves when the listening line arrives across multiple chunks", async () => { - const detector = new ServerListeningDetector(); - - detector.push("[01:23:30.571] INFO (#148): Listen"); - detector.push("ing on http://0.0.0.0:7011\n"); - - await expect(detector.promise).resolves.toBeUndefined(); - }); - - it("rejects when the server exits before logging readiness", async () => { - const detector = new ServerListeningDetector(); - const error = new Error("server exited"); - - detector.fail(error); - - await expect(detector.promise).rejects.toBe(error); - }); -}); diff --git a/apps/desktop/src/serverListeningDetector.ts b/apps/desktop/src/serverListeningDetector.ts deleted file mode 100644 index e738aacc38d..00000000000 --- a/apps/desktop/src/serverListeningDetector.ts +++ /dev/null @@ -1,56 +0,0 @@ -const LISTENING_LOG_FRAGMENT = "Listening on http://"; -const MAX_BUFFER_CHARS = 8_192; - -export class ServerListeningDetector { - private buffer = ""; - private settled = false; - private readonly resolvePromise: () => void; - private readonly rejectPromise: (error: unknown) => void; - readonly promise: Promise; - - constructor() { - let resolvePromise: (() => void) | null = null; - let rejectPromise: ((error: unknown) => void) | null = null; - - this.promise = new Promise((resolve, reject) => { - resolvePromise = resolve; - rejectPromise = reject; - }); - - this.resolvePromise = () => { - if (this.settled) { - return; - } - this.settled = true; - resolvePromise?.(); - }; - this.rejectPromise = (error) => { - if (this.settled) { - return; - } - this.settled = true; - rejectPromise?.(error); - }; - } - - push(chunk: unknown): void { - if (this.settled) { - return; - } - - const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); - this.buffer = `${this.buffer}${text.replace(/\r/g, "")}`; - if (this.buffer.includes(LISTENING_LOG_FRAGMENT)) { - this.resolvePromise(); - return; - } - - if (this.buffer.length > MAX_BUFFER_CHARS) { - this.buffer = this.buffer.slice(-MAX_BUFFER_CHARS); - } - } - - fail(error: unknown): void { - this.rejectPromise(error); - } -} diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index c1bd9f1a189..fbadf2cf4ea 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -1,13 +1,33 @@ import os from "node:os"; import { assert, expect, it } from "@effect/vitest"; -import { ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect"; +import { ConfigProvider, Effect, FileSystem, Layer, Option, Path, Schema } from "effect"; +import { + DesktopBackendBootstrap, + type DesktopBackendBootstrap as DesktopBackendBootstrapValue, +} from "@t3tools/contracts"; import { NetService } from "@t3tools/shared/Net"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { deriveServerPaths } from "../config.ts"; import { resolveServerConfig } from "./config.ts"; +const encodeDesktopBootstrap = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); + +const makeDesktopBootstrap = ( + overrides: Partial = {}, +): DesktopBackendBootstrapValue => ({ + mode: "desktop", + noBrowser: true, + port: 4888, + t3Home: "/tmp/t3-bootstrap-home", + host: "127.0.0.1", + desktopBootstrapToken: "desktop-bootstrap-token", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + ...overrides, +}); + it.layer(NodeServices.layer)("cli config resolution", (it) => { const defaultObservabilityConfig = { traceMinLevel: "Info", @@ -21,10 +41,11 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { otlpServiceName: "t3-server", } as const; - const openBootstrapFd = Effect.fn(function* (payload: Record) { + const openBootstrapFd = Effect.fn(function* (payload: DesktopBackendBootstrapValue) { const fs = yield* FileSystem.FileSystem; const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); - yield* fs.writeFileString(filePath, `${JSON.stringify(payload)}\n`); + const encoded = yield* encodeDesktopBootstrap(payload); + yield* fs.writeFileString(filePath, `${encoded}\n`); const { fd } = yield* fs.open(filePath, { flag: "r" }); return fd; }); @@ -165,13 +186,13 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { Effect.gen(function* () { const { join } = yield* Path.Path; const baseDir = join(os.tmpdir(), "t3-cli-config-false-flags"); - const fd = yield* openBootstrapFd({ - noBrowser: true, - autoBootstrapProjectFromCwd: true, - logWebSocketEvents: true, - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }); + const fd = yield* openBootstrapFd( + makeDesktopBootstrap({ + noBrowser: true, + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }), + ); const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173")); const resolved = yield* resolveServerConfig( @@ -221,7 +242,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { devUrl: new URL("http://127.0.0.1:4173"), noBrowser: false, startupPresentation: "browser", - desktopBootstrapToken: undefined, + desktopBootstrapToken: "desktop-bootstrap-token", autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, tailscaleServeEnabled: false, @@ -234,21 +255,20 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { Effect.gen(function* () { const { join } = yield* Path.Path; const baseDir = "/tmp/t3-bootstrap-home"; - const fd = yield* openBootstrapFd({ - mode: "desktop", - port: 4888, - host: "127.0.0.2", - t3Home: baseDir, - devUrl: "http://127.0.0.1:5173", - noBrowser: true, - autoBootstrapProjectFromCwd: false, - logWebSocketEvents: true, - tailscaleServeEnabled: false, - tailscaleServePort: 443, - otlpTracesUrl: "http://localhost:4318/v1/traces", - otlpMetricsUrl: "http://localhost:4318/v1/metrics", - }); - const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173")); + const fd = yield* openBootstrapFd( + makeDesktopBootstrap({ + port: 4888, + host: "127.0.0.2", + t3Home: baseDir, + noBrowser: true, + desktopBootstrapToken: "desktop-token", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + otlpTracesUrl: "http://localhost:4318/v1/traces", + otlpMetricsUrl: "http://localhost:4318/v1/metrics", + }), + ); + const derivedPaths = yield* deriveServerPaths(baseDir, undefined); const resolved = yield* resolveServerConfig( { @@ -292,17 +312,17 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { baseDir, ...derivedPaths, host: "127.0.0.2", - staticDir: undefined, - devUrl: new URL("http://127.0.0.1:5173"), + staticDir: resolved.staticDir, + devUrl: undefined, noBrowser: true, startupPresentation: "browser", - desktopBootstrapToken: undefined, + desktopBootstrapToken: "desktop-token", autoBootstrapProjectFromCwd: false, - logWebSocketEvents: true, + logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, }); - assert.equal(join(baseDir, "dev"), resolved.stateDir); + assert.equal(join(baseDir, "userdata"), resolved.stateDir); }), ); @@ -359,18 +379,17 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { Effect.gen(function* () { const { join } = yield* Path.Path; const baseDir = join(os.tmpdir(), "t3-cli-config-env-wins"); - const fd = yield* openBootstrapFd({ - mode: "desktop", - port: 4888, - host: "127.0.0.2", - t3Home: "/tmp/t3-bootstrap-home", - devUrl: "http://127.0.0.1:5173", - noBrowser: false, - autoBootstrapProjectFromCwd: false, - logWebSocketEvents: false, - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }); + const fd = yield* openBootstrapFd( + makeDesktopBootstrap({ + port: 4888, + host: "127.0.0.2", + t3Home: "/tmp/t3-bootstrap-home", + noBrowser: false, + desktopBootstrapToken: "desktop-token", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }), + ); const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173")); const resolved = yield* resolveServerConfig( @@ -422,7 +441,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { devUrl: new URL("http://127.0.0.1:4173"), noBrowser: true, startupPresentation: "browser", - desktopBootstrapToken: undefined, + desktopBootstrapToken: "desktop-token", autoBootstrapProjectFromCwd: true, logWebSocketEvents: true, tailscaleServeEnabled: false, diff --git a/packages/contracts/src/desktopBootstrap.ts b/packages/contracts/src/desktopBootstrap.ts new file mode 100644 index 00000000000..eed5625c65a --- /dev/null +++ b/packages/contracts/src/desktopBootstrap.ts @@ -0,0 +1,16 @@ +import { Schema } from "effect"; + +export const DesktopBackendBootstrap = Schema.Struct({ + mode: Schema.Literal("desktop"), + noBrowser: Schema.Boolean, + port: Schema.Number, + t3Home: Schema.String, + host: Schema.String, + desktopBootstrapToken: Schema.String, + tailscaleServeEnabled: Schema.Boolean, + tailscaleServePort: Schema.Number, + otlpTracesUrl: Schema.optional(Schema.String), + otlpMetricsUrl: Schema.optional(Schema.String), +}); + +export type DesktopBackendBootstrap = typeof DesktopBackendBootstrap.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 1a3647eb314..8402c82647d 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,6 +1,7 @@ export * from "./baseSchemas.ts"; export * from "./auth.ts"; export * from "./environment.ts"; +export * from "./desktopBootstrap.ts"; export * from "./remoteAccess.ts"; export * from "./ipc.ts"; export * from "./terminal.ts"; From 91c982f43f8b752b83287d3d3ea15a4b84b6b96a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 11:22:18 -0700 Subject: [PATCH 05/43] Refactor desktop runtime to Effect - Convert backend port and persistence flows to Effect - Add desktop environment, logging, shutdown, and backend manager modules - Expand tests around networking and saved state handling --- apps/desktop/src/backendPort.test.ts | 225 +- apps/desktop/src/backendPort.ts | 91 +- apps/desktop/src/clientPersistence.test.ts | 304 +- apps/desktop/src/clientPersistence.ts | 406 ++- .../desktop/src/desktopBackendManager.test.ts | 165 + apps/desktop/src/desktopBackendManager.ts | 474 +++ apps/desktop/src/desktopEnvironment.test.ts | 88 + apps/desktop/src/desktopEnvironment.ts | 189 + apps/desktop/src/desktopLogger.test.ts | 111 + apps/desktop/src/desktopLogger.ts | 220 ++ apps/desktop/src/desktopNetworkInterfaces.ts | 18 + apps/desktop/src/desktopSettings.test.ts | 314 +- apps/desktop/src/desktopSettings.ts | 123 +- apps/desktop/src/desktopShutdown.test.ts | 43 + apps/desktop/src/desktopShutdown.ts | 30 + apps/desktop/src/main.ts | 3228 ++++++++++------- apps/desktop/src/rotatingFileSink.test.ts | 129 +- apps/desktop/src/serverExposure.ts | 19 +- apps/desktop/src/sshEnvironment.test.ts | 227 +- apps/desktop/src/sshEnvironment.ts | 695 ++-- .../src/tailscaleEndpointProvider.test.ts | 205 +- apps/desktop/src/tailscaleEndpointProvider.ts | 125 +- packages/shared/src/Net.ts | 172 +- 23 files changed, 4943 insertions(+), 2658 deletions(-) create mode 100644 apps/desktop/src/desktopBackendManager.test.ts create mode 100644 apps/desktop/src/desktopBackendManager.ts create mode 100644 apps/desktop/src/desktopEnvironment.test.ts create mode 100644 apps/desktop/src/desktopEnvironment.ts create mode 100644 apps/desktop/src/desktopLogger.test.ts create mode 100644 apps/desktop/src/desktopLogger.ts create mode 100644 apps/desktop/src/desktopNetworkInterfaces.ts create mode 100644 apps/desktop/src/desktopShutdown.test.ts create mode 100644 apps/desktop/src/desktopShutdown.ts diff --git a/apps/desktop/src/backendPort.test.ts b/apps/desktop/src/backendPort.test.ts index 774e31b8066..c1608fd5a0e 100644 --- a/apps/desktop/src/backendPort.test.ts +++ b/apps/desktop/src/backendPort.test.ts @@ -1,109 +1,150 @@ -import { describe, expect, it, vi } from "vitest"; +import { assert, describe, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { NetService } from "@t3tools/shared/Net"; -import { resolveDesktopBackendPort } from "./backendPort.ts"; +import { resolveDesktopBackendPortEffect } from "./backendPort.ts"; -describe("resolveDesktopBackendPort", () => { - it("returns the starting port when it is available", async () => { - const canListenOnHost = vi.fn(async (port: number) => port === 3773); +type ProbeCall = readonly [port: number, host: string]; - await expect( - resolveDesktopBackendPort({ +describe("resolveDesktopBackendPortEffect", () => { + it.effect("returns the starting port when it is available", () => + Effect.gen(function* () { + const calls: ProbeCall[] = []; + const port = yield* resolveDesktopBackendPortEffect({ host: "127.0.0.1", startPort: 3773, - canListenOnHost, - }), - ).resolves.toBe(3773); - - expect(canListenOnHost).toHaveBeenCalledTimes(1); - expect(canListenOnHost).toHaveBeenCalledWith(3773, "127.0.0.1"); - }); - - it("increments sequentially until it finds an available port", async () => { - const canListenOnHost = vi.fn(async (port: number) => port === 3775); - - await expect( - resolveDesktopBackendPort({ + canListenOnHost: (candidatePort, host) => + Effect.sync(() => { + calls.push([candidatePort, host]); + return candidatePort === 3773; + }), + }); + + assert.equal(port, 3773); + assert.deepEqual(calls, [[3773, "127.0.0.1"]]); + }), + ); + + it.effect("increments sequentially until it finds an available port", () => + Effect.gen(function* () { + const calls: ProbeCall[] = []; + const port = yield* resolveDesktopBackendPortEffect({ host: "127.0.0.1", startPort: 3773, - canListenOnHost, - }), - ).resolves.toBe(3775); - - expect(canListenOnHost.mock.calls).toEqual([ - [3773, "127.0.0.1"], - [3774, "127.0.0.1"], - [3775, "127.0.0.1"], - ]); - }); - - it("treats wildcard-bound ports as unavailable even when loopback probing succeeds", async () => { - const canListenOnHost = vi.fn(async (port: number, host: string) => { - if (port === 3773 && host === "127.0.0.1") return true; - if (port === 3773 && host === "0.0.0.0") return false; - return port === 3774; - }); - - await expect( - resolveDesktopBackendPort({ + canListenOnHost: (candidatePort, host) => + Effect.sync(() => { + calls.push([candidatePort, host]); + return candidatePort === 3775; + }), + }); + + assert.equal(port, 3775); + assert.deepEqual(calls, [ + [3773, "127.0.0.1"], + [3774, "127.0.0.1"], + [3775, "127.0.0.1"], + ]); + }), + ); + + it.effect("treats wildcard-bound ports as unavailable even when loopback probing succeeds", () => + Effect.gen(function* () { + const calls: ProbeCall[] = []; + const port = yield* resolveDesktopBackendPortEffect({ host: "127.0.0.1", requiredHosts: ["0.0.0.0"], startPort: 3773, - canListenOnHost, - }), - ).resolves.toBe(3774); - - expect(canListenOnHost.mock.calls).toEqual([ - [3773, "127.0.0.1"], - [3773, "0.0.0.0"], - [3774, "127.0.0.1"], - [3774, "0.0.0.0"], - ]); - }); - - it("checks overlapping hosts sequentially to avoid self-interference", async () => { - let inFlightCount = 0; - const canListenOnHost = vi.fn(async (_port: number, _host: string) => { - inFlightCount += 1; - const overlapped = inFlightCount > 1; - await Promise.resolve(); - inFlightCount -= 1; - return !overlapped; - }); - - await expect( - resolveDesktopBackendPort({ + canListenOnHost: (candidatePort, host) => + Effect.sync(() => { + calls.push([candidatePort, host]); + if (candidatePort === 3773 && host === "127.0.0.1") return true; + if (candidatePort === 3773 && host === "0.0.0.0") return false; + return candidatePort === 3774; + }), + }); + + assert.equal(port, 3774); + assert.deepEqual(calls, [ + [3773, "127.0.0.1"], + [3773, "0.0.0.0"], + [3774, "127.0.0.1"], + [3774, "0.0.0.0"], + ]); + }), + ); + + it.effect("checks overlapping hosts sequentially to avoid self-interference", () => + Effect.gen(function* () { + let inFlightCount = 0; + const calls: ProbeCall[] = []; + const port = yield* resolveDesktopBackendPortEffect({ host: "127.0.0.1", requiredHosts: ["0.0.0.0", "::"], startPort: 3773, maxPort: 3773, - canListenOnHost, - }), - ).resolves.toBe(3773); - - expect(canListenOnHost.mock.calls).toEqual([ - [3773, "127.0.0.1"], - [3773, "0.0.0.0"], - [3773, "::"], - ]); - }); - - it("fails when the scan range is exhausted", async () => { - const canListenOnHost = vi.fn(async () => false); - - await expect( - resolveDesktopBackendPort({ + canListenOnHost: (candidatePort, host) => + Effect.gen(function* () { + calls.push([candidatePort, host]); + inFlightCount += 1; + const overlapped = inFlightCount > 1; + yield* Effect.yieldNow; + inFlightCount -= 1; + return !overlapped; + }), + }); + + assert.equal(port, 3773); + assert.deepEqual(calls, [ + [3773, "127.0.0.1"], + [3773, "0.0.0.0"], + [3773, "::"], + ]); + }), + ); + + it.effect("fails when the scan range is exhausted", () => + Effect.gen(function* () { + const calls: ProbeCall[] = []; + const result = yield* Effect.flip( + resolveDesktopBackendPortEffect({ + host: "127.0.0.1", + startPort: 65_534, + maxPort: 65_535, + canListenOnHost: (candidatePort, host) => + Effect.sync(() => { + calls.push([candidatePort, host]); + return false; + }), + }), + ); + + assert.equal( + result.message, + "No desktop backend port is available on hosts 127.0.0.1 between 65534 and 65535", + ); + assert.deepEqual(calls, [ + [65_534, "127.0.0.1"], + [65_535, "127.0.0.1"], + ]); + }), + ); + + it.effect("uses the injected NetService by default", () => + Effect.gen(function* () { + const port = yield* resolveDesktopBackendPortEffect({ host: "127.0.0.1", - startPort: 65534, - maxPort: 65535, - canListenOnHost, + startPort: 3773, + maxPort: 3773, + }); + + assert.equal(port, 3773); + }).pipe( + Effect.provideService(NetService, { + canListenOnHost: (port) => Effect.succeed(port === 3773), + isPortAvailableOnLoopback: () => Effect.succeed(true), + reserveLoopbackPort: () => Effect.succeed(3773), + findAvailablePort: (preferred) => Effect.succeed(preferred), }), - ).rejects.toThrow( - "No desktop backend port is available on hosts 127.0.0.1 between 65534 and 65535", - ); - - expect(canListenOnHost.mock.calls).toEqual([ - [65534, "127.0.0.1"], - [65535, "127.0.0.1"], - ]); - }); + ), + ); }); diff --git a/apps/desktop/src/backendPort.ts b/apps/desktop/src/backendPort.ts index 1ce90a257fa..d38a5105643 100644 --- a/apps/desktop/src/backendPort.ts +++ b/apps/desktop/src/backendPort.ts @@ -4,20 +4,26 @@ import { NetService } from "@t3tools/shared/Net"; export const DEFAULT_DESKTOP_BACKEND_PORT = 3773; const MAX_TCP_PORT = 65_535; -export interface ResolveDesktopBackendPortOptions { +export interface ResolveDesktopBackendPortEffectOptions { readonly host: string; readonly startPort?: number; readonly maxPort?: number; readonly requiredHosts?: ReadonlyArray; - readonly canListenOnHost?: (port: number, host: string) => Promise; + readonly canListenOnHost?: (port: number, host: string) => Effect.Effect; } -const defaultCanListenOnHost = async (port: number, host: string): Promise => - Effect.service(NetService).pipe( - Effect.flatMap((net) => net.canListenOnHost(port, host)), - Effect.provide(NetService.layer), - Effect.runPromise, - ); +const defaultCanListenOnHostEffect = ( + port: number, + host: string, +): Effect.Effect => + Effect.gen(function* () { + const net = yield* NetService; + return yield* net.canListenOnHost(port, host); + }).pipe(Effect.mapError(toError)); + +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} const isValidPort = (port: number): boolean => Number.isInteger(port) && port >= 1 && port <= MAX_TCP_PORT; @@ -34,50 +40,59 @@ const normalizeHosts = ( ), ); -async function canListenOnAllHosts( +function canListenOnAllHostsEffect( port: number, hosts: ReadonlyArray, - canListenOnHost: (port: number, host: string) => Promise, -): Promise { - for (const candidateHost of hosts) { - if (!(await canListenOnHost(port, candidateHost))) { - return false; + canListenOnHost: (port: number, host: string) => Effect.Effect, +): Effect.Effect { + return Effect.gen(function* () { + for (const candidateHost of hosts) { + if (!(yield* canListenOnHost(port, candidateHost))) { + return false; + } } - } - return true; + return true; + }); } -export async function resolveDesktopBackendPort({ +export function resolveDesktopBackendPortEffect({ host, startPort = DEFAULT_DESKTOP_BACKEND_PORT, maxPort = MAX_TCP_PORT, requiredHosts = [], - canListenOnHost = defaultCanListenOnHost, -}: ResolveDesktopBackendPortOptions): Promise { - if (!isValidPort(startPort)) { - throw new Error(`Invalid desktop backend start port: ${startPort}`); - } + canListenOnHost = defaultCanListenOnHostEffect as ( + port: number, + host: string, + ) => Effect.Effect, +}: ResolveDesktopBackendPortEffectOptions): Effect.Effect { + return Effect.gen(function* () { + if (!isValidPort(startPort)) { + return yield* Effect.fail(new Error(`Invalid desktop backend start port: ${startPort}`)); + } - if (!isValidPort(maxPort)) { - throw new Error(`Invalid desktop backend max port: ${maxPort}`); - } + if (!isValidPort(maxPort)) { + return yield* Effect.fail(new Error(`Invalid desktop backend max port: ${maxPort}`)); + } - if (maxPort < startPort) { - throw new Error(`Desktop backend max port ${maxPort} is below start port ${startPort}`); - } + if (maxPort < startPort) { + return yield* Effect.fail( + new Error(`Desktop backend max port ${maxPort} is below start port ${startPort}`), + ); + } - const hostsToCheck = normalizeHosts(host, requiredHosts); + const hostsToCheck = normalizeHosts(host, requiredHosts); - // Keep desktop startup predictable across app restarts by probing upward from - // the same preferred port instead of picking a fresh ephemeral port. - for (let port = startPort; port <= maxPort; port += 1) { - if (await canListenOnAllHosts(port, hostsToCheck, canListenOnHost)) { - return port; + for (let port = startPort; port <= maxPort; port += 1) { + if (yield* canListenOnAllHostsEffect(port, hostsToCheck, canListenOnHost)) { + return port; + } } - } - throw new Error( - `No desktop backend port is available on hosts ${hostsToCheck.join(", ")} between ${startPort} and ${maxPort}`, - ); + return yield* Effect.fail( + new Error( + `No desktop backend port is available on hosts ${hostsToCheck.join(", ")} between ${startPort} and ${maxPort}`, + ), + ); + }); } diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts index f0c7c30e202..a327641f1b7 100644 --- a/apps/desktop/src/clientPersistence.test.ts +++ b/apps/desktop/src/clientPersistence.test.ts @@ -1,37 +1,66 @@ -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; - +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; import { EnvironmentId, type ClientSettings, type PersistedSavedEnvironmentRecord, } from "@t3tools/contracts"; -import { afterEach, describe, expect, it } from "vitest"; +import { Effect, FileSystem, Path, Schema } from "effect"; import { - readClientSettings, - readSavedEnvironmentRegistry, - readSavedEnvironmentSecret, - removeSavedEnvironmentSecret, - writeClientSettings, - writeSavedEnvironmentRegistry, - writeSavedEnvironmentSecret, + readClientSettingsEffect, + readSavedEnvironmentRegistryEffect, + readSavedEnvironmentSecretEffect, + removeSavedEnvironmentSecretEffect, + writeClientSettingsEffect, + writeSavedEnvironmentRegistryEffect, + writeSavedEnvironmentSecretEffect, type DesktopSecretStorage, } from "./clientPersistence.ts"; -const tempDirectories: string[] = []; +const DesktopSshTargetSchema = Schema.Struct({ + alias: Schema.String, + hostname: Schema.String, + username: Schema.NullOr(Schema.String), + port: Schema.NullOr(Schema.Number), +}); + +const PersistedSavedEnvironmentStorageRecordSchema = Schema.Struct({ + environmentId: EnvironmentId, + label: Schema.String, + httpBaseUrl: Schema.String, + wsBaseUrl: Schema.String, + createdAt: Schema.String, + lastConnectedAt: Schema.NullOr(Schema.String), + desktopSsh: Schema.optional(DesktopSshTargetSchema), + encryptedBearerToken: Schema.optional(Schema.String), +}); -afterEach(() => { - for (const directory of tempDirectories.splice(0)) { - fs.rmSync(directory, { recursive: true, force: true }); - } +const SavedEnvironmentRegistryDocumentSchema = Schema.Struct({ + records: Schema.Array(PersistedSavedEnvironmentStorageRecordSchema), }); -function makeTempPath(fileName: string): string { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "t3-client-persistence-test-")); - tempDirectories.push(directory); - return path.join(directory, fileName); +const decodeSavedEnvironmentRegistryDocument = Schema.decodeEffect( + Schema.fromJsonString(SavedEnvironmentRegistryDocumentSchema), +); + +function makeTempPath(fileName: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-client-persistence-test-", + }); + return path.join(directory, fileName); + }); +} + +function readRegistryDocument(filePath: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const raw = yield* fs.readFileString(filePath); + return yield* decodeSavedEnvironmentRegistryDocument(raw); + }); } function makeSecretStorage(available: boolean): DesktopSecretStorage { @@ -82,168 +111,179 @@ const savedRegistryRecord: PersistedSavedEnvironmentRecord = { }; describe("clientPersistence", () => { - it("persists and reloads client settings", () => { - const settingsPath = makeTempPath("client-settings.json"); + it.effect("persists and reloads client settings", () => + Effect.gen(function* () { + const settingsPath = yield* makeTempPath("client-settings.json"); - writeClientSettings(settingsPath, clientSettings); + yield* writeClientSettingsEffect(settingsPath, clientSettings); - expect(readClientSettings(settingsPath)).toEqual(clientSettings); - }); + const settings = yield* readClientSettingsEffect(settingsPath); + assert.deepEqual(settings, clientSettings); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); - it("persists and reloads saved environment metadata", () => { - const registryPath = makeTempPath("saved-environments.json"); + it.effect("persists and reloads saved environment metadata", () => + Effect.gen(function* () { + const registryPath = yield* makeTempPath("saved-environments.json"); - writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); + yield* writeSavedEnvironmentRegistryEffect(registryPath, [savedRegistryRecord]); - expect(readSavedEnvironmentRegistry(registryPath)).toEqual([savedRegistryRecord]); - }); + const records = yield* readSavedEnvironmentRegistryEffect(registryPath); + assert.deepEqual(records, [savedRegistryRecord]); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); - it("persists encrypted saved environment secrets when encryption is available", () => { - const registryPath = makeTempPath("saved-environments.json"); - const secretStorage = makeSecretStorage(true); + it.effect("persists encrypted saved environment secrets when encryption is available", () => + Effect.gen(function* () { + const registryPath = yield* makeTempPath("saved-environments.json"); + const secretStorage = makeSecretStorage(true); - writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); + yield* writeSavedEnvironmentRegistryEffect(registryPath, [savedRegistryRecord]); - expect( - writeSavedEnvironmentSecret({ + const written = yield* writeSavedEnvironmentSecretEffect({ registryPath, environmentId: savedRegistryRecord.environmentId, secret: "bearer-token", secretStorage, - }), - ).toBe(true); + }); + assert.equal(written, true); - expect( - readSavedEnvironmentSecret({ + const secret = yield* readSavedEnvironmentSecretEffect({ registryPath, environmentId: savedRegistryRecord.environmentId, secretStorage, - }), - ).toBe("bearer-token"); - - expect(JSON.parse(fs.readFileSync(registryPath, "utf8"))).toEqual({ - records: [ - { - ...savedRegistryRecord, - encryptedBearerToken: Buffer.from("enc:bearer-token", "utf8").toString("base64"), - }, - ], - }); - }); - - it("preserves existing secrets when encryption is unavailable", () => { - const registryPath = makeTempPath("saved-environments.json"); - const availableSecretStorage = makeSecretStorage(true); - - writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); - - writeSavedEnvironmentSecret({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secret: "bearer-token", - secretStorage: availableSecretStorage, - }); + }); + assert.equal(secret, "bearer-token"); + + const document = yield* readRegistryDocument(registryPath); + assert.deepEqual(document, { + records: [ + { + ...savedRegistryRecord, + encryptedBearerToken: Buffer.from("enc:bearer-token", "utf8").toString("base64"), + }, + ], + }); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("preserves existing secrets when encryption is unavailable", () => + Effect.gen(function* () { + const registryPath = yield* makeTempPath("saved-environments.json"); + const availableSecretStorage = makeSecretStorage(true); + + yield* writeSavedEnvironmentRegistryEffect(registryPath, [savedRegistryRecord]); + + yield* writeSavedEnvironmentSecretEffect({ + registryPath, + environmentId: savedRegistryRecord.environmentId, + secret: "bearer-token", + secretStorage: availableSecretStorage, + }); - expect( - writeSavedEnvironmentSecret({ + const written = yield* writeSavedEnvironmentSecretEffect({ registryPath, environmentId: savedRegistryRecord.environmentId, secret: "next-token", secretStorage: makeSecretStorage(false), - }), - ).toBe(false); + }); + assert.equal(written, false); - expect( - readSavedEnvironmentSecret({ + const secret = yield* readSavedEnvironmentSecretEffect({ registryPath, environmentId: savedRegistryRecord.environmentId, secretStorage: availableSecretStorage, - }), - ).toBe("bearer-token"); - }); - - it("removes saved environment secrets", () => { - const registryPath = makeTempPath("saved-environments.json"); - const secretStorage = makeSecretStorage(true); + }); + assert.equal(secret, "bearer-token"); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); - writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); + it.effect("removes saved environment secrets", () => + Effect.gen(function* () { + const registryPath = yield* makeTempPath("saved-environments.json"); + const secretStorage = makeSecretStorage(true); - writeSavedEnvironmentSecret({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secret: "bearer-token", - secretStorage, - }); - - removeSavedEnvironmentSecret({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - }); + yield* writeSavedEnvironmentRegistryEffect(registryPath, [savedRegistryRecord]); - expect( - readSavedEnvironmentSecret({ + yield* writeSavedEnvironmentSecretEffect({ registryPath, environmentId: savedRegistryRecord.environmentId, + secret: "bearer-token", secretStorage, - }), - ).toBeNull(); - }); + }); - it("treats malformed secrets documents as empty", () => { - const registryPath = makeTempPath("saved-environments.json"); - fs.writeFileSync(registryPath, "{}\n", "utf8"); + yield* removeSavedEnvironmentSecretEffect({ + registryPath, + environmentId: savedRegistryRecord.environmentId, + }); - expect( - readSavedEnvironmentSecret({ + const secret = yield* readSavedEnvironmentSecretEffect({ + registryPath, + environmentId: savedRegistryRecord.environmentId, + secretStorage, + }); + assert.equal(secret, null); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("treats malformed secrets documents as empty", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const registryPath = yield* makeTempPath("saved-environments.json"); + yield* fs.writeFileString(registryPath, "{}\n"); + + const secret = yield* readSavedEnvironmentSecretEffect({ registryPath, environmentId: savedRegistryRecord.environmentId, secretStorage: makeSecretStorage(true), - }), - ).toBeNull(); + }); + assert.equal(secret, null); - expect(() => - removeSavedEnvironmentSecret({ + yield* removeSavedEnvironmentSecretEffect({ registryPath, environmentId: savedRegistryRecord.environmentId, - }), - ).not.toThrow(); - }); + }); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); - it("returns false when writing a secret without metadata", () => { - const registryPath = makeTempPath("saved-environments.json"); + it.effect("returns false when writing a secret without metadata", () => + Effect.gen(function* () { + const registryPath = yield* makeTempPath("saved-environments.json"); - expect( - writeSavedEnvironmentSecret({ + const written = yield* writeSavedEnvironmentSecretEffect({ registryPath, environmentId: savedRegistryRecord.environmentId, secret: "bearer-token", secretStorage: makeSecretStorage(true), - }), - ).toBe(false); - }); + }); + assert.equal(written, false); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); - it("preserves encrypted secrets when metadata is rewritten", () => { - const registryPath = makeTempPath("saved-environments.json"); - const secretStorage = makeSecretStorage(true); + it.effect("preserves encrypted secrets when metadata is rewritten", () => + Effect.gen(function* () { + const registryPath = yield* makeTempPath("saved-environments.json"); + const secretStorage = makeSecretStorage(true); - writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); + yield* writeSavedEnvironmentRegistryEffect(registryPath, [savedRegistryRecord]); - writeSavedEnvironmentSecret({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secret: "bearer-token", - secretStorage, - }); + yield* writeSavedEnvironmentSecretEffect({ + registryPath, + environmentId: savedRegistryRecord.environmentId, + secret: "bearer-token", + secretStorage, + }); - writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); + yield* writeSavedEnvironmentRegistryEffect(registryPath, [savedRegistryRecord]); - expect(readSavedEnvironmentRegistry(registryPath)).toEqual([savedRegistryRecord]); - expect( - readSavedEnvironmentSecret({ + const records = yield* readSavedEnvironmentRegistryEffect(registryPath); + assert.deepEqual(records, [savedRegistryRecord]); + const secret = yield* readSavedEnvironmentSecretEffect({ registryPath, environmentId: savedRegistryRecord.environmentId, secretStorage, - }), - ).toBe("bearer-token"); - }); + }); + assert.equal(secret, "bearer-token"); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); }); diff --git a/apps/desktop/src/clientPersistence.ts b/apps/desktop/src/clientPersistence.ts index 09a3494dbc7..5a682bb3f98 100644 --- a/apps/desktop/src/clientPersistence.ts +++ b/apps/desktop/src/clientPersistence.ts @@ -1,20 +1,26 @@ -import * as FS from "node:fs"; -import * as Path from "node:path"; - import { ClientSettingsSchema, + EnvironmentId, type ClientSettings, type PersistedSavedEnvironmentRecord, } from "@t3tools/contracts"; -import { Predicate } from "effect"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Random from "effect/Random"; import * as Schema from "effect/Schema"; interface ClientSettingsDocument { readonly settings: ClientSettings; } -interface PersistedSavedEnvironmentStorageRecord extends PersistedSavedEnvironmentRecord { - readonly encryptedBearerToken?: string; +interface PersistedSavedEnvironmentStorageRecord extends Omit< + PersistedSavedEnvironmentRecord, + "desktopSsh" +> { + readonly desktopSsh?: PersistedSavedEnvironmentRecord["desktopSsh"] | undefined; + readonly encryptedBearerToken?: string | undefined; } interface SavedEnvironmentRegistryDocument { @@ -27,57 +33,87 @@ export interface DesktopSecretStorage { readonly decryptString: (value: Buffer) => string; } -function readJsonFile(filePath: string): T | null { - try { - if (!FS.existsSync(filePath)) { - return null; - } - return JSON.parse(FS.readFileSync(filePath, "utf8")) as T; - } catch { - return null; - } -} +const ClientSettingsDocumentSchema = Schema.Struct({ + settings: ClientSettingsSchema, +}); -function writeJsonFile(filePath: string, value: unknown): void { - const directory = Path.dirname(filePath); - const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; - FS.mkdirSync(directory, { recursive: true }); - FS.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); - FS.renameSync(tempPath, filePath); -} +const DesktopSshTargetSchema = Schema.Struct({ + alias: Schema.String, + hostname: Schema.String, + username: Schema.NullOr(Schema.String), + port: Schema.NullOr(Schema.Number), +}); -function isPersistedSavedEnvironmentStorageRecord( - value: unknown, -): value is PersistedSavedEnvironmentStorageRecord { - return ( - Predicate.isObject(value) && - typeof value.environmentId === "string" && - typeof value.label === "string" && - typeof value.httpBaseUrl === "string" && - typeof value.wsBaseUrl === "string" && - typeof value.createdAt === "string" && - (value.lastConnectedAt === null || typeof value.lastConnectedAt === "string") && - (value.desktopSsh === undefined || - (Predicate.isObject(value.desktopSsh) && - typeof value.desktopSsh.alias === "string" && - typeof value.desktopSsh.hostname === "string" && - (value.desktopSsh.username === null || typeof value.desktopSsh.username === "string") && - (value.desktopSsh.port === null || typeof value.desktopSsh.port === "number"))) && - (value.encryptedBearerToken === undefined || typeof value.encryptedBearerToken === "string") - ); +const PersistedSavedEnvironmentStorageRecordSchema = Schema.Struct({ + environmentId: EnvironmentId, + label: Schema.String, + httpBaseUrl: Schema.String, + wsBaseUrl: Schema.String, + createdAt: Schema.String, + lastConnectedAt: Schema.NullOr(Schema.String), + desktopSsh: Schema.optional(DesktopSshTargetSchema), + encryptedBearerToken: Schema.optional(Schema.String), +}); + +const SavedEnvironmentRegistryDocumentSchema = Schema.Struct({ + records: Schema.Array(PersistedSavedEnvironmentStorageRecordSchema), +}); + +const decodeClientSettingsDocumentJson = Schema.decodeEffect( + Schema.fromJsonString(ClientSettingsDocumentSchema), +); +const encodeClientSettingsDocumentJson = Schema.encodeEffect( + Schema.fromJsonString(ClientSettingsDocumentSchema), +); +const decodeSavedEnvironmentRegistryDocumentJson = Schema.decodeEffect( + Schema.fromJsonString(SavedEnvironmentRegistryDocumentSchema), +); +const encodeSavedEnvironmentRegistryDocumentJson = Schema.encodeEffect( + Schema.fromJsonString(SavedEnvironmentRegistryDocumentSchema), +); + +function readJsonFileEffect( + filePath: string, + decode: (raw: string) => Effect.Effect, +): Effect.Effect, never, FileSystem.FileSystem> { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const raw = yield* fileSystem.readFileString(filePath).pipe(Effect.option); + return yield* Option.match(raw, { + onNone: () => Effect.succeed(Option.none()), + onSome: (value) => + decode(value).pipe( + Effect.option, + Effect.map((decoded) => decoded), + ), + }); + }); } -function readSavedEnvironmentRegistryDocument(filePath: string): SavedEnvironmentRegistryDocument { - const parsed = readJsonFile(filePath); - if (!Predicate.isObject(parsed)) { - return { records: [] }; - } +function writeJsonFileEffect( + filePath: string, + value: T, + encode: (value: T) => Effect.Effect, +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = path.dirname(filePath); + const suffix = (yield* Random.nextUUIDv4).replace(/-/g, ""); + const tempPath = `${filePath}.${process.pid}.${suffix}.tmp`; + const encoded = yield* encode(value); + yield* fileSystem.makeDirectory(directory, { recursive: true }); + yield* fileSystem.writeFileString(tempPath, `${encoded}\n`); + yield* fileSystem.rename(tempPath, filePath); + }); +} - return { - records: Array.isArray(parsed.records) - ? parsed.records.filter(isPersistedSavedEnvironmentStorageRecord) - : [], - }; +function readSavedEnvironmentRegistryDocumentEffect( + filePath: string, +): Effect.Effect { + return readJsonFileEffect(filePath, decodeSavedEnvironmentRegistryDocumentJson).pipe( + Effect.map(Option.getOrElse((): SavedEnvironmentRegistryDocument => ({ records: [] }))), + ); } function toPersistedSavedEnvironmentRecord( @@ -94,145 +130,179 @@ function toPersistedSavedEnvironmentRecord( return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; } -export function readClientSettings(settingsPath: string): ClientSettings | null { - const raw = readJsonFile(settingsPath)?.settings; - if (!raw) { - return null; - } - try { - return Schema.decodeUnknownSync(ClientSettingsSchema)(raw); - } catch { - return null; - } +export function readClientSettingsEffect( + settingsPath: string, +): Effect.Effect { + return readJsonFileEffect(settingsPath, decodeClientSettingsDocumentJson).pipe( + Effect.map(Option.match({ onNone: () => null, onSome: (document) => document.settings })), + ); } -export function writeClientSettings(settingsPath: string, settings: ClientSettings): void { - writeJsonFile(settingsPath, { settings } satisfies ClientSettingsDocument); +export function writeClientSettingsEffect( + settingsPath: string, + settings: ClientSettings, +): Effect.Effect { + return writeJsonFileEffect( + settingsPath, + { settings } satisfies ClientSettingsDocument, + encodeClientSettingsDocumentJson, + ); } -export function readSavedEnvironmentRegistry( +export function readSavedEnvironmentRegistryEffect( registryPath: string, -): readonly PersistedSavedEnvironmentRecord[] { - return readSavedEnvironmentRegistryDocument(registryPath).records.map((record) => - toPersistedSavedEnvironmentRecord(record), +): Effect.Effect { + return readSavedEnvironmentRegistryDocumentEffect(registryPath).pipe( + Effect.map((document) => + document.records.map((record) => toPersistedSavedEnvironmentRecord(record)), + ), ); } -export function writeSavedEnvironmentRegistry( +export function writeSavedEnvironmentRegistryEffect( registryPath: string, records: readonly PersistedSavedEnvironmentRecord[], -): void { - const currentDocument = readSavedEnvironmentRegistryDocument(registryPath); - const encryptedBearerTokenById = new Map( - currentDocument.records.flatMap((record) => - record.encryptedBearerToken - ? [[record.environmentId, record.encryptedBearerToken] as const] - : [], - ), - ); - writeJsonFile(registryPath, { - records: records.map((record) => { - const encryptedBearerToken = encryptedBearerTokenById.get(record.environmentId); - return encryptedBearerToken - ? { - environmentId: record.environmentId, - label: record.label, - httpBaseUrl: record.httpBaseUrl, - wsBaseUrl: record.wsBaseUrl, - createdAt: record.createdAt, - lastConnectedAt: record.lastConnectedAt, - ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), - encryptedBearerToken, - } - : record; - }), - } satisfies SavedEnvironmentRegistryDocument); +): Effect.Effect { + return Effect.gen(function* () { + const currentDocument = yield* readSavedEnvironmentRegistryDocumentEffect(registryPath); + const encryptedBearerTokenById = new Map( + currentDocument.records.flatMap((record) => + record.encryptedBearerToken + ? [[record.environmentId, record.encryptedBearerToken] as const] + : [], + ), + ); + yield* writeJsonFileEffect( + registryPath, + { + records: records.map((record) => { + const encryptedBearerToken = encryptedBearerTokenById.get(record.environmentId); + return encryptedBearerToken + ? { + environmentId: record.environmentId, + label: record.label, + httpBaseUrl: record.httpBaseUrl, + wsBaseUrl: record.wsBaseUrl, + createdAt: record.createdAt, + lastConnectedAt: record.lastConnectedAt, + ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), + encryptedBearerToken, + } + : record; + }), + } satisfies SavedEnvironmentRegistryDocument, + encodeSavedEnvironmentRegistryDocumentJson, + ); + }); } -export function readSavedEnvironmentSecret(input: { +export function readSavedEnvironmentSecretEffect(input: { readonly registryPath: string; readonly environmentId: string; readonly secretStorage: DesktopSecretStorage; -}): string | null { - const document = readSavedEnvironmentRegistryDocument(input.registryPath); - const encoded = document.records.find( - (record) => record.environmentId === input.environmentId, - )?.encryptedBearerToken; - if (!encoded) { - return null; - } - - if (!input.secretStorage.isEncryptionAvailable()) { - return null; - } - - try { - return input.secretStorage.decryptString(Buffer.from(encoded, "base64")); - } catch { - return null; - } +}): Effect.Effect { + return Effect.gen(function* () { + const document = yield* readSavedEnvironmentRegistryDocumentEffect(input.registryPath); + const encoded = document.records.find( + (record) => record.environmentId === input.environmentId, + )?.encryptedBearerToken; + if (!encoded) { + return null; + } + + if (!input.secretStorage.isEncryptionAvailable()) { + return null; + } + + return yield* Effect.sync(() => + input.secretStorage.decryptString(Buffer.from(encoded, "base64")), + ).pipe(Effect.catchDefect(() => Effect.succeed(null))); + }); } -export function writeSavedEnvironmentSecret(input: { +export function writeSavedEnvironmentSecretEffect(input: { readonly registryPath: string; readonly environmentId: string; readonly secret: string; readonly secretStorage: DesktopSecretStorage; -}): boolean { - const document = readSavedEnvironmentRegistryDocument(input.registryPath); - - if (!input.secretStorage.isEncryptionAvailable()) { - return false; - } - - let found = false; - - writeJsonFile(input.registryPath, { - records: document.records.map((record) => { - if (record.environmentId !== input.environmentId) { - return record; - } - - found = true; - const encryptedBearerToken = input.secretStorage - .encryptString(input.secret) - .toString("base64"); - const nextRecord = { - environmentId: record.environmentId, - label: record.label, - httpBaseUrl: record.httpBaseUrl, - wsBaseUrl: record.wsBaseUrl, - createdAt: record.createdAt, - lastConnectedAt: record.lastConnectedAt, - encryptedBearerToken, - }; - return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; - }), - } satisfies SavedEnvironmentRegistryDocument); - return found; +}): Effect.Effect { + return Effect.gen(function* () { + const document = yield* readSavedEnvironmentRegistryDocumentEffect(input.registryPath); + + if (!input.secretStorage.isEncryptionAvailable()) { + return false; + } + + let found = false; + + yield* writeJsonFileEffect( + input.registryPath, + { + records: document.records.map((record) => { + if (record.environmentId !== input.environmentId) { + return record; + } + + found = true; + const encryptedBearerToken = input.secretStorage + .encryptString(input.secret) + .toString("base64"); + const nextRecord = { + environmentId: record.environmentId, + label: record.label, + httpBaseUrl: record.httpBaseUrl, + wsBaseUrl: record.wsBaseUrl, + createdAt: record.createdAt, + lastConnectedAt: record.lastConnectedAt, + encryptedBearerToken, + }; + return record.desktopSsh + ? { + environmentId: nextRecord.environmentId, + label: nextRecord.label, + httpBaseUrl: nextRecord.httpBaseUrl, + wsBaseUrl: nextRecord.wsBaseUrl, + createdAt: nextRecord.createdAt, + lastConnectedAt: nextRecord.lastConnectedAt, + encryptedBearerToken: nextRecord.encryptedBearerToken, + desktopSsh: record.desktopSsh, + } + : nextRecord; + }), + } satisfies SavedEnvironmentRegistryDocument, + encodeSavedEnvironmentRegistryDocumentJson, + ); + return found; + }); } -export function removeSavedEnvironmentSecret(input: { +export function removeSavedEnvironmentSecretEffect(input: { readonly registryPath: string; readonly environmentId: string; -}): void { - const document = readSavedEnvironmentRegistryDocument(input.registryPath); - if ( - !document.records.some( - (record) => - record.environmentId === input.environmentId && record.encryptedBearerToken !== undefined, - ) - ) { - return; - } - - writeJsonFile(input.registryPath, { - records: document.records.map((record) => { - if (record.environmentId !== input.environmentId) { - return record; - } - - return toPersistedSavedEnvironmentRecord(record); - }), - } satisfies SavedEnvironmentRegistryDocument); +}): Effect.Effect { + return Effect.gen(function* () { + const document = yield* readSavedEnvironmentRegistryDocumentEffect(input.registryPath); + if ( + !document.records.some( + (record) => + record.environmentId === input.environmentId && record.encryptedBearerToken !== undefined, + ) + ) { + return; + } + + yield* writeJsonFileEffect( + input.registryPath, + { + records: document.records.map((record) => { + if (record.environmentId !== input.environmentId) { + return record; + } + + return toPersistedSavedEnvironmentRecord(record); + }), + } satisfies SavedEnvironmentRegistryDocument, + encodeSavedEnvironmentRegistryDocumentJson, + ); + }); } diff --git a/apps/desktop/src/desktopBackendManager.test.ts b/apps/desktop/src/desktopBackendManager.test.ts new file mode 100644 index 00000000000..53d2ed28515 --- /dev/null +++ b/apps/desktop/src/desktopBackendManager.test.ts @@ -0,0 +1,165 @@ +import { assert, describe, it } from "@effect/vitest"; +import { Deferred, Duration, Effect, FileSystem, Layer, Option, Queue, Scope } from "effect"; +import { TestClock } from "effect/testing"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + DesktopBackendConfiguration, + DesktopBackendEvents, + DesktopBackendManager, + DesktopBackendManagerLive, + DesktopBackendProcessRunner, + type DesktopBackendEventsShape, + type DesktopBackendProcessRunnerShape, + type DesktopBackendStartConfig, +} from "./desktopBackendManager.ts"; + +const baseConfig: DesktopBackendStartConfig = { + executablePath: "/electron", + entryPath: "/server/bin.mjs", + cwd: "/server", + env: { ELECTRON_RUN_AS_NODE: "1" }, + bootstrap: { + mode: "desktop", + noBrowser: true, + port: 3773, + t3Home: "/tmp/t3", + host: "127.0.0.1", + desktopBootstrapToken: "token", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }, + httpBaseUrl: new URL("http://127.0.0.1:3773"), + captureOutput: true, +}; + +function makeManagerLayer(input: { + readonly runner: DesktopBackendProcessRunnerShape; + readonly events?: Partial; + readonly config?: DesktopBackendStartConfig; +}) { + return DesktopBackendManagerLive.pipe( + Layer.provide( + Layer.mergeAll( + FileSystem.layerNoop({ + exists: () => Effect.succeed(true), + }), + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected child process spawn")), + ), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("unexpected HTTP request")), + ), + Layer.succeed(DesktopBackendConfiguration, { + resolve: Effect.succeed(input.config ?? baseConfig), + }), + Layer.succeed(DesktopBackendProcessRunner, input.runner), + Layer.succeed(DesktopBackendEvents, { + onStarting: Effect.void, + onStarted: () => Effect.void, + onReady: Effect.void, + onReadinessFailure: () => Effect.void, + onOutput: () => Effect.void, + onExit: () => Effect.void, + onRestartScheduled: () => Effect.void, + ...input.events, + } satisfies DesktopBackendEventsShape), + ), + ), + ); +} + +describe("DesktopBackendManager", () => { + it.effect("starts the configured backend and closes the scoped process on stop", () => { + return Effect.gen(function* () { + let startCount = 0; + let closedCount = 0; + const closed = yield* Deferred.make(); + const startedPids = yield* Queue.unbounded(); + + const layer = makeManagerLayer({ + events: { + onStarted: ({ pid }) => Queue.offer(startedPids, pid).pipe(Effect.asVoid), + }, + runner: { + run: (options) => + Effect.gen(function* () { + startCount += 1; + const scope = yield* Scope.Scope; + yield* Scope.addFinalizer( + scope, + Effect.sync(() => { + closedCount += 1; + }).pipe(Effect.andThen(Deferred.succeed(closed, void 0))), + ); + yield* options.onStarted?.(123) ?? Effect.void; + yield* options.onReady?.() ?? Effect.void; + yield* Deferred.await(closed); + return { code: 0, reason: "code=0", cause: 0 }; + }), + }, + }); + + yield* Effect.gen(function* () { + const manager = yield* DesktopBackendManager; + yield* manager.start; + assert.equal(yield* Queue.take(startedPids), 123); + + const runningSnapshot = yield* manager.snapshot; + assert.equal(runningSnapshot.ready, true); + assert.deepEqual(runningSnapshot.activePid, Option.some(123)); + + yield* manager.stop(); + assert.equal(startCount, 1); + assert.equal(closedCount, 1); + + const stoppedSnapshot = yield* manager.snapshot; + assert.equal(stoppedSnapshot.desiredRunning, false); + assert.equal(stoppedSnapshot.ready, false); + assert.equal(Option.isNone(stoppedSnapshot.activePid), true); + }).pipe(Effect.provide(layer)); + }); + }); + + it.effect("restarts an unexpectedly exited backend with the Effect clock", () => { + return Effect.gen(function* () { + const starts = yield* Queue.unbounded(); + const restartDelays = yield* Queue.unbounded(); + let startCount = 0; + + const layer = makeManagerLayer({ + events: { + onRestartScheduled: ({ delay }) => + Queue.offer(restartDelays, Duration.toMillis(delay)).pipe(Effect.asVoid), + }, + runner: { + run: (options) => + Effect.gen(function* () { + startCount += 1; + yield* Queue.offer(starts, startCount); + yield* options.onStarted?.(100 + startCount) ?? Effect.void; + return { + code: 1, + reason: `code=1 run=${startCount}`, + cause: ChildProcessSpawner.ExitCode(1), + }; + }), + }, + }); + + yield* Effect.gen(function* () { + const manager = yield* DesktopBackendManager; + yield* manager.start; + + assert.equal(yield* Queue.take(starts), 1); + assert.equal(yield* Queue.take(restartDelays), 500); + + yield* TestClock.adjust(Duration.millis(500)); + assert.equal(yield* Queue.take(starts), 2); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), layer))); + }); + }); +}); diff --git a/apps/desktop/src/desktopBackendManager.ts b/apps/desktop/src/desktopBackendManager.ts new file mode 100644 index 00000000000..689d4329bfc --- /dev/null +++ b/apps/desktop/src/desktopBackendManager.ts @@ -0,0 +1,474 @@ +import type { DesktopBackendBootstrap } from "@t3tools/contracts"; +import { + Context, + Duration, + Effect, + Exit, + Fiber, + FileSystem, + Layer, + Option, + Ref, + Scope, + Semaphore, +} from "effect"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + runBackendProcess, + type BackendProcessExit, + type RunBackendProcessOptions, +} from "./backendProcess.ts"; +import type { BackendTimeoutError } from "./backendReadiness.ts"; + +const INITIAL_RESTART_DELAY = Duration.millis(500); +const MAX_RESTART_DELAY = Duration.seconds(10); + +type BackendRunnerRequirements = + | ChildProcessSpawner.ChildProcessSpawner + | HttpClient.HttpClient + | Scope.Scope; + +export interface DesktopBackendStartConfig { + readonly executablePath: string; + readonly entryPath: string; + readonly cwd: string; + readonly env: Record; + readonly bootstrap: DesktopBackendBootstrap; + readonly httpBaseUrl: URL; + readonly captureOutput: boolean; +} + +export interface DesktopBackendSnapshot { + readonly desiredRunning: boolean; + readonly ready: boolean; + readonly activePid: Option.Option; + readonly restartAttempt: number; + readonly restartScheduled: boolean; + readonly shuttingDown: boolean; +} + +export interface DesktopBackendProcessRunnerShape { + readonly run: ( + options: RunBackendProcessOptions, + ) => Effect.Effect; +} + +export class DesktopBackendProcessRunner extends Context.Service< + DesktopBackendProcessRunner, + DesktopBackendProcessRunnerShape +>()("t3/desktop/BackendProcessRunner") {} + +export const DesktopBackendProcessRunnerLive = Layer.succeed(DesktopBackendProcessRunner, { + run: runBackendProcess, +} satisfies DesktopBackendProcessRunnerShape); + +export interface DesktopBackendConfigurationShape { + readonly resolve: Effect.Effect; +} + +export class DesktopBackendConfiguration extends Context.Service< + DesktopBackendConfiguration, + DesktopBackendConfigurationShape +>()("t3/desktop/BackendConfiguration") {} + +export interface DesktopBackendEventsShape { + readonly onStarting: Effect.Effect; + readonly onStarted: (input: { + readonly pid: number; + readonly config: DesktopBackendStartConfig; + }) => Effect.Effect; + readonly onReady: Effect.Effect; + readonly onReadinessFailure: (error: BackendTimeoutError) => Effect.Effect; + readonly onOutput: (streamName: "stdout" | "stderr", chunk: Uint8Array) => Effect.Effect; + readonly onExit: (input: { + readonly pid: Option.Option; + readonly reason: string; + }) => Effect.Effect; + readonly onRestartScheduled: (input: { + readonly reason: string; + readonly delay: Duration.Duration; + }) => Effect.Effect; +} + +export class DesktopBackendEvents extends Context.Service< + DesktopBackendEvents, + DesktopBackendEventsShape +>()("t3/desktop/BackendEvents") {} + +export const DesktopBackendEventsSilent = Layer.succeed(DesktopBackendEvents, { + onStarting: Effect.void, + onStarted: () => Effect.void, + onReady: Effect.void, + onReadinessFailure: () => Effect.void, + onOutput: () => Effect.void, + onExit: () => Effect.void, + onRestartScheduled: () => Effect.void, +} satisfies DesktopBackendEventsShape); + +export interface DesktopBackendManagerShape { + readonly start: Effect.Effect; + readonly stop: (options?: { readonly timeout?: Duration.Duration }) => Effect.Effect; + readonly shutdown: Effect.Effect; + readonly snapshot: Effect.Effect; +} + +export class DesktopBackendManager extends Context.Service< + DesktopBackendManager, + DesktopBackendManagerShape +>()("t3/desktop/BackendManager") {} + +interface ActiveBackendRun { + readonly id: number; + readonly scope: Scope.Closeable; + readonly fiber: Option.Option>; + readonly pid: Option.Option; +} + +interface BackendManagerState { + readonly desiredRunning: boolean; + readonly ready: boolean; + readonly active: Option.Option; + readonly restartAttempt: number; + readonly restartFiber: Option.Option>; + readonly nextRunId: number; + readonly shuttingDown: boolean; +} + +const initialState: BackendManagerState = { + desiredRunning: false, + ready: false, + active: Option.none(), + restartAttempt: 0, + restartFiber: Option.none(), + nextRunId: 1, + shuttingDown: false, +}; + +const activePid = (active: Option.Option): Option.Option => + Option.flatMap(active, (run) => run.pid); + +const withActiveRun = + (runId: number, f: (run: ActiveBackendRun) => ActiveBackendRun) => + (state: BackendManagerState): BackendManagerState => ({ + ...state, + active: Option.map(state.active, (run) => (run.id === runId ? f(run) : run)), + }); + +const calculateRestartDelay = (attempt: number): Duration.Duration => + Duration.min(Duration.times(INITIAL_RESTART_DELAY, 2 ** attempt), MAX_RESTART_DELAY); + +const closeRun = ( + run: ActiveBackendRun, + options?: { readonly timeout?: Duration.Duration }, +): Effect.Effect => { + const waitForFiber = Option.match(run.fiber, { + onNone: () => Effect.void, + onSome: (fiber) => Fiber.await(fiber).pipe(Effect.asVoid), + }); + const close = Scope.close(run.scope, Exit.void).pipe(Effect.andThen(waitForFiber)); + + return ( + options?.timeout ? close.pipe(Effect.timeoutOption(options.timeout), Effect.asVoid) : close + ).pipe(Effect.ignore); +}; + +export const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(function* () { + const parentScope = yield* Scope.Scope; + const fileSystem = yield* FileSystem.FileSystem; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const configuration = yield* DesktopBackendConfiguration; + const events = yield* DesktopBackendEvents; + const runner = yield* DesktopBackendProcessRunner; + const state = yield* Ref.make(initialState); + const mutex = yield* Semaphore.make(1); + + const updateActiveRun = (runId: number, f: (run: ActiveBackendRun) => ActiveBackendRun) => + Ref.update(state, withActiveRun(runId, f)); + + const snapshot = Ref.get(state).pipe( + Effect.map( + (current): DesktopBackendSnapshot => ({ + desiredRunning: current.desiredRunning, + ready: current.ready, + activePid: activePid(current.active), + restartAttempt: current.restartAttempt, + restartScheduled: Option.isSome(current.restartFiber), + shuttingDown: current.shuttingDown, + }), + ), + ); + + const cancelRestart = Effect.gen(function* () { + const restartFiber = yield* Ref.modify(state, (current) => [ + current.restartFiber, + { + ...current, + restartFiber: Option.none(), + }, + ]); + + yield* Option.match(restartFiber, { + onNone: () => Effect.void, + onSome: (fiber) => Fiber.interrupt(fiber).pipe(Effect.asVoid), + }); + }); + + const start: Effect.Effect = Effect.suspend(() => + mutex.withPermits(1)( + Effect.gen(function* () { + const current = yield* Ref.get(state); + if (current.shuttingDown || Option.isSome(current.active)) { + return; + } + + yield* events.onStarting; + const config = yield* configuration.resolve.pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + ); + const entryExists = yield* fileSystem + .exists(config.entryPath) + .pipe(Effect.orElseSucceed(() => false)); + + yield* Ref.update(state, (latest) => ({ + ...latest, + desiredRunning: true, + ready: false, + restartFiber: Option.none(), + })); + + if (!entryExists) { + yield* scheduleRestart(`missing server entry at ${config.entryPath}`); + return; + } + + const runScope = yield* Scope.make("sequential"); + const runId = yield* Ref.modify(state, (latest) => [ + latest.nextRunId, + { + ...latest, + active: Option.some({ + id: latest.nextRunId, + scope: runScope, + fiber: Option.none(), + pid: Option.none(), + } satisfies ActiveBackendRun), + nextRunId: latest.nextRunId + 1, + }, + ]); + + const finalizeRun = (reason: string) => + mutex.withPermits(1)( + Effect.gen(function* () { + const { isCurrentRun, nextState, pid } = yield* Ref.modify( + state, + ( + latest, + ): readonly [ + { + readonly isCurrentRun: boolean; + readonly nextState: BackendManagerState; + readonly pid: Option.Option; + }, + BackendManagerState, + ] => { + const currentRun = Option.getOrUndefined(latest.active); + if (currentRun?.id !== runId) { + return [ + { + isCurrentRun: false, + nextState: latest, + pid: Option.none(), + }, + latest, + ] as const; + } + + const next = { + ...latest, + active: Option.none(), + ready: false, + }; + return [ + { + isCurrentRun: true, + nextState: next, + pid: currentRun.pid, + }, + next, + ] as const; + }, + ); + + if (isCurrentRun) { + yield* events.onExit({ + pid, + reason, + }); + } + + if (isCurrentRun && nextState.desiredRunning && !nextState.shuttingDown) { + yield* scheduleRestart(reason); + } + }), + ); + + const program = runner + .run({ + ...config, + onStarted: (pid) => + Effect.gen(function* () { + yield* updateActiveRun(runId, (run) => ({ + ...run, + pid: Option.some(pid), + })); + yield* Ref.update(state, (latest) => ({ + ...latest, + restartAttempt: 0, + })); + yield* events.onStarted({ pid, config }); + }), + onReady: () => + Effect.gen(function* () { + yield* Ref.update(state, (latest) => ({ + ...latest, + ready: Option.match(latest.active, { + onNone: () => latest.ready, + onSome: (run) => (run.id === runId ? true : latest.ready), + }), + })); + yield* events.onReady; + }), + onReadinessFailure: events.onReadinessFailure, + onOutput: events.onOutput, + }) + .pipe( + Scope.provide(runScope), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.matchEffect({ + onFailure: (error) => finalizeRun(formatUnknownError(error)), + onSuccess: (exit) => finalizeRun(exit.reason), + }), + Effect.ensuring(Scope.close(runScope, Exit.void).pipe(Effect.ignore)), + ); + + const fiber = yield* Effect.forkIn(program, parentScope); + yield* updateActiveRun(runId, (run) => ({ + ...run, + fiber: Option.some(fiber), + })); + }), + ), + ); + + const scheduleRestart = (reason: string): Effect.Effect => + Effect.gen(function* () { + const scheduled = yield* Ref.modify(state, (latest) => { + if (latest.shuttingDown || !latest.desiredRunning || Option.isSome(latest.restartFiber)) { + return [Option.none(), latest] as const; + } + + const delay = calculateRestartDelay(latest.restartAttempt); + return [ + Option.some(delay), + { + ...latest, + restartAttempt: latest.restartAttempt + 1, + }, + ] as const; + }); + + yield* Option.match(scheduled, { + onNone: () => Effect.void, + onSome: (delay) => + Effect.gen(function* () { + yield* events.onRestartScheduled({ reason, delay }); + const restartFiber = yield* Effect.forkIn( + Effect.sleep(delay).pipe( + Effect.andThen( + Ref.update(state, (latest) => ({ + ...latest, + restartFiber: Option.none(), + })), + ), + Effect.andThen(start), + Effect.catchCause((cause) => + Effect.logError("desktop backend restart fiber failed", { cause }), + ), + ), + parentScope, + ); + yield* Ref.update(state, (latest) => + Option.isNone(latest.restartFiber) + ? { + ...latest, + restartFiber: Option.some(restartFiber), + } + : latest, + ); + }), + }); + }); + + const stop = (options?: { readonly timeout?: Duration.Duration }): Effect.Effect => + Effect.gen(function* () { + const { active, restartFiber } = yield* mutex.withPermits(1)( + Ref.modify(state, (latest) => [ + { + active: latest.active, + restartFiber: latest.restartFiber, + }, + { + ...latest, + desiredRunning: false, + ready: false, + active: Option.none(), + restartFiber: Option.none>(), + }, + ]), + ); + + yield* Option.match(restartFiber, { + onNone: () => Effect.void, + onSome: (fiber) => Fiber.interrupt(fiber).pipe(Effect.asVoid), + }); + yield* Option.match(active, { + onNone: () => Effect.void, + onSome: (run) => closeRun(run, options), + }); + }); + + const shutdown = Effect.gen(function* () { + yield* Ref.update(state, (latest) => ({ + ...latest, + shuttingDown: true, + desiredRunning: false, + })); + yield* cancelRestart; + yield* stop(); + }); + + yield* Scope.addFinalizer(parentScope, shutdown); + + return DesktopBackendManager.of({ + start, + stop, + shutdown, + snapshot, + }); +}); + +export const DesktopBackendManagerLive = Layer.effect( + DesktopBackendManager, + makeDesktopBackendManager(), +); + +function formatUnknownError(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} diff --git a/apps/desktop/src/desktopEnvironment.test.ts b/apps/desktop/src/desktopEnvironment.test.ts new file mode 100644 index 00000000000..9e790649373 --- /dev/null +++ b/apps/desktop/src/desktopEnvironment.test.ts @@ -0,0 +1,88 @@ +import { assert, describe, it } from "@effect/vitest"; +import { Effect, Option } from "effect"; +import * as EffectPath from "effect/Path"; + +import { makeDesktopEnvironment, resolveDesktopHomeDirectory } from "./desktopEnvironment.ts"; + +const makeEnvironment = (overrides: Partial[0]> = {}) => + makeDesktopEnvironment({ + dirname: "/repo/apps/desktop/dist-electron", + env: {}, + cwd: "/cwd", + platform: "darwin", + processArch: "arm64", + appVersion: "0.0.22", + appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar", + isPackaged: false, + resourcesPath: "/Applications/T3 Code.app/Contents/Resources", + runningUnderArm64Translation: false, + ...overrides, + }).pipe(Effect.provide(EffectPath.layer)); + +describe("DesktopEnvironment", () => { + it("resolves home directory from platform env with cwd fallback", () => { + assert.equal( + resolveDesktopHomeDirectory({ + env: { HOME: " /Users/alice " }, + cwd: "/cwd", + }), + "/Users/alice", + ); + assert.equal(resolveDesktopHomeDirectory({ env: {}, cwd: "/cwd" }), "/cwd"); + }); + + it.effect("derives state paths and development identity inside Effect", () => + Effect.gen(function* () { + const environment = yield* makeEnvironment({ + env: { + HOME: "/Users/alice", + T3CODE_HOME: " /tmp/t3 ", + VITE_DEV_SERVER_URL: " http://localhost:5173 ", + T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH: " /remote/server.mjs ", + }, + }); + + assert.equal(environment.isDevelopment, true); + assert.equal(environment.baseDir, "/tmp/t3"); + assert.equal(environment.stateDir, "/tmp/t3/userdata"); + assert.equal(environment.desktopSettingsPath, "/tmp/t3/userdata/desktop-settings.json"); + assert.equal(environment.clientSettingsPath, "/tmp/t3/userdata/client-settings.json"); + assert.equal( + environment.savedEnvironmentRegistryPath, + "/tmp/t3/userdata/saved-environments.json", + ); + assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json"); + assert.equal(environment.logDir, "/tmp/t3/userdata/logs"); + assert.equal(environment.rootDir, "/repo"); + assert.equal(environment.appRoot, "/repo"); + assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs"); + assert.equal(environment.backendCwd, "/repo"); + assert.equal(environment.appUserModelId, "com.t3tools.t3code.dev"); + assert.equal(environment.linuxWmClass, "t3code-dev"); + assert.deepEqual(environment.devServerUrl, Option.some("http://localhost:5173")); + assert.deepEqual(environment.devRemoteT3ServerEntryPath, Option.some("/remote/server.mjs")); + }), + ); + + it.effect("resolves picker defaults without nullish sentinels", () => + Effect.gen(function* () { + const environment = yield* makeEnvironment({ + env: { HOME: "/Users/alice" }, + }); + + assert.deepEqual(environment.resolvePickFolderDefaultPath(null), Option.none()); + assert.deepEqual( + environment.resolvePickFolderDefaultPath({ initialPath: " " }), + Option.none(), + ); + assert.deepEqual( + environment.resolvePickFolderDefaultPath({ initialPath: "~" }), + Option.some("/Users/alice"), + ); + assert.deepEqual( + environment.resolvePickFolderDefaultPath({ initialPath: "~/project" }), + Option.some("/Users/alice/project"), + ); + }), + ); +}); diff --git a/apps/desktop/src/desktopEnvironment.ts b/apps/desktop/src/desktopEnvironment.ts new file mode 100644 index 00000000000..cebec86cb8d --- /dev/null +++ b/apps/desktop/src/desktopEnvironment.ts @@ -0,0 +1,189 @@ +import type { DesktopAppBranding, DesktopRuntimeInfo } from "@t3tools/contracts"; +import { Context, Effect, Option } from "effect"; +import * as EffectPath from "effect/Path"; + +import { resolveDesktopAppBranding } from "./appBranding.ts"; +import { type DesktopSettings, resolveDefaultDesktopSettings } from "./desktopSettings.ts"; +import { resolveDesktopRuntimeInfo } from "./runtimeArch.ts"; + +export interface MakeDesktopEnvironmentInput { + readonly dirname: string; + readonly env: NodeJS.ProcessEnv; + readonly cwd: string; + readonly platform: NodeJS.Platform; + readonly processArch: string; + readonly appVersion: string; + readonly appPath: string; + readonly isPackaged: boolean; + readonly resourcesPath: string; + readonly runningUnderArm64Translation: boolean; +} + +export interface DesktopEnvironmentShape { + readonly path: EffectPath.Path; + readonly dirname: string; + readonly platform: NodeJS.Platform; + readonly processArch: string; + readonly isPackaged: boolean; + readonly isDevelopment: boolean; + readonly appVersion: string; + readonly appPath: string; + readonly resourcesPath: string; + readonly homeDirectory: string; + readonly baseDir: string; + readonly stateDir: string; + readonly desktopSettingsPath: string; + readonly clientSettingsPath: string; + readonly savedEnvironmentRegistryPath: string; + readonly serverSettingsPath: string; + readonly logDir: string; + readonly rootDir: string; + readonly appRoot: string; + readonly backendEntryPath: string; + readonly backendCwd: string; + readonly preloadPath: string; + readonly appUpdateYmlPath: string; + readonly devServerUrl: Option.Option; + readonly devRemoteT3ServerEntryPath: Option.Option; + readonly branding: DesktopAppBranding; + readonly displayName: string; + readonly appUserModelId: string; + readonly linuxDesktopEntryName: string; + readonly linuxWmClass: string; + readonly userDataDirName: string; + readonly legacyUserDataDirName: string; + readonly defaultDesktopSettings: DesktopSettings; + readonly runtimeInfo: DesktopRuntimeInfo; + readonly resolvePickFolderDefaultPath: (rawOptions: unknown) => Option.Option; + readonly resolveResourcePathCandidates: (fileName: string) => readonly string[]; + readonly developmentDockIconPath: string; +} + +export class DesktopEnvironment extends Context.Service< + DesktopEnvironment, + DesktopEnvironmentShape +>()("t3/desktop/Environment") {} + +const trimmedEnvOption = (env: NodeJS.ProcessEnv, name: string): Option.Option => + (() => { + const value = env[name]?.trim(); + return value && value.length > 0 ? Option.some(value) : Option.none(); + })(); + +export function resolveDesktopHomeDirectory(input: { + readonly env: NodeJS.ProcessEnv; + readonly cwd: string; +}): string { + const home = + input.env.HOME?.trim() || + input.env.USERPROFILE?.trim() || + `${input.env.HOMEDRIVE ?? ""}${input.env.HOMEPATH ?? ""}`.trim(); + return home.length > 0 ? home : input.cwd; +} + +export const makeDesktopEnvironment = ( + input: MakeDesktopEnvironmentInput, +): Effect.Effect => + Effect.gen(function* () { + const path = yield* EffectPath.Path; + const homeDirectory = resolveDesktopHomeDirectory({ + env: input.env, + cwd: input.cwd, + }); + const devServerUrl = trimmedEnvOption(input.env, "VITE_DEV_SERVER_URL"); + const isDevelopment = Option.isSome(devServerUrl); + const baseDir = Option.getOrElse(trimmedEnvOption(input.env, "T3CODE_HOME"), () => + path.join(homeDirectory, ".t3"), + ); + const stateDir = path.join(baseDir, "userdata"); + const rootDir = path.resolve(input.dirname, "../../.."); + const appRoot = input.isPackaged ? input.appPath : rootDir; + const branding = resolveDesktopAppBranding({ + isDevelopment, + appVersion: input.appVersion, + }); + const displayName = branding.displayName; + const userDataDirName = isDevelopment ? "t3code-dev" : "t3code"; + const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; + const resourcesPath = input.resourcesPath; + + const environment: DesktopEnvironmentShape = { + path, + dirname: input.dirname, + platform: input.platform, + processArch: input.processArch, + isPackaged: input.isPackaged, + isDevelopment, + appVersion: input.appVersion, + appPath: input.appPath, + resourcesPath, + homeDirectory, + baseDir, + stateDir, + desktopSettingsPath: path.join(stateDir, "desktop-settings.json"), + clientSettingsPath: path.join(stateDir, "client-settings.json"), + savedEnvironmentRegistryPath: path.join(stateDir, "saved-environments.json"), + serverSettingsPath: path.join(stateDir, "settings.json"), + logDir: path.join(stateDir, "logs"), + rootDir, + appRoot, + backendEntryPath: path.join(appRoot, "apps/server/dist/bin.mjs"), + backendCwd: input.isPackaged ? homeDirectory : appRoot, + preloadPath: path.join(input.dirname, "preload.cjs"), + appUpdateYmlPath: input.isPackaged + ? path.join(resourcesPath, "app-update.yml") + : path.join(input.appPath, "dev-app-update.yml"), + devServerUrl, + devRemoteT3ServerEntryPath: trimmedEnvOption( + input.env, + "T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH", + ), + branding, + displayName, + appUserModelId: isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code", + linuxDesktopEntryName: isDevelopment ? "t3code-dev.desktop" : "t3code.desktop", + linuxWmClass: isDevelopment ? "t3code-dev" : "t3code", + userDataDirName, + legacyUserDataDirName, + defaultDesktopSettings: resolveDefaultDesktopSettings(input.appVersion), + runtimeInfo: resolveDesktopRuntimeInfo({ + platform: input.platform, + processArch: input.processArch, + runningUnderArm64Translation: input.runningUnderArm64Translation, + }), + resolvePickFolderDefaultPath: (rawOptions) => { + if (typeof rawOptions !== "object" || rawOptions === null) { + return Option.none(); + } + + const { initialPath } = rawOptions as { initialPath?: unknown }; + if (typeof initialPath !== "string") { + return Option.none(); + } + + const trimmedPath = initialPath.trim(); + if (trimmedPath.length === 0) { + return Option.none(); + } + + if (trimmedPath === "~") { + return Option.some(homeDirectory); + } + + if (trimmedPath.startsWith("~/") || trimmedPath.startsWith("~\\")) { + return Option.some(path.join(homeDirectory, trimmedPath.slice(2))); + } + + return Option.some(path.resolve(trimmedPath)); + }, + resolveResourcePathCandidates: (fileName) => [ + path.join(input.dirname, "../resources", fileName), + path.join(input.dirname, "../prod-resources", fileName), + path.join(resourcesPath, "resources", fileName), + path.join(resourcesPath, fileName), + ], + developmentDockIconPath: path.join(rootDir, "assets", "dev", "blueprint-macos-1024.png"), + }; + + return environment; + }); diff --git a/apps/desktop/src/desktopLogger.test.ts b/apps/desktop/src/desktopLogger.test.ts new file mode 100644 index 00000000000..4a82b2048a8 --- /dev/null +++ b/apps/desktop/src/desktopLogger.test.ts @@ -0,0 +1,111 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; +import * as EffectPath from "effect/Path"; + +import { DesktopEnvironment, makeDesktopEnvironment } from "./desktopEnvironment.ts"; +import { + DesktopBackendOutputLog, + DesktopBackendOutputLogLive, + DesktopLoggerLive, + makeRotatingLogFileWriter, +} from "./desktopLogger.ts"; + +const textEncoder = new TextEncoder(); + +const makePackagedEnvironment = (baseDir: string) => + makeDesktopEnvironment({ + dirname: "/repo/apps/desktop/dist-electron", + env: { + HOME: baseDir, + T3CODE_HOME: baseDir, + }, + cwd: "/cwd", + platform: "darwin", + processArch: "arm64", + appVersion: "0.0.22", + appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar", + isPackaged: true, + resourcesPath: "/Applications/T3 Code.app/Contents/Resources", + runningUnderArm64Translation: false, + }).pipe(Effect.provide(EffectPath.layer)); + +describe("DesktopLogger", () => { + it.effect("rotates log files through the Effect FileSystem service", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-logger-", + }); + const logPath = path.join(dir, "desktop-main.log"); + const writer = yield* makeRotatingLogFileWriter({ + filePath: logPath, + maxBytes: 8, + maxFiles: 2, + }); + + yield* writer.writeText("12345678"); + yield* writer.writeText("abc"); + + assert.equal(yield* fileSystem.readFileString(logPath), "abc"); + assert.equal( + yield* fileSystem.readFileString(path.join(dir, "desktop-main.log.1")), + "12345678", + ); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("writes packaged desktop Effect logs through the logger layer", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-logger-layer-", + }); + const environment = yield* makePackagedEnvironment(baseDir); + + yield* Effect.logInfo("desktop logger layer test").pipe( + Effect.annotateLogs({ testRun: "desktop-logger-layer" }), + Effect.provide(DesktopLoggerLive), + Effect.provideService(DesktopEnvironment, environment), + Effect.scoped, + ); + + const contents = yield* fileSystem.readFileString( + path.join(environment.logDir, "desktop-main.log"), + ); + assert.match(contents, /desktop logger layer test/); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("writes packaged backend child output through an Effect service", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-output-", + }); + const environment = yield* makePackagedEnvironment(baseDir); + + yield* Effect.gen(function* () { + const outputLog = yield* DesktopBackendOutputLog; + yield* outputLog.writeSessionBoundary({ + phase: "START", + runId: "run-1", + details: "pid=123 cwd=/tmp/project", + }); + yield* outputLog.writeOutputChunk("stdout", textEncoder.encode("server ready\n")); + }).pipe( + Effect.provide(DesktopBackendOutputLogLive), + Effect.provideService(DesktopEnvironment, environment), + ); + + const contents = yield* fileSystem.readFileString( + path.join(environment.logDir, "server-child.log"), + ); + assert.match(contents, /APP SESSION START run=run-1 pid=123 cwd=\/tmp\/project/); + assert.match(contents, /server ready/); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); +}); diff --git a/apps/desktop/src/desktopLogger.ts b/apps/desktop/src/desktopLogger.ts new file mode 100644 index 00000000000..73f89fde384 --- /dev/null +++ b/apps/desktop/src/desktopLogger.ts @@ -0,0 +1,220 @@ +import { + Context, + DateTime, + Duration, + Effect, + FileSystem, + Layer, + Logger, + Option, + Path, + References, + Ref, + Semaphore, +} from "effect"; + +import { DesktopEnvironment } from "./desktopEnvironment.ts"; + +const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; +const DESKTOP_LOG_FILE_MAX_FILES = 10; +const DESKTOP_LOG_BATCH_WINDOW = Duration.millis(250); + +export interface RotatingLogFileWriter { + readonly writeBytes: (chunk: Uint8Array) => Effect.Effect; + readonly writeText: (chunk: string) => Effect.Effect; +} + +export interface DesktopBackendOutputLogShape { + readonly writeSessionBoundary: (input: { + readonly phase: "START" | "END"; + readonly runId: string; + readonly details: string; + }) => Effect.Effect; + readonly writeOutputChunk: ( + streamName: "stdout" | "stderr", + chunk: Uint8Array, + ) => Effect.Effect; +} + +export class DesktopBackendOutputLog extends Context.Service< + DesktopBackendOutputLog, + DesktopBackendOutputLogShape +>()("t3/desktop/BackendOutputLog") {} + +const textEncoder = new TextEncoder(); + +const sanitizeLogValue = (value: string): string => value.replace(/\s+/g, " ").trim(); + +export const DesktopBackendOutputLogNoop: DesktopBackendOutputLogShape = { + writeSessionBoundary: () => Effect.void, + writeOutputChunk: () => Effect.void, +}; + +const refreshFileSize = ( + fileSystem: FileSystem.FileSystem, + filePath: string, +): Effect.Effect => + Effect.gen(function* () { + return yield* fileSystem.stat(filePath).pipe( + Effect.map((stat) => Number(stat.size)), + Effect.orElseSucceed(() => 0), + ); + }); + +export const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(function* (input: { + readonly filePath: string; + readonly maxBytes?: number; + readonly maxFiles?: number; +}): Effect.fn.Return { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const maxBytes = input.maxBytes ?? DESKTOP_LOG_FILE_MAX_BYTES; + const maxFiles = input.maxFiles ?? DESKTOP_LOG_FILE_MAX_FILES; + const directory = path.dirname(input.filePath); + const baseName = path.basename(input.filePath); + + if (maxBytes < 1) { + return yield* Effect.fail(new Error(`maxBytes must be >= 1 (received ${maxBytes})`)); + } + if (maxFiles < 1) { + return yield* Effect.fail(new Error(`maxFiles must be >= 1 (received ${maxFiles})`)); + } + + yield* fileSystem.makeDirectory(directory, { recursive: true }); + + const withSuffix = (index: number) => `${input.filePath}.${index}`; + const currentSize = yield* Ref.make(yield* refreshFileSize(fileSystem, input.filePath)); + const mutex = yield* Semaphore.make(1); + + const pruneOverflowBackups = Effect.gen(function* () { + const entries = yield* fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])); + for (const entry of entries) { + if (!entry.startsWith(`${baseName}.`)) continue; + const suffix = Number(entry.slice(baseName.length + 1)); + if (!Number.isInteger(suffix) || suffix <= maxFiles) continue; + yield* fileSystem.remove(path.join(directory, entry), { force: true }).pipe(Effect.ignore); + } + }); + + const rotate = Effect.gen(function* () { + yield* fileSystem.remove(withSuffix(maxFiles), { force: true }).pipe(Effect.ignore); + for (let index = maxFiles - 1; index >= 1; index -= 1) { + const source = withSuffix(index); + const sourceExists = yield* fileSystem.exists(source).pipe(Effect.orElseSucceed(() => false)); + if (sourceExists) { + yield* fileSystem.rename(source, withSuffix(index + 1)); + } + } + const currentExists = yield* fileSystem + .exists(input.filePath) + .pipe(Effect.orElseSucceed(() => false)); + if (currentExists) { + yield* fileSystem.rename(input.filePath, withSuffix(1)); + } + yield* Ref.set(currentSize, 0); + }).pipe( + Effect.catch(() => + refreshFileSize(fileSystem, input.filePath).pipe( + Effect.flatMap((size) => Ref.set(currentSize, size)), + ), + ), + ); + + const writeBytes = (chunk: Uint8Array): Effect.Effect => { + if (chunk.byteLength === 0) return Effect.void; + + return mutex.withPermits(1)( + Effect.gen(function* () { + const beforeSize = yield* Ref.get(currentSize); + if (beforeSize > 0 && beforeSize + chunk.byteLength > maxBytes) { + yield* rotate; + } + + yield* fileSystem.writeFile(input.filePath, chunk, { flag: "a" }); + const afterSize = (yield* Ref.get(currentSize)) + chunk.byteLength; + yield* Ref.set(currentSize, afterSize); + + if (afterSize > maxBytes) { + yield* rotate; + } + }).pipe( + Effect.catch(() => + refreshFileSize(fileSystem, input.filePath).pipe( + Effect.flatMap((size) => Ref.set(currentSize, size)), + ), + ), + ), + ); + }; + + yield* pruneOverflowBackups; + + return { + writeBytes, + writeText: (chunk) => writeBytes(textEncoder.encode(chunk)), + } satisfies RotatingLogFileWriter; +}); + +const makeDesktopFileLogger = Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + const writer = yield* makeRotatingLogFileWriter({ + filePath: environment.path.join(environment.logDir, "desktop-main.log"), + }); + + return yield* Logger.batched(Logger.formatJson, { + window: DESKTOP_LOG_BATCH_WINDOW, + flush: (messages) => + messages.length === 0 ? Effect.void : writer.writeText(`${messages.join("\n")}\n`), + }); +}); + +export const DesktopLoggerLive = Layer.unwrap( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + const packagedFileLogger = environment.isPackaged + ? yield* makeDesktopFileLogger.pipe(Effect.option) + : Option.none>(); + const loggers: Array> = [ + Logger.consolePretty(), + Logger.tracerLogger, + ]; + + if (Option.isSome(packagedFileLogger)) { + loggers.push(packagedFileLogger.value); + } + + return Layer.mergeAll( + Logger.layer(loggers, { mergeWithExisting: false }), + Layer.succeed(References.MinimumLogLevel, "Info"), + ); + }), +); + +export const DesktopBackendOutputLogLive = Layer.effect( + DesktopBackendOutputLog, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + if (environment.isDevelopment) { + return DesktopBackendOutputLogNoop; + } + + const writer = yield* makeRotatingLogFileWriter({ + filePath: environment.path.join(environment.logDir, "server-child.log"), + }).pipe(Effect.option); + + return Option.match(writer, { + onNone: () => DesktopBackendOutputLogNoop, + onSome: (logFile) => + ({ + writeSessionBoundary: ({ phase, runId, details }) => + Effect.gen(function* () { + const timestamp = DateTime.formatIso(yield* DateTime.now); + yield* logFile.writeText( + `[${timestamp}] ---- APP SESSION ${phase} run=${runId} ${sanitizeLogValue(details)} ----\n`, + ); + }), + writeOutputChunk: (_streamName, chunk) => logFile.writeBytes(chunk), + }) satisfies DesktopBackendOutputLogShape, + }); + }), +); diff --git a/apps/desktop/src/desktopNetworkInterfaces.ts b/apps/desktop/src/desktopNetworkInterfaces.ts new file mode 100644 index 00000000000..68c484d863b --- /dev/null +++ b/apps/desktop/src/desktopNetworkInterfaces.ts @@ -0,0 +1,18 @@ +import * as OS from "node:os"; + +import { Context, Effect, Layer } from "effect"; + +import type { DesktopNetworkInterfaces } from "./serverExposure.ts"; + +export interface DesktopNetworkInterfacesServiceShape { + readonly read: Effect.Effect; +} + +export class DesktopNetworkInterfacesService extends Context.Service< + DesktopNetworkInterfacesService, + DesktopNetworkInterfacesServiceShape +>()("t3/desktop/NetworkInterfaces") {} + +export const DesktopNetworkInterfacesLive = Layer.succeed(DesktopNetworkInterfacesService, { + read: Effect.sync(() => OS.networkInterfaces()), +} satisfies DesktopNetworkInterfacesServiceShape); diff --git a/apps/desktop/src/desktopSettings.test.ts b/apps/desktop/src/desktopSettings.test.ts index cc58dc810ef..98481b4f783 100644 --- a/apps/desktop/src/desktopSettings.test.ts +++ b/apps/desktop/src/desktopSettings.test.ts @@ -1,40 +1,57 @@ -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; - -import { afterEach, describe, expect, it } from "vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { Effect, FileSystem, Path, Schema } from "effect"; import { DEFAULT_DESKTOP_SETTINGS, - readDesktopSettings, + readDesktopSettingsEffect, resolveDefaultDesktopSettings, setDesktopServerExposurePreference, setDesktopTailscaleServePreference, setDesktopUpdateChannelPreference, - writeDesktopSettings, + writeDesktopSettingsEffect, } from "./desktopSettings.ts"; -const tempDirectories: string[] = []; - -afterEach(() => { - for (const directory of tempDirectories.splice(0)) { - fs.rmSync(directory, { recursive: true, force: true }); - } +const DesktopSettingsPatch = Schema.Struct({ + serverExposureMode: Schema.optional(Schema.Literals(["local-only", "network-accessible"])), + tailscaleServeEnabled: Schema.optional(Schema.Boolean), + tailscaleServePort: Schema.optional(Schema.Number), + updateChannel: Schema.optional(Schema.Literals(["latest", "nightly"])), + updateChannelConfiguredByUser: Schema.optional(Schema.Boolean), }); +const encodeDesktopSettingsPatch = Schema.encodeEffect(Schema.fromJsonString(DesktopSettingsPatch)); + function makeSettingsPath() { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "t3-desktop-settings-test-")); - tempDirectories.push(directory); - return path.join(directory, "desktop-settings.json"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-desktop-settings-test-", + }); + return path.join(directory, "desktop-settings.json"); + }); } -describe("desktopSettings", () => { - it("returns defaults when no settings file exists", () => { - expect(readDesktopSettings(makeSettingsPath(), "0.0.17")).toEqual(DEFAULT_DESKTOP_SETTINGS); +function writeSettingsPatch(filePath: string, patch: typeof DesktopSettingsPatch.Type) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const encoded = yield* encodeDesktopSettingsPatch(patch); + yield* fs.writeFileString(filePath, `${encoded}\n`); }); +} + +describe("desktopSettings", () => { + it.effect("returns defaults when no settings file exists", () => + Effect.gen(function* () { + const settingsPath = yield* makeSettingsPath(); + const settings = yield* readDesktopSettingsEffect(settingsPath, "0.0.17"); + assert.deepEqual(settings, DEFAULT_DESKTOP_SETTINGS); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); it("defaults packaged nightly builds to the nightly update channel", () => { - expect(resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1")).toEqual({ + assert.deepEqual(resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), { serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: 443, @@ -43,28 +60,31 @@ describe("desktopSettings", () => { }); }); - it("persists and reloads the configured server exposure mode", () => { - const settingsPath = makeSettingsPath(); + it.effect("persists and reloads the configured server exposure mode", () => + Effect.gen(function* () { + const settingsPath = yield* makeSettingsPath(); - writeDesktopSettings(settingsPath, { - serverExposureMode: "network-accessible", - tailscaleServeEnabled: true, - tailscaleServePort: 8443, - updateChannel: "latest", - updateChannelConfiguredByUser: true, - }); + yield* writeDesktopSettingsEffect(settingsPath, { + serverExposureMode: "network-accessible", + tailscaleServeEnabled: true, + tailscaleServePort: 8443, + updateChannel: "latest", + updateChannelConfiguredByUser: true, + }); - expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual({ - serverExposureMode: "network-accessible", - tailscaleServeEnabled: true, - tailscaleServePort: 8443, - updateChannel: "latest", - updateChannelConfiguredByUser: true, - }); - }); + const settings = yield* readDesktopSettingsEffect(settingsPath, "0.0.17"); + assert.deepEqual(settings, { + serverExposureMode: "network-accessible", + tailscaleServeEnabled: true, + tailscaleServePort: 8443, + updateChannel: "latest", + updateChannelConfiguredByUser: true, + }); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); it("preserves the requested network-accessible preference across temporary fallback", () => { - expect( + assert.deepEqual( setDesktopServerExposurePreference( { serverExposureMode: "local-only", @@ -75,17 +95,18 @@ describe("desktopSettings", () => { }, "network-accessible", ), - ).toEqual({ - serverExposureMode: "network-accessible", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - updateChannel: "latest", - updateChannelConfiguredByUser: false, - }); + { + serverExposureMode: "network-accessible", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + updateChannel: "latest", + updateChannelConfiguredByUser: false, + }, + ); }); it("persists the requested Tailscale Serve preference", () => { - expect( + assert.deepEqual( setDesktopTailscaleServePreference( { serverExposureMode: "local-only", @@ -96,17 +117,18 @@ describe("desktopSettings", () => { }, { enabled: true, port: 8443 }, ), - ).toEqual({ - serverExposureMode: "local-only", - tailscaleServeEnabled: true, - tailscaleServePort: 8443, - updateChannel: "latest", - updateChannelConfiguredByUser: false, - }); + { + serverExposureMode: "local-only", + tailscaleServeEnabled: true, + tailscaleServePort: 8443, + updateChannel: "latest", + updateChannelConfiguredByUser: false, + }, + ); }); it("preserves the configured Tailscale Serve port when no new port is requested", () => { - expect( + assert.deepEqual( setDesktopTailscaleServePreference( { serverExposureMode: "local-only", @@ -117,17 +139,18 @@ describe("desktopSettings", () => { }, { enabled: true }, ), - ).toEqual({ - serverExposureMode: "local-only", - tailscaleServeEnabled: true, - tailscaleServePort: 8443, - updateChannel: "latest", - updateChannelConfiguredByUser: false, - }); + { + serverExposureMode: "local-only", + tailscaleServeEnabled: true, + tailscaleServePort: 8443, + updateChannel: "latest", + updateChannelConfiguredByUser: false, + }, + ); }); it("persists the requested nightly update channel", () => { - expect( + assert.deepEqual( setDesktopUpdateChannelPreference( { serverExposureMode: "local-only", @@ -138,93 +161,110 @@ describe("desktopSettings", () => { }, "nightly", ), - ).toEqual({ - serverExposureMode: "local-only", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - updateChannel: "nightly", - updateChannelConfiguredByUser: true, - }); + { + serverExposureMode: "local-only", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + updateChannel: "nightly", + updateChannelConfiguredByUser: true, + }, + ); }); - it("falls back to defaults when the settings file is malformed", () => { - const settingsPath = makeSettingsPath(); - fs.writeFileSync(settingsPath, "{not-json", "utf8"); + it.effect("falls back to defaults when the settings file is malformed", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const settingsPath = yield* makeSettingsPath(); + yield* fs.writeFileString(settingsPath, "{not-json"); - expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual(DEFAULT_DESKTOP_SETTINGS); - }); + const settings = yield* readDesktopSettingsEffect(settingsPath, "0.0.17"); + assert.deepEqual(settings, DEFAULT_DESKTOP_SETTINGS); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); - it("falls back to the nightly channel for legacy nightly settings without an update track", () => { - const settingsPath = makeSettingsPath(); - fs.writeFileSync(settingsPath, JSON.stringify({ serverExposureMode: "local-only" }), "utf8"); + it.effect( + "falls back to the nightly channel for legacy nightly settings without an update track", + () => + Effect.gen(function* () { + const settingsPath = yield* makeSettingsPath(); + yield* writeSettingsPatch(settingsPath, { serverExposureMode: "local-only" }); - expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({ - serverExposureMode: "local-only", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - updateChannel: "nightly", - updateChannelConfiguredByUser: false, - }); - }); + const settings = yield* readDesktopSettingsEffect( + settingsPath, + "0.0.17-nightly.20260415.1", + ); + assert.deepEqual(settings, { + serverExposureMode: "local-only", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + updateChannel: "nightly", + updateChannelConfiguredByUser: false, + }); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); - it("migrates legacy implicit stable settings to nightly when running a nightly build", () => { - const settingsPath = makeSettingsPath(); - fs.writeFileSync( - settingsPath, - JSON.stringify({ - serverExposureMode: "local-only", - updateChannel: "latest", - }), - "utf8", - ); + it.effect( + "migrates legacy implicit stable settings to nightly when running a nightly build", + () => + Effect.gen(function* () { + const settingsPath = yield* makeSettingsPath(); + yield* writeSettingsPatch(settingsPath, { + serverExposureMode: "local-only", + updateChannel: "latest", + }); - expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({ - serverExposureMode: "local-only", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - updateChannel: "nightly", - updateChannelConfiguredByUser: false, - }); - }); + const settings = yield* readDesktopSettingsEffect( + settingsPath, + "0.0.17-nightly.20260415.1", + ); + assert.deepEqual(settings, { + serverExposureMode: "local-only", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + updateChannel: "nightly", + updateChannelConfiguredByUser: false, + }); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); - it("preserves an explicit stable choice on nightly builds", () => { - const settingsPath = makeSettingsPath(); - fs.writeFileSync( - settingsPath, - JSON.stringify({ + it.effect("preserves an explicit stable choice on nightly builds", () => + Effect.gen(function* () { + const settingsPath = yield* makeSettingsPath(); + yield* writeSettingsPatch(settingsPath, { serverExposureMode: "local-only", updateChannel: "latest", updateChannelConfiguredByUser: true, - }), - "utf8", - ); + }); - expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({ - serverExposureMode: "local-only", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - updateChannel: "latest", - updateChannelConfiguredByUser: true, - }); - }); + const settings = yield* readDesktopSettingsEffect(settingsPath, "0.0.17-nightly.20260415.1"); + assert.deepEqual(settings, { + serverExposureMode: "local-only", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + updateChannel: "latest", + updateChannelConfiguredByUser: true, + }); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); - it("falls back to the default Tailscale Serve port when the persisted port is invalid", () => { - const settingsPath = makeSettingsPath(); - fs.writeFileSync( - settingsPath, - JSON.stringify({ - tailscaleServeEnabled: true, - tailscaleServePort: 0, - }), - "utf8", - ); + it.effect( + "falls back to the default Tailscale Serve port when the persisted port is invalid", + () => + Effect.gen(function* () { + const settingsPath = yield* makeSettingsPath(); + yield* writeSettingsPatch(settingsPath, { + tailscaleServeEnabled: true, + tailscaleServePort: 0, + }); - expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual({ - serverExposureMode: "local-only", - tailscaleServeEnabled: true, - tailscaleServePort: 443, - updateChannel: "latest", - updateChannelConfiguredByUser: false, - }); - }); + const settings = yield* readDesktopSettingsEffect(settingsPath, "0.0.17"); + assert.deepEqual(settings, { + serverExposureMode: "local-only", + tailscaleServeEnabled: true, + tailscaleServePort: 443, + updateChannel: "latest", + updateChannelConfiguredByUser: false, + }); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); }); diff --git a/apps/desktop/src/desktopSettings.ts b/apps/desktop/src/desktopSettings.ts index 5a61faef803..deb7689d2e5 100644 --- a/apps/desktop/src/desktopSettings.ts +++ b/apps/desktop/src/desktopSettings.ts @@ -1,6 +1,11 @@ -import * as FS from "node:fs"; -import * as Path from "node:path"; import type { DesktopServerExposureMode, DesktopUpdateChannel } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import type { PlatformError } from "effect/PlatformError"; +import * as Random from "effect/Random"; +import * as Schema from "effect/Schema"; import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; @@ -22,6 +27,23 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { updateChannelConfiguredByUser: false, }; +const DesktopSettingsDocument = Schema.Struct({ + serverExposureMode: Schema.optional(Schema.Literals(["local-only", "network-accessible"])), + tailscaleServeEnabled: Schema.optional(Schema.Boolean), + tailscaleServePort: Schema.optional(Schema.Number), + updateChannel: Schema.optional(Schema.Literals(["latest", "nightly"])), + updateChannelConfiguredByUser: Schema.optional(Schema.Boolean), +}); + +type DesktopSettingsDocument = typeof DesktopSettingsDocument.Type; + +const decodeDesktopSettingsJson = Schema.decodeEffect( + Schema.fromJsonString(DesktopSettingsDocument), +); +const encodeDesktopSettingsJson = Schema.encodeEffect( + Schema.fromJsonString(DesktopSettingsDocument), +); + export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings { return { ...DEFAULT_DESKTOP_SETTINGS, @@ -75,51 +97,62 @@ export function setDesktopUpdateChannelPreference( }; } -export function readDesktopSettings(settingsPath: string, appVersion: string): DesktopSettings { +function normalizeDesktopSettingsDocument( + parsed: DesktopSettingsDocument, + appVersion: string, +): DesktopSettings { const defaultSettings = resolveDefaultDesktopSettings(appVersion); + const parsedUpdateChannel = Option.fromNullishOr(parsed.updateChannel); + const isLegacySettings = parsed.updateChannelConfiguredByUser === undefined; + const updateChannelConfiguredByUser = + parsed.updateChannelConfiguredByUser === true || + (isLegacySettings && Option.contains(parsedUpdateChannel, "nightly")); - try { - if (!FS.existsSync(settingsPath)) { - return defaultSettings; - } - - const raw = FS.readFileSync(settingsPath, "utf8"); - const parsed = JSON.parse(raw) as { - readonly serverExposureMode?: unknown; - readonly tailscaleServeEnabled?: unknown; - readonly tailscaleServePort?: unknown; - readonly updateChannel?: unknown; - readonly updateChannelConfiguredByUser?: unknown; - }; - const parsedUpdateChannel = - parsed.updateChannel === "nightly" || parsed.updateChannel === "latest" - ? parsed.updateChannel - : null; - const isLegacySettings = parsed.updateChannelConfiguredByUser === undefined; - const updateChannelConfiguredByUser = - parsed.updateChannelConfiguredByUser === true || - (isLegacySettings && parsedUpdateChannel === "nightly"); - - return { - serverExposureMode: - parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", - tailscaleServeEnabled: parsed.tailscaleServeEnabled === true, - tailscaleServePort: normalizeTailscaleServePort(parsed.tailscaleServePort), - updateChannel: - updateChannelConfiguredByUser && parsedUpdateChannel !== null - ? parsedUpdateChannel - : defaultSettings.updateChannel, - updateChannelConfiguredByUser, - }; - } catch { - return defaultSettings; - } + return { + serverExposureMode: + parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", + tailscaleServeEnabled: parsed.tailscaleServeEnabled === true, + tailscaleServePort: normalizeTailscaleServePort(parsed.tailscaleServePort), + updateChannel: updateChannelConfiguredByUser + ? Option.getOrElse(parsedUpdateChannel, () => defaultSettings.updateChannel) + : defaultSettings.updateChannel, + updateChannelConfiguredByUser, + }; } -export function writeDesktopSettings(settingsPath: string, settings: DesktopSettings): void { - const directory = Path.dirname(settingsPath); - const tempPath = `${settingsPath}.${process.pid}.${Date.now()}.tmp`; - FS.mkdirSync(directory, { recursive: true }); - FS.writeFileSync(tempPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); - FS.renameSync(tempPath, settingsPath); +export function readDesktopSettingsEffect( + settingsPath: string, + appVersion: string, +): Effect.Effect { + const defaultSettings = resolveDefaultDesktopSettings(appVersion); + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const raw = yield* fileSystem.readFileString(settingsPath).pipe(Effect.option); + return yield* Option.match(raw, { + onNone: () => Effect.succeed(defaultSettings), + onSome: (value) => + decodeDesktopSettingsJson(value).pipe( + Effect.map((parsed) => normalizeDesktopSettingsDocument(parsed, appVersion)), + Effect.catch(() => Effect.succeed(defaultSettings)), + ), + }); + }); +} + +export function writeDesktopSettingsEffect( + settingsPath: string, + settings: DesktopSettings, +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = path.dirname(settingsPath); + const suffix = (yield* Random.nextUUIDv4).replace(/-/g, ""); + const tempPath = `${settingsPath}.${process.pid}.${suffix}.tmp`; + const encoded = yield* encodeDesktopSettingsJson(settings); + yield* fileSystem.makeDirectory(directory, { recursive: true }); + yield* fileSystem.writeFileString(tempPath, `${encoded}\n`); + yield* fileSystem.rename(tempPath, settingsPath); + }); } diff --git a/apps/desktop/src/desktopShutdown.test.ts b/apps/desktop/src/desktopShutdown.test.ts new file mode 100644 index 00000000000..47f286fda5a --- /dev/null +++ b/apps/desktop/src/desktopShutdown.test.ts @@ -0,0 +1,43 @@ +import { assert, describe, it } from "@effect/vitest"; +import { Effect, Fiber } from "effect"; + +import { makeDesktopShutdown } from "./desktopShutdown.ts"; + +describe("DesktopShutdown", () => { + it.effect("unblocks request waiters when shutdown is requested", () => + Effect.gen(function* () { + const shutdown = yield* makeDesktopShutdown; + const waiter = yield* shutdown.awaitRequest.pipe(Effect.as("requested"), Effect.forkChild); + + yield* shutdown.request; + + assert.equal(yield* Fiber.join(waiter), "requested"); + }), + ); + + it.effect("tracks completion after resources finish closing", () => + Effect.gen(function* () { + const shutdown = yield* makeDesktopShutdown; + const waiter = yield* shutdown.awaitComplete.pipe(Effect.as("complete"), Effect.forkChild); + + assert.equal(yield* shutdown.isComplete, false); + yield* shutdown.markComplete; + + assert.equal(yield* shutdown.isComplete, true); + assert.equal(yield* Fiber.join(waiter), "complete"); + }), + ); + + it.effect("allows repeated requests and completion marks", () => + Effect.gen(function* () { + const shutdown = yield* makeDesktopShutdown; + + yield* shutdown.request; + yield* shutdown.request; + yield* shutdown.markComplete; + yield* shutdown.markComplete; + + assert.equal(yield* shutdown.isComplete, true); + }), + ); +}); diff --git a/apps/desktop/src/desktopShutdown.ts b/apps/desktop/src/desktopShutdown.ts new file mode 100644 index 00000000000..db464ffb37b --- /dev/null +++ b/apps/desktop/src/desktopShutdown.ts @@ -0,0 +1,30 @@ +import { Context, Deferred, Effect, Ref } from "effect"; + +export interface DesktopShutdownShape { + readonly request: Effect.Effect; + readonly awaitRequest: Effect.Effect; + readonly markComplete: Effect.Effect; + readonly awaitComplete: Effect.Effect; + readonly isComplete: Effect.Effect; +} + +export class DesktopShutdown extends Context.Service()( + "t3/desktop/Shutdown", +) {} + +export const makeDesktopShutdown = Effect.gen(function* () { + const requested = yield* Deferred.make(); + const completed = yield* Deferred.make(); + const completedRef = yield* Ref.make(false); + + return DesktopShutdown.of({ + request: Deferred.succeed(requested, undefined).pipe(Effect.asVoid), + awaitRequest: Deferred.await(requested), + markComplete: Ref.set(completedRef, true).pipe( + Effect.andThen(Deferred.succeed(completed, undefined)), + Effect.asVoid, + ), + awaitComplete: Deferred.await(completed), + isComplete: Ref.get(completedRef), + }); +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index d2c80608d2f..1283f21006f 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,19 +1,28 @@ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Crypto from "node:crypto"; -import * as FS from "node:fs"; -import * as OS from "node:os"; -import * as Path from "node:path"; +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as EffectPath from "effect/Path"; +import * as Random from "effect/Random"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { app, BrowserWindow, type BrowserWindowConstructorOptions, + type MenuItemConstructorOptions, + type OpenDialogOptions, clipboard, dialog, ipcMain, @@ -24,11 +33,12 @@ import { safeStorage, shell, } from "electron"; -import type { MenuItemConstructorOptions, OpenDialogOptions } from "electron"; +import { autoUpdater } from "electron-updater"; + import type { ClientSettings, + ContextMenuItem, DesktopTheme, - DesktopAppBranding, DesktopServerExposureMode, DesktopServerExposureState, DesktopUpdateChannel, @@ -37,38 +47,64 @@ import type { DesktopUpdateCheckResult, DesktopUpdateState, } from "@t3tools/contracts"; -import { autoUpdater } from "electron-updater"; - -import type { ContextMenuItem } from "@t3tools/contracts"; -import { RotatingFileSink } from "@t3tools/shared/logging"; +import * as NetService from "@t3tools/shared/Net"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; -import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPort } from "./backendPort.ts"; + +import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPortEffect } from "./backendPort.ts"; import { type DesktopSettings, DEFAULT_DESKTOP_SETTINGS, - readDesktopSettings, + readDesktopSettingsEffect, setDesktopServerExposurePreference, setDesktopTailscaleServePreference, setDesktopUpdateChannelPreference, - writeDesktopSettings, + writeDesktopSettingsEffect, } from "./desktopSettings.ts"; import { - readClientSettings, - readSavedEnvironmentRegistry, - readSavedEnvironmentSecret, - removeSavedEnvironmentSecret, - writeClientSettings, - writeSavedEnvironmentRegistry, - writeSavedEnvironmentSecret, + readClientSettingsEffect, + readSavedEnvironmentRegistryEffect, + readSavedEnvironmentSecretEffect, + removeSavedEnvironmentSecretEffect, + writeClientSettingsEffect, + writeSavedEnvironmentRegistryEffect, + writeSavedEnvironmentSecretEffect, } from "./clientPersistence.ts"; -import { runBackendProcess } from "./backendProcess.ts"; import { showDesktopConfirmDialog } from "./confirmDialog.ts"; +import { + DesktopBackendConfiguration, + DesktopBackendEvents, + DesktopBackendManager, + DesktopBackendManagerLive, + DesktopBackendProcessRunnerLive, + type DesktopBackendManagerShape, + type DesktopBackendStartConfig, +} from "./desktopBackendManager.ts"; +import { + DesktopNetworkInterfacesLive, + DesktopNetworkInterfacesService, +} from "./desktopNetworkInterfaces.ts"; +import { + DesktopBackendOutputLog, + DesktopBackendOutputLogLive, + DesktopLoggerLive, +} from "./desktopLogger.ts"; +import { + DesktopEnvironment, + makeDesktopEnvironment, + type DesktopEnvironmentShape, +} from "./desktopEnvironment.ts"; +import { DesktopShutdown, makeDesktopShutdown } from "./desktopShutdown.ts"; import { resolveDesktopCoreAdvertisedEndpoints, resolveDesktopServerExposure, } from "./serverExposure.ts"; -import { DesktopSshEnvironmentBridge, resolveRemoteT3CliPackageSpec } from "./sshEnvironment.ts"; +import { + DesktopSshEnvironmentBridge, + DesktopSshEnvironmentManager, + type DesktopSshEnvironmentBridgeShape, + resolveRemoteT3CliPackageSpec, +} from "./sshEnvironment.ts"; import { syncShellEnvironment } from "./syncShellEnvironment.ts"; import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts"; import { doesVersionMatchDesktopUpdateChannel } from "./updateChannels.ts"; @@ -84,13 +120,10 @@ import { reduceDesktopUpdateStateOnNoUpdate, reduceDesktopUpdateStateOnUpdateAvailable, } from "./updateMachine.ts"; -import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts"; -import { resolveDesktopAppBranding } from "./appBranding.ts"; +import { isArm64HostRunningIntelBuild } from "./runtimeArch.ts"; import { bindFirstRevealTrigger, type RevealSubscription } from "./windowReveal.ts"; import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; -syncShellEnvironment(); - const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONFIRM_CHANNEL = "desktop:confirm"; const SET_THEME_CHANNEL = "desktop:set-theme"; @@ -116,64 +149,15 @@ const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; -const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); -const STATE_DIR = Path.join(BASE_DIR, "userdata"); -const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json"); -const CLIENT_SETTINGS_PATH = Path.join(STATE_DIR, "client-settings.json"); -const SAVED_ENVIRONMENT_REGISTRY_PATH = Path.join(STATE_DIR, "saved-environments.json"); + const DESKTOP_SCHEME = "t3"; -const ROOT_DIR = Path.resolve(__dirname, "../../.."); -const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); -// Dev-only SSH launcher override. Set this to an absolute path on the SSH host -// for a built server entry, for example: -// "/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs" -const DEV_REMOTE_T3_SERVER_ENTRY_PATH = - process.env.T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH?.trim() ?? ""; -const desktopAppBranding: DesktopAppBranding = resolveDesktopAppBranding({ - isDevelopment, - appVersion: app.getVersion(), -}); -const APP_DISPLAY_NAME = desktopAppBranding.displayName; -const APP_USER_MODEL_ID = isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code"; -const LINUX_DESKTOP_ENTRY_NAME = isDevelopment ? "t3code-dev.desktop" : "t3code.desktop"; -const LINUX_WM_CLASS = isDevelopment ? "t3code-dev" : "t3code"; -const USER_DATA_DIR_NAME = isDevelopment ? "t3code-dev" : "t3code"; -const LEGACY_USER_DATA_DIR_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i; const COMMIT_HASH_DISPLAY_LENGTH = 12; -const LOG_DIR = Path.join(STATE_DIR, "logs"); -const LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; -const LOG_FILE_MAX_FILES = 10; -const APP_RUN_ID = Crypto.randomBytes(6).toString("hex"); -const SERVER_SETTINGS_PATH = Path.join(STATE_DIR, "settings.json"); +const AppPackageMetadata = Schema.Struct({ + t3codeCommitHash: Schema.optional(Schema.String), +}); const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000; const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; - -function resolvePickFolderDefaultPath(rawOptions: unknown): string | undefined { - if (typeof rawOptions !== "object" || rawOptions === null) { - return undefined; - } - - const { initialPath } = rawOptions as { initialPath?: unknown }; - if (typeof initialPath !== "string") { - return undefined; - } - - const trimmedPath = initialPath.trim(); - if (trimmedPath.length === 0) { - return undefined; - } - - if (trimmedPath === "~") { - return OS.homedir(); - } - - if (trimmedPath.startsWith("~/") || trimmedPath.startsWith("~\\")) { - return Path.join(OS.homedir(), trimmedPath.slice(2)); - } - - return Path.resolve(trimmedPath); -} const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const; const TITLEBAR_HEIGHT = 40; @@ -219,8 +203,11 @@ type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; type LinuxDesktopNamedApp = Electron.App & { setDesktopName?: (desktopName: string) => void; }; +interface BackendObservabilitySettings { + readonly otlpTracesUrl: string | undefined; + readonly otlpMetricsUrl: string | undefined; +} let mainWindow: BrowserWindow | null = null; -let backendProcessFiber: Fiber.Fiber | null = null; let backendReady = false; let backendPort = 0; let backendBindHost = DESKTOP_LOOPBACK_HOST; @@ -229,20 +216,46 @@ let backendHttpUrl: Option.Option = Option.none(); let backendWsUrl = ""; let backendEndpointUrl: string | null = null; let backendAdvertisedHost: string | null = null; -let restartAttempt = 0; -let restartTimer: ReturnType | null = null; let isQuitting = false; let desktopProtocolRegistered = false; -let aboutCommitHashCache: string | null | undefined; -let desktopLogSink: RotatingFileSink | null = null; -let backendLogSink: RotatingFileSink | null = null; -let restoreStdIoCapture: (() => void) | null = null; -let backendObservabilitySettings = readPersistedBackendObservabilitySettings(); -let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH, app.getVersion()); +let appUpdateYmlConfig: Option.Option> = Option.none(); +let aboutCommitHashCache: Option.Option | undefined; +let desktopIconPaths: Readonly>> = { + ico: Option.none(), + icns: Option.none(), + png: Option.none(), +}; +let appRunId = "startup"; +let backendObservabilitySettings: BackendObservabilitySettings = { + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, +}; +let desktopSettings = DEFAULT_DESKTOP_SETTINGS; let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode; let destructiveMenuIconCache: Electron.NativeImage | null | undefined; -const backendProcessLayer = Layer.merge(NodeServices.layer, NodeHttpClient.layerUndici); + +interface DesktopEffectRunner { + (effect: Effect.Effect): Promise; +} + +type DesktopWindowBoundaryServices = DesktopEnvironment | DesktopSshEnvironmentBridge; +type DesktopLifecycleBoundaryServices = DesktopShutdown | DesktopWindowBoundaryServices; +type DesktopIpcBoundaryServices = + | ChildProcessSpawner.ChildProcessSpawner + | HttpClient.HttpClient + | FileSystem.FileSystem + | EffectPath.Path + | DesktopEnvironment + | DesktopBackendManager + | DesktopNetworkInterfacesService + | DesktopShutdown + | DesktopWindowBoundaryServices; + +function makeDesktopEffectRunner(context: Context.Context): DesktopEffectRunner { + return (effect: Effect.Effect) => + Effect.runPromiseWith(context as unknown as Context.Context)(effect); +} function requireBackendHttpUrl(): URL { return Option.getOrThrowWith( @@ -258,43 +271,96 @@ function getBackendHttpUrlHref(): string | null { }); } -const desktopRuntimeInfo = resolveDesktopRuntimeInfo({ - platform: process.platform, - processArch: process.arch, - runningUnderArm64Translation: app.runningUnderARM64Translation === true, -}); -const initialUpdateState = (): DesktopUpdateState => +const initialUpdateState = (environment: DesktopEnvironmentShape): DesktopUpdateState => createInitialDesktopUpdateState( - app.getVersion(), - desktopRuntimeInfo, + environment.appVersion, + environment.runtimeInfo, desktopSettings.updateChannel, ); -function logTimestamp(): string { - return new Date().toISOString(); +function nowIsoTimestamp(): string { + return DateTime.formatIso(DateTime.nowUnsafe()); } -function logScope(scope: string): string { - return `${scope} run=${APP_RUN_ID}`; -} +const withDesktopLogAnnotations = ( + effect: Effect.Effect, + annotations?: Record, +): Effect.Effect => + effect.pipe( + Effect.annotateLogs({ + scope: "desktop", + runId: appRunId, + ...annotations, + }), + ); -function sanitizeLogValue(value: string): string { - return value.replace(/\s+/g, " ").trim(); -} +const logDesktopInfo = ( + message: string, + annotations?: Record, +): Effect.Effect => withDesktopLogAnnotations(Effect.logInfo(message), annotations); + +const logDesktopWarning = ( + message: string, + annotations?: Record, +): Effect.Effect => withDesktopLogAnnotations(Effect.logWarning(message), annotations); + +const logDesktopError = ( + message: string, + annotations?: Record, +): Effect.Effect => withDesktopLogAnnotations(Effect.logError(message), annotations); + +const logUpdaterInfo = ( + message: string, + annotations?: Record, +): Effect.Effect => + withDesktopLogAnnotations(Effect.logInfo(message), { + component: "desktop-updater", + ...annotations, + }); -function readPersistedBackendObservabilitySettings(): { - readonly otlpTracesUrl: string | undefined; - readonly otlpMetricsUrl: string | undefined; -} { - try { - if (!FS.existsSync(SERVER_SETTINGS_PATH)) { +const logUpdaterError = ( + message: string, + annotations?: Record, +): Effect.Effect => + withDesktopLogAnnotations(Effect.logError(message), { + component: "desktop-updater", + ...annotations, + }); + +function readPersistedBackendObservabilitySettings(): Effect.Effect< + BackendObservabilitySettings, + never, + FileSystem.FileSystem | DesktopEnvironment +> { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment; + const exists = yield* fileSystem + .exists(environment.serverSettingsPath) + .pipe(Effect.orElseSucceed(() => false)); + if (!exists) { return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; } - return parsePersistedServerObservabilitySettings(FS.readFileSync(SERVER_SETTINGS_PATH, "utf8")); - } catch (error) { - console.warn("[desktop] failed to read persisted backend observability settings", error); - return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; - } + + const raw = yield* fileSystem + .readFileString(environment.serverSettingsPath) + .pipe(Effect.option); + if (Option.isNone(raw)) { + yield* logDesktopWarning("failed to read persisted backend observability settings"); + return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; + } + + return yield* Effect.try({ + try: () => parsePersistedServerObservabilitySettings(raw.value), + catch: (error) => error, + }).pipe( + Effect.catch((error) => + logDesktopWarning("failed to parse persisted backend observability settings", { + error, + }).pipe(Effect.as({ otlpTracesUrl: undefined, otlpMetricsUrl: undefined })), + ), + ); + }); } function resolveConfiguredDesktopBackendPort(rawPort: string | undefined): number | undefined { @@ -310,9 +376,9 @@ function resolveConfiguredDesktopBackendPort(rawPort: string | undefined): numbe return parsedPort; } -function resolveDesktopDevServerUrl(): string { - const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); - if (!devServerUrl) { +function resolveDesktopDevServerUrl(environment: DesktopEnvironmentShape): string { + const devServerUrl = Option.getOrUndefined(environment.devServerUrl); + if (devServerUrl === undefined) { throw new Error("VITE_DEV_SERVER_URL is required in desktop development."); } @@ -344,25 +410,28 @@ function getDesktopServerExposureState(): DesktopServerExposureState { }; } -async function getDesktopAdvertisedEndpoints() { - const exposure = resolveDesktopServerExposure({ - mode: desktopServerExposureMode, - port: backendPort, - networkInterfaces: OS.networkInterfaces(), - ...(backendAdvertisedHost ? { advertisedHostOverride: backendAdvertisedHost } : {}), - }); - const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({ - port: backendPort, - exposure, - customHttpsEndpointUrls: resolveCustomHttpsEndpointUrls(), - }); - const tailscaleEndpoints = await resolveTailscaleAdvertisedEndpoints({ - port: backendPort, - serveEnabled: desktopSettings.tailscaleServeEnabled, - servePort: desktopSettings.tailscaleServePort, - networkInterfaces: OS.networkInterfaces(), +function getDesktopAdvertisedEndpoints() { + return Effect.gen(function* () { + const networkInterfaces = yield* (yield* DesktopNetworkInterfacesService).read; + const exposure = resolveDesktopServerExposure({ + mode: desktopServerExposureMode, + port: backendPort, + networkInterfaces, + ...(backendAdvertisedHost ? { advertisedHostOverride: backendAdvertisedHost } : {}), + }); + const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({ + port: backendPort, + exposure, + customHttpsEndpointUrls: resolveCustomHttpsEndpointUrls(), + }); + const tailscaleEndpoints = yield* resolveTailscaleAdvertisedEndpoints({ + port: backendPort, + serveEnabled: desktopSettings.tailscaleServeEnabled, + servePort: desktopSettings.tailscaleServePort, + networkInterfaces, + }); + return [...coreEndpoints, ...tailscaleEndpoints]; }); - return [...coreEndpoints, ...tailscaleEndpoints]; } function getDesktopSecretStorage() { @@ -385,100 +454,106 @@ function resolveCustomHttpsEndpointUrls(): readonly string[] { .filter((entry) => entry.length > 0); } -async function applyDesktopServerExposureMode( +function applyDesktopServerExposureMode( mode: DesktopServerExposureMode, options?: { readonly persist?: boolean; readonly rejectIfUnavailable?: boolean; }, -): Promise { - const advertisedHostOverride = resolveAdvertisedHostOverride(); - const requestedMode = mode; - let exposure = resolveDesktopServerExposure({ - mode, - port: backendPort, - networkInterfaces: OS.networkInterfaces(), - ...(advertisedHostOverride ? { advertisedHostOverride } : {}), - }); - - if (requestedMode === "network-accessible" && exposure.endpointUrl === null) { - if (options?.rejectIfUnavailable) { - throw new Error("No reachable network address is available for this desktop right now."); - } - exposure = resolveDesktopServerExposure({ - mode: "local-only", +): Effect.Effect< + DesktopServerExposureState, + unknown, + FileSystem.FileSystem | EffectPath.Path | DesktopEnvironment | DesktopNetworkInterfacesService +> { + return Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + const networkInterfaces = yield* (yield* DesktopNetworkInterfacesService).read; + const advertisedHostOverride = resolveAdvertisedHostOverride(); + const requestedMode = mode; + let exposure = resolveDesktopServerExposure({ + mode, port: backendPort, - networkInterfaces: OS.networkInterfaces(), + networkInterfaces, ...(advertisedHostOverride ? { advertisedHostOverride } : {}), }); - } - desktopServerExposureMode = exposure.mode; - desktopSettings = setDesktopServerExposurePreference(desktopSettings, requestedMode); - backendBindHost = exposure.bindHost; - backendHttpUrl = Option.some(new URL(exposure.localHttpUrl)); - backendWsUrl = exposure.localWsUrl; - backendEndpointUrl = exposure.endpointUrl; - backendAdvertisedHost = exposure.advertisedHost; + if (requestedMode === "network-accessible" && exposure.endpointUrl === null) { + if (options?.rejectIfUnavailable) { + return yield* Effect.fail( + new Error("No reachable network address is available for this desktop right now."), + ); + } + exposure = resolveDesktopServerExposure({ + mode: "local-only", + port: backendPort, + networkInterfaces, + ...(advertisedHostOverride ? { advertisedHostOverride } : {}), + }); + } - if (options?.persist) { - writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings); - } + desktopServerExposureMode = exposure.mode; + desktopSettings = setDesktopServerExposurePreference(desktopSettings, requestedMode); + backendBindHost = exposure.bindHost; + backendHttpUrl = Option.some(new URL(exposure.localHttpUrl)); + backendWsUrl = exposure.localWsUrl; + backendEndpointUrl = exposure.endpointUrl; + backendAdvertisedHost = exposure.advertisedHost; + + if (options?.persist) { + yield* writeDesktopSettingsEffect(environment.desktopSettingsPath, desktopSettings); + } - return getDesktopServerExposureState(); + return getDesktopServerExposureState(); + }); } -async function applyDesktopTailscaleServeEnabled( +function applyDesktopTailscaleServeEnabled( nextSettings: DesktopSettings, -): Promise { - desktopSettings = nextSettings; - writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings); - relaunchDesktopApp( - desktopSettings.tailscaleServeEnabled ? "tailscale-serve-enabled" : "tailscale-serve-disabled", - ); - return getDesktopServerExposureState(); +): Effect.Effect< + DesktopServerExposureState, + unknown, + FileSystem.FileSystem | EffectPath.Path | DesktopEnvironment | DesktopShutdown +> { + return Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + desktopSettings = nextSettings; + yield* writeDesktopSettingsEffect(environment.desktopSettingsPath, desktopSettings); + yield* relaunchDesktopAppEffect( + desktopSettings.tailscaleServeEnabled + ? "tailscale-serve-enabled" + : "tailscale-serve-disabled", + ); + return getDesktopServerExposureState(); + }); } -function relaunchDesktopApp(reason: string): void { - writeDesktopLogHeader(`desktop relaunch requested reason=${reason}`); - setImmediate(() => { - isQuitting = true; - clearUpdatePollTimer(); - void stopBackendAndWaitForExit() - .catch((error) => { - writeDesktopLogHeader( - `desktop relaunch backend shutdown warning message=${formatErrorMessage(error)}`, - ); - }) - .then(() => desktopSshEnvironmentBridge.dispose().catch(() => undefined)) - .finally(() => { - restoreStdIoCapture?.(); - if (isDevelopment) { - app.exit(75); - return; - } - app.relaunch({ - execPath: process.execPath, - args: process.argv.slice(1), +function relaunchDesktopAppEffect( + reason: string, +): Effect.Effect { + return Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + const context = yield* Effect.context(); + const runEffect = makeDesktopEffectRunner(context); + yield* logDesktopInfo("desktop relaunch requested", { reason }); + yield* Effect.sync(() => { + setImmediate(() => { + isQuitting = true; + void runEffect(requestDesktopShutdownAndWait()).finally(() => { + if (environment.isDevelopment) { + app.exit(75); + return; + } + app.relaunch({ + execPath: process.execPath, + args: process.argv.slice(1), + }); + app.exit(0); }); - app.exit(0); }); + }); }); } -function writeDesktopLogHeader(message: string): void { - if (!desktopLogSink) return; - desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`); -} - -function writeBackendSessionBoundary(phase: "START" | "END", details: string): void { - if (!backendLogSink) return; - const normalizedDetails = sanitizeLogValue(details); - backendLogSink.write( - `[${logTimestamp()}] ---- APP SESSION ${phase} run=${APP_RUN_ID} ${normalizedDetails} ----\n`, - ); -} - function formatErrorMessage(error: unknown): string { if (error instanceof Error) { return error.message; @@ -513,112 +588,219 @@ function getSafeTheme(rawTheme: unknown): DesktopTheme | null { return null; } -function handleBackendReady(): void { - backendReady = true; - writeDesktopLogHeader("bootstrap backend ready source=http"); +function handleBackendReady( + runEffect: DesktopEffectRunner, + environment: DesktopEnvironmentShape, +): Effect.Effect { + return Effect.gen(function* () { + yield* Effect.sync(() => { + backendReady = true; + }); + yield* logDesktopInfo("bootstrap backend ready", { source: "http" }); - const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null; - if (isDevelopment || existingWindow !== null) { - return; - } + const createdWindow = yield* Effect.sync(() => { + const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null; + if (environment.isDevelopment || existingWindow !== null) { + return false; + } - mainWindow = createWindow(); - writeDesktopLogHeader("bootstrap main window created"); + mainWindow = createWindow(runEffect, environment); + return true; + }); + if (createdWindow) { + yield* logDesktopInfo("bootstrap main window created"); + } + }); } -function createBackendWindowIfReady(): void { +function createBackendWindowIfReady( + runEffect: DesktopEffectRunner, + environment: DesktopEnvironmentShape, +): void { if (!backendReady) return; const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null; if (existingWindow !== null) return; - mainWindow = createWindow(); + mainWindow = createWindow(runEffect, environment); } -function writeDesktopStreamChunk( - streamName: "stdout" | "stderr", - chunk: unknown, - encoding: BufferEncoding | undefined, -): void { - if (!desktopLogSink) return; - const buffer = Buffer.isBuffer(chunk) - ? chunk - : Buffer.from(String(chunk), typeof chunk === "string" ? encoding : undefined); - desktopLogSink.write(`[${logTimestamp()}] [${logScope(streamName)}] `); - desktopLogSink.write(buffer); - if (buffer.length === 0 || buffer[buffer.length - 1] !== 0x0a) { - desktopLogSink.write("\n"); - } -} +const resolveBackendStartConfig: Effect.Effect< + DesktopBackendStartConfig, + never, + FileSystem.FileSystem | DesktopEnvironment +> = Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + backendObservabilitySettings = yield* readPersistedBackendObservabilitySettings(); + const captureBackendLogs = !environment.isDevelopment; -function installStdIoCapture(): void { - if (!app.isPackaged || desktopLogSink === null || restoreStdIoCapture !== null) { - return; - } + return { + executablePath: process.execPath, + entryPath: environment.backendEntryPath, + cwd: environment.backendCwd, + env: { + ...backendChildEnv(), + ELECTRON_RUN_AS_NODE: "1", + }, + bootstrap: { + mode: "desktop", + noBrowser: true, + port: backendPort, + t3Home: environment.baseDir, + host: backendBindHost, + desktopBootstrapToken: backendBootstrapToken, + tailscaleServeEnabled: desktopSettings.tailscaleServeEnabled, + tailscaleServePort: desktopSettings.tailscaleServePort, + ...(backendObservabilitySettings.otlpTracesUrl + ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl } + : {}), + ...(backendObservabilitySettings.otlpMetricsUrl + ? { otlpMetricsUrl: backendObservabilitySettings.otlpMetricsUrl } + : {}), + }, + httpBaseUrl: requireBackendHttpUrl(), + captureOutput: captureBackendLogs, + }; +}); - const originalStdoutWrite = process.stdout.write.bind(process.stdout); - const originalStderrWrite = process.stderr.write.bind(process.stderr); - - const patchWrite = - (streamName: "stdout" | "stderr", originalWrite: typeof process.stdout.write) => - ( - chunk: string | Uint8Array, - encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), - callback?: (error?: Error | null) => void, - ): boolean => { - const encoding = typeof encodingOrCallback === "string" ? encodingOrCallback : undefined; - writeDesktopStreamChunk(streamName, chunk, encoding); - if (typeof encodingOrCallback === "function") { - return originalWrite(chunk, encodingOrCallback); - } - if (callback !== undefined) { - return originalWrite(chunk, encoding, callback); - } - if (encoding !== undefined) { - return originalWrite(chunk, encoding); - } - return originalWrite(chunk); - }; +const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso)); - process.stdout.write = patchWrite("stdout", originalStdoutWrite); - process.stderr.write = patchWrite("stderr", originalStderrWrite); +const randomHexString = (length: number): Effect.Effect => + Effect.gen(function* () { + let value = ""; + while (value.length < length) { + value += (yield* Random.nextUUIDv4).replace(/-/g, ""); + } + return value.slice(0, length); + }); - restoreStdIoCapture = () => { - process.stdout.write = originalStdoutWrite; - process.stderr.write = originalStderrWrite; - restoreStdIoCapture = null; - }; -} +const desktopEnvironmentLayer = Layer.effect( + DesktopEnvironment, + makeDesktopEnvironment({ + dirname: __dirname, + env: process.env, + cwd: process.cwd(), + platform: process.platform, + processArch: process.arch, + appVersion: app.getVersion(), + appPath: app.getAppPath(), + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + runningUnderArm64Translation: app.runningUnderARM64Translation === true, + }), +).pipe(Layer.provide(EffectPath.layer)); -function initializePackagedLogging(): void { - if (!app.isPackaged) return; - try { - desktopLogSink = new RotatingFileSink({ - filePath: Path.join(LOG_DIR, "desktop-main.log"), - maxBytes: LOG_FILE_MAX_BYTES, - maxFiles: LOG_FILE_MAX_FILES, - }); - backendLogSink = new RotatingFileSink({ - filePath: Path.join(LOG_DIR, "server-child.log"), - maxBytes: LOG_FILE_MAX_BYTES, - maxFiles: LOG_FILE_MAX_FILES, - }); - installStdIoCapture(); - writeDesktopLogHeader(`runtime log capture enabled logDir=${LOG_DIR}`); - } catch (error) { - // Logging setup should never block app startup. - console.error("[desktop] failed to initialize packaged logging", error); - } -} +const desktopLoggerLayer = DesktopLoggerLive.pipe(Layer.provide(NodeServices.layer)); -function writeBackendOutputChunk(_streamName: "stdout" | "stderr", chunk: Uint8Array): void { - backendLogSink?.write(Buffer.from(chunk)); -} +const desktopBackendOutputLogLayer = DesktopBackendOutputLogLive.pipe( + Layer.provide(NodeServices.layer), +); + +const desktopBackendConfigurationLayer = Layer.effect( + DesktopBackendConfiguration, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + return { + resolve: resolveBackendStartConfig.pipe( + Effect.provideService(DesktopEnvironment, environment), + ), + }; + }), +); +const desktopSshEnvironmentBridgeLayer = DesktopSshEnvironmentBridge.layer({ + getMainWindow: () => mainWindow, +}); +const desktopBackendEventsLayer = Layer.effect( + DesktopBackendEvents, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + const backendOutputLog = yield* DesktopBackendOutputLog; + const context = yield* Effect.context(); + const runEffect = makeDesktopEffectRunner(context); -initializePackagedLogging(); + return { + onStarting: Effect.sync(() => { + backendReady = false; + }), + onStarted: ({ pid, config }) => + backendOutputLog.writeSessionBoundary({ + phase: "START", + runId: appRunId, + details: `pid=${pid} port=${config.bootstrap.port} cwd=${config.cwd}`, + }), + onReady: handleBackendReady(runEffect, environment), + onReadinessFailure: (error) => + logDesktopWarning("backend readiness check failed during bootstrap", { + error: formatErrorMessage(error), + }), + onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), + onExit: ({ pid, reason }) => + Effect.gen(function* () { + yield* Option.match(pid, { + onNone: () => Effect.void, + onSome: (value) => + backendOutputLog.writeSessionBoundary({ + phase: "END", + runId: appRunId, + details: `pid=${value} ${reason}`, + }), + }); + yield* Effect.sync(() => { + backendReady = false; + }); + }), + onRestartScheduled: ({ reason, delay }) => + logDesktopError("backend exited unexpectedly; restart scheduled", { + reason, + delayMs: Duration.toMillis(delay), + }), + }; + }), +); -if (process.platform === "linux") { - app.commandLine.appendSwitch("class", LINUX_WM_CLASS); +function resolveDesktopSshCliRunner(environment: DesktopEnvironmentShape): RemoteT3RunnerOptions { + const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath); + if (environment.isDevelopment && devRemoteEntryPath !== undefined) { + return { nodeScriptPath: devRemoteEntryPath }; + } + return { + packageSpec: resolveRemoteT3CliPackageSpec({ + appVersion: environment.appVersion, + updateChannel: desktopSettings.updateChannel, + isDevelopment: environment.isDevelopment, + }), + }; } +const desktopSshEnvironmentLayer = Layer.unwrap( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + return DesktopSshEnvironmentManager.layer({ + resolveCliRunner: () => resolveDesktopSshCliRunner(environment), + }); + }), +); + +const desktopBackendDependenciesLayer = Layer.mergeAll( + NodeServices.layer, + NodeHttpClient.layerUndici, + NetService.layer, + DesktopBackendProcessRunnerLive, + desktopBackendConfigurationLayer, + desktopBackendEventsLayer.pipe(Layer.provide(desktopBackendOutputLogLayer)), +); + +const desktopRuntimeLayer = Layer.mergeAll( + desktopLoggerLayer, + DesktopBackendManagerLive.pipe(Layer.provide(desktopBackendDependenciesLayer)), + NetService.layer, + NodeServices.layer, + NodeHttpClient.layerUndici, + DesktopNetworkInterfacesLive, + desktopSshEnvironmentLayer, +).pipe( + Layer.provideMerge(desktopSshEnvironmentBridgeLayer), + Layer.provideMerge(desktopEnvironmentLayer), +); + function getDestructiveMenuIcon(): Electron.NativeImage | undefined { if (process.platform !== "darwin") return undefined; if (destructiveMenuIconCache !== undefined) { @@ -641,29 +823,20 @@ function getDestructiveMenuIcon(): Electron.NativeImage | undefined { return undefined; } } -let updatePollTimer: ReturnType | null = null; -let updateStartupTimer: ReturnType | null = null; +let updatePollerScope: Option.Option = Option.none(); let updateCheckInFlight = false; let updateDownloadInFlight = false; let updateInstallInFlight = false; let updaterConfigured = false; -let updateState: DesktopUpdateState = initialUpdateState(); - -const desktopSshEnvironmentBridge = new DesktopSshEnvironmentBridge({ - getMainWindow: () => mainWindow, - resolveCliRunner: (): RemoteT3RunnerOptions => { - if (isDevelopment && DEV_REMOTE_T3_SERVER_ENTRY_PATH.length > 0) { - return { nodeScriptPath: DEV_REMOTE_T3_SERVER_ENTRY_PATH }; - } - return { - packageSpec: resolveRemoteT3CliPackageSpec({ - appVersion: app.getVersion(), - updateChannel: desktopSettings.updateChannel, - isDevelopment, - }), - }; +let updateState: DesktopUpdateState = createInitialDesktopUpdateState( + "0.0.0", + { + hostArch: "other", + appArch: "other", + runningUnderArm64Translation: false, }, -}); + DEFAULT_DESKTOP_SETTINGS.updateChannel, +); function resolveUpdaterErrorContext(): DesktopUpdateErrorContext { if (updateInstallInFlight) return "install"; @@ -672,6 +845,27 @@ function resolveUpdaterErrorContext(): DesktopUpdateErrorContext { return updateState.errorContext; } +function addScopedListener>( + target: unknown, + eventName: string, + listener: (...args: Args) => void, +): Effect.Effect { + const eventTarget = target as { + on: (eventName: string, listener: (...args: Array) => void) => unknown; + removeListener: (eventName: string, listener: (...args: Array) => void) => unknown; + }; + const untypedListener = listener as unknown as (...args: Array) => void; + return Effect.acquireRelease( + Effect.sync(() => { + eventTarget.on(eventName, untypedListener); + }), + () => + Effect.sync(() => { + eventTarget.removeListener(eventName, untypedListener); + }), + ).pipe(Effect.asVoid); +} + protocol.registerSchemesAsPrivileged([ { scheme: DESKTOP_SCHEME, @@ -684,33 +878,34 @@ protocol.registerSchemesAsPrivileged([ }, ]); -function resolveAppRoot(): string { - if (!app.isPackaged) { - return ROOT_DIR; +function parseAppUpdateYml(raw: string): Option.Option> { + // The YAML is simple key-value pairs — avoid pulling in a YAML parser by + // doing a line-based parse (fields: provider, owner, repo, releaseType, ...). + const entries: Record = {}; + for (const line of raw.split("\n")) { + const match = line.match(/^(\w+):\s*(.+)$/); + if (match?.[1] && match[2]) entries[match[1]] = match[2].trim(); } - return app.getAppPath(); + return entries.provider ? Option.some(entries) : Option.none(); } /** Read the baked-in app-update.yml config (if applicable). */ -function readAppUpdateYml(): Record | null { - try { - // electron-updater reads from process.resourcesPath in packaged builds, - // or dev-app-update.yml via app.getAppPath() in dev. - const ymlPath = app.isPackaged - ? Path.join(process.resourcesPath, "app-update.yml") - : Path.join(app.getAppPath(), "dev-app-update.yml"); - const raw = FS.readFileSync(ymlPath, "utf-8"); - // The YAML is simple key-value pairs — avoid pulling in a YAML parser by - // doing a line-based parse (fields: provider, owner, repo, releaseType, …). - const entries: Record = {}; - for (const line of raw.split("\n")) { - const match = line.match(/^(\w+):\s*(.+)$/); - if (match?.[1] && match[2]) entries[match[1]] = match[2].trim(); - } - return entries.provider ? entries : null; - } catch { - return null; - } +function readAppUpdateYmlEffect(): Effect.Effect< + Option.Option>, + never, + FileSystem.FileSystem | DesktopEnvironment +> { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment; + const raw = yield* fileSystem + .readFileString(environment.appUpdateYmlPath, "utf-8") + .pipe(Effect.option); + return Option.match(raw, { + onNone: () => Option.none>(), + onSome: parseAppUpdateYml, + }); + }); } function normalizeCommitHash(value: unknown): string | null { @@ -724,161 +919,230 @@ function normalizeCommitHash(value: unknown): string | null { return trimmed.slice(0, COMMIT_HASH_DISPLAY_LENGTH).toLowerCase(); } -function resolveEmbeddedCommitHash(): string | null { - const packageJsonPath = Path.join(resolveAppRoot(), "package.json"); - if (!FS.existsSync(packageJsonPath)) { - return null; - } - - try { - const raw = FS.readFileSync(packageJsonPath, "utf8"); - const parsed = JSON.parse(raw) as { t3codeCommitHash?: unknown }; - return normalizeCommitHash(parsed.t3codeCommitHash); - } catch { - return null; - } +function resolveEmbeddedCommitHashEffect(): Effect.Effect< + Option.Option, + never, + FileSystem.FileSystem | DesktopEnvironment +> { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment; + const packageJsonPath = environment.path.join(environment.appRoot, "package.json"); + const raw = yield* fileSystem.readFileString(packageJsonPath).pipe(Effect.option); + return yield* Option.match(raw, { + onNone: () => Effect.succeed(Option.none()), + onSome: (value) => + Schema.decodeEffect(Schema.fromJsonString(AppPackageMetadata))(value).pipe( + Effect.map((parsed) => + Option.fromNullishOr(normalizeCommitHash(parsed.t3codeCommitHash)), + ), + Effect.catch(() => Effect.succeed(Option.none())), + ), + }); + }); } -function resolveAboutCommitHash(): string | null { +function resolveAboutCommitHash(): Effect.Effect< + Option.Option, + never, + FileSystem.FileSystem | DesktopEnvironment +> { if (aboutCommitHashCache !== undefined) { - return aboutCommitHashCache; + return Effect.succeed(aboutCommitHashCache); } const envCommitHash = normalizeCommitHash(process.env.T3CODE_COMMIT_HASH); if (envCommitHash) { - aboutCommitHashCache = envCommitHash; - return aboutCommitHashCache; + aboutCommitHashCache = Option.some(envCommitHash); + return Effect.succeed(aboutCommitHashCache); } // Only packaged builds are required to expose commit metadata. - if (!app.isPackaged) { - aboutCommitHashCache = null; - return aboutCommitHashCache; - } - - aboutCommitHashCache = resolveEmbeddedCommitHash(); - - return aboutCommitHashCache; -} + return Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + if (!environment.isPackaged) { + aboutCommitHashCache = Option.none(); + return aboutCommitHashCache; + } -function resolveBackendEntry(): string { - return Path.join(resolveAppRoot(), "apps/server/dist/bin.mjs"); + return yield* resolveEmbeddedCommitHashEffect().pipe( + Effect.tap((commitHash) => + Effect.sync(() => { + aboutCommitHashCache = commitHash; + }), + ), + ); + }); } -function resolveBackendCwd(): string { - if (!app.isPackaged) { - return resolveAppRoot(); - } - return OS.homedir(); +function resolveDesktopStaticDir(): Effect.Effect< + Option.Option, + never, + FileSystem.FileSystem | DesktopEnvironment +> { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment; + const candidates = [ + environment.path.join(environment.appRoot, "apps/server/dist/client"), + environment.path.join(environment.appRoot, "apps/web/dist"), + ]; + for (const candidate of candidates) { + const hasIndex = yield* fileSystem + .exists(environment.path.join(candidate, "index.html")) + .pipe(Effect.orElseSucceed(() => false)); + if (hasIndex) { + return Option.some(candidate); + } + } + return Option.none(); + }); } -function resolveDesktopStaticDir(): string | null { - const appRoot = resolveAppRoot(); - const candidates = [ - Path.join(appRoot, "apps/server/dist/client"), - Path.join(appRoot, "apps/web/dist"), - ]; - - for (const candidate of candidates) { - if (FS.existsSync(Path.join(candidate, "index.html"))) { - return candidate; +function normalizeDesktopProtocolPathname(rawPath: string): Option.Option { + const segments: string[] = []; + for (const segment of rawPath.split("/")) { + if (segment.length === 0 || segment === ".") { + continue; + } + if (segment === "..") { + return Option.none(); } + segments.push(segment); } - - return null; + return Option.some(segments.join("/")); } -function resolveDesktopStaticPath(staticRoot: string, requestUrl: string): string { - const url = new URL(requestUrl); - const rawPath = decodeURIComponent(url.pathname); - const normalizedPath = Path.posix.normalize(rawPath).replace(/^\/+/, ""); - if (normalizedPath.includes("..")) { - return Path.join(staticRoot, "index.html"); - } +function resolveDesktopStaticPath( + staticRoot: string, + requestUrl: string, +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment; + const url = new URL(requestUrl); + const rawPath = decodeURIComponent(url.pathname); + const normalizedPath = normalizeDesktopProtocolPathname(rawPath); + if (Option.isNone(normalizedPath)) { + return environment.path.join(staticRoot, "index.html"); + } - const requestedPath = normalizedPath.length > 0 ? normalizedPath : "index.html"; - const resolvedPath = Path.join(staticRoot, requestedPath); + const requestedPath = normalizedPath.value.length > 0 ? normalizedPath.value : "index.html"; + const resolvedPath = environment.path.join(staticRoot, requestedPath); - if (Path.extname(resolvedPath)) { - return resolvedPath; - } + if (environment.path.extname(resolvedPath)) { + return resolvedPath; + } - const nestedIndex = Path.join(resolvedPath, "index.html"); - if (FS.existsSync(nestedIndex)) { - return nestedIndex; - } + const nestedIndex = environment.path.join(resolvedPath, "index.html"); + const nestedIndexExists = yield* fileSystem + .exists(nestedIndex) + .pipe(Effect.orElseSucceed(() => false)); + if (nestedIndexExists) { + return nestedIndex; + } - return Path.join(staticRoot, "index.html"); + return environment.path.join(staticRoot, "index.html"); + }); } -function isStaticAssetRequest(requestUrl: string): boolean { +function isStaticAssetRequest(requestUrl: string, environment: DesktopEnvironmentShape): boolean { try { const url = new URL(requestUrl); - return Path.extname(url.pathname).length > 0; + return environment.path.extname(url.pathname).length > 0; } catch { return false; } } -function handleFatalStartupError(stage: string, error: unknown): void { - const message = formatErrorMessage(error); - const detail = - error instanceof Error && typeof error.stack === "string" ? `\n${error.stack}` : ""; - writeDesktopLogHeader(`fatal startup error stage=${stage} message=${message}`); - console.error(`[desktop] fatal startup error (${stage})`, error); - if (!isQuitting) { - isQuitting = true; - dialog.showErrorBox("T3 Code failed to start", `Stage: ${stage}\n${message}${detail}`); - } - stopBackend(); - restoreStdIoCapture?.(); - app.quit(); +function handleFatalStartupError( + stage: string, + error: unknown, +): Effect.Effect { + return Effect.gen(function* () { + const shutdown = yield* DesktopShutdown; + const message = formatErrorMessage(error); + const detail = + error instanceof Error && typeof error.stack === "string" ? `\n${error.stack}` : ""; + yield* logDesktopError("fatal startup error", { + stage, + message, + ...(detail.length > 0 ? { detail } : {}), + }); + yield* Effect.sync(() => { + if (!isQuitting) { + isQuitting = true; + dialog.showErrorBox("T3 Code failed to start", `Stage: ${stage}\n${message}${detail}`); + } + }); + yield* shutdown.request; + yield* Effect.sync(() => { + app.quit(); + }); + }); } -function registerDesktopProtocol(): void { - if (isDevelopment || desktopProtocolRegistered) return; +function registerDesktopProtocol(): Effect.Effect< + void, + unknown, + FileSystem.FileSystem | DesktopEnvironment +> { + return Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + if (environment.isDevelopment || desktopProtocolRegistered) return; + const context = yield* Effect.context(); + const runProtocolEffect = makeDesktopEffectRunner(context); + + const staticRoot = yield* resolveDesktopStaticDir(); + if (Option.isNone(staticRoot)) { + return yield* Effect.fail( + new Error("Desktop static bundle missing. Build apps/server (with bundled client) first."), + ); + } - const staticRoot = resolveDesktopStaticDir(); - if (!staticRoot) { - throw new Error( - "Desktop static bundle missing. Build apps/server (with bundled client) first.", - ); - } + const staticRootResolved = environment.path.resolve(staticRoot.value); + const staticRootPrefix = `${staticRootResolved}${environment.path.sep}`; + const fallbackIndex = environment.path.join(staticRootResolved, "index.html"); + + yield* Effect.sync(() => { + protocol.registerFileProtocol(DESKTOP_SCHEME, (request, callback) => { + const resolution = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const candidate = yield* resolveDesktopStaticPath(staticRootResolved, request.url); + const environment = yield* DesktopEnvironment; + const resolvedCandidate = environment.path.resolve(candidate); + const isInRoot = + resolvedCandidate === fallbackIndex || resolvedCandidate.startsWith(staticRootPrefix); + const isAssetRequest = isStaticAssetRequest(request.url, environment); + const exists = yield* fileSystem + .exists(resolvedCandidate) + .pipe(Effect.orElseSucceed(() => false)); + + if (!isInRoot || !exists) { + return isAssetRequest ? ({ error: -6 } as const) : ({ path: fallbackIndex } as const); + } - const staticRootResolved = Path.resolve(staticRoot); - const staticRootPrefix = `${staticRootResolved}${Path.sep}`; - const fallbackIndex = Path.join(staticRootResolved, "index.html"); - - protocol.registerFileProtocol(DESKTOP_SCHEME, (request, callback) => { - try { - const candidate = resolveDesktopStaticPath(staticRootResolved, request.url); - const resolvedCandidate = Path.resolve(candidate); - const isInRoot = - resolvedCandidate === fallbackIndex || resolvedCandidate.startsWith(staticRootPrefix); - const isAssetRequest = isStaticAssetRequest(request.url); - - if (!isInRoot || !FS.existsSync(resolvedCandidate)) { - if (isAssetRequest) { - callback({ error: -6 }); - return; - } - callback({ path: fallbackIndex }); - return; - } + return { path: resolvedCandidate } as const; + }).pipe(Effect.catch(() => Effect.succeed({ path: fallbackIndex } as const))); - callback({ path: resolvedCandidate }); - } catch { - callback({ path: fallbackIndex }); - } - }); + void runProtocolEffect(resolution).then(callback, () => { + callback({ path: fallbackIndex }); + }); + }); - desktopProtocolRegistered = true; + desktopProtocolRegistered = true; + }); + }); } -function dispatchMenuAction(action: string): void { +function dispatchMenuAction( + action: string, + runEffect: DesktopEffectRunner, + environment: DesktopEnvironmentShape, +): void { const existingWindow = BrowserWindow.getFocusedWindow() ?? mainWindow ?? BrowserWindow.getAllWindows()[0]; - const targetWindow = existingWindow ?? createWindow(); + const targetWindow = existingWindow ?? createWindow(runEffect, environment); if (!existingWindow) { mainWindow = targetWindow; } @@ -897,19 +1161,24 @@ function dispatchMenuAction(action: string): void { send(); } -function handleCheckForUpdatesMenuClick(): void { - const hasUpdateFeedConfig = - readAppUpdateYml() !== null || Boolean(process.env.T3CODE_DESKTOP_MOCK_UPDATES); +function handleCheckForUpdatesMenuClick( + runEffect: DesktopEffectRunner, + environment: DesktopEnvironmentShape, +): void { const disabledReason = getAutoUpdateDisabledReason({ - isDevelopment, + isDevelopment: environment.isDevelopment, isPackaged: app.isPackaged, platform: process.platform, appImage: process.env.APPIMAGE, disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1", - hasUpdateFeedConfig, + hasUpdateFeedConfig: hasDesktopUpdateFeedConfig(), }); if (disabledReason) { - console.info("[desktop-updater] Manual update check requested, but updates are disabled."); + void runEffect( + logUpdaterInfo("manual update check requested, but updates are disabled", { + disabledReason, + }), + ); void dialog.showMessageBox({ type: "info", title: "Updates unavailable", @@ -921,141 +1190,176 @@ function handleCheckForUpdatesMenuClick(): void { } if (!BrowserWindow.getAllWindows().length) { - mainWindow = createWindow(); + mainWindow = createWindow(runEffect, environment); } - void checkForUpdatesFromMenu(); + void runEffect(checkForUpdatesFromMenu()); } -async function checkForUpdatesFromMenu(): Promise { - await checkForUpdates("menu"); +function hasDesktopUpdateFeedConfig(): boolean { + return Option.isSome(appUpdateYmlConfig) || Boolean(process.env.T3CODE_DESKTOP_MOCK_UPDATES); +} - if (updateState.status === "up-to-date") { - void dialog.showMessageBox({ - type: "info", - title: "You're up to date!", - message: `T3 Code ${updateState.currentVersion} is currently the newest version available.`, - buttons: ["OK"], - }); - } else if (updateState.status === "error") { - void dialog.showMessageBox({ - type: "warning", - title: "Update check failed", - message: "Could not check for updates.", - detail: updateState.message ?? "An unknown error occurred. Please try again later.", - buttons: ["OK"], - }); - } +function checkForUpdatesFromMenu(): Effect.Effect { + return Effect.gen(function* () { + yield* checkForUpdates("menu"); + + if (updateState.status === "up-to-date") { + yield* Effect.promise(() => + dialog.showMessageBox({ + type: "info", + title: "You're up to date!", + message: `T3 Code ${updateState.currentVersion} is currently the newest version available.`, + buttons: ["OK"], + }), + ); + } else if (updateState.status === "error") { + yield* Effect.promise(() => + dialog.showMessageBox({ + type: "warning", + title: "Update check failed", + message: "Could not check for updates.", + detail: updateState.message ?? "An unknown error occurred. Please try again later.", + buttons: ["OK"], + }), + ); + } + }); } -function configureApplicationMenu(): void { - const template: MenuItemConstructorOptions[] = []; +function configureApplicationMenu(): Effect.Effect { + return Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + const context = yield* Effect.context(); + const runEffect = makeDesktopEffectRunner(context); + const template: MenuItemConstructorOptions[] = []; + + if (process.platform === "darwin") { + template.push({ + label: app.name, + submenu: [ + { role: "about" }, + { + label: "Check for Updates...", + click: () => handleCheckForUpdatesMenuClick(runEffect, environment), + }, + { type: "separator" }, + { + label: "Settings...", + accelerator: "CmdOrCtrl+,", + click: () => dispatchMenuAction("open-settings", runEffect, environment), + }, + { type: "separator" }, + { role: "services" }, + { type: "separator" }, + { role: "hide" }, + { role: "hideOthers" }, + { role: "unhide" }, + { type: "separator" }, + { role: "quit" }, + ], + }); + } - if (process.platform === "darwin") { - template.push({ - label: app.name, - submenu: [ - { role: "about" }, - { - label: "Check for Updates...", - click: () => handleCheckForUpdatesMenuClick(), - }, - { type: "separator" }, - { - label: "Settings...", - accelerator: "CmdOrCtrl+,", - click: () => dispatchMenuAction("open-settings"), - }, - { type: "separator" }, - { role: "services" }, - { type: "separator" }, - { role: "hide" }, - { role: "hideOthers" }, - { role: "unhide" }, - { type: "separator" }, - { role: "quit" }, - ], - }); - } - - template.push( - { - label: "File", - submenu: [ - ...(process.platform === "darwin" - ? [] - : [ - { - label: "Settings...", - accelerator: "CmdOrCtrl+,", - click: () => dispatchMenuAction("open-settings"), - }, - { type: "separator" as const }, - ]), - { role: process.platform === "darwin" ? "close" : "quit" }, - ], - }, - { role: "editMenu" }, - { - label: "View", - submenu: [ - { role: "reload" }, - { role: "forceReload" }, - { role: "toggleDevTools" }, - { type: "separator" }, - { role: "resetZoom" }, - { role: "zoomIn", accelerator: "CmdOrCtrl+=" }, - { role: "zoomIn", accelerator: "CmdOrCtrl+Plus", visible: false }, - { role: "zoomOut" }, - { type: "separator" }, - { role: "togglefullscreen" }, - ], - }, - { role: "windowMenu" }, - { - role: "help", - submenu: [ - { - label: "Check for Updates...", - click: () => handleCheckForUpdatesMenuClick(), - }, - ], - }, - ); + template.push( + { + label: "File", + submenu: [ + ...(process.platform === "darwin" + ? [] + : [ + { + label: "Settings...", + accelerator: "CmdOrCtrl+,", + click: () => dispatchMenuAction("open-settings", runEffect, environment), + }, + { type: "separator" as const }, + ]), + { role: process.platform === "darwin" ? "close" : "quit" }, + ], + }, + { role: "editMenu" }, + { + label: "View", + submenu: [ + { role: "reload" }, + { role: "forceReload" }, + { role: "toggleDevTools" }, + { type: "separator" }, + { role: "resetZoom" }, + { role: "zoomIn", accelerator: "CmdOrCtrl+=" }, + { role: "zoomIn", accelerator: "CmdOrCtrl+Plus", visible: false }, + { role: "zoomOut" }, + { type: "separator" }, + { role: "togglefullscreen" }, + ], + }, + { role: "windowMenu" }, + { + role: "help", + submenu: [ + { + label: "Check for Updates...", + click: () => handleCheckForUpdatesMenuClick(runEffect, environment), + }, + ], + }, + ); - Menu.setApplicationMenu(Menu.buildFromTemplate(template)); + yield* Effect.sync(() => { + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); + }); + }); } -function resolveResourcePath(fileName: string): string | null { - const candidates = [ - Path.join(__dirname, "../resources", fileName), - Path.join(__dirname, "../prod-resources", fileName), - Path.join(process.resourcesPath, "resources", fileName), - Path.join(process.resourcesPath, fileName), - ]; +function resolveResourcePath( + fileName: string, +): Effect.Effect, never, FileSystem.FileSystem | DesktopEnvironment> { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment; + const candidates = environment.resolveResourcePathCandidates(fileName); + for (const candidate of candidates) { + const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)); + if (exists) { + return Option.some(candidate); + } + } + return Option.none(); + }); +} - for (const candidate of candidates) { - if (FS.existsSync(candidate)) { - return candidate; +function resolveIconPath( + ext: "ico" | "icns" | "png", +): Effect.Effect, never, FileSystem.FileSystem | DesktopEnvironment> { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment; + if (environment.isDevelopment && process.platform === "darwin" && ext === "png") { + const developmentDockIconPath = environment.developmentDockIconPath; + const developmentDockIconExists = yield* fileSystem + .exists(developmentDockIconPath) + .pipe(Effect.orElseSucceed(() => false)); + if (developmentDockIconExists) { + return Option.some(developmentDockIconPath); + } } - } - return null; + return yield* resolveResourcePath(`icon.${ext}`); + }); } -function resolveIconPath(ext: "ico" | "icns" | "png"): string | null { - if (isDevelopment && process.platform === "darwin" && ext === "png") { - const developmentDockIconPath = Path.join( - ROOT_DIR, - "assets", - "dev", - "blueprint-macos-1024.png", +function resolveDesktopIconPaths(): Effect.Effect< + void, + never, + FileSystem.FileSystem | DesktopEnvironment +> { + return Effect.gen(function* () { + const [ico, icns, png] = yield* Effect.all( + [resolveIconPath("ico"), resolveIconPath("icns"), resolveIconPath("png")] as const, + { concurrency: "unbounded" }, ); - if (FS.existsSync(developmentDockIconPath)) { - return developmentDockIconPath; - } - } - - return resolveResourcePath(`icon.${ext}`); + desktopIconPaths = { ico, icns, png }; + }); } /** @@ -1070,56 +1374,101 @@ function resolveIconPath(ext: "ico" | "icns" | "png"): string | null { * directory already exists we keep using it so existing users don't * lose their Chromium profile data (localStorage, cookies, sessions). */ -function resolveUserDataPath(): string { - const appDataBase = - process.platform === "win32" - ? process.env.APPDATA || Path.join(OS.homedir(), "AppData", "Roaming") - : process.platform === "darwin" - ? Path.join(OS.homedir(), "Library", "Application Support") - : process.env.XDG_CONFIG_HOME || Path.join(OS.homedir(), ".config"); - - const legacyPath = Path.join(appDataBase, LEGACY_USER_DATA_DIR_NAME); - if (FS.existsSync(legacyPath)) { - return legacyPath; - } - - return Path.join(appDataBase, USER_DATA_DIR_NAME); +function resolveUserDataPath(): Effect.Effect< + string, + never, + FileSystem.FileSystem | DesktopEnvironment +> { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment; + const appDataBase = + process.platform === "win32" + ? process.env.APPDATA || + environment.path.join(environment.homeDirectory, "AppData", "Roaming") + : process.platform === "darwin" + ? environment.path.join(environment.homeDirectory, "Library", "Application Support") + : process.env.XDG_CONFIG_HOME || + environment.path.join(environment.homeDirectory, ".config"); + const legacyPath = environment.path.join(appDataBase, environment.legacyUserDataDirName); + const legacyPathExists = yield* fileSystem + .exists(legacyPath) + .pipe(Effect.orElseSucceed(() => false)); + return legacyPathExists + ? legacyPath + : environment.path.join(appDataBase, environment.userDataDirName); + }); } -function configureAppIdentity(): void { - app.setName(APP_DISPLAY_NAME); - const commitHash = resolveAboutCommitHash(); - app.setAboutPanelOptions({ - applicationName: APP_DISPLAY_NAME, - applicationVersion: app.getVersion(), - version: commitHash ?? "unknown", - }); +function configureAppIdentity(): Effect.Effect< + void, + never, + FileSystem.FileSystem | DesktopEnvironment +> { + return Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + const commitHash = yield* resolveAboutCommitHash(); + yield* Effect.sync(() => { + app.setName(environment.displayName); + app.setAboutPanelOptions({ + applicationName: environment.displayName, + applicationVersion: environment.appVersion, + version: Option.getOrElse(commitHash, () => "unknown"), + }); - if (process.platform === "win32") { - app.setAppUserModelId(APP_USER_MODEL_ID); - } + if (process.platform === "win32") { + app.setAppUserModelId(environment.appUserModelId); + } - if (process.platform === "linux") { - (app as LinuxDesktopNamedApp).setDesktopName?.(LINUX_DESKTOP_ENTRY_NAME); - } + if (process.platform === "linux") { + (app as LinuxDesktopNamedApp).setDesktopName?.(environment.linuxDesktopEntryName); + } - if (process.platform === "darwin" && app.dock) { - const iconPath = resolveIconPath("png"); - if (iconPath) { - app.dock.setIcon(iconPath); - } - } + if (process.platform === "darwin" && app.dock) { + const iconPath = Option.getOrUndefined(desktopIconPaths.png); + if (iconPath) { + app.dock.setIcon(iconPath); + } + } + }); + }); } -function clearUpdatePollTimer(): void { - if (updateStartupTimer) { - clearTimeout(updateStartupTimer); - updateStartupTimer = null; - } - if (updatePollTimer) { - clearInterval(updatePollTimer); - updatePollTimer = null; - } +function clearUpdatePollTimer(): Effect.Effect { + return Effect.gen(function* () { + const scope = updatePollerScope; + updatePollerScope = Option.none(); + yield* Option.match(scope, { + onNone: () => Effect.void, + onSome: (value) => Scope.close(value, Exit.void).pipe(Effect.ignore), + }); + }); +} + +function startUpdatePollers(): Effect.Effect { + return Effect.gen(function* () { + yield* clearUpdatePollTimer(); + const parentScope = yield* Scope.Scope; + const scope = yield* Scope.make("sequential"); + updatePollerScope = Option.some(scope); + yield* Scope.addFinalizer(parentScope, Scope.close(scope, Exit.void).pipe(Effect.ignore)); + + yield* Effect.sleep(Duration.millis(AUTO_UPDATE_STARTUP_DELAY_MS)).pipe( + Effect.andThen(checkForUpdates("startup")), + Effect.catchCause((cause) => + logUpdaterError("startup update check failed", { cause: Cause.pretty(cause) }), + ), + Effect.forkIn(scope), + ); + yield* Effect.sleep(Duration.millis(AUTO_UPDATE_POLL_INTERVAL_MS)).pipe( + Effect.andThen(checkForUpdates("poll")), + Effect.forever, + Effect.catchCause((cause) => + logUpdaterError("poll update check failed", { cause: Cause.pretty(cause) }), + ), + Effect.forkIn(scope), + ); + }); } function revealWindow(window: BrowserWindow): void { @@ -1157,757 +1506,761 @@ function setUpdateState(patch: Partial): void { function createBaseUpdateState( channel: DesktopUpdateChannel, enabled: boolean, + environment: DesktopEnvironmentShape, ): DesktopUpdateState { return { - ...createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo, channel), + ...createInitialDesktopUpdateState(environment.appVersion, environment.runtimeInfo, channel), enabled, status: enabled ? "idle" : "disabled", }; } -function applyAutoUpdaterChannel(channel: DesktopUpdateChannel): void { - autoUpdater.channel = channel; - autoUpdater.allowPrerelease = channel === "nightly"; - autoUpdater.allowDowngrade = channel === "nightly"; - console.info( - `[desktop-updater] Using update channel '${channel}' (allowPrerelease=${channel === "nightly"}, allowDowngrade=${channel === "nightly"}).`, - ); +function applyAutoUpdaterChannel(channel: DesktopUpdateChannel): Effect.Effect { + return Effect.gen(function* () { + const allowsPrerelease = channel === "nightly"; + yield* Effect.sync(() => { + autoUpdater.channel = channel; + autoUpdater.allowPrerelease = allowsPrerelease; + autoUpdater.allowDowngrade = allowsPrerelease; + }); + yield* logUpdaterInfo("using update channel", { + channel, + allowPrerelease: allowsPrerelease, + allowDowngrade: allowsPrerelease, + }); + }); } -function shouldEnableAutoUpdates(): boolean { - const hasUpdateFeedConfig = - readAppUpdateYml() !== null || Boolean(process.env.T3CODE_DESKTOP_MOCK_UPDATES); +function shouldEnableAutoUpdates(environment: DesktopEnvironmentShape): boolean { return ( getAutoUpdateDisabledReason({ - isDevelopment, + isDevelopment: environment.isDevelopment, isPackaged: app.isPackaged, platform: process.platform, appImage: process.env.APPIMAGE, disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1", - hasUpdateFeedConfig, + hasUpdateFeedConfig: hasDesktopUpdateFeedConfig(), }) === null ); } -async function checkForUpdates(reason: string): Promise { - if (isQuitting || !updaterConfigured || updateCheckInFlight) return false; - if (updateState.status === "downloading" || updateState.status === "downloaded") { - console.info( - `[desktop-updater] Skipping update check (${reason}) while status=${updateState.status}.`, - ); - return false; - } - updateCheckInFlight = true; - setUpdateState(reduceDesktopUpdateStateOnCheckStart(updateState, new Date().toISOString())); - console.info(`[desktop-updater] Checking for updates (${reason})...`); - - try { - await autoUpdater.checkForUpdates(); - return true; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - setUpdateState( - reduceDesktopUpdateStateOnCheckFailure(updateState, message, new Date().toISOString()), - ); - console.error(`[desktop-updater] Failed to check for updates: ${message}`); - return true; - } finally { - updateCheckInFlight = false; - } -} - -async function downloadAvailableUpdate(): Promise<{ - accepted: boolean; - completed: boolean; -}> { - if (!updaterConfigured || updateDownloadInFlight || updateState.status !== "available") { - return { accepted: false, completed: false }; - } - updateDownloadInFlight = true; - setUpdateState(reduceDesktopUpdateStateOnDownloadStart(updateState)); - autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(desktopRuntimeInfo); - console.info("[desktop-updater] Downloading update..."); - - try { - await autoUpdater.downloadUpdate(); - return { accepted: true, completed: true }; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - setUpdateState(reduceDesktopUpdateStateOnDownloadFailure(updateState, message)); - console.error(`[desktop-updater] Failed to download update: ${message}`); - return { accepted: true, completed: false }; - } finally { - updateDownloadInFlight = false; - } -} +function checkForUpdates(reason: string): Effect.Effect { + return Effect.gen(function* () { + if (isQuitting || !updaterConfigured || updateCheckInFlight) return false; + if (updateState.status === "downloading" || updateState.status === "downloaded") { + yield* logUpdaterInfo("skipping update check while update is active", { + reason, + status: updateState.status, + }); + return false; + } -async function installDownloadedUpdate(): Promise<{ - accepted: boolean; - completed: boolean; -}> { - if (isQuitting || !updaterConfigured || updateState.status !== "downloaded") { - return { accepted: false, completed: false }; - } + updateCheckInFlight = true; + const checkedAt = yield* currentIsoTimestamp; + setUpdateState(reduceDesktopUpdateStateOnCheckStart(updateState, checkedAt)); + yield* logUpdaterInfo("checking for updates", { reason }); - isQuitting = true; - updateInstallInFlight = true; - clearUpdatePollTimer(); - try { - await stopBackendAndWaitForExit(); - // Destroy all windows before launching the NSIS installer to avoid the installer finding live windows it needs to close. - for (const win of BrowserWindow.getAllWindows()) { - win.destroy(); - } - // `quitAndInstall()` only starts the handoff to the updater. The actual - // install may still fail asynchronously, so keep the action incomplete - // until we either quit or receive an updater error. - autoUpdater.quitAndInstall(true, true); - return { accepted: true, completed: false }; - } catch (error: unknown) { - const message = formatErrorMessage(error); - updateInstallInFlight = false; - isQuitting = false; - setUpdateState(reduceDesktopUpdateStateOnInstallFailure(updateState, message)); - console.error(`[desktop-updater] Failed to install update: ${message}`); - return { accepted: true, completed: false }; - } + return yield* Effect.promise(() => autoUpdater.checkForUpdates()).pipe( + Effect.as(true), + Effect.catch((error: unknown) => + Effect.gen(function* () { + const failedAt = yield* currentIsoTimestamp; + const message = formatErrorMessage(error); + setUpdateState(reduceDesktopUpdateStateOnCheckFailure(updateState, message, failedAt)); + yield* logUpdaterError("failed to check for updates", { message }); + return true; + }), + ), + Effect.ensuring( + Effect.sync(() => { + updateCheckInFlight = false; + }), + ), + ); + }); } -function configureAutoUpdater(): void { - const githubToken = - process.env.T3CODE_DESKTOP_UPDATE_GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim() || ""; - if (githubToken) { - // When a token is provided, re-configure the feed with `private: true` so - // electron-updater uses the GitHub API (api.github.com) instead of the - // public Atom feed (github.com/…/releases.atom) which rejects Bearer auth. - const appUpdateYml = readAppUpdateYml(); - if (appUpdateYml?.provider === "github") { - autoUpdater.setFeedURL({ - ...appUpdateYml, - provider: "github" as const, - private: true, - token: githubToken, - }); +function downloadAvailableUpdate(): Effect.Effect< + { + accepted: boolean; + completed: boolean; + }, + never, + DesktopEnvironment +> { + return Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + if (!updaterConfigured || updateDownloadInFlight || updateState.status !== "available") { + return { accepted: false, completed: false }; } - } - if (process.env.T3CODE_DESKTOP_MOCK_UPDATES) { - autoUpdater.setFeedURL({ - provider: "generic", - url: `http://localhost:${process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT ?? 3000}`, - }); - } + updateDownloadInFlight = true; + setUpdateState(reduceDesktopUpdateStateOnDownloadStart(updateState)); + autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(environment.runtimeInfo); + yield* logUpdaterInfo("downloading update"); - const enabled = shouldEnableAutoUpdates(); - setUpdateState(createBaseUpdateState(desktopSettings.updateChannel, enabled)); - if (!enabled) { - return; - } - updaterConfigured = true; - - autoUpdater.autoDownload = false; - autoUpdater.autoInstallOnAppQuit = false; - applyAutoUpdaterChannel(desktopSettings.updateChannel); - autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(desktopRuntimeInfo); - let lastLoggedDownloadMilestone = -1; - - if (isArm64HostRunningIntelBuild(desktopRuntimeInfo)) { - console.info( - "[desktop-updater] Apple Silicon host detected while running Intel build; updates will switch to arm64 packages.", + return yield* Effect.promise(() => autoUpdater.downloadUpdate()).pipe( + Effect.as({ accepted: true, completed: true }), + Effect.catch((error: unknown) => + Effect.sync(() => { + const message = formatErrorMessage(error); + setUpdateState(reduceDesktopUpdateStateOnDownloadFailure(updateState, message)); + return { accepted: true, completed: false }; + }).pipe( + Effect.tap(() => + logUpdaterError("failed to download update", { message: formatErrorMessage(error) }), + ), + ), + ), + Effect.ensuring( + Effect.sync(() => { + updateDownloadInFlight = false; + }), + ), ); - } - - autoUpdater.on("checking-for-update", () => { - console.info("[desktop-updater] Looking for updates..."); }); - autoUpdater.on("update-available", (info) => { - if (!doesVersionMatchDesktopUpdateChannel(info.version, updateState.channel)) { - console.info( - `[desktop-updater] Ignoring ${info.version} because it does not match the selected '${updateState.channel}' channel.`, - ); - setUpdateState(reduceDesktopUpdateStateOnNoUpdate(updateState, new Date().toISOString())); - lastLoggedDownloadMilestone = -1; - return; +} + +function installDownloadedUpdate(): Effect.Effect< + { + accepted: boolean; + completed: boolean; + }, + never, + DesktopBackendManager +> { + return Effect.gen(function* () { + if (isQuitting || !updaterConfigured || updateState.status !== "downloaded") { + return { accepted: false, completed: false }; } - setUpdateState( - reduceDesktopUpdateStateOnUpdateAvailable( - updateState, - info.version, - new Date().toISOString(), + isQuitting = true; + updateInstallInFlight = true; + yield* clearUpdatePollTimer(); + + return yield* Effect.gen(function* () { + const backendManager = yield* DesktopBackendManager; + yield* backendManager.stop({ timeout: Duration.seconds(5) }); + yield* Effect.sync(() => { + // Destroy all windows before launching the NSIS installer to avoid the installer finding live windows it needs to close. + for (const win of BrowserWindow.getAllWindows()) { + win.destroy(); + } + // `quitAndInstall()` only starts the handoff to the updater. The actual + // install may still fail asynchronously, so keep the action incomplete + // until we either quit or receive an updater error. + autoUpdater.quitAndInstall(true, true); + }); + return { accepted: true, completed: false }; + }).pipe( + Effect.catch((error: unknown) => + Effect.gen(function* () { + const message = formatErrorMessage(error); + yield* Effect.sync(() => { + updateInstallInFlight = false; + isQuitting = false; + setUpdateState(reduceDesktopUpdateStateOnInstallFailure(updateState, message)); + }); + yield* logUpdaterError("failed to install update", { message }); + return { accepted: true, completed: false }; + }), ), ); - lastLoggedDownloadMilestone = -1; - console.info(`[desktop-updater] Update available: ${info.version}`); - }); - autoUpdater.on("update-not-available", () => { - setUpdateState(reduceDesktopUpdateStateOnNoUpdate(updateState, new Date().toISOString())); - lastLoggedDownloadMilestone = -1; - console.info("[desktop-updater] No updates available."); }); - autoUpdater.on("error", (error) => { - const message = formatErrorMessage(error); - if (updateInstallInFlight) { - updateInstallInFlight = false; - isQuitting = false; - setUpdateState(reduceDesktopUpdateStateOnInstallFailure(updateState, message)); - console.error(`[desktop-updater] Updater error: ${message}`); - return; +} + +function configureAutoUpdater(): Effect.Effect { + return Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + const context = yield* Effect.context(); + const runEffect = makeDesktopEffectRunner(context); + const githubToken = + process.env.T3CODE_DESKTOP_UPDATE_GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim() || ""; + if (githubToken) { + // When a token is provided, re-configure the feed with `private: true` so + // electron-updater uses the GitHub API (api.github.com) instead of the + // public Atom feed (github.com/…/releases.atom) which rejects Bearer auth. + const appUpdateYml = Option.getOrUndefined(appUpdateYmlConfig); + if (appUpdateYml?.provider === "github") { + autoUpdater.setFeedURL({ + ...appUpdateYml, + provider: "github" as const, + private: true, + token: githubToken, + }); + } } - if (!updateCheckInFlight && !updateDownloadInFlight) { - setUpdateState({ - status: "error", - message, - checkedAt: new Date().toISOString(), - downloadPercent: null, - errorContext: resolveUpdaterErrorContext(), - canRetry: updateState.availableVersion !== null || updateState.downloadedVersion !== null, + + if (process.env.T3CODE_DESKTOP_MOCK_UPDATES) { + autoUpdater.setFeedURL({ + provider: "generic", + url: `http://localhost:${process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT ?? 3000}`, }); } - console.error(`[desktop-updater] Updater error: ${message}`); - }); - autoUpdater.on("download-progress", (progress) => { - const percent = Math.floor(progress.percent); - if ( - shouldBroadcastDownloadProgress(updateState, progress.percent) || - updateState.message !== null - ) { - setUpdateState(reduceDesktopUpdateStateOnDownloadProgress(updateState, progress.percent)); - } - const milestone = percent - (percent % 10); - if (milestone > lastLoggedDownloadMilestone) { - lastLoggedDownloadMilestone = milestone; - console.info(`[desktop-updater] Download progress: ${percent}%`); - } - }); - autoUpdater.on("update-downloaded", (info) => { - setUpdateState(reduceDesktopUpdateStateOnDownloadComplete(updateState, info.version)); - console.info(`[desktop-updater] Update downloaded: ${info.version}`); - }); - clearUpdatePollTimer(); + const enabled = shouldEnableAutoUpdates(environment); + setUpdateState(createBaseUpdateState(desktopSettings.updateChannel, enabled, environment)); + if (!enabled) { + return; + } + updaterConfigured = true; - updateStartupTimer = setTimeout(() => { - updateStartupTimer = null; - void checkForUpdates("startup"); - }, AUTO_UPDATE_STARTUP_DELAY_MS); - updateStartupTimer.unref(); + autoUpdater.autoDownload = false; + autoUpdater.autoInstallOnAppQuit = false; + yield* applyAutoUpdaterChannel(desktopSettings.updateChannel); + autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(environment.runtimeInfo); + let lastLoggedDownloadMilestone = -1; - updatePollTimer = setInterval(() => { - void checkForUpdates("poll"); - }, AUTO_UPDATE_POLL_INTERVAL_MS); - updatePollTimer.unref(); -} -function scheduleBackendRestart(reason: string): void { - if (isQuitting || restartTimer) return; + if (isArm64HostRunningIntelBuild(environment.runtimeInfo)) { + yield* logUpdaterInfo( + "Apple Silicon host detected while running Intel build; updates will switch to arm64 packages", + ); + } - const delayMs = Math.min(500 * 2 ** restartAttempt, 10_000); - restartAttempt += 1; - console.error(`[desktop] backend exited unexpectedly (${reason}); restarting in ${delayMs}ms`); + yield* addScopedListener(autoUpdater, "checking-for-update", () => { + void runEffect(logUpdaterInfo("looking for updates")); + }); + yield* addScopedListener(autoUpdater, "update-available", (info: { version: string }) => { + if (!doesVersionMatchDesktopUpdateChannel(info.version, updateState.channel)) { + void runEffect( + logUpdaterInfo("ignoring update that does not match selected channel", { + version: info.version, + channel: updateState.channel, + }), + ); + setUpdateState(reduceDesktopUpdateStateOnNoUpdate(updateState, nowIsoTimestamp())); + lastLoggedDownloadMilestone = -1; + return; + } - restartTimer = setTimeout(() => { - restartTimer = null; - startBackend(); - }, delayMs); -} + setUpdateState( + reduceDesktopUpdateStateOnUpdateAvailable(updateState, info.version, nowIsoTimestamp()), + ); + lastLoggedDownloadMilestone = -1; + void runEffect(logUpdaterInfo("update available", { version: info.version })); + }); + yield* addScopedListener(autoUpdater, "update-not-available", () => { + setUpdateState(reduceDesktopUpdateStateOnNoUpdate(updateState, nowIsoTimestamp())); + lastLoggedDownloadMilestone = -1; + void runEffect(logUpdaterInfo("no updates available")); + }); + yield* addScopedListener(autoUpdater, "error", (error: unknown) => { + const message = formatErrorMessage(error); + if (updateInstallInFlight) { + updateInstallInFlight = false; + isQuitting = false; + setUpdateState(reduceDesktopUpdateStateOnInstallFailure(updateState, message)); + void runEffect(logUpdaterError("updater error", { message })); + return; + } + if (!updateCheckInFlight && !updateDownloadInFlight) { + setUpdateState({ + status: "error", + message, + checkedAt: nowIsoTimestamp(), + downloadPercent: null, + errorContext: resolveUpdaterErrorContext(), + canRetry: updateState.availableVersion !== null || updateState.downloadedVersion !== null, + }); + } + void runEffect(logUpdaterError("updater error", { message })); + }); + yield* addScopedListener(autoUpdater, "download-progress", (progress: { percent: number }) => { + const percent = Math.floor(progress.percent); + if ( + shouldBroadcastDownloadProgress(updateState, progress.percent) || + updateState.message !== null + ) { + setUpdateState(reduceDesktopUpdateStateOnDownloadProgress(updateState, progress.percent)); + } + const milestone = percent - (percent % 10); + if (milestone > lastLoggedDownloadMilestone) { + lastLoggedDownloadMilestone = milestone; + void runEffect(logUpdaterInfo("download progress", { percent })); + } + }); + yield* addScopedListener(autoUpdater, "update-downloaded", (info: { version: string }) => { + setUpdateState(reduceDesktopUpdateStateOnDownloadComplete(updateState, info.version)); + void runEffect(logUpdaterInfo("update downloaded", { version: info.version })); + }); -function clearBackendRestartTimer(): void { - if (restartTimer) { - clearTimeout(restartTimer); - restartTimer = null; - } + yield* startUpdatePollers(); + }); } -function startBackend(): void { - if (isQuitting || backendProcessFiber !== null) return; - - backendReady = false; - backendObservabilitySettings = readPersistedBackendObservabilitySettings(); - const backendEntry = resolveBackendEntry(); - if (!FS.existsSync(backendEntry)) { - scheduleBackendRestart(`missing server entry at ${backendEntry}`); - return; - } - - const captureBackendLogs = !isDevelopment; - let backendSessionClosed = false; - let backendSessionStarted = false; - let backendPid: number | null = null; - let startedFiber: Fiber.Fiber | null = null; - const closeBackendSession = (details: string) => { - if (backendSessionClosed || !backendSessionStarted) return; - backendSessionClosed = true; - writeBackendSessionBoundary("END", details); - }; - - const clearStartedBackendState = (): void => { - if (backendProcessFiber === startedFiber) { - backendProcessFiber = null; - } - backendReady = false; - }; - - const finalizeBackendSession = (details: string): void => { - clearStartedBackendState(); - closeBackendSession(details); - }; - - const program = Effect.scoped( - runBackendProcess({ - executablePath: process.execPath, - entryPath: backendEntry, - cwd: resolveBackendCwd(), - env: { - ...backendChildEnv(), - ELECTRON_RUN_AS_NODE: "1", - }, - bootstrap: { - mode: "desktop", - noBrowser: true, - port: backendPort, - t3Home: BASE_DIR, - host: backendBindHost, - desktopBootstrapToken: backendBootstrapToken, - tailscaleServeEnabled: desktopSettings.tailscaleServeEnabled, - tailscaleServePort: desktopSettings.tailscaleServePort, - ...(backendObservabilitySettings.otlpTracesUrl - ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl } - : {}), - ...(backendObservabilitySettings.otlpMetricsUrl - ? { otlpMetricsUrl: backendObservabilitySettings.otlpMetricsUrl } - : {}), - }, - httpBaseUrl: requireBackendHttpUrl(), - captureOutput: captureBackendLogs, - onStarted: (pid) => - Effect.sync(() => { - backendPid = pid; - backendSessionStarted = true; - restartAttempt = 0; - writeBackendSessionBoundary( - "START", - `pid=${pid} port=${backendPort} cwd=${resolveBackendCwd()}`, - ); - }), - onReady: () => Effect.sync(handleBackendReady), - onReadinessFailure: (error) => - Effect.sync(() => { - writeDesktopLogHeader( - `bootstrap backend readiness warning message=${formatErrorMessage(error)}`, - ); - console.warn("[desktop] backend readiness check failed during bootstrap", error); - }), - onOutput: (streamName, chunk) => - Effect.sync(() => { - writeBackendOutputChunk(streamName, chunk); - }), - }).pipe( - Effect.match({ - onFailure: (error) => { - const message = formatErrorMessage(error); - finalizeBackendSession(`pid=${backendPid ?? "unknown"} error=${message}`); - if (isQuitting) { - return; - } - scheduleBackendRestart(message); - }, - onSuccess: (exit) => { - finalizeBackendSession(`pid=${backendPid ?? "unknown"} ${exit.reason}`); - if (isQuitting) { - return; - } - scheduleBackendRestart(exit.reason); - }, - }), - ), - ).pipe( - Effect.ensuring( - Effect.sync(() => { - finalizeBackendSession(`pid=${backendPid ?? "unknown"} interrupted`); +function startBackend(): Effect.Effect { + if (isQuitting) return Effect.void; + return Effect.gen(function* () { + const backendManager = yield* DesktopBackendManager; + yield* backendManager.start; + }).pipe( + Effect.catchCause((cause) => + logDesktopError("failed to start backend", { + cause: Cause.pretty(cause), }), ), - Effect.provide(backendProcessLayer), ); - - startedFiber = Effect.runFork(program); - backendProcessFiber = startedFiber; } -function stopBackend(): void { - clearBackendRestartTimer(); - backendReady = false; - - const fiber = backendProcessFiber; - backendProcessFiber = null; - if (fiber !== null) { - Effect.runFork(Fiber.interrupt(fiber).pipe(Effect.ignore)); - } +function closeDesktopResourcesWithManager( + backendManager: DesktopBackendManagerShape, + desktopSshEnvironmentBridge: DesktopSshEnvironmentBridgeShape, +): Effect.Effect { + return Effect.gen(function* () { + yield* backendManager.shutdown; + updateInstallInFlight = false; + yield* clearUpdatePollTimer(); + yield* desktopSshEnvironmentBridge.disposeEffect().pipe(Effect.ignore); + }); } -async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise { - clearBackendRestartTimer(); - backendReady = false; - - const fiber = backendProcessFiber; - backendProcessFiber = null; - if (fiber === null) return; +function requestDesktopShutdownAndWait(): Effect.Effect { + return Effect.gen(function* () { + const shutdown = yield* DesktopShutdown; + yield* shutdown.request; + yield* shutdown.awaitComplete; + }); +} - await Effect.runPromise( - Fiber.interrupt(fiber).pipe( - Effect.timeoutOption(Duration.millis(timeoutMs)), - Effect.asVoid, - Effect.ignore, +function quitFromSignal(signal: "SIGINT" | "SIGTERM", runEffect: DesktopEffectRunner): void { + if (isQuitting) return; + isQuitting = true; + void runEffect( + logDesktopInfo("process signal received", { signal }).pipe( + Effect.andThen(requestDesktopShutdownAndWait()), ), - ); + ).finally(() => { + app.quit(); + }); } -function registerIpcHandlers(): void { - ipcMain.removeAllListeners(GET_APP_BRANDING_CHANNEL); - ipcMain.on(GET_APP_BRANDING_CHANNEL, (event) => { - event.returnValue = desktopAppBranding; - }); +const syncIpcListenerChannels = [ + GET_APP_BRANDING_CHANNEL, + GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, +] as const; + +const handledIpcChannels = [ + GET_CLIENT_SETTINGS_CHANNEL, + SET_CLIENT_SETTINGS_CHANNEL, + GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, + SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, + GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, + SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, + REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, + GET_SERVER_EXPOSURE_STATE_CHANNEL, + SET_SERVER_EXPOSURE_MODE_CHANNEL, + SET_TAILSCALE_SERVE_ENABLED_CHANNEL, + GET_ADVERTISED_ENDPOINTS_CHANNEL, + PICK_FOLDER_CHANNEL, + CONFIRM_CHANNEL, + SET_THEME_CHANNEL, + CONTEXT_MENU_CHANNEL, + OPEN_EXTERNAL_CHANNEL, + UPDATE_GET_STATE_CHANNEL, + UPDATE_SET_CHANNEL_CHANNEL, + UPDATE_DOWNLOAD_CHANNEL, + UPDATE_INSTALL_CHANNEL, + UPDATE_CHECK_CHANNEL, +] as const; + +function clearDesktopIpcHandlers(): void { + for (const channel of syncIpcListenerChannels) { + ipcMain.removeAllListeners(channel); + } + for (const channel of handledIpcChannels) { + ipcMain.removeHandler(channel); + } +} + +function registerIpcHandlers() { + return Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + const desktopSshEnvironmentBridge = yield* DesktopSshEnvironmentBridge; + const context = yield* Effect.context(); + const runIpcEffect = makeDesktopEffectRunner(context); + + yield* Effect.acquireRelease( + Effect.sync(() => { + ipcMain.removeAllListeners(GET_APP_BRANDING_CHANNEL); + ipcMain.on(GET_APP_BRANDING_CHANNEL, (event) => { + event.returnValue = environment.branding; + }); - ipcMain.removeAllListeners(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); - ipcMain.on(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, (event) => { - event.returnValue = { - label: "Local environment", - httpBaseUrl: getBackendHttpUrlHref(), - wsBaseUrl: backendWsUrl || null, - bootstrapToken: backendBootstrapToken || undefined, - } as const; - }); + ipcMain.removeAllListeners(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); + ipcMain.on(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, (event) => { + event.returnValue = { + label: "Local environment", + httpBaseUrl: getBackendHttpUrlHref(), + wsBaseUrl: backendWsUrl || null, + bootstrapToken: backendBootstrapToken || undefined, + } as const; + }); - ipcMain.removeHandler(GET_CLIENT_SETTINGS_CHANNEL); - ipcMain.handle(GET_CLIENT_SETTINGS_CHANNEL, async () => readClientSettings(CLIENT_SETTINGS_PATH)); + ipcMain.removeHandler(GET_CLIENT_SETTINGS_CHANNEL); + ipcMain.handle(GET_CLIENT_SETTINGS_CHANNEL, async () => + runIpcEffect(readClientSettingsEffect(environment.clientSettingsPath)), + ); - ipcMain.removeHandler(SET_CLIENT_SETTINGS_CHANNEL); - ipcMain.handle(SET_CLIENT_SETTINGS_CHANNEL, async (_event, rawSettings: unknown) => { - if (typeof rawSettings !== "object" || rawSettings === null) { - throw new Error("Invalid client settings payload."); - } + ipcMain.removeHandler(SET_CLIENT_SETTINGS_CHANNEL); + ipcMain.handle(SET_CLIENT_SETTINGS_CHANNEL, async (_event, rawSettings: unknown) => { + if (typeof rawSettings !== "object" || rawSettings === null) { + throw new Error("Invalid client settings payload."); + } - writeClientSettings(CLIENT_SETTINGS_PATH, rawSettings as ClientSettings); - }); + await runIpcEffect( + writeClientSettingsEffect( + environment.clientSettingsPath, + rawSettings as ClientSettings, + ), + ); + }); - ipcMain.removeHandler(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL); - ipcMain.handle(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, async () => - readSavedEnvironmentRegistry(SAVED_ENVIRONMENT_REGISTRY_PATH), - ); + ipcMain.removeHandler(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL); + ipcMain.handle(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, async () => + runIpcEffect( + readSavedEnvironmentRegistryEffect(environment.savedEnvironmentRegistryPath), + ), + ); - ipcMain.removeHandler(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL); - ipcMain.handle(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, async (_event, rawRecords: unknown) => { - if (!Array.isArray(rawRecords)) { - throw new Error("Invalid saved environment registry payload."); - } + ipcMain.removeHandler(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL); + ipcMain.handle( + SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, + async (_event, rawRecords: unknown) => { + if (!Array.isArray(rawRecords)) { + throw new Error("Invalid saved environment registry payload."); + } - writeSavedEnvironmentRegistry( - SAVED_ENVIRONMENT_REGISTRY_PATH, - rawRecords as readonly PersistedSavedEnvironmentRecord[], - ); - }); + await runIpcEffect( + writeSavedEnvironmentRegistryEffect( + environment.savedEnvironmentRegistryPath, + rawRecords as readonly PersistedSavedEnvironmentRecord[], + ), + ); + }, + ); - ipcMain.removeHandler(GET_SAVED_ENVIRONMENT_SECRET_CHANNEL); - ipcMain.handle( - GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, - async (_event, rawEnvironmentId: unknown) => { - if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) { - return null; - } + ipcMain.removeHandler(GET_SAVED_ENVIRONMENT_SECRET_CHANNEL); + ipcMain.handle( + GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, + async (_event, rawEnvironmentId: unknown) => { + if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) { + return null; + } - return readSavedEnvironmentSecret({ - registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH, - environmentId: rawEnvironmentId, - secretStorage: getDesktopSecretStorage(), - }); - }, - ); + return runIpcEffect( + readSavedEnvironmentSecretEffect({ + registryPath: environment.savedEnvironmentRegistryPath, + environmentId: rawEnvironmentId, + secretStorage: getDesktopSecretStorage(), + }), + ); + }, + ); - ipcMain.removeHandler(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL); - ipcMain.handle( - SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, - async (_event, rawEnvironmentId: unknown, rawSecret: unknown) => { - if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) { - throw new Error("Invalid saved environment id."); - } - if (typeof rawSecret !== "string" || rawSecret.trim().length === 0) { - throw new Error("Invalid saved environment secret."); - } + ipcMain.removeHandler(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL); + ipcMain.handle( + SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, + async (_event, rawEnvironmentId: unknown, rawSecret: unknown) => { + if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) { + throw new Error("Invalid saved environment id."); + } + if (typeof rawSecret !== "string" || rawSecret.trim().length === 0) { + throw new Error("Invalid saved environment secret."); + } - return writeSavedEnvironmentSecret({ - registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH, - environmentId: rawEnvironmentId, - secret: rawSecret, - secretStorage: getDesktopSecretStorage(), - }); - }, - ); + return runIpcEffect( + writeSavedEnvironmentSecretEffect({ + registryPath: environment.savedEnvironmentRegistryPath, + environmentId: rawEnvironmentId, + secret: rawSecret, + secretStorage: getDesktopSecretStorage(), + }), + ); + }, + ); - ipcMain.removeHandler(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL); - ipcMain.handle( - REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, - async (_event, rawEnvironmentId: unknown) => { - if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) { - return; - } + ipcMain.removeHandler(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL); + ipcMain.handle( + REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, + async (_event, rawEnvironmentId: unknown) => { + if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) { + return; + } - removeSavedEnvironmentSecret({ - registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH, - environmentId: rawEnvironmentId, - }); - }, - ); + await runIpcEffect( + removeSavedEnvironmentSecretEffect({ + registryPath: environment.savedEnvironmentRegistryPath, + environmentId: rawEnvironmentId, + }), + ); + }, + ); - desktopSshEnvironmentBridge.registerIpcHandlers(ipcMain); + ipcMain.removeHandler(GET_SERVER_EXPOSURE_STATE_CHANNEL); + ipcMain.handle(GET_SERVER_EXPOSURE_STATE_CHANNEL, async () => + getDesktopServerExposureState(), + ); - ipcMain.removeHandler(GET_SERVER_EXPOSURE_STATE_CHANNEL); - ipcMain.handle(GET_SERVER_EXPOSURE_STATE_CHANNEL, async () => getDesktopServerExposureState()); + ipcMain.removeHandler(SET_SERVER_EXPOSURE_MODE_CHANNEL); + ipcMain.handle(SET_SERVER_EXPOSURE_MODE_CHANNEL, async (_event, rawMode: unknown) => { + if (rawMode !== "local-only" && rawMode !== "network-accessible") { + throw new Error("Invalid desktop server exposure input."); + } - ipcMain.removeHandler(SET_SERVER_EXPOSURE_MODE_CHANNEL); - ipcMain.handle(SET_SERVER_EXPOSURE_MODE_CHANNEL, async (_event, rawMode: unknown) => { - if (rawMode !== "local-only" && rawMode !== "network-accessible") { - throw new Error("Invalid desktop server exposure input."); - } + const nextMode = rawMode as DesktopServerExposureMode; + if (nextMode === desktopServerExposureMode) { + return getDesktopServerExposureState(); + } - const nextMode = rawMode as DesktopServerExposureMode; - if (nextMode === desktopServerExposureMode) { - return getDesktopServerExposureState(); - } + const nextState = await runIpcEffect( + applyDesktopServerExposureMode(nextMode, { + persist: true, + rejectIfUnavailable: true, + }), + ); + await runIpcEffect(relaunchDesktopAppEffect(`serverExposureMode=${nextMode}`)); + return nextState; + }); - const nextState = await applyDesktopServerExposureMode(nextMode, { - persist: true, - rejectIfUnavailable: true, - }); - relaunchDesktopApp(`serverExposureMode=${nextMode}`); - return nextState; - }); + ipcMain.removeHandler(SET_TAILSCALE_SERVE_ENABLED_CHANNEL); + ipcMain.handle(SET_TAILSCALE_SERVE_ENABLED_CHANNEL, async (_event, rawInput: unknown) => { + if (typeof rawInput !== "object" || rawInput === null) { + throw new Error("Invalid Tailscale Serve input."); + } + const input = rawInput as { + readonly enabled?: unknown; + readonly port?: unknown; + }; + if (typeof input.enabled !== "boolean") { + throw new Error("Invalid Tailscale Serve input."); + } + const nextSettings = setDesktopTailscaleServePreference(desktopSettings, { + enabled: input.enabled, + ...(typeof input.port === "number" ? { port: input.port } : {}), + }); + if (nextSettings === desktopSettings) { + return getDesktopServerExposureState(); + } + return runIpcEffect(applyDesktopTailscaleServeEnabled(nextSettings)); + }); - ipcMain.removeHandler(SET_TAILSCALE_SERVE_ENABLED_CHANNEL); - ipcMain.handle(SET_TAILSCALE_SERVE_ENABLED_CHANNEL, async (_event, rawInput: unknown) => { - if (typeof rawInput !== "object" || rawInput === null) { - throw new Error("Invalid Tailscale Serve input."); - } - const input = rawInput as { - readonly enabled?: unknown; - readonly port?: unknown; - }; - if (typeof input.enabled !== "boolean") { - throw new Error("Invalid Tailscale Serve input."); - } - const nextSettings = setDesktopTailscaleServePreference(desktopSettings, { - enabled: input.enabled, - ...(typeof input.port === "number" ? { port: input.port } : {}), - }); - if (nextSettings === desktopSettings) { - return getDesktopServerExposureState(); - } - return applyDesktopTailscaleServeEnabled(nextSettings); - }); + ipcMain.removeHandler(GET_ADVERTISED_ENDPOINTS_CHANNEL); + ipcMain.handle(GET_ADVERTISED_ENDPOINTS_CHANNEL, async () => + runIpcEffect(getDesktopAdvertisedEndpoints()), + ); - ipcMain.removeHandler(GET_ADVERTISED_ENDPOINTS_CHANNEL); - ipcMain.handle(GET_ADVERTISED_ENDPOINTS_CHANNEL, async () => getDesktopAdvertisedEndpoints()); + ipcMain.removeHandler(PICK_FOLDER_CHANNEL); + ipcMain.handle(PICK_FOLDER_CHANNEL, async (_event, rawOptions: unknown) => { + const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; + const defaultPath = Option.getOrUndefined( + environment.resolvePickFolderDefaultPath(rawOptions), + ); + const openDialogOptions: OpenDialogOptions = { + properties: ["openDirectory", "createDirectory"], + ...(defaultPath ? { defaultPath } : {}), + }; + const result = owner + ? await dialog.showOpenDialog(owner, openDialogOptions) + : await dialog.showOpenDialog(openDialogOptions); + if (result.canceled) return null; + return result.filePaths[0] ?? null; + }); - ipcMain.removeHandler(PICK_FOLDER_CHANNEL); - ipcMain.handle(PICK_FOLDER_CHANNEL, async (_event, rawOptions: unknown) => { - const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; - const defaultPath = resolvePickFolderDefaultPath(rawOptions); - const openDialogOptions: OpenDialogOptions = { - properties: ["openDirectory", "createDirectory"], - ...(defaultPath ? { defaultPath } : {}), - }; - const result = owner - ? await dialog.showOpenDialog(owner, openDialogOptions) - : await dialog.showOpenDialog(openDialogOptions); - if (result.canceled) return null; - return result.filePaths[0] ?? null; - }); + ipcMain.removeHandler(CONFIRM_CHANNEL); + ipcMain.handle(CONFIRM_CHANNEL, async (_event, message: unknown) => { + if (typeof message !== "string") { + return false; + } - ipcMain.removeHandler(CONFIRM_CHANNEL); - ipcMain.handle(CONFIRM_CHANNEL, async (_event, message: unknown) => { - if (typeof message !== "string") { - return false; - } + const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; + return showDesktopConfirmDialog(message, owner); + }); - const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; - return showDesktopConfirmDialog(message, owner); - }); + ipcMain.removeHandler(SET_THEME_CHANNEL); + ipcMain.handle(SET_THEME_CHANNEL, async (_event, rawTheme: unknown) => { + const theme = getSafeTheme(rawTheme); + if (!theme) { + return; + } - ipcMain.removeHandler(SET_THEME_CHANNEL); - ipcMain.handle(SET_THEME_CHANNEL, async (_event, rawTheme: unknown) => { - const theme = getSafeTheme(rawTheme); - if (!theme) { - return; - } + nativeTheme.themeSource = theme; + }); - nativeTheme.themeSource = theme; - }); + ipcMain.removeHandler(CONTEXT_MENU_CHANNEL); + ipcMain.handle( + CONTEXT_MENU_CHANNEL, + async (_event, items: ContextMenuItem[], position?: { x: number; y: number }) => { + const normalizedItems = normalizeContextMenuItems(items); + if (normalizedItems.length === 0) { + return null; + } - ipcMain.removeHandler(CONTEXT_MENU_CHANNEL); - ipcMain.handle( - CONTEXT_MENU_CHANNEL, - async (_event, items: ContextMenuItem[], position?: { x: number; y: number }) => { - const normalizedItems = normalizeContextMenuItems(items); - if (normalizedItems.length === 0) { - return null; - } + const popupPosition = + position && + Number.isFinite(position.x) && + Number.isFinite(position.y) && + position.x >= 0 && + position.y >= 0 + ? { + x: Math.floor(position.x), + y: Math.floor(position.y), + } + : null; + + const window = BrowserWindow.getFocusedWindow() ?? mainWindow; + if (!window) return null; + + return new Promise((resolve) => { + const buildTemplate = ( + entries: readonly ContextMenuItem[], + ): MenuItemConstructorOptions[] => { + const template: MenuItemConstructorOptions[] = []; + let hasInsertedDestructiveSeparator = false; + for (const item of entries) { + if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { + template.push({ type: "separator" }); + hasInsertedDestructiveSeparator = true; + } + const itemOption: MenuItemConstructorOptions = { + label: item.label, + enabled: !item.disabled, + }; + if (item.children && item.children.length > 0) { + itemOption.submenu = buildTemplate(item.children); + } else { + itemOption.click = () => resolve(item.id); + } + if (item.destructive && (!item.children || item.children.length === 0)) { + const destructiveIcon = getDestructiveMenuIcon(); + if (destructiveIcon) { + itemOption.icon = destructiveIcon; + } + } + template.push(itemOption); + } + return template; + }; + + const menu = Menu.buildFromTemplate(buildTemplate(normalizedItems)); + menu.popup({ + window, + ...popupPosition, + callback: () => resolve(null), + }); + }); + }, + ); - const popupPosition = - position && - Number.isFinite(position.x) && - Number.isFinite(position.y) && - position.x >= 0 && - position.y >= 0 - ? { - x: Math.floor(position.x), - y: Math.floor(position.y), - } - : null; - - const window = BrowserWindow.getFocusedWindow() ?? mainWindow; - if (!window) return null; - - return new Promise((resolve) => { - const buildTemplate = ( - entries: readonly ContextMenuItem[], - ): MenuItemConstructorOptions[] => { - const template: MenuItemConstructorOptions[] = []; - let hasInsertedDestructiveSeparator = false; - for (const item of entries) { - if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { - template.push({ type: "separator" }); - hasInsertedDestructiveSeparator = true; - } - const itemOption: MenuItemConstructorOptions = { - label: item.label, - enabled: !item.disabled, - }; - if (item.children && item.children.length > 0) { - itemOption.submenu = buildTemplate(item.children); - } else { - itemOption.click = () => resolve(item.id); - } - if (item.destructive && (!item.children || item.children.length === 0)) { - const destructiveIcon = getDestructiveMenuIcon(); - if (destructiveIcon) { - itemOption.icon = destructiveIcon; - } - } - template.push(itemOption); + ipcMain.removeHandler(OPEN_EXTERNAL_CHANNEL); + ipcMain.handle(OPEN_EXTERNAL_CHANNEL, async (_event, rawUrl: unknown) => { + const externalUrl = getSafeExternalUrl(rawUrl); + if (!externalUrl) { + return false; } - return template; - }; - - const menu = Menu.buildFromTemplate(buildTemplate(normalizedItems)); - menu.popup({ - window, - ...popupPosition, - callback: () => resolve(null), - }); - }); - }, - ); - ipcMain.removeHandler(OPEN_EXTERNAL_CHANNEL); - ipcMain.handle(OPEN_EXTERNAL_CHANNEL, async (_event, rawUrl: unknown) => { - const externalUrl = getSafeExternalUrl(rawUrl); - if (!externalUrl) { - return false; - } + try { + await shell.openExternal(externalUrl); + return true; + } catch { + return false; + } + }); - try { - await shell.openExternal(externalUrl); - return true; - } catch { - return false; - } - }); + ipcMain.removeHandler(UPDATE_GET_STATE_CHANNEL); + ipcMain.handle(UPDATE_GET_STATE_CHANNEL, async () => updateState); - ipcMain.removeHandler(UPDATE_GET_STATE_CHANNEL); - ipcMain.handle(UPDATE_GET_STATE_CHANNEL, async () => updateState); + ipcMain.removeHandler(UPDATE_SET_CHANNEL_CHANNEL); + ipcMain.handle(UPDATE_SET_CHANNEL_CHANNEL, async (_event, rawChannel: unknown) => { + if (rawChannel !== "latest" && rawChannel !== "nightly") { + throw new Error("Invalid desktop update channel input."); + } + if (updateCheckInFlight || updateDownloadInFlight || updateInstallInFlight) { + throw new Error("Cannot change update tracks while an update action is in progress."); + } - ipcMain.removeHandler(UPDATE_SET_CHANNEL_CHANNEL); - ipcMain.handle(UPDATE_SET_CHANNEL_CHANNEL, async (_event, rawChannel: unknown) => { - if (rawChannel !== "latest" && rawChannel !== "nightly") { - throw new Error("Invalid desktop update channel input."); - } - if (updateCheckInFlight || updateDownloadInFlight || updateInstallInFlight) { - throw new Error("Cannot change update tracks while an update action is in progress."); - } + const nextChannel = rawChannel as DesktopUpdateChannel; - const nextChannel = rawChannel as DesktopUpdateChannel; + desktopSettings = setDesktopUpdateChannelPreference(desktopSettings, nextChannel); + await runIpcEffect( + writeDesktopSettingsEffect(environment.desktopSettingsPath, desktopSettings), + ); - desktopSettings = setDesktopUpdateChannelPreference(desktopSettings, nextChannel); - writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings); + if (nextChannel === updateState.channel) { + return updateState; + } - if (nextChannel === updateState.channel) { - return updateState; - } + const enabled = shouldEnableAutoUpdates(environment); + setUpdateState(createBaseUpdateState(nextChannel, enabled, environment)); - const enabled = shouldEnableAutoUpdates(); - setUpdateState(createBaseUpdateState(nextChannel, enabled)); + if (!enabled || !updaterConfigured) { + return updateState; + } - if (!enabled || !updaterConfigured) { - return updateState; - } + applyAutoUpdaterChannel(nextChannel); + const allowDowngrade = autoUpdater.allowDowngrade; + // An explicit channel switch should allow the immediate nightly->stable rollback path. + autoUpdater.allowDowngrade = true; + try { + await runIpcEffect(checkForUpdates("channel-change")); + } finally { + autoUpdater.allowDowngrade = allowDowngrade; + } + return updateState; + }); - applyAutoUpdaterChannel(nextChannel); - const allowDowngrade = autoUpdater.allowDowngrade; - // An explicit channel switch should allow the immediate nightly->stable rollback path. - autoUpdater.allowDowngrade = true; - try { - await checkForUpdates("channel-change"); - } finally { - autoUpdater.allowDowngrade = allowDowngrade; - } - return updateState; - }); + ipcMain.removeHandler(UPDATE_DOWNLOAD_CHANNEL); + ipcMain.handle(UPDATE_DOWNLOAD_CHANNEL, async () => { + const result = await runIpcEffect(downloadAvailableUpdate()); + return { + accepted: result.accepted, + completed: result.completed, + state: updateState, + } satisfies DesktopUpdateActionResult; + }); - ipcMain.removeHandler(UPDATE_DOWNLOAD_CHANNEL); - ipcMain.handle(UPDATE_DOWNLOAD_CHANNEL, async () => { - const result = await downloadAvailableUpdate(); - return { - accepted: result.accepted, - completed: result.completed, - state: updateState, - } satisfies DesktopUpdateActionResult; - }); + ipcMain.removeHandler(UPDATE_INSTALL_CHANNEL); + ipcMain.handle(UPDATE_INSTALL_CHANNEL, async () => { + if (isQuitting) { + return { + accepted: false, + completed: false, + state: updateState, + } satisfies DesktopUpdateActionResult; + } + const result = await runIpcEffect(installDownloadedUpdate()); + return { + accepted: result.accepted, + completed: result.completed, + state: updateState, + } satisfies DesktopUpdateActionResult; + }); - ipcMain.removeHandler(UPDATE_INSTALL_CHANNEL); - ipcMain.handle(UPDATE_INSTALL_CHANNEL, async () => { - if (isQuitting) { - return { - accepted: false, - completed: false, - state: updateState, - } satisfies DesktopUpdateActionResult; - } - const result = await installDownloadedUpdate(); - return { - accepted: result.accepted, - completed: result.completed, - state: updateState, - } satisfies DesktopUpdateActionResult; - }); + ipcMain.removeHandler(UPDATE_CHECK_CHANNEL); + ipcMain.handle(UPDATE_CHECK_CHANNEL, async () => { + if (!updaterConfigured) { + return { + checked: false, + state: updateState, + } satisfies DesktopUpdateCheckResult; + } + const checked = await runIpcEffect(checkForUpdates("web-ui")); + return { + checked, + state: updateState, + } satisfies DesktopUpdateCheckResult; + }); + }), + () => Effect.sync(clearDesktopIpcHandlers), + ).pipe(Effect.asVoid); - ipcMain.removeHandler(UPDATE_CHECK_CHANNEL); - ipcMain.handle(UPDATE_CHECK_CHANNEL, async () => { - if (!updaterConfigured) { - return { - checked: false, - state: updateState, - } satisfies DesktopUpdateCheckResult; - } - const checked = await checkForUpdates("web-ui"); - return { - checked, - state: updateState, - } satisfies DesktopUpdateCheckResult; + yield* desktopSshEnvironmentBridge.registerIpcHandlers(ipcMain); }); } function getIconOption(): { icon: string } | Record { if (process.platform === "darwin") return {}; // macOS uses .icns from app bundle const ext = process.platform === "win32" ? "ico" : "png"; - const iconPath = resolveIconPath(ext); + const iconPath = Option.getOrUndefined(desktopIconPaths[ext]); return iconPath ? { icon: iconPath } : {}; } @@ -1953,9 +2306,10 @@ function syncAllWindowAppearance(): void { } } -nativeTheme.on("updated", syncAllWindowAppearance); - -function createWindow(): BrowserWindow { +function createWindow( + runEffect: DesktopEffectRunner, + environment: DesktopEnvironmentShape, +): BrowserWindow { const window = new BrowserWindow({ width: 1100, height: 780, @@ -1965,10 +2319,10 @@ function createWindow(): BrowserWindow { autoHideMenuBar: true, backgroundColor: getInitialWindowBackgroundColor(), ...getIconOption(), - title: APP_DISPLAY_NAME, + title: environment.displayName, ...getWindowTitleBarOptions(), webPreferences: { - preload: Path.join(__dirname, "preload.cjs"), + preload: environment.preloadPath, contextIsolation: true, nodeIntegration: false, sandbox: true, @@ -2032,10 +2386,10 @@ function createWindow(): BrowserWindow { window.on("page-title-updated", (event) => { event.preventDefault(); - window.setTitle(APP_DISPLAY_NAME); + window.setTitle(environment.displayName); }); window.webContents.on("did-finish-load", () => { - window.setTitle(APP_DISPLAY_NAME); + window.setTitle(environment.displayName); emitUpdateState(); }); @@ -2051,16 +2405,21 @@ function createWindow(): BrowserWindow { } bindFirstRevealTrigger(revealSubscribers, () => revealWindow(window)); - if (isDevelopment) { - void window.loadURL(resolveDesktopDevServerUrl()); + if (environment.isDevelopment) { + void window.loadURL(resolveDesktopDevServerUrl(environment)); window.webContents.openDevTools({ mode: "detach" }); } else { void window.loadURL(requireBackendHttpUrl().href); } window.on("closed", () => { - desktopSshEnvironmentBridge.cancelPendingPasswordPrompts( - "SSH authentication was cancelled because the app window closed.", + void runEffect( + Effect.gen(function* () { + const desktopSshEnvironmentBridge = yield* DesktopSshEnvironmentBridge; + yield* desktopSshEnvironmentBridge.cancelPendingPasswordPromptsEffect( + "SSH authentication was cancelled because the app window closed.", + ); + }), ); if (mainWindow === window) { mainWindow = null; @@ -2070,131 +2429,220 @@ function createWindow(): BrowserWindow { return window; } -// Override Electron's userData path before the `ready` event so that -// Chromium session data uses a filesystem-friendly directory name. -// Must be called synchronously at the top level — before `app.whenReady()`. -app.setPath("userData", resolveUserDataPath()); - -configureAppIdentity(); - -async function bootstrap(): Promise { - writeDesktopLogHeader("bootstrap start"); - const configuredBackendPort = resolveConfiguredDesktopBackendPort(process.env.T3CODE_PORT); - if (isDevelopment && configuredBackendPort === undefined) { - throw new Error("T3CODE_PORT is required in desktop development."); - } +function bootstrap() { + return Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + const context = yield* Effect.context(); + const runEffect = makeDesktopEffectRunner(context); + yield* logDesktopInfo("bootstrap start"); + const configuredBackendPort = resolveConfiguredDesktopBackendPort(process.env.T3CODE_PORT); + if (environment.isDevelopment && configuredBackendPort === undefined) { + return yield* Effect.fail(new Error("T3CODE_PORT is required in desktop development.")); + } - backendPort = - configuredBackendPort ?? - (await resolveDesktopBackendPort({ - host: DESKTOP_LOOPBACK_HOST, - startPort: DEFAULT_DESKTOP_BACKEND_PORT, - requiredHosts: DESKTOP_REQUIRED_PORT_PROBE_HOSTS, - })); - writeDesktopLogHeader( - configuredBackendPort === undefined - ? `selected backend port via sequential scan startPort=${DEFAULT_DESKTOP_BACKEND_PORT} port=${backendPort}` - : `using configured backend port port=${backendPort}`, - ); - backendBootstrapToken = Crypto.randomBytes(24).toString("hex"); - if (desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode) { - writeDesktopLogHeader( - `bootstrap restoring persisted server exposure mode mode=${desktopSettings.serverExposureMode}`, - ); - } - const serverExposureState = await applyDesktopServerExposureMode( - desktopSettings.serverExposureMode, - { - persist: desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode, - }, - ); - writeDesktopLogHeader(`bootstrap resolved backend endpoint baseUrl=${getBackendHttpUrlHref()}`); - if (serverExposureState.endpointUrl) { - writeDesktopLogHeader( - `bootstrap enabled network access endpointUrl=${serverExposureState.endpointUrl}`, + backendPort = + configuredBackendPort ?? + (yield* resolveDesktopBackendPortEffect({ + host: DESKTOP_LOOPBACK_HOST, + startPort: DEFAULT_DESKTOP_BACKEND_PORT, + requiredHosts: DESKTOP_REQUIRED_PORT_PROBE_HOSTS, + })); + yield* logDesktopInfo( + configuredBackendPort === undefined + ? "selected backend port via sequential scan" + : "using configured backend port", + { + port: backendPort, + ...(configuredBackendPort === undefined ? { startPort: DEFAULT_DESKTOP_BACKEND_PORT } : {}), + }, ); - } else if (desktopSettings.serverExposureMode === "network-accessible") { - writeDesktopLogHeader( - "bootstrap fell back to local-only because no advertised network host was available", + backendBootstrapToken = yield* randomHexString(48); + if (desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode) { + yield* logDesktopInfo("bootstrap restoring persisted server exposure mode", { + mode: desktopSettings.serverExposureMode, + }); + } + const serverExposureState = yield* applyDesktopServerExposureMode( + desktopSettings.serverExposureMode, + { + persist: desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode, + }, ); - } + yield* logDesktopInfo("bootstrap resolved backend endpoint", { + baseUrl: getBackendHttpUrlHref(), + }); + if (serverExposureState.endpointUrl) { + yield* logDesktopInfo("bootstrap enabled network access", { + endpointUrl: serverExposureState.endpointUrl, + }); + } else if (desktopSettings.serverExposureMode === "network-accessible") { + yield* logDesktopWarning( + "bootstrap fell back to local-only because no advertised network host was available", + ); + } - registerIpcHandlers(); - writeDesktopLogHeader("bootstrap ipc handlers registered"); - startBackend(); - writeDesktopLogHeader("bootstrap backend start requested"); + yield* registerIpcHandlers(); + yield* logDesktopInfo("bootstrap ipc handlers registered"); + yield* startBackend(); + yield* logDesktopInfo("bootstrap backend start requested"); - if (isDevelopment) { - mainWindow = createWindow(); - writeDesktopLogHeader("bootstrap main window created"); - } + if (environment.isDevelopment) { + mainWindow = createWindow(runEffect, environment); + yield* logDesktopInfo("bootstrap main window created"); + } + }); } -app.on("before-quit", () => { +function handleBeforeQuit( + event: Electron.Event, + runEffect: DesktopEffectRunner, + allowQuit: () => boolean, + markQuitAllowed: () => void, +): void { isQuitting = true; - updateInstallInFlight = false; - writeDesktopLogHeader("before-quit received"); - clearUpdatePollTimer(); - stopBackend(); - void desktopSshEnvironmentBridge.dispose().catch(() => undefined); - restoreStdIoCapture?.(); -}); - -app - .whenReady() - .then(() => { - writeDesktopLogHeader("app ready"); - configureAppIdentity(); - configureApplicationMenu(); - registerDesktopProtocol(); - configureAutoUpdater(); - void bootstrap().catch((error) => { - handleFatalStartupError("bootstrap", error); - }); + void runEffect(logDesktopInfo("before-quit received")); + if (allowQuit()) { + return; + } - app.on("activate", () => { - const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0]; - if (existingWindow) { - revealWindow(existingWindow); - return; - } - if (isDevelopment) { - mainWindow = createWindow(); - return; - } - createBackendWindowIfReady(); - }); - }) - .catch((error) => { - handleFatalStartupError("whenReady", error); + event.preventDefault(); + void runEffect(requestDesktopShutdownAndWait()).finally(() => { + markQuitAllowed(); + app.quit(); }); +} -app.on("window-all-closed", () => { +function handleActivate( + runEffect: DesktopEffectRunner, + environment: DesktopEnvironmentShape, +): void { + const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0]; + if (existingWindow) { + revealWindow(existingWindow); + return; + } + if (environment.isDevelopment) { + mainWindow = createWindow(runEffect, environment); + return; + } + createBackendWindowIfReady(runEffect, environment); +} + +function handleWindowAllClosed(): void { if (process.platform !== "darwin" && !isQuitting) { app.quit(); } -}); +} -if (process.platform !== "win32") { - process.on("SIGINT", () => { - if (isQuitting) return; - isQuitting = true; - writeDesktopLogHeader("SIGINT received"); - clearUpdatePollTimer(); - stopBackend(); - void desktopSshEnvironmentBridge.dispose().catch(() => undefined); - restoreStdIoCapture?.(); - app.quit(); - }); +function registerDesktopLifecycleHandlers(): Effect.Effect< + void, + never, + Scope.Scope | DesktopLifecycleBoundaryServices +> { + return Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + const context = yield* Effect.context(); + const runEffect = makeDesktopEffectRunner(context); + let quitAllowed = false; + yield* addScopedListener(nativeTheme, "updated", () => { + syncAllWindowAppearance(); + }); + yield* addScopedListener(app, "before-quit", (event: Electron.Event) => { + handleBeforeQuit( + event, + runEffect, + () => quitAllowed, + () => { + quitAllowed = true; + }, + ); + }); + yield* addScopedListener(app, "activate", () => { + handleActivate(runEffect, environment); + }); + yield* addScopedListener(app, "window-all-closed", () => { + handleWindowAllClosed(); + }); - process.on("SIGTERM", () => { - if (isQuitting) return; - isQuitting = true; - writeDesktopLogHeader("SIGTERM received"); - clearUpdatePollTimer(); - stopBackend(); - void desktopSshEnvironmentBridge.dispose().catch(() => undefined); - restoreStdIoCapture?.(); - app.quit(); + if (process.platform !== "win32") { + yield* addScopedListener(process, "SIGINT", () => { + quitFromSignal("SIGINT", runEffect); + }); + yield* addScopedListener(process, "SIGTERM", () => { + quitFromSignal("SIGTERM", runEffect); + }); + } }); } + +function fatalStartupCause(stage: string, cause: Cause.Cause) { + return handleFatalStartupError(stage, new Error(Cause.pretty(cause))).pipe( + Effect.andThen(Effect.failCause(cause)), + ); +} + +const waitForElectronReady = Effect.promise(() => app.whenReady()).pipe(Effect.asVoid); + +const program = Effect.scoped( + Effect.gen(function* () { + const shutdown = yield* makeDesktopShutdown; + + yield* Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + appRunId = (yield* Random.nextUUIDv4).replace(/-/g, "").slice(0, 12); + const backendManager = yield* DesktopBackendManager; + const desktopSshEnvironmentBridge = yield* DesktopSshEnvironmentBridge; + const sshPasswordPromptScope = yield* Scope.make("sequential"); + yield* desktopSshEnvironmentBridge.installPasswordPromptScope(sshPasswordPromptScope); + yield* Scope.addFinalizer( + yield* Scope.Scope, + closeDesktopResourcesWithManager(backendManager, desktopSshEnvironmentBridge).pipe( + Effect.ensuring(shutdown.markComplete), + ), + ); + + yield* Effect.sync(syncShellEnvironment); + const userDataPath = yield* resolveUserDataPath(); + yield* Effect.sync(() => { + // Must happen before Electron's ready event so Chromium profile data + // lands in the desktop-specific userData directory. + app.setPath("userData", userDataPath); + }); + appUpdateYmlConfig = yield* readAppUpdateYmlEffect(); + yield* resolveDesktopIconPaths(); + yield* logDesktopInfo("runtime logging configured", { logDir: environment.logDir }); + desktopSettings = yield* readDesktopSettingsEffect( + environment.desktopSettingsPath, + environment.appVersion, + ); + desktopServerExposureMode = desktopSettings.serverExposureMode; + updateState = initialUpdateState(environment); + + if (process.platform === "linux") { + app.commandLine.appendSwitch("class", environment.linuxWmClass); + } + + yield* configureAppIdentity(); + yield* registerDesktopLifecycleHandlers(); + + yield* waitForElectronReady.pipe( + Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)), + ); + yield* logDesktopInfo("app ready"); + yield* configureAppIdentity(); + yield* configureApplicationMenu(); + yield* registerDesktopProtocol(); + yield* configureAutoUpdater(); + yield* bootstrap().pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); + yield* shutdown.awaitRequest; + }).pipe(Effect.provideService(DesktopShutdown, shutdown)); + }), +).pipe( + Effect.catchCause((cause) => + logDesktopError("desktop main fiber failed", { + cause: Cause.pretty(cause), + }), + ), +); + +program.pipe(Effect.provide(desktopRuntimeLayer), NodeRuntime.runMain); diff --git a/apps/desktop/src/rotatingFileSink.test.ts b/apps/desktop/src/rotatingFileSink.test.ts index 53dd98ade8c..10ac4372b60 100644 --- a/apps/desktop/src/rotatingFileSink.test.ts +++ b/apps/desktop/src/rotatingFileSink.test.ts @@ -1,74 +1,83 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; import { RotatingFileSink } from "@t3tools/shared/logging"; -import { afterEach, describe, expect, it } from "vitest"; - -const tempRoots: string[] = []; +import { Effect, FileSystem, Path } from "effect"; -function makeTempDir(): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-rotating-log-")); - tempRoots.push(dir); - return dir; +function makeTempDir() { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectoryScoped({ prefix: "t3-rotating-log-" }); + }); } -afterEach(() => { - for (const dir of tempRoots.splice(0)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - describe("RotatingFileSink", () => { - it("rotates when writes exceed max bytes", () => { - const dir = makeTempDir(); - const logPath = path.join(dir, "desktop-main.log"); - const sink = new RotatingFileSink({ - filePath: logPath, - maxBytes: 10, - maxFiles: 3, - }); + it.effect("rotates when writes exceed max bytes", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* makeTempDir(); + const logPath = path.join(dir, "desktop-main.log"); + const sink = new RotatingFileSink({ + filePath: logPath, + maxBytes: 10, + maxFiles: 3, + }); - sink.write("12345"); - sink.write("67890"); - sink.write("abc"); + yield* Effect.sync(() => { + sink.write("12345"); + sink.write("67890"); + sink.write("abc"); + }); - expect(fs.readFileSync(path.join(dir, "desktop-main.log"), "utf8")).toBe("abc"); - expect(fs.readFileSync(path.join(dir, "desktop-main.log.1"), "utf8")).toBe("1234567890"); - }); + assert.equal(yield* fs.readFileString(path.join(dir, "desktop-main.log")), "abc"); + assert.equal(yield* fs.readFileString(path.join(dir, "desktop-main.log.1")), "1234567890"); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); - it("retains only maxFiles backups", () => { - const dir = makeTempDir(); - const logPath = path.join(dir, "server-child.log"); - const sink = new RotatingFileSink({ - filePath: logPath, - maxBytes: 4, - maxFiles: 2, - }); + it.effect("retains only maxFiles backups", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* makeTempDir(); + const logPath = path.join(dir, "server-child.log"); + const sink = new RotatingFileSink({ + filePath: logPath, + maxBytes: 4, + maxFiles: 2, + }); - sink.write("aaaa"); - sink.write("bbbb"); - sink.write("cccc"); - sink.write("dddd"); + yield* Effect.sync(() => { + sink.write("aaaa"); + sink.write("bbbb"); + sink.write("cccc"); + sink.write("dddd"); + }); - expect(fs.existsSync(path.join(dir, "server-child.log.1"))).toBe(true); - expect(fs.existsSync(path.join(dir, "server-child.log.2"))).toBe(true); - expect(fs.existsSync(path.join(dir, "server-child.log.3"))).toBe(false); - }); + assert.equal(yield* fs.exists(path.join(dir, "server-child.log.1")), true); + assert.equal(yield* fs.exists(path.join(dir, "server-child.log.2")), true); + assert.equal(yield* fs.exists(path.join(dir, "server-child.log.3")), false); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); - it("prunes stale backups above maxFiles on startup", () => { - const dir = makeTempDir(); - const logPath = path.join(dir, "desktop-main.log"); - fs.writeFileSync(path.join(dir, "desktop-main.log.1"), "first"); - fs.writeFileSync(path.join(dir, "desktop-main.log.4"), "stale"); + it.effect("prunes stale backups above maxFiles on startup", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* makeTempDir(); + const logPath = path.join(dir, "desktop-main.log"); + yield* fs.writeFileString(path.join(dir, "desktop-main.log.1"), "first"); + yield* fs.writeFileString(path.join(dir, "desktop-main.log.4"), "stale"); - const sink = new RotatingFileSink({ - filePath: logPath, - maxBytes: 16, - maxFiles: 2, - }); - sink.write("hello"); + yield* Effect.sync(() => { + const sink = new RotatingFileSink({ + filePath: logPath, + maxBytes: 16, + maxFiles: 2, + }); + sink.write("hello"); + }); - expect(fs.existsSync(path.join(dir, "desktop-main.log.4"))).toBe(false); - }); + assert.equal(yield* fs.exists(path.join(dir, "desktop-main.log.4")), false); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); }); diff --git a/apps/desktop/src/serverExposure.ts b/apps/desktop/src/serverExposure.ts index b73b850ad13..d2352591831 100644 --- a/apps/desktop/src/serverExposure.ts +++ b/apps/desktop/src/serverExposure.ts @@ -1,4 +1,3 @@ -import type { NetworkInterfaceInfo } from "node:os"; import { createAdvertisedEndpoint, type CreateAdvertisedEndpointInput, @@ -12,6 +11,20 @@ import type { const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; +export interface DesktopNetworkInterfaceInfo { + readonly address: string; + readonly family: string | number; + readonly internal: boolean; + readonly netmask?: string; + readonly mac?: string; + readonly cidr?: string | null; + readonly scopeid?: number; +} + +export type DesktopNetworkInterfaces = Readonly< + Record +>; + export interface DesktopServerExposure { readonly mode: DesktopServerExposureMode; readonly bindHost: string; @@ -58,7 +71,7 @@ function isHttpsEndpointUrl(value: string): boolean { } export function resolveLanAdvertisedHost( - networkInterfaces: NodeJS.Dict, + networkInterfaces: DesktopNetworkInterfaces, explicitHost: string | undefined, ): string | null { const normalizedExplicitHost = normalizeOptionalHost(explicitHost); @@ -83,7 +96,7 @@ export function resolveLanAdvertisedHost( export function resolveDesktopServerExposure(input: { readonly mode: DesktopServerExposureMode; readonly port: number; - readonly networkInterfaces: NodeJS.Dict; + readonly networkInterfaces: DesktopNetworkInterfaces; readonly advertisedHostOverride?: string; }): DesktopServerExposure { const localHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${input.port}`; diff --git a/apps/desktop/src/sshEnvironment.test.ts b/apps/desktop/src/sshEnvironment.test.ts index d22e09957d1..3075d9d5a1a 100644 --- a/apps/desktop/src/sshEnvironment.test.ts +++ b/apps/desktop/src/sshEnvironment.test.ts @@ -1,98 +1,167 @@ -import * as FS from "node:fs"; -import * as OS from "node:os"; -import * as Path from "node:path"; - -import { afterEach, describe, expect, it } from "vitest"; - +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; +import { assert, describe, it } from "@effect/vitest"; +import { NetService } from "@t3tools/shared/Net"; import { SshPasswordPromptError } from "@t3tools/ssh/errors"; +import { Effect, FileSystem, Layer, Path } from "effect"; + +import { + DesktopSshEnvironmentBridge, + type DesktopSshBridgeIpcMain, + DesktopSshEnvironmentManager, + discoverDesktopSshHostsEffect, + isSshPasswordPromptCancellation, +} from "./sshEnvironment.ts"; -import { discoverDesktopSshHosts, isSshPasswordPromptCancellation } from "./sshEnvironment.ts"; +function makeTempHomeDir() { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectoryScoped({ prefix: "t3-ssh-env-test-" }); + }); +} -const tempDirectories: string[] = []; +class TestIpcMain implements DesktopSshBridgeIpcMain { + readonly handlers = new Map< + string, + (event: unknown, ...args: readonly unknown[]) => unknown | Promise + >(); -afterEach(() => { - for (const directory of tempDirectories.splice(0)) { - FS.rmSync(directory, { recursive: true, force: true }); + removeHandler(channel: string): void { + this.handlers.delete(channel); } -}); -function makeTempHomeDir(): string { - const directory = FS.mkdtempSync(Path.join(OS.tmpdir(), "t3-ssh-env-test-")); - tempDirectories.push(directory); - return directory; + handle( + channel: string, + listener: (event: unknown, ...args: readonly unknown[]) => unknown | Promise, + ): void { + this.handlers.set(channel, listener); + } } describe("sshEnvironment", () => { it("treats password prompt timeouts as cancellable authentication prompts", () => { - expect( + assert.equal( isSshPasswordPromptCancellation( new SshPasswordPromptError({ message: "SSH authentication timed out for devbox.", }), ), - ).toBe(true); + true, + ); }); - it("wires desktop host discovery through the ssh package runtime", async () => { - const homeDir = makeTempHomeDir(); - const sshDir = Path.join(homeDir, ".ssh"); - FS.mkdirSync(Path.join(sshDir, "config.d"), { recursive: true }); - FS.writeFileSync( - Path.join(sshDir, "config"), - ["Host devbox", " HostName devbox.example.com", "Include config.d/*.conf", ""].join("\n"), - "utf8", - ); - FS.writeFileSync( - Path.join(sshDir, "config.d", "team.conf"), - [ - "Host staging", - " HostName staging.example.com", - "Host *", - " ServerAliveInterval 30", - "", - ].join("\n"), - "utf8", - ); - FS.writeFileSync( - Path.join(sshDir, "known_hosts"), - [ - "known.example.com ssh-ed25519 AAAA", - "|1|hashed|entry ssh-ed25519 AAAA", - "[bastion.example.com]:2222 ssh-ed25519 AAAA", - "", - ].join("\n"), - "utf8", - ); + it.effect("wires desktop host discovery through the ssh package runtime", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homeDir = yield* makeTempHomeDir(); + const sshDir = path.join(homeDir, ".ssh"); + yield* fs.makeDirectory(path.join(sshDir, "config.d"), { recursive: true }); + yield* fs.writeFileString( + path.join(sshDir, "config"), + ["Host devbox", " HostName devbox.example.com", "Include config.d/*.conf", ""].join("\n"), + ); + yield* fs.writeFileString( + path.join(sshDir, "config.d", "team.conf"), + [ + "Host staging", + " HostName staging.example.com", + "Host *", + " ServerAliveInterval 30", + "", + ].join("\n"), + ); + yield* fs.writeFileString( + path.join(sshDir, "known_hosts"), + [ + "known.example.com ssh-ed25519 AAAA", + "|1|hashed|entry ssh-ed25519 AAAA", + "[bastion.example.com]:2222 ssh-ed25519 AAAA", + "", + ].join("\n"), + ); - await expect(discoverDesktopSshHosts({ homeDir })).resolves.toEqual([ - { - alias: "bastion.example.com", - hostname: "bastion.example.com", - username: null, - port: null, - source: "known-hosts", - }, - { - alias: "devbox", - hostname: "devbox", - username: null, - port: null, - source: "ssh-config", - }, - { - alias: "known.example.com", - hostname: "known.example.com", - username: null, - port: null, - source: "known-hosts", - }, - { - alias: "staging", - hostname: "staging", - username: null, - port: null, - source: "ssh-config", - }, - ]); - }); + const hosts = yield* discoverDesktopSshHostsEffect({ homeDir }); + assert.deepEqual(hosts, [ + { + alias: "bastion.example.com", + hostname: "bastion.example.com", + username: null, + port: null, + source: "known-hosts", + }, + { + alias: "devbox", + hostname: "devbox", + username: null, + port: null, + source: "ssh-config", + }, + { + alias: "known.example.com", + hostname: "known.example.com", + username: null, + port: null, + source: "known-hosts", + }, + { + alias: "staging", + hostname: "staging", + username: null, + port: null, + source: "ssh-config", + }, + ]); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("runs SSH IPC handlers with the captured Effect context", () => + Effect.gen(function* () { + const ipcMain = new TestIpcMain(); + const bridge = yield* DesktopSshEnvironmentBridge; + + yield* bridge.registerIpcHandlers(ipcMain); + + const discoverHosts = ipcMain.handlers.get("desktop:discover-ssh-hosts"); + assert.ok(discoverHosts); + + const hosts = yield* Effect.promise(() => Promise.resolve(discoverHosts({}))); + assert.deepEqual(hosts, [ + { + alias: "devbox", + hostname: "devbox.example.com", + username: null, + port: null, + source: "ssh-config", + }, + ]); + }).pipe( + Effect.provide( + Layer.mergeAll( + DesktopSshEnvironmentBridge.layer({ getMainWindow: () => null }), + Layer.succeed( + DesktopSshEnvironmentManager, + DesktopSshEnvironmentManager.of({ + discoverHosts: () => + Effect.succeed([ + { + alias: "devbox", + hostname: "devbox.example.com", + username: null, + port: null, + source: "ssh-config" as const, + }, + ]), + ensureEnvironment: () => Effect.die("unexpected ensureEnvironment"), + disconnectEnvironment: () => Effect.die("unexpected disconnectEnvironment"), + }), + ), + NodeServices.layer, + NodeHttpClient.layerUndici, + NetService.layer, + ), + ), + Effect.scoped, + ), + ); }); diff --git a/apps/desktop/src/sshEnvironment.ts b/apps/desktop/src/sshEnvironment.ts index e847e07d498..7e128a23e9c 100644 --- a/apps/desktop/src/sshEnvironment.ts +++ b/apps/desktop/src/sshEnvironment.ts @@ -1,10 +1,7 @@ -import * as Crypto from "node:crypto"; - -import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; -import * as NodeServices from "@effect/platform-node/NodeServices"; import { NetService } from "@t3tools/shared/Net"; import type { AuthBearerBootstrapResult, + DesktopSshEnvironmentBootstrap, AuthSessionState, AuthWebSocketTokenResult, DesktopDiscoveredSshHost, @@ -24,7 +21,24 @@ import { SshEnvironmentManager, type RemoteT3RunnerOptions, } from "@t3tools/ssh/tunnel"; -import { Effect, Exit, Layer, ManagedRuntime, Scope } from "effect"; +import { + Cause, + Context, + DateTime, + Deferred, + Duration, + Effect, + Exit, + FileSystem, + Fiber, + Layer, + Option, + Path, + Random, + Scope, +} from "effect"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; export { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; @@ -39,101 +53,115 @@ const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt"; const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt"; const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000; const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; +const SSH_HANDLED_IPC_CHANNELS = [ + DISCOVER_SSH_HOSTS_CHANNEL, + ENSURE_SSH_ENVIRONMENT_CHANNEL, + DISCONNECT_SSH_ENVIRONMENT_CHANNEL, + FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, + BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, + FETCH_SSH_SESSION_STATE_CHANNEL, + ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, + RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, +] as const; interface DesktopSshEnvironmentManagerOptions { - readonly passwordProvider?: (request: SshPasswordRequest) => Promise; + readonly passwordProvider?: ( + request: SshPasswordRequest, + ) => Effect.Effect; readonly resolveCliPackageSpec?: () => string; readonly resolveCliRunner?: () => RemoteT3RunnerOptions; } -const sshRuntime = ManagedRuntime.make( - Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, NetService.layer), -); - -function createDesktopSshRuntime( - passwordPrompt: SshPasswordPromptShape, - scope: Scope.Scope, - options: DesktopSshEnvironmentManagerOptions, -) { - return ManagedRuntime.make( - Layer.mergeAll( - NodeServices.layer, - NodeHttpClient.layerUndici, - NetService.layer, - Layer.succeed(Scope.Scope, scope), - Layer.succeed(SshPasswordPrompt, SshPasswordPrompt.of(passwordPrompt)), - SshEnvironmentManager.layer({ - ...(options.resolveCliPackageSpec === undefined - ? {} - : { resolveCliPackageSpec: options.resolveCliPackageSpec }), - ...(options.resolveCliRunner === undefined - ? {} - : { resolveCliRunner: options.resolveCliRunner }), - }), - ), - ); +export function discoverDesktopSshHostsEffect(input?: { readonly homeDir?: string }) { + return discoverSshHosts(input ?? {}); } -export async function discoverDesktopSshHosts(input?: { - readonly homeDir?: string; -}): Promise { - return await sshRuntime.runPromise(discoverSshHosts(input ?? {})); +type DesktopSshEnvironmentEffectContext = + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | Path.Path + | HttpClient.HttpClient + | NetService; + +export interface DesktopSshEnvironmentManagerShape { + readonly discoverHosts: (input?: { + readonly homeDir?: string; + }) => Effect.Effect< + readonly DesktopDiscoveredSshHost[], + unknown, + FileSystem.FileSystem | Path.Path + >; + readonly ensureEnvironment: ( + target: DesktopSshEnvironmentTarget, + options?: { readonly issuePairingToken?: boolean }, + ) => Effect.Effect; + readonly disconnectEnvironment: ( + target: DesktopSshEnvironmentTarget, + ) => Effect.Effect; } -export class DesktopSshEnvironmentManager { - private readonly runtime: ReturnType; - private readonly scope: Scope.Scope; - - constructor(options: DesktopSshEnvironmentManagerOptions = {}) { - const passwordPrompt: SshPasswordPromptShape = { - isAvailable: options.passwordProvider !== undefined, - request: (request) => { - const passwordProvider = options.passwordProvider; - if (!passwordProvider) { - return Effect.succeed(null); - } - - return Effect.tryPromise({ - try: () => passwordProvider(request), - catch: (cause) => +function makeDesktopSshPasswordPrompt( + passwordProvider: DesktopSshEnvironmentManagerOptions["passwordProvider"], +): SshPasswordPromptShape { + return { + isAvailable: passwordProvider !== undefined, + request: (request) => { + if (!passwordProvider) { + return Effect.succeed(null); + } + + return passwordProvider(request).pipe( + Effect.catchCause((cause) => + Effect.fail( new SshPasswordPromptError({ - message: cause instanceof Error ? cause.message : "SSH password prompt failed.", - cause, + message: "SSH password prompt failed.", + cause: Cause.squash(cause), }), - }); - }, - }; - this.scope = Effect.runSync(Scope.make()); - this.runtime = createDesktopSshRuntime(passwordPrompt, this.scope, options); - } - - async discoverHosts(): Promise { - return await discoverDesktopSshHosts(); - } - - async ensureEnvironment( - target: DesktopSshEnvironmentTarget, - options?: { readonly issuePairingToken?: boolean }, - ) { - return await this.runtime.runPromise( - Effect.service(SshEnvironmentManager).pipe( - Effect.flatMap((manager) => manager.ensureEnvironment(target, options)), - ), - ); - } + ), + ), + ); + }, + }; +} - async disconnectEnvironment(target: DesktopSshEnvironmentTarget): Promise { - await this.runtime.runPromise( - Effect.service(SshEnvironmentManager).pipe( - Effect.flatMap((manager) => manager.disconnectEnvironment(target)), +const makeDesktopSshEnvironmentManager = Effect.fn("desktop.ssh.manager.make")(function* ( + options: DesktopSshEnvironmentManagerOptions = {}, +) { + const manager = yield* SshEnvironmentManager; + const bridge = yield* DesktopSshEnvironmentBridge; + const passwordPrompt = SshPasswordPrompt.of( + makeDesktopSshPasswordPrompt(options.passwordProvider ?? bridge.passwordProvider), + ); + const withPasswordPrompt = ( + effect: Effect.Effect, + ): Effect.Effect> => + effect.pipe(Effect.provideService(SshPasswordPrompt, passwordPrompt)); + + return DesktopSshEnvironmentManager.of({ + discoverHosts: discoverDesktopSshHostsEffect, + ensureEnvironment: (target, ensureOptions) => + withPasswordPrompt(manager.ensureEnvironment(target, ensureOptions)), + disconnectEnvironment: (target) => withPasswordPrompt(manager.disconnectEnvironment(target)), + }); +}); + +export class DesktopSshEnvironmentManager extends Context.Service< + DesktopSshEnvironmentManager, + DesktopSshEnvironmentManagerShape +>()("@t3tools/desktop/DesktopSshEnvironmentManager") { + static readonly layer = (options: DesktopSshEnvironmentManagerOptions = {}) => + Layer.effect(DesktopSshEnvironmentManager, makeDesktopSshEnvironmentManager(options)).pipe( + Layer.provide( + SshEnvironmentManager.layer({ + ...(options.resolveCliPackageSpec === undefined + ? {} + : { resolveCliPackageSpec: options.resolveCliPackageSpec }), + ...(options.resolveCliRunner === undefined + ? {} + : { resolveCliRunner: options.resolveCliRunner }), + }), ), ); - } - - async dispose(): Promise { - await this.runtime.runPromise(Scope.close(this.scope, Exit.void)); - await this.runtime.dispose(); - } } function getSafeDesktopSshTarget(rawTarget: unknown): DesktopSshEnvironmentTarget | null { @@ -192,15 +220,12 @@ export interface DesktopSshBridgeIpcMain { export interface DesktopSshEnvironmentBridgeOptions { readonly getMainWindow: () => DesktopSshBridgeWindow | null; - readonly resolveCliPackageSpec?: () => string; - readonly resolveCliRunner?: () => RemoteT3RunnerOptions; readonly passwordPromptTimeoutMs?: number; } interface PendingSshPasswordPrompt { - readonly resolve: (password: string | null) => void; - readonly reject: (error: Error) => void; - readonly timeout: ReturnType; + readonly deferred: Deferred.Deferred; + readonly timeoutFiber: Fiber.Fiber; } export function isSshPasswordPromptCancellation(error: unknown): error is SshPasswordPromptError { @@ -211,210 +236,326 @@ export function isSshPasswordPromptCancellation(error: unknown): error is SshPas ); } +export interface DesktopSshEnvironmentBridgeShape { + readonly installPasswordPromptScope: (scope: Scope.Closeable) => Effect.Effect; + readonly passwordProvider: (request: SshPasswordRequest) => Effect.Effect; + readonly registerIpcHandlers: ( + ipcMain: DesktopSshBridgeIpcMain, + ) => Effect.Effect< + void, + never, + Scope.Scope | DesktopSshEnvironmentManager | DesktopSshEnvironmentEffectContext + >; + readonly cancelPendingPasswordPromptsEffect: (reason: string) => Effect.Effect; + readonly disposeEffect: () => Effect.Effect; +} + +function clearDesktopSshIpcHandlers(ipcMain: DesktopSshBridgeIpcMain): void { + for (const channel of SSH_HANDLED_IPC_CHANNELS) { + ipcMain.removeHandler(channel); + } +} + /** * Wires the SSH environment manager to Electron IPC, owning the renderer-facing * password prompt state so `main.ts` only needs to register, cancel, and dispose. */ -export class DesktopSshEnvironmentBridge { - private readonly options: DesktopSshEnvironmentBridgeOptions; - private readonly manager: DesktopSshEnvironmentManager; - private readonly pendingPrompts = new Map(); - private readonly passwordPromptTimeoutMs: number; - - constructor(options: DesktopSshEnvironmentBridgeOptions) { - this.options = options; - this.passwordPromptTimeoutMs = - options.passwordPromptTimeoutMs ?? DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS; - this.manager = new DesktopSshEnvironmentManager({ - passwordProvider: (request) => this.requestPasswordFromRenderer(request), - ...(options.resolveCliPackageSpec === undefined - ? {} - : { resolveCliPackageSpec: options.resolveCliPackageSpec }), - ...(options.resolveCliRunner === undefined - ? {} - : { resolveCliRunner: options.resolveCliRunner }), - }); - } - - registerIpcHandlers(ipcMain: DesktopSshBridgeIpcMain): void { - ipcMain.removeHandler(DISCOVER_SSH_HOSTS_CHANNEL); - ipcMain.handle(DISCOVER_SSH_HOSTS_CHANNEL, async () => this.manager.discoverHosts()); +function makeDesktopSshEnvironmentBridge( + options: DesktopSshEnvironmentBridgeOptions, +): DesktopSshEnvironmentBridgeShape { + let passwordPromptScope: Option.Option = Option.none(); + const pendingPrompts = new Map(); + const passwordPromptTimeoutMs = + options.passwordPromptTimeoutMs ?? DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS; + let disposed = false; + + const cancelPendingPasswordPromptsEffect = (reason: string): Effect.Effect => { + const prompts = Array.from(pendingPrompts); + pendingPrompts.clear(); + return Effect.forEach( + prompts, + ([, pending]) => + Fiber.interrupt(pending.timeoutFiber).pipe( + Effect.ignore, + Effect.andThen(Deferred.fail(pending.deferred, new Error(reason))), + Effect.asVoid, + ), + { discard: true }, + ).pipe(Effect.asVoid); + }; - ipcMain.removeHandler(ENSURE_SSH_ENVIRONMENT_CHANNEL); - ipcMain.handle(ENSURE_SSH_ENVIRONMENT_CHANNEL, async (_event, rawTarget, rawOptions) => { - const target = getSafeDesktopSshTarget(rawTarget); - if (!target) { - throw new Error("Invalid desktop SSH target."); - } + const resolvePasswordPromptEffect = ( + rawRequestId: unknown, + rawPassword: unknown, + ): Effect.Effect => { + if (typeof rawRequestId !== "string" || rawRequestId.trim().length === 0) { + return Effect.fail(new Error("Invalid SSH password prompt id.")); + } + if (rawPassword !== null && typeof rawPassword !== "string") { + return Effect.fail(new Error("Invalid SSH password prompt response.")); + } - const issuePairingToken = - typeof rawOptions === "object" && - rawOptions !== null && - "issuePairingToken" in rawOptions && - (rawOptions as { issuePairingToken?: unknown }).issuePairingToken === true; - - try { - return await this.manager.ensureEnvironment(target, { - issuePairingToken, - }); - } catch (error) { - if (isSshPasswordPromptCancellation(error)) { - return { - type: SSH_PASSWORD_PROMPT_CANCELLED_RESULT, - message: error.message, - }; - } - throw error; - } - }); + const pending = pendingPrompts.get(rawRequestId); + if (!pending) { + return Effect.fail(new Error("SSH password prompt expired. Try connecting again.")); + } - ipcMain.removeHandler(DISCONNECT_SSH_ENVIRONMENT_CHANNEL); - ipcMain.handle(DISCONNECT_SSH_ENVIRONMENT_CHANNEL, async (_event, rawTarget) => { - const target = getSafeDesktopSshTarget(rawTarget); - if (!target) { - throw new Error("Invalid desktop SSH target."); - } + pendingPrompts.delete(rawRequestId); + return Fiber.interrupt(pending.timeoutFiber).pipe( + Effect.ignore, + Effect.andThen(Deferred.succeed(pending.deferred, rawPassword)), + Effect.asVoid, + ); + }; - await this.manager.disconnectEnvironment(target); - }); + const requestPasswordFromRendererEffect = ( + input: SshPasswordRequest, + ): Effect.Effect => { + const scope = Option.getOrUndefined(passwordPromptScope); + if (scope === undefined) { + return Effect.fail(new Error("SSH password prompt scope has not been initialized.")); + } - ipcMain.removeHandler(FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL); - ipcMain.handle(FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, async (_event, rawHttpBaseUrl) => - sshRuntime.runPromise( - fetchLoopbackSshJson({ - httpBaseUrl: rawHttpBaseUrl, - pathname: "/.well-known/t3/environment", - }), - ), - ); + return Effect.gen(function* () { + const window = options.getMainWindow(); + if (!window || window.isDestroyed()) { + return yield* Effect.fail( + new Error("T3 Code window is not available for SSH authentication."), + ); + } - ipcMain.removeHandler(BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL); - ipcMain.handle( - BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, - async (_event, rawHttpBaseUrl, rawCredential) => - sshRuntime.runPromise( - fetchLoopbackSshJson({ - httpBaseUrl: rawHttpBaseUrl, - pathname: "/api/auth/bootstrap/bearer", - method: "POST", - body: { credential: rawCredential }, + const requestId = yield* Random.nextUUIDv4; + const now = yield* DateTime.now; + const request: DesktopSshPasswordPromptRequest = { + requestId, + destination: input.destination, + username: input.username, + prompt: input.prompt, + expiresAt: DateTime.formatIso(DateTime.add(now, { milliseconds: passwordPromptTimeoutMs })), + }; + const deferred = yield* Deferred.make(); + const timeoutFiber = yield* Effect.sleep(Duration.millis(passwordPromptTimeoutMs)).pipe( + Effect.andThen( + Effect.sync(() => { + pendingPrompts.delete(request.requestId); }), ), - ); + Effect.andThen( + Deferred.fail( + deferred, + new Error(`SSH authentication timed out for ${input.destination}.`), + ), + ), + Effect.asVoid, + Effect.forkIn(scope), + ); + + pendingPrompts.set(request.requestId, { deferred, timeoutFiber }); + + yield* Effect.try({ + try: () => { + if (window.isDestroyed()) { + throw new Error("T3 Code window is not available for SSH authentication."); + } + window.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, request); + if (window.isDestroyed()) { + throw new Error("T3 Code window is not available for SSH authentication."); + } + if (window.isMinimized()) { + window.restore(); + } + if (window.isDestroyed()) { + throw new Error("T3 Code window is not available for SSH authentication."); + } + window.focus(); + }, + catch: (error) => + error instanceof Error + ? error + : new Error("T3 Code window is not available for SSH authentication."), + }).pipe( + Effect.catch((error) => + Effect.fail(error).pipe( + Effect.ensuring( + Effect.sync(() => { + pendingPrompts.delete(request.requestId); + }).pipe(Effect.andThen(Fiber.interrupt(timeoutFiber).pipe(Effect.ignore))), + ), + ), + ), + ); - ipcMain.removeHandler(FETCH_SSH_SESSION_STATE_CHANNEL); - ipcMain.handle( - FETCH_SSH_SESSION_STATE_CHANNEL, - async (_event, rawHttpBaseUrl, rawBearerToken) => - sshRuntime.runPromise( - fetchLoopbackSshJson({ - httpBaseUrl: rawHttpBaseUrl, - pathname: "/api/auth/session", - bearerToken: rawBearerToken, - }), + return yield* Deferred.await(deferred).pipe( + Effect.ensuring( + Effect.sync(() => { + pendingPrompts.delete(request.requestId); + }).pipe(Effect.andThen(Fiber.interrupt(timeoutFiber).pipe(Effect.ignore))), ), - ); + ); + }); + }; - ipcMain.removeHandler(ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL); - ipcMain.handle( - ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, - async (_event, rawHttpBaseUrl, rawBearerToken) => - sshRuntime.runPromise( - fetchLoopbackSshJson({ - httpBaseUrl: rawHttpBaseUrl, - pathname: "/api/auth/ws-token", - method: "POST", - bearerToken: rawBearerToken, + return { + installPasswordPromptScope: (scope) => + Effect.sync(() => { + passwordPromptScope = Option.some(scope); + }), + passwordProvider: requestPasswordFromRendererEffect, + registerIpcHandlers: (ipcMain) => + Effect.acquireRelease( + Effect.gen(function* () { + const context = yield* Effect.context< + DesktopSshEnvironmentManager | DesktopSshEnvironmentEffectContext + >(); + const runEffect = (effect: Effect.Effect): Promise => + Effect.runPromiseWith(context as unknown as Context.Context)(effect); + + yield* Effect.sync(() => { + clearDesktopSshIpcHandlers(ipcMain); + + ipcMain.handle(DISCOVER_SSH_HOSTS_CHANNEL, () => + runEffect( + Effect.gen(function* () { + const manager = yield* DesktopSshEnvironmentManager; + return yield* manager.discoverHosts(); + }), + ), + ); + + ipcMain.handle( + ENSURE_SSH_ENVIRONMENT_CHANNEL, + async (_event, rawTarget, rawOptions) => { + const target = getSafeDesktopSshTarget(rawTarget); + if (!target) { + throw new Error("Invalid desktop SSH target."); + } + + const issuePairingToken = + typeof rawOptions === "object" && + rawOptions !== null && + "issuePairingToken" in rawOptions && + (rawOptions as { issuePairingToken?: unknown }).issuePairingToken === true; + + try { + return await runEffect( + Effect.gen(function* () { + const manager = yield* DesktopSshEnvironmentManager; + return yield* manager.ensureEnvironment(target, { + issuePairingToken, + }); + }), + ); + } catch (error) { + if (isSshPasswordPromptCancellation(error)) { + return { + type: SSH_PASSWORD_PROMPT_CANCELLED_RESULT, + message: error.message, + }; + } + throw error; + } + }, + ); + + ipcMain.handle(DISCONNECT_SSH_ENVIRONMENT_CHANNEL, async (_event, rawTarget) => { + const target = getSafeDesktopSshTarget(rawTarget); + if (!target) { + throw new Error("Invalid desktop SSH target."); + } + + await runEffect( + Effect.gen(function* () { + const manager = yield* DesktopSshEnvironmentManager; + yield* manager.disconnectEnvironment(target); + }), + ); + }); + + ipcMain.handle( + FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, + async (_event, rawHttpBaseUrl) => + runEffect( + fetchLoopbackSshJson({ + httpBaseUrl: rawHttpBaseUrl, + pathname: "/.well-known/t3/environment", + }), + ), + ); + + ipcMain.handle( + BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, + async (_event, rawHttpBaseUrl, rawCredential) => + runEffect( + fetchLoopbackSshJson({ + httpBaseUrl: rawHttpBaseUrl, + pathname: "/api/auth/bootstrap/bearer", + method: "POST", + body: { credential: rawCredential }, + }), + ), + ); + + ipcMain.handle( + FETCH_SSH_SESSION_STATE_CHANNEL, + async (_event, rawHttpBaseUrl, rawBearerToken) => + runEffect( + fetchLoopbackSshJson({ + httpBaseUrl: rawHttpBaseUrl, + pathname: "/api/auth/session", + bearerToken: rawBearerToken, + }), + ), + ); + + ipcMain.handle( + ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, + async (_event, rawHttpBaseUrl, rawBearerToken) => + runEffect( + fetchLoopbackSshJson({ + httpBaseUrl: rawHttpBaseUrl, + pathname: "/api/auth/ws-token", + method: "POST", + bearerToken: rawBearerToken, + }), + ), + ); + + ipcMain.handle( + RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, + async (_event, rawRequestId, rawPassword) => { + await runEffect(resolvePasswordPromptEffect(rawRequestId, rawPassword)); + }, + ); + }); + }), + () => Effect.sync(() => clearDesktopSshIpcHandlers(ipcMain)), + ).pipe(Effect.asVoid), + cancelPendingPasswordPromptsEffect, + disposeEffect: () => { + if (disposed) return Effect.void; + disposed = true; + const scope = passwordPromptScope; + passwordPromptScope = Option.none(); + return cancelPendingPasswordPromptsEffect("SSH environment bridge disposed.").pipe( + Effect.andThen( + Option.match(scope, { + onNone: () => Effect.void, + onSome: (scope) => Scope.close(scope, Exit.void), }), ), - ); + Effect.ignore, + ); + }, + }; +} - ipcMain.removeHandler(RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL); - ipcMain.handle( - RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, - async (_event, rawRequestId, rawPassword) => { - if (typeof rawRequestId !== "string" || rawRequestId.trim().length === 0) { - throw new Error("Invalid SSH password prompt id."); - } - if (rawPassword !== null && typeof rawPassword !== "string") { - throw new Error("Invalid SSH password prompt response."); - } - - const pending = this.pendingPrompts.get(rawRequestId); - if (!pending) { - throw new Error("SSH password prompt expired. Try connecting again."); - } - - clearTimeout(pending.timeout); - this.pendingPrompts.delete(rawRequestId); - pending.resolve(rawPassword); - }, +export class DesktopSshEnvironmentBridge extends Context.Service< + DesktopSshEnvironmentBridge, + DesktopSshEnvironmentBridgeShape +>()("@t3tools/desktop/DesktopSshEnvironmentBridge") { + static readonly layer = (options: DesktopSshEnvironmentBridgeOptions) => + Layer.succeed( + DesktopSshEnvironmentBridge, + DesktopSshEnvironmentBridge.of(makeDesktopSshEnvironmentBridge(options)), ); - } - - cancelPendingPasswordPrompts(reason: string): void { - for (const [requestId, pending] of this.pendingPrompts) { - clearTimeout(pending.timeout); - this.pendingPrompts.delete(requestId); - pending.reject(new Error(reason)); - } - } - - async dispose(): Promise { - this.cancelPendingPasswordPrompts("SSH environment bridge disposed."); - await this.manager.dispose(); - } - - private async requestPasswordFromRenderer(input: SshPasswordRequest): Promise { - const window = this.options.getMainWindow(); - if (!window || window.isDestroyed()) { - throw new Error("T3 Code window is not available for SSH authentication."); - } - - const request: DesktopSshPasswordPromptRequest = { - requestId: Crypto.randomUUID(), - destination: input.destination, - username: input.username, - prompt: input.prompt, - expiresAt: new Date(Date.now() + this.passwordPromptTimeoutMs).toISOString(), - }; - - return await new Promise((resolve, reject) => { - const rejectPrompt = (error: Error) => { - clearTimeout(timeout); - this.pendingPrompts.delete(request.requestId); - reject(error); - }; - const timeout = setTimeout(() => { - this.pendingPrompts.delete(request.requestId); - reject(new Error(`SSH authentication timed out for ${input.destination}.`)); - }, this.passwordPromptTimeoutMs); - timeout.unref(); - - this.pendingPrompts.set(request.requestId, { resolve, reject, timeout }); - - try { - if (window.isDestroyed()) { - throw new Error("T3 Code window is not available for SSH authentication."); - } - window.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, request); - if (window.isDestroyed()) { - throw new Error("T3 Code window is not available for SSH authentication."); - } - if (window.isMinimized()) { - window.restore(); - } - if (window.isDestroyed()) { - throw new Error("T3 Code window is not available for SSH authentication."); - } - window.focus(); - } catch (error) { - rejectPrompt( - error instanceof Error - ? error - : new Error("T3 Code window is not available for SSH authentication."), - ); - } - }); - } } diff --git a/apps/desktop/src/tailscaleEndpointProvider.test.ts b/apps/desktop/src/tailscaleEndpointProvider.test.ts index 2e92b7ee5d3..72573050b98 100644 --- a/apps/desktop/src/tailscaleEndpointProvider.test.ts +++ b/apps/desktop/src/tailscaleEndpointProvider.test.ts @@ -1,5 +1,7 @@ -import { describe, expect, it } from "vitest"; -import { Effect } from "effect"; +import { assert, describe, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { isTailscaleIpv4Address, @@ -7,27 +9,40 @@ import { resolveTailscaleAdvertisedEndpoints, } from "./tailscaleEndpointProvider.ts"; +const unusedTailscaleExternalServicesLayer = Layer.mergeAll( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("unexpected Tailscale HTTPS probe")), + ), + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected tailscale status process")), + ), +); + describe("tailscale endpoint provider", () => { it("detects Tailnet IPv4 addresses", () => { - expect(isTailscaleIpv4Address("100.64.0.1")).toBe(true); - expect(isTailscaleIpv4Address("100.127.255.254")).toBe(true); - expect(isTailscaleIpv4Address("100.128.0.1")).toBe(false); - expect(isTailscaleIpv4Address("192.168.1.44")).toBe(false); + assert.equal(isTailscaleIpv4Address("100.64.0.1"), true); + assert.equal(isTailscaleIpv4Address("100.127.255.254"), true); + assert.equal(isTailscaleIpv4Address("100.128.0.1"), false); + assert.equal(isTailscaleIpv4Address("192.168.1.44"), false); }); - it("parses MagicDNS names from tailscale status", async () => { - expect( - Effect.runSync( - parseTailscaleMagicDnsName(JSON.stringify({ Self: { DNSName: "desktop.tail.ts.net." } })), - ), - ).toBe("desktop.tail.ts.net"); - expect(Effect.runSync(parseTailscaleMagicDnsName("{}"))).toBeNull(); - await expect(Effect.runPromise(parseTailscaleMagicDnsName("not-json"))).rejects.toBeDefined(); - }); + it.effect("parses MagicDNS names from tailscale status", () => + Effect.gen(function* () { + const dnsName = yield* parseTailscaleMagicDnsName( + `{"Self":{"DNSName":"desktop.tail.ts.net."}}`, + ); + assert.equal(dnsName, "desktop.tail.ts.net"); + assert.equal(yield* parseTailscaleMagicDnsName("{}"), null); + const malformed = yield* Effect.result(parseTailscaleMagicDnsName("not-json")); + assert.isTrue(malformed._tag === "Failure"); + }), + ); - it("resolves Tailscale endpoints as add-on advertised endpoints", async () => { - await expect( - resolveTailscaleAdvertisedEndpoints({ + it.effect("resolves Tailscale endpoints as add-on advertised endpoints", () => + Effect.gen(function* () { + const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ port: 3773, networkInterfaces: { tailscale0: [ @@ -41,82 +56,86 @@ describe("tailscale endpoint provider", () => { }, ], }, - statusJson: JSON.stringify({ Self: { DNSName: "desktop.tail.ts.net." } }), - }), - ).resolves.toEqual([ - { - id: "tailscale-ip:http://100.100.100.100:3773", - label: "Tailscale IP", - provider: { - id: "tailscale", - label: "Tailscale", - kind: "private-network", - isAddon: true, - }, - httpBaseUrl: "http://100.100.100.100:3773/", - wsBaseUrl: "ws://100.100.100.100:3773/", - reachability: "private-network", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", + statusJson: `{"Self":{"DNSName":"desktop.tail.ts.net."}}`, + }); + assert.deepEqual(endpoints, [ + { + id: "tailscale-ip:http://100.100.100.100:3773", + label: "Tailscale IP", + provider: { + id: "tailscale", + label: "Tailscale", + kind: "private-network", + isAddon: true, + }, + httpBaseUrl: "http://100.100.100.100:3773/", + wsBaseUrl: "ws://100.100.100.100:3773/", + reachability: "private-network", + compatibility: { + hostedHttpsApp: "mixed-content-blocked", + desktopApp: "compatible", + }, + source: "desktop-addon", + status: "available", + description: "Reachable from devices on the same Tailnet.", }, - source: "desktop-addon", - status: "available", - description: "Reachable from devices on the same Tailnet.", - }, - { - id: "tailscale-magicdns:https://desktop.tail.ts.net/", - label: "Tailscale HTTPS", - provider: { - id: "tailscale", - label: "Tailscale", - kind: "private-network", - isAddon: true, + { + id: "tailscale-magicdns:https://desktop.tail.ts.net/", + label: "Tailscale HTTPS", + provider: { + id: "tailscale", + label: "Tailscale", + kind: "private-network", + isAddon: true, + }, + httpBaseUrl: "https://desktop.tail.ts.net/", + wsBaseUrl: "wss://desktop.tail.ts.net/", + reachability: "private-network", + compatibility: { + hostedHttpsApp: "requires-configuration", + desktopApp: "compatible", + }, + source: "desktop-addon", + status: "unavailable", + description: "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.", }, - httpBaseUrl: "https://desktop.tail.ts.net/", - wsBaseUrl: "wss://desktop.tail.ts.net/", - reachability: "private-network", - compatibility: { - hostedHttpsApp: "requires-configuration", - desktopApp: "compatible", - }, - source: "desktop-addon", - status: "unavailable", - description: "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.", - }, - ]); - }); + ]); + }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)), + ); - it("marks the Tailscale HTTPS endpoint available after Serve is enabled and reachable", async () => { - await expect( - resolveTailscaleAdvertisedEndpoints({ - port: 3773, - networkInterfaces: {}, - statusJson: JSON.stringify({ Self: { DNSName: "desktop.tail.ts.net." } }), - serveEnabled: true, - probe: async () => true, - }), - ).resolves.toEqual([ - { - id: "tailscale-magicdns:https://desktop.tail.ts.net/", - label: "Tailscale HTTPS", - provider: { - id: "tailscale", - label: "Tailscale", - kind: "private-network", - isAddon: true, - }, - httpBaseUrl: "https://desktop.tail.ts.net/", - wsBaseUrl: "wss://desktop.tail.ts.net/", - reachability: "private-network", - compatibility: { - hostedHttpsApp: "compatible", - desktopApp: "compatible", - }, - source: "desktop-addon", - status: "available", - description: "HTTPS endpoint served by Tailscale Serve.", - }, - ]); - }); + it.effect( + "marks the Tailscale HTTPS endpoint available after Serve is enabled and reachable", + () => + Effect.gen(function* () { + const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ + port: 3773, + networkInterfaces: {}, + statusJson: `{"Self":{"DNSName":"desktop.tail.ts.net."}}`, + serveEnabled: true, + probe: () => Effect.succeed(true), + }); + assert.deepEqual(endpoints, [ + { + id: "tailscale-magicdns:https://desktop.tail.ts.net/", + label: "Tailscale HTTPS", + provider: { + id: "tailscale", + label: "Tailscale", + kind: "private-network", + isAddon: true, + }, + httpBaseUrl: "https://desktop.tail.ts.net/", + wsBaseUrl: "wss://desktop.tail.ts.net/", + reachability: "private-network", + compatibility: { + hostedHttpsApp: "compatible", + desktopApp: "compatible", + }, + source: "desktop-addon", + status: "available", + description: "HTTPS endpoint served by Tailscale Serve.", + }, + ]); + }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)), + ); }); diff --git a/apps/desktop/src/tailscaleEndpointProvider.ts b/apps/desktop/src/tailscaleEndpointProvider.ts index 053eac5d442..fbadf495bad 100644 --- a/apps/desktop/src/tailscaleEndpointProvider.ts +++ b/apps/desktop/src/tailscaleEndpointProvider.ts @@ -1,7 +1,3 @@ -import type { NetworkInterfaceInfo } from "node:os"; - -import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; -import * as NodeServices from "@effect/platform-node/NodeServices"; import { createAdvertisedEndpoint, type CreateAdvertisedEndpointInput, @@ -14,11 +10,13 @@ import { probeTailscaleHttpsEndpoint, readTailscaleStatus, } from "@t3tools/tailscale"; -import { Effect, Layer } from "effect"; +import { Effect, Option } from "effect"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; -export { isTailscaleIpv4Address, parseTailscaleMagicDnsName } from "@t3tools/tailscale"; +import type { DesktopNetworkInterfaces } from "./serverExposure.ts"; -const TailscaleDesktopLayer = Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici); +export { isTailscaleIpv4Address, parseTailscaleMagicDnsName } from "@t3tools/tailscale"; const TAILSCALE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { id: "tailscale", @@ -39,7 +37,7 @@ function createTailscaleEndpoint( export function resolveTailscaleIpAdvertisedEndpoints(input: { readonly port: number; - readonly networkInterfaces: NodeJS.Dict; + readonly networkInterfaces: DesktopNetworkInterfaces; }): readonly AdvertisedEndpoint[] { const seen = new Set(); const endpoints: AdvertisedEndpoint[] = []; @@ -70,73 +68,80 @@ export function resolveTailscaleIpAdvertisedEndpoints(input: { return endpoints; } -export async function resolveTailscaleMagicDnsAdvertisedEndpoint(input: { +export const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn( + "resolveTailscaleMagicDnsAdvertisedEndpoint", +)(function* (input: { readonly dnsName: string | null; readonly serveEnabled: boolean; readonly servePort?: number; - readonly probe?: (baseUrl: string) => Promise; -}): Promise { + readonly probe?: (baseUrl: string) => Effect.Effect; +}): Effect.fn.Return, never, HttpClient.HttpClient> { if (!input.dnsName) { - return null; + return Option.none(); } const httpBaseUrl = buildTailscaleHttpsBaseUrl({ magicDnsName: input.dnsName, ...(input.servePort === undefined ? {} : { servePort: input.servePort }), }); + const probe = (input.probe?.(httpBaseUrl) ?? + probeTailscaleHttpsEndpoint({ + baseUrl: httpBaseUrl, + })) as Effect.Effect; const isReachable = input.serveEnabled - ? await (input.probe?.(httpBaseUrl) ?? - Effect.runPromise( - probeTailscaleHttpsEndpoint({ baseUrl: httpBaseUrl }).pipe( - Effect.provide(TailscaleDesktopLayer), - ), - )) + ? yield* probe.pipe(Effect.catch(() => Effect.succeed(false))) : false; - return createTailscaleEndpoint({ - id: `tailscale-magicdns:${httpBaseUrl}`, - label: "Tailscale HTTPS", - httpBaseUrl, - reachability: "private-network", - hostedHttpsCompatibility: isReachable ? "compatible" : "requires-configuration", - status: isReachable ? "available" : "unavailable", - description: isReachable - ? "HTTPS endpoint served by Tailscale Serve." - : "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.", - }); -} + return Option.some( + createTailscaleEndpoint({ + id: `tailscale-magicdns:${httpBaseUrl}`, + label: "Tailscale HTTPS", + httpBaseUrl, + reachability: "private-network", + hostedHttpsCompatibility: isReachable ? "compatible" : "requires-configuration", + status: isReachable ? "available" : "unavailable", + description: isReachable + ? "HTTPS endpoint served by Tailscale Serve." + : "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.", + }), + ); +}); -export async function resolveTailscaleAdvertisedEndpoints(input: { - readonly port: number; - readonly serveEnabled?: boolean; - readonly servePort?: number; - readonly networkInterfaces: NodeJS.Dict; - readonly statusJson?: string | null; - readonly probe?: (baseUrl: string) => Promise; -}): Promise { - const ipEndpoints = resolveTailscaleIpAdvertisedEndpoints(input); - const dnsName = - input.statusJson === undefined - ? await Effect.runPromise( - readTailscaleStatus.pipe( +export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAdvertisedEndpoints")( + function* (input: { + readonly port: number; + readonly serveEnabled?: boolean; + readonly servePort?: number; + readonly networkInterfaces: DesktopNetworkInterfaces; + readonly statusJson?: string | null; + readonly probe?: (baseUrl: string) => Effect.Effect; + }): Effect.fn.Return< + readonly AdvertisedEndpoint[], + never, + ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient + > { + const ipEndpoints = resolveTailscaleIpAdvertisedEndpoints(input); + const dnsName = + input.statusJson === undefined + ? yield* readTailscaleStatus.pipe( Effect.map((status) => status.magicDnsName), Effect.catch(() => Effect.succeed(null)), - Effect.provide(TailscaleDesktopLayer), - ), - ) - : input.statusJson - ? await Effect.runPromise( - parseTailscaleMagicDnsName(input.statusJson).pipe( - Effect.catch(() => Effect.succeed(null)), - ), ) - : null; - const magicDnsEndpoint = await resolveTailscaleMagicDnsAdvertisedEndpoint({ - dnsName, - serveEnabled: input.serveEnabled === true, - ...(input.servePort === undefined ? {} : { servePort: input.servePort }), - ...(input.probe === undefined ? {} : { probe: input.probe }), - }); + : input.statusJson + ? yield* parseTailscaleMagicDnsName(input.statusJson).pipe( + Effect.catch(() => Effect.succeed(null)), + ) + : null; + const magicDnsEndpoint = yield* resolveTailscaleMagicDnsAdvertisedEndpoint({ + dnsName, + serveEnabled: input.serveEnabled === true, + ...(input.servePort === undefined ? {} : { servePort: input.servePort }), + ...(input.probe === undefined ? {} : { probe: input.probe }), + }); - return magicDnsEndpoint ? [...ipEndpoints, magicDnsEndpoint] : ipEndpoints; -} + return Option.match(magicDnsEndpoint, { + onNone: () => ipEndpoints, + onSome: (endpoint) => [...ipEndpoints, endpoint], + }); + }, +); diff --git a/packages/shared/src/Net.ts b/packages/shared/src/Net.ts index d0a44cc5724..c6f01f9234c 100644 --- a/packages/shared/src/Net.ts +++ b/packages/shared/src/Net.ts @@ -82,99 +82,103 @@ export interface NetServiceShape { readonly findAvailablePort: (preferred: number) => Effect.Effect; } -/** - * NetService - Service tag for startup networking helpers. - */ -export class NetService extends Context.Service()( - "@t3tools/shared/Net/NetService", -) { - static readonly layer = Layer.sync(NetService, () => { - /** - * Returns true when a TCP server can bind to {host, port}. - * `EADDRNOTAVAIL` is treated as available so IPv6-absent hosts don't fail - * loopback availability checks. - */ - const canListenOnHost = (port: number, host: string): Effect.Effect => - Effect.callback((resume) => { - const server = Net.createServer(); - let settled = false; - - const settle = (value: boolean) => { - if (settled) return; - settled = true; - resume(Effect.succeed(value)); - }; - - server.unref(); - - server.once("error", (cause) => { - if (isErrnoExceptionWithCode(cause) && cause.code === "EADDRNOTAVAIL") { - settle(true); - return; - } - settle(false); - }); +export const make = () => { + /** + * Returns true when a TCP server can bind to {host, port}. + * `EADDRNOTAVAIL` is treated as available so IPv6-absent hosts don't fail + * loopback availability checks. + */ + const canListenOnHost = (port: number, host: string): Effect.Effect => + Effect.callback((resume) => { + const server = Net.createServer(); + let settled = false; + + const settle = (value: boolean) => { + if (settled) return; + settled = true; + resume(Effect.succeed(value)); + }; + + server.unref(); + + server.once("error", (cause) => { + if (isErrnoExceptionWithCode(cause) && cause.code === "EADDRNOTAVAIL") { + settle(true); + return; + } + settle(false); + }); - server.once("listening", () => { - server.close(() => { - settle(true); - }); + server.once("listening", () => { + server.close(() => { + settle(true); }); + }); - server.listen({ host, port }); + server.listen({ host, port }); - return Effect.sync(() => { - closeServer(server); - }); + return Effect.sync(() => { + closeServer(server); }); + }); - /** - * Reserve an ephemeral loopback port and release it immediately. - * Returns the reserved port number. - */ - const reserveLoopbackPort = (host = "127.0.0.1"): Effect.Effect => - Effect.callback((resume) => { - const probe = Net.createServer(); - let settled = false; - - const settle = (effect: Effect.Effect) => { - if (settled) return; - settled = true; - resume(effect); - }; - - probe.once("error", (cause) => { - settle(Effect.fail(new NetError({ message: "Failed to reserve loopback port", cause }))); - }); + /** + * Reserve an ephemeral loopback port and release it immediately. + * Returns the reserved port number. + */ + const reserveLoopbackPort = (host = "127.0.0.1"): Effect.Effect => + Effect.callback((resume) => { + const probe = Net.createServer(); + let settled = false; + + const settle = (effect: Effect.Effect) => { + if (settled) return; + settled = true; + resume(effect); + }; + + probe.once("error", (cause) => { + settle(Effect.fail(new NetError({ message: "Failed to reserve loopback port", cause }))); + }); - probe.listen(0, host, () => { - const address = probe.address(); - const port = typeof address === "object" && address !== null ? address.port : 0; - probe.close(() => { - if (port > 0) { - settle(Effect.succeed(port)); - return; - } - settle(Effect.fail(new NetError({ message: "Failed to reserve loopback port" }))); - }); + probe.listen(0, host, () => { + const address = probe.address(); + const port = typeof address === "object" && address !== null ? address.port : 0; + probe.close(() => { + if (port > 0) { + settle(Effect.succeed(port)); + return; + } + settle(Effect.fail(new NetError({ message: "Failed to reserve loopback port" }))); }); + }); - return Effect.sync(() => { - closeServer(probe); - }); + return Effect.sync(() => { + closeServer(probe); }); + }); - return { - canListenOnHost, - isPortAvailableOnLoopback: (port) => - Effect.zipWith( - canListenOnHost(port, "127.0.0.1"), - canListenOnHost(port, "::1"), - (ipv4, ipv6) => ipv4 && ipv6, - ), - reserveLoopbackPort, - findAvailablePort: (preferred) => - Effect.catch(tryReservePort(preferred), () => tryReservePort(0)), - } satisfies NetServiceShape; - }); + return { + canListenOnHost, + isPortAvailableOnLoopback: (port) => + Effect.zipWith( + canListenOnHost(port, "127.0.0.1"), + canListenOnHost(port, "::1"), + (ipv4, ipv6) => ipv4 && ipv6, + ), + reserveLoopbackPort, + findAvailablePort: (preferred) => + Effect.catch(tryReservePort(preferred), () => tryReservePort(0)), + } satisfies NetServiceShape; +}; + +/** + * NetService - Service tag for startup networking helpers. + */ +export class NetService extends Context.Service()( + "@t3tools/shared/Net/NetService", +) { + static readonly layer = Layer.sync(NetService, make); } + +export const layer = NetService.layer; From f9000d47056a596b63cc5776bfa6fb04e6ce596a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 12:07:57 -0700 Subject: [PATCH 06/43] Refactor desktop shell env sync into Effect service - Move shell environment hydration behind a DesktopShellEnvironment service - Add Effect-based tests for macOS, Linux, Windows, and probe command flow --- apps/desktop/src/main.ts | 22 +- apps/desktop/src/syncShellEnvironment.test.ts | 742 ++++++++++------ apps/desktop/src/syncShellEnvironment.ts | 819 ++++++++++++++++-- 3 files changed, 1221 insertions(+), 362 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 1283f21006f..9870efbbeeb 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -105,7 +105,12 @@ import { type DesktopSshEnvironmentBridgeShape, resolveRemoteT3CliPackageSpec, } from "./sshEnvironment.ts"; -import { syncShellEnvironment } from "./syncShellEnvironment.ts"; +import { + DesktopShellEnvironment, + DesktopShellEnvironmentConfigLive, + DesktopShellEnvironmentLive, + DesktopShellEnvironmentProbeLive, +} from "./syncShellEnvironment.ts"; import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts"; import { doesVersionMatchDesktopUpdateChannel } from "./updateChannels.ts"; import { @@ -246,6 +251,7 @@ type DesktopIpcBoundaryServices = | HttpClient.HttpClient | FileSystem.FileSystem | EffectPath.Path + | DesktopShellEnvironment | DesktopEnvironment | DesktopBackendManager | DesktopNetworkInterfacesService @@ -779,6 +785,16 @@ const desktopSshEnvironmentLayer = Layer.unwrap( }), ); +const desktopShellEnvironmentProbeLayer = DesktopShellEnvironmentProbeLive.pipe( + Layer.provide(NodeServices.layer), +); + +const desktopShellEnvironmentLayer = DesktopShellEnvironmentLive.pipe( + Layer.provide( + Layer.mergeAll(DesktopShellEnvironmentConfigLive, desktopShellEnvironmentProbeLayer), + ), +); + const desktopBackendDependenciesLayer = Layer.mergeAll( NodeServices.layer, NodeHttpClient.layerUndici, @@ -795,6 +811,7 @@ const desktopRuntimeLayer = Layer.mergeAll( NodeServices.layer, NodeHttpClient.layerUndici, DesktopNetworkInterfacesLive, + desktopShellEnvironmentLayer, desktopSshEnvironmentLayer, ).pipe( Layer.provideMerge(desktopSshEnvironmentBridgeLayer), @@ -2591,6 +2608,7 @@ const program = Effect.scoped( const environment = yield* DesktopEnvironment; appRunId = (yield* Random.nextUUIDv4).replace(/-/g, "").slice(0, 12); const backendManager = yield* DesktopBackendManager; + const shellEnvironment = yield* DesktopShellEnvironment; const desktopSshEnvironmentBridge = yield* DesktopSshEnvironmentBridge; const sshPasswordPromptScope = yield* Scope.make("sequential"); yield* desktopSshEnvironmentBridge.installPasswordPromptScope(sshPasswordPromptScope); @@ -2601,7 +2619,7 @@ const program = Effect.scoped( ), ); - yield* Effect.sync(syncShellEnvironment); + yield* shellEnvironment.sync; const userDataPath = yield* resolveUserDataPath(); yield* Effect.sync(() => { // Must happen before Electron's ready event so Chromium profile data diff --git a/apps/desktop/src/syncShellEnvironment.test.ts b/apps/desktop/src/syncShellEnvironment.test.ts index 1c13f77256c..f9216c9f7c7 100644 --- a/apps/desktop/src/syncShellEnvironment.test.ts +++ b/apps/desktop/src/syncShellEnvironment.test.ts @@ -1,289 +1,475 @@ -import { describe, expect, it, vi } from "vitest"; - -import { syncShellEnvironment } from "./syncShellEnvironment.ts"; - -describe("syncShellEnvironment", () => { - it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => { - const env: NodeJS.ProcessEnv = { - SHELL: "/bin/zsh", - PATH: "/Users/test/.local/bin:/usr/bin", - }; - const readEnvironment = vi.fn(() => ({ - PATH: "/opt/homebrew/bin:/usr/bin", - SSH_AUTH_SOCK: "/tmp/secretive.sock", - HOMEBREW_PREFIX: "/opt/homebrew", - })); - - syncShellEnvironment(env, { - platform: "darwin", - readEnvironment, - }); - - expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", [ - "PATH", - "SSH_AUTH_SOCK", - "HOMEBREW_PREFIX", - "HOMEBREW_CELLAR", - "HOMEBREW_REPOSITORY", - "XDG_CONFIG_HOME", - "XDG_DATA_HOME", - ]); - expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin"); - expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock"); - expect(env.HOMEBREW_PREFIX).toBe("/opt/homebrew"); - }); +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { Effect, Layer, Logger, Option, Sink, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; - it("preserves an inherited SSH_AUTH_SOCK value", () => { - const env: NodeJS.ProcessEnv = { - SHELL: "/bin/zsh", - PATH: "/usr/bin", - SSH_AUTH_SOCK: "/tmp/inherited.sock", - }; - const readEnvironment = vi.fn(() => ({ - PATH: "/opt/homebrew/bin:/usr/bin", - SSH_AUTH_SOCK: "/tmp/login-shell.sock", - })); - - syncShellEnvironment(env, { - platform: "darwin", - readEnvironment, - }); - - expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); - expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock"); - }); +import { + DesktopShellEnvironment, + DesktopShellEnvironmentConfig, + DesktopShellEnvironmentLive, + DesktopShellEnvironmentProbe, + DesktopShellEnvironmentProbeLive, + type DesktopShellEnvironmentProbeShape, + type WindowsEnvironmentProbeOptions, +} from "./syncShellEnvironment.ts"; - it("preserves inherited values when the login shell omits them", () => { - const env: NodeJS.ProcessEnv = { - SHELL: "/bin/zsh", - PATH: "/usr/bin", - SSH_AUTH_SOCK: "/tmp/inherited.sock", - }; - const readEnvironment = vi.fn(() => ({ - PATH: "/opt/homebrew/bin:/usr/bin", - })); - - syncShellEnvironment(env, { - platform: "darwin", - readEnvironment, - }); - - expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); - expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock"); - }); +const textEncoder = new TextEncoder(); +const LOGIN_SHELL_ENV_NAMES = [ + "PATH", + "SSH_AUTH_SOCK", + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", +] as const; - it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on linux", () => { - const env: NodeJS.ProcessEnv = { - SHELL: "/bin/zsh", - PATH: "/usr/bin", - }; - const readEnvironment = vi.fn(() => ({ - PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin", - SSH_AUTH_SOCK: "/tmp/secretive.sock", - })); - - syncShellEnvironment(env, { - platform: "linux", - readEnvironment, - }); - - expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", [ - "PATH", - "SSH_AUTH_SOCK", - "HOMEBREW_PREFIX", - "HOMEBREW_CELLAR", - "HOMEBREW_REPOSITORY", - "XDG_CONFIG_HOME", - "XDG_DATA_HOME", - ]); - expect(env.PATH).toBe("/home/linuxbrew/.linuxbrew/bin:/usr/bin"); - expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock"); +function makeProcess(options?: { + readonly stdout?: Stream.Stream; + readonly stderr?: Stream.Stream; + readonly exitCode?: Effect.Effect; +}): ChildProcessSpawner.ChildProcessHandle { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + stdout: options?.stdout ?? Stream.empty, + stderr: options?.stderr ?? Stream.empty, + all: Stream.merge(options?.stdout ?? Stream.empty, options?.stderr ?? Stream.empty), + exitCode: options?.exitCode ?? Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), }); +} - it("falls back to launchctl PATH on macOS when shell probing does not return one", () => { - const env: NodeJS.ProcessEnv = { - SHELL: "/opt/homebrew/bin/nu", - PATH: "/usr/bin", - }; - const readEnvironment = vi - .fn() - .mockImplementationOnce(() => { - throw new Error("unknown flag"); - }) - .mockImplementationOnce(() => ({})); - const readLaunchctlPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin"); - const logWarning = vi.fn(); - - syncShellEnvironment(env, { - platform: "darwin", - readEnvironment, - readLaunchctlPath, - userShell: "/bin/zsh", - logWarning, - }); - - expect(readEnvironment).toHaveBeenNthCalledWith(1, "/opt/homebrew/bin/nu", [ - "PATH", - "SSH_AUTH_SOCK", - "HOMEBREW_PREFIX", - "HOMEBREW_CELLAR", - "HOMEBREW_REPOSITORY", - "XDG_CONFIG_HOME", - "XDG_DATA_HOME", - ]); - expect(readEnvironment).toHaveBeenNthCalledWith(2, "/bin/zsh", [ - "PATH", - "SSH_AUTH_SOCK", - "HOMEBREW_PREFIX", - "HOMEBREW_CELLAR", - "HOMEBREW_REPOSITORY", - "XDG_CONFIG_HOME", - "XDG_DATA_HOME", - ]); - expect(readLaunchctlPath).toHaveBeenCalledTimes(1); - expect(logWarning).toHaveBeenCalledWith( - "Failed to read login shell environment from /opt/homebrew/bin/nu.", - expect.any(Error), - ); - expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); - }); +const defaultProbe: DesktopShellEnvironmentProbeShape = { + readLoginShellEnvironment: () => Effect.succeed({}), + readLaunchctlPath: Effect.succeed(Option.none()), + readWindowsShellEnvironment: () => Effect.succeed({}), + isWindowsCommandAvailable: () => Effect.succeed(true), +}; - it("does nothing on unsupported platforms", () => { - const env: NodeJS.ProcessEnv = { - SHELL: "C:/Program Files/Git/bin/bash.exe", - PATH: "C:\\Windows\\System32", - SSH_AUTH_SOCK: "/tmp/inherited.sock", - }; - const readEnvironment = vi.fn(() => ({ - PATH: "/usr/local/bin:/usr/bin", - SSH_AUTH_SOCK: "/tmp/secretive.sock", - })); - - syncShellEnvironment(env, { - platform: "freebsd", - readEnvironment, - }); - - expect(readEnvironment).not.toHaveBeenCalled(); - expect(env.PATH).toBe("C:\\Windows\\System32"); - expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock"); +function probeLayer(probe: Partial) { + return Layer.succeed(DesktopShellEnvironmentProbe, { + ...defaultProbe, + ...probe, }); +} - it("hydrates PATH on Windows by merging PowerShell PATH with inherited PATH", () => { - const env: NodeJS.ProcessEnv = { - PATH: "C:\\Windows\\System32", - APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", - LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", - USERPROFILE: "C:\\Users\\testuser", - }; - const readWindowsEnvironment = vi.fn(() => ({ - PATH: "C:\\Custom\\Bin;C:\\Windows\\System32", - })); - const isWindowsCommandAvailable = vi.fn(() => true); - - syncShellEnvironment(env, { - platform: "win32", - readWindowsEnvironment, - isWindowsCommandAvailable, - }); - - expect(readWindowsEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false }); - expect(env.PATH).toBe( - [ - "C:\\Users\\testuser\\AppData\\Roaming\\npm", - "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", - "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", - "C:\\Users\\testuser\\AppData\\Local\\pnpm", - "C:\\Users\\testuser\\.bun\\bin", - "C:\\Users\\testuser\\scoop\\shims", - "C:\\Custom\\Bin", - "C:\\Windows\\System32", - ].join(";"), - ); - expect(isWindowsCommandAvailable).toHaveBeenCalledTimes(1); - }); +function runShellEnvironment(input: { + readonly env: NodeJS.ProcessEnv; + readonly platform: NodeJS.Platform; + readonly userShell?: string; + readonly probe: Partial; + readonly logger?: Logger.Logger; +}) { + const dependencyLayer = Layer.mergeAll( + Layer.succeed(DesktopShellEnvironmentConfig, { + env: input.env, + platform: input.platform, + userShell: + input.userShell === undefined ? Option.none() : Option.some(input.userShell), + }), + probeLayer(input.probe), + ); + const shellEnvironmentLayer = DesktopShellEnvironmentLive.pipe(Layer.provide(dependencyLayer)); + const layer = + input.logger === undefined + ? shellEnvironmentLayer + : Layer.mergeAll( + shellEnvironmentLayer, + Logger.layer([input.logger], { mergeWithExisting: false }), + ); - it("loads the PowerShell profile on Windows when node is not available", () => { - const env: NodeJS.ProcessEnv = { - PATH: "C:\\Windows\\System32", - APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", - LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", - USERPROFILE: "C:\\Users\\testuser", - }; - const readWindowsEnvironment = vi.fn( - (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => - options?.loadProfile - ? { - PATH: "C:\\Profile\\Node;C:\\Windows\\System32", - FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", - FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", - } - : { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }, - ); - const isWindowsCommandAvailable = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true); - - syncShellEnvironment(env, { - platform: "win32", - readWindowsEnvironment, - isWindowsCommandAvailable, - }); - - expect(env.PATH).toBe( - [ - "C:\\Profile\\Node", - "C:\\Windows\\System32", - "C:\\Users\\testuser\\AppData\\Roaming\\npm", - "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", - "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", - "C:\\Users\\testuser\\AppData\\Local\\pnpm", - "C:\\Users\\testuser\\.bun\\bin", - "C:\\Users\\testuser\\scoop\\shims", - "C:\\Custom\\Bin", - ].join(";"), - ); - expect(env.FNM_DIR).toBe("C:\\Users\\testuser\\AppData\\Roaming\\fnm"); - expect(env.FNM_MULTISHELL_PATH).toBe( - "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", - ); - expect(readWindowsEnvironment).toHaveBeenNthCalledWith(1, ["PATH"], { loadProfile: false }); - expect(readWindowsEnvironment).toHaveBeenNthCalledWith( - 2, - ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], - { loadProfile: true }, - ); - }); + return Effect.gen(function* () { + const shellEnvironment = yield* DesktopShellEnvironment; + yield* shellEnvironment.sync; + }).pipe(Effect.provide(layer)); +} - it("preserves baseline Windows env when the profile probe fails", () => { - const env: NodeJS.ProcessEnv = { - PATH: "C:\\Windows\\System32", - APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", - USERPROFILE: "C:\\Users\\testuser", - }; - const readWindowsEnvironment = vi.fn( - (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => { - if (options?.loadProfile) { - throw new Error("profile load failed"); - } - return { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }; - }, - ); - const isWindowsCommandAvailable = vi.fn(() => false); - - syncShellEnvironment(env, { - platform: "win32", - readWindowsEnvironment, - isWindowsCommandAvailable, - }); - - expect(env.PATH).toBe( - [ - "C:\\Users\\testuser\\AppData\\Roaming\\npm", - "C:\\Users\\testuser\\.bun\\bin", - "C:\\Users\\testuser\\scoop\\shims", - "C:\\Custom\\Bin", - "C:\\Windows\\System32", - ].join(";"), - ); - expect(env.SSH_AUTH_SOCK).toBeUndefined(); - }); +describe("DesktopShellEnvironment", () => { + it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/Users/test/.local/bin:/usr/bin", + }; + const calls: Array<{ readonly shell: string; readonly names: ReadonlyArray }> = []; + + yield* runShellEnvironment({ + env, + platform: "darwin", + probe: { + readLoginShellEnvironment: (shell, names) => + Effect.sync(() => { + calls.push({ shell, names }); + return { + PATH: "/opt/homebrew/bin:/usr/bin", + SSH_AUTH_SOCK: "/tmp/secretive.sock", + HOMEBREW_PREFIX: "/opt/homebrew", + }; + }), + }, + }); + + assert.deepEqual(calls, [{ shell: "/bin/zsh", names: LOGIN_SHELL_ENV_NAMES }]); + assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin"); + assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock"); + assert.equal(env.HOMEBREW_PREFIX, "/opt/homebrew"); + }), + ); + + it.effect("preserves an inherited SSH_AUTH_SOCK value", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + SSH_AUTH_SOCK: "/tmp/inherited.sock", + }; + + yield* runShellEnvironment({ + env, + platform: "darwin", + probe: { + readLoginShellEnvironment: () => + Effect.succeed({ + PATH: "/opt/homebrew/bin:/usr/bin", + SSH_AUTH_SOCK: "/tmp/login-shell.sock", + }), + }, + }); + + assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin"); + assert.equal(env.SSH_AUTH_SOCK, "/tmp/inherited.sock"); + }), + ); + + it.effect("preserves inherited values when the login shell omits them", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + SSH_AUTH_SOCK: "/tmp/inherited.sock", + }; + + yield* runShellEnvironment({ + env, + platform: "darwin", + probe: { + readLoginShellEnvironment: () => + Effect.succeed({ + PATH: "/opt/homebrew/bin:/usr/bin", + }), + }, + }); + + assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin"); + assert.equal(env.SSH_AUTH_SOCK, "/tmp/inherited.sock"); + }), + ); + + it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on linux", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + }; + const calls: Array<{ readonly shell: string; readonly names: ReadonlyArray }> = []; + + yield* runShellEnvironment({ + env, + platform: "linux", + probe: { + readLoginShellEnvironment: (shell, names) => + Effect.sync(() => { + calls.push({ shell, names }); + return { + PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin", + SSH_AUTH_SOCK: "/tmp/secretive.sock", + }; + }), + }, + }); + + assert.deepEqual(calls, [{ shell: "/bin/zsh", names: LOGIN_SHELL_ENV_NAMES }]); + assert.equal(env.PATH, "/home/linuxbrew/.linuxbrew/bin:/usr/bin"); + assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock"); + }), + ); + + it.effect("falls back to launchctl PATH on macOS when shell probing does not return one", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/opt/homebrew/bin/nu", + PATH: "/usr/bin", + }; + const calls: Array<{ readonly shell: string; readonly names: ReadonlyArray }> = []; + let launchctlReadCount = 0; + const messages: string[] = []; + const logger = Logger.make(({ message }) => { + messages.push(String(message)); + }); + + yield* runShellEnvironment({ + env, + platform: "darwin", + userShell: "/bin/zsh", + logger, + probe: { + readLoginShellEnvironment: (shell, names) => + Effect.gen(function* () { + calls.push({ shell, names }); + if (calls.length === 1) { + return yield* Effect.fail(new Error("unknown flag")); + } + return {}; + }), + readLaunchctlPath: Effect.sync(() => { + launchctlReadCount += 1; + return Option.some("/opt/homebrew/bin:/usr/bin"); + }), + }, + }); + + assert.deepEqual(calls, [ + { shell: "/opt/homebrew/bin/nu", names: LOGIN_SHELL_ENV_NAMES }, + { shell: "/bin/zsh", names: LOGIN_SHELL_ENV_NAMES }, + ]); + assert.equal(launchctlReadCount, 1); + assert.isTrue( + messages.some((message) => message.includes("failed to read login shell environment")), + ); + assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin"); + }), + ); + + it.effect("does nothing on unsupported platforms", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "C:/Program Files/Git/bin/bash.exe", + PATH: "C:\\Windows\\System32", + SSH_AUTH_SOCK: "/tmp/inherited.sock", + }; + let readCount = 0; + + yield* runShellEnvironment({ + env, + platform: "freebsd", + probe: { + readLoginShellEnvironment: () => + Effect.sync(() => { + readCount += 1; + return { + PATH: "/usr/local/bin:/usr/bin", + SSH_AUTH_SOCK: "/tmp/secretive.sock", + }; + }), + }, + }); + + assert.equal(readCount, 0); + assert.equal(env.PATH, "C:\\Windows\\System32"); + assert.equal(env.SSH_AUTH_SOCK, "/tmp/inherited.sock"); + }), + ); + + it.effect("hydrates PATH on Windows by merging PowerShell PATH with inherited PATH", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }; + const windowsReads: Array<{ + readonly names: ReadonlyArray; + readonly options: WindowsEnvironmentProbeOptions; + }> = []; + let commandAvailabilityChecks = 0; + + yield* runShellEnvironment({ + env, + platform: "win32", + probe: { + readWindowsShellEnvironment: (names, options) => + Effect.sync(() => { + windowsReads.push({ names, options }); + return { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }; + }), + isWindowsCommandAvailable: () => + Effect.sync(() => { + commandAvailabilityChecks += 1; + return true; + }), + }, + }); + + assert.deepEqual(windowsReads, [{ names: ["PATH"], options: { loadProfile: false } }]); + assert.equal( + env.PATH, + [ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Custom\\Bin", + "C:\\Windows\\System32", + ].join(";"), + ); + assert.equal(commandAvailabilityChecks, 1); + }), + ); + + it.effect("loads the PowerShell profile on Windows when node is not available", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }; + const windowsReads: Array<{ + readonly names: ReadonlyArray; + readonly options: WindowsEnvironmentProbeOptions; + }> = []; + + yield* runShellEnvironment({ + env, + platform: "win32", + probe: { + readWindowsShellEnvironment: (names, options) => + Effect.sync(() => { + windowsReads.push({ names, options }); + return options.loadProfile + ? { + PATH: "C:\\Profile\\Node;C:\\Windows\\System32", + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", + FNM_MULTISHELL_PATH: + "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", + } + : { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }; + }), + isWindowsCommandAvailable: () => Effect.succeed(false), + }, + }); + + assert.equal( + env.PATH, + [ + "C:\\Profile\\Node", + "C:\\Windows\\System32", + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Custom\\Bin", + ].join(";"), + ); + assert.equal(env.FNM_DIR, "C:\\Users\\testuser\\AppData\\Roaming\\fnm"); + assert.equal( + env.FNM_MULTISHELL_PATH, + "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", + ); + assert.deepEqual(windowsReads, [ + { names: ["PATH"], options: { loadProfile: false } }, + { names: ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], options: { loadProfile: true } }, + ]); + }), + ); + + it.effect("preserves baseline Windows env when the profile probe fails", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + USERPROFILE: "C:\\Users\\testuser", + }; + + yield* runShellEnvironment({ + env, + platform: "win32", + probe: { + readWindowsShellEnvironment: (_names, options) => + Effect.gen(function* () { + if (options.loadProfile) { + return yield* Effect.fail(new Error("profile load failed")); + } + return { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }; + }), + isWindowsCommandAvailable: () => Effect.succeed(false), + }, + }); + + assert.equal( + env.PATH, + [ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Custom\\Bin", + "C:\\Windows\\System32", + ].join(";"), + ); + assert.isUndefined(env.SSH_AUTH_SOCK); + }), + ); + + it.effect("live probe reads login shell variables through ChildProcessSpawner", () => + Effect.gen(function* () { + let spawnedCommand: ChildProcess.Command | undefined; + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.sync(() => { + spawnedCommand = command; + return makeProcess({ + stdout: Stream.make( + textEncoder.encode( + [ + "__T3CODE_ENV_PATH_START__", + "/opt/homebrew/bin:/usr/bin", + "__T3CODE_ENV_PATH_END__", + "__T3CODE_ENV_SSH_AUTH_SOCK_START__", + "/tmp/live.sock", + "__T3CODE_ENV_SSH_AUTH_SOCK_END__", + ].join("\n"), + ), + ), + }); + }), + ), + ); + + const result = yield* Effect.gen(function* () { + const probe = yield* DesktopShellEnvironmentProbe; + return yield* probe.readLoginShellEnvironment("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"]); + }).pipe( + Effect.provide( + DesktopShellEnvironmentProbeLive.pipe( + Layer.provide(Layer.merge(NodeServices.layer, spawnerLayer)), + ), + ), + Effect.scoped, + ); + + assert.deepEqual(result, { + PATH: "/opt/homebrew/bin:/usr/bin", + SSH_AUTH_SOCK: "/tmp/live.sock", + }); + assert.isDefined(spawnedCommand); + if (spawnedCommand?._tag === "StandardCommand") { + assert.equal(spawnedCommand.command, "/bin/zsh"); + assert.equal(spawnedCommand.args[0], "-ilc"); + assert.include(spawnedCommand.args[1] ?? "", "__T3CODE_ENV_PATH_START__"); + assert.equal(spawnedCommand.options.stdout, "pipe"); + assert.equal(spawnedCommand.options.stderr, "pipe"); + } + }), + ); }); diff --git a/apps/desktop/src/syncShellEnvironment.ts b/apps/desktop/src/syncShellEnvironment.ts index 373187bda6d..1716c6d9dd7 100644 --- a/apps/desktop/src/syncShellEnvironment.ts +++ b/apps/desktop/src/syncShellEnvironment.ts @@ -1,20 +1,29 @@ import { - listLoginShellCandidates, - mergePathEntries, - readPathFromLaunchctl, - readEnvironmentFromLoginShell, - resolveWindowsEnvironment, -} from "@t3tools/shared/shell"; -import type { - CommandAvailabilityOptions, - ShellEnvironmentReader, - WindowsShellEnvironmentReader, -} from "@t3tools/shared/shell"; - -type WindowsCommandAvailabilityChecker = ( - command: string, - options?: CommandAvailabilityOptions, -) => boolean; + Context, + Data, + Duration, + Effect, + FileSystem, + Layer, + Option, + Path, + Scope, + Stream, +} from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { DesktopEnvironment } from "./desktopEnvironment.ts"; + +type EnvironmentPatch = Partial>; + +export interface WindowsEnvironmentProbeOptions { + readonly loadProfile?: boolean; +} + +export interface CommandAvailabilityOptions { + readonly platform: NodeJS.Platform; + readonly env: NodeJS.ProcessEnv; +} const LOGIN_SHELL_ENV_NAMES = [ "PATH", @@ -25,85 +34,731 @@ const LOGIN_SHELL_ENV_NAMES = [ "XDG_CONFIG_HOME", "XDG_DATA_HOME", ] as const; +const SHELL_ENV_NAME_PATTERN = /^[A-Z0-9_]+$/; +const WINDOWS_PATH_DELIMITER = ";"; +const POSIX_PATH_DELIMITER = ":"; +const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; +const LOGIN_SHELL_TIMEOUT = Duration.seconds(5); +const LAUNCHCTL_TIMEOUT = Duration.seconds(2); +const PROCESS_TERMINATE_GRACE = Duration.seconds(1); -function logShellEnvironmentWarning(message: string, error?: unknown): void { - console.warn(`[desktop] ${message}`, error instanceof Error ? error.message : (error ?? "")); -} - -export function syncShellEnvironment( - env: NodeJS.ProcessEnv = process.env, - options: { - platform?: NodeJS.Platform; - readEnvironment?: ShellEnvironmentReader; - readWindowsEnvironment?: WindowsShellEnvironmentReader; - isWindowsCommandAvailable?: WindowsCommandAvailabilityChecker; - readLaunchctlPath?: typeof readPathFromLaunchctl; - userShell?: string; - logWarning?: (message: string, error?: unknown) => void; - } = {}, -): void { - const platform = options.platform ?? process.platform; - - const logWarning = options.logWarning ?? logShellEnvironmentWarning; - const readEnvironment = options.readEnvironment ?? readEnvironmentFromLoginShell; - const shellEnvironment: Partial> = {}; - - try { - if (platform === "win32") { - const repairedEnvironment = resolveWindowsEnvironment(env, { - ...(options.readWindowsEnvironment - ? { readEnvironment: options.readWindowsEnvironment } - : {}), - ...(options.isWindowsCommandAvailable - ? { commandAvailable: options.isWindowsCommandAvailable } - : {}), - }); - for (const [key, value] of Object.entries(repairedEnvironment)) { - if (value !== undefined) { - env[key] = value; - } +export class DesktopShellEnvironmentCommandError extends Data.TaggedError( + "DesktopShellEnvironmentCommandError", +)<{ + readonly command: readonly string[]; + readonly message: string; + readonly exitCode: number | null; + readonly stderr: string; +}> {} + +export interface DesktopShellEnvironmentConfigShape { + readonly env: NodeJS.ProcessEnv; + readonly platform: NodeJS.Platform; + readonly userShell: Option.Option; +} + +export class DesktopShellEnvironmentConfig extends Context.Service< + DesktopShellEnvironmentConfig, + DesktopShellEnvironmentConfigShape +>()("t3/desktop/ShellEnvironmentConfig") {} + +export interface DesktopShellEnvironmentProbeShape { + readonly readLoginShellEnvironment: ( + shell: string, + names: ReadonlyArray, + ) => Effect.Effect; + readonly readLaunchctlPath: Effect.Effect, unknown>; + readonly readWindowsShellEnvironment: ( + names: ReadonlyArray, + options: WindowsEnvironmentProbeOptions, + ) => Effect.Effect; + readonly isWindowsCommandAvailable: ( + command: string, + options: CommandAvailabilityOptions, + ) => Effect.Effect; +} + +export class DesktopShellEnvironmentProbe extends Context.Service< + DesktopShellEnvironmentProbe, + DesktopShellEnvironmentProbeShape +>()("t3/desktop/ShellEnvironmentProbe") {} + +export interface DesktopShellEnvironmentShape { + readonly sync: Effect.Effect; +} + +export class DesktopShellEnvironment extends Context.Service< + DesktopShellEnvironment, + DesktopShellEnvironmentShape +>()("t3/desktop/ShellEnvironment") {} + +const trimNonEmptyOption = (value: string | null | undefined): Option.Option => { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? Option.some(trimmed) : Option.none(); +}; + +function listLoginShellCandidates(input: { + readonly platform: NodeJS.Platform; + readonly shell: string | undefined; + readonly userShell: Option.Option; +}): ReadonlyArray { + const fallbackShell = + input.platform === "darwin" ? "/bin/zsh" : input.platform === "linux" ? "/bin/bash" : ""; + const seen = new Set(); + const candidates: string[] = []; + + for (const candidate of [ + trimNonEmptyOption(input.shell), + input.userShell, + trimNonEmptyOption(fallbackShell), + ]) { + if (Option.isNone(candidate) || seen.has(candidate.value)) { + continue; + } + seen.add(candidate.value); + candidates.push(candidate.value); + } + + return candidates; +} + +function pathDelimiterForPlatform(platform: NodeJS.Platform): string { + return platform === "win32" ? WINDOWS_PATH_DELIMITER : POSIX_PATH_DELIMITER; +} + +function stripWrappingQuotes(value: string): string { + return value.replace(/^"+|"+$/g, ""); +} + +function normalizePathEntryForComparison(entry: string, platform: NodeJS.Platform): string { + const normalized = stripWrappingQuotes(entry.trim()); + return platform === "win32" ? normalized.toLowerCase() : normalized; +} + +function mergePathValues( + preferredPath: Option.Option, + inheritedPath: Option.Option, + platform: NodeJS.Platform, +): Option.Option { + const delimiter = pathDelimiterForPlatform(platform); + const merged: string[] = []; + const seen = new Set(); + + for (const rawValue of [preferredPath, inheritedPath]) { + if (Option.isNone(rawValue)) continue; + + for (const entry of rawValue.value.split(delimiter)) { + const trimmed = entry.trim(); + if (trimmed.length === 0) continue; + + const normalized = normalizePathEntryForComparison(trimmed, platform); + if (normalized.length === 0 || seen.has(normalized)) continue; + + seen.add(normalized); + merged.push(trimmed); + } + } + + return merged.length > 0 ? Option.some(merged.join(delimiter)) : Option.none(); +} + +function readEnvPath(env: NodeJS.ProcessEnv): Option.Option { + return trimNonEmptyOption(env.PATH ?? env.Path ?? env.path); +} + +function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray { + const appData = env.APPDATA?.trim(); + const localAppData = env.LOCALAPPDATA?.trim(); + const userProfile = env.USERPROFILE?.trim(); + + return [ + ...(appData ? [`${appData}\\npm`] : []), + ...(localAppData ? [`${localAppData}\\Programs\\nodejs`, `${localAppData}\\Volta\\bin`] : []), + ...(localAppData ? [`${localAppData}\\pnpm`] : []), + ...(userProfile ? [`${userProfile}\\.bun\\bin`, `${userProfile}\\scoop\\shims`] : []), + ]; +} + +function mergeWindowsEnv( + currentEnv: NodeJS.ProcessEnv, + patch: Partial, +): NodeJS.ProcessEnv { + const nextEnv: NodeJS.ProcessEnv = { ...currentEnv }; + for (const [key, value] of Object.entries(patch)) { + if (value !== undefined) { + nextEnv[key] = value; + } + } + return nextEnv; +} + +function envCaptureStart(name: string): string { + return `__T3CODE_ENV_${name}_START__`; +} + +function envCaptureEnd(name: string): string { + return `__T3CODE_ENV_${name}_END__`; +} + +function buildEnvironmentCaptureCommand(names: ReadonlyArray): string { + return names + .map((name) => { + if (!SHELL_ENV_NAME_PATTERN.test(name)) { + throw new Error(`Unsupported environment variable name: ${name}`); } - return; + + return [ + `printf '%s\\n' '${envCaptureStart(name)}'`, + `printenv ${name} || true`, + `printf '%s\\n' '${envCaptureEnd(name)}'`, + ].join("; "); + }) + .join("; "); +} + +function buildWindowsEnvironmentCaptureCommand(names: ReadonlyArray): string { + return [ + "$ErrorActionPreference = 'Stop'", + ...names.flatMap((name) => { + if (!SHELL_ENV_NAME_PATTERN.test(name)) { + throw new Error(`Unsupported environment variable name: ${name}`); + } + + return [ + `Write-Output '${envCaptureStart(name)}'`, + `$value = [Environment]::GetEnvironmentVariable('${name}')`, + "if ($null -ne $value -and $value.Length -gt 0) { Write-Output $value }", + `Write-Output '${envCaptureEnd(name)}'`, + ]; + }), + ].join("; "); +} + +function extractEnvironmentValue(output: string, name: string): Option.Option { + const startMarker = envCaptureStart(name); + const endMarker = envCaptureEnd(name); + const startIndex = output.indexOf(startMarker); + if (startIndex === -1) return Option.none(); + + const valueStartIndex = startIndex + startMarker.length; + const endIndex = output.indexOf(endMarker, valueStartIndex); + if (endIndex === -1) return Option.none(); + + const value = output + .slice(valueStartIndex, endIndex) + .replace(/^\r?\n/, "") + .replace(/\r?\n$/, ""); + + return value.length > 0 ? Option.some(value) : Option.none(); +} + +function extractEnvironment(output: string, names: ReadonlyArray): EnvironmentPatch { + const environment: EnvironmentPatch = {}; + for (const name of names) { + const value = extractEnvironmentValue(output, name); + if (Option.isSome(value)) { + environment[name] = value.value; } + } + return environment; +} - if (platform !== "darwin" && platform !== "linux") return; +const collectProcessOutput = (stream: Stream.Stream): Effect.Effect => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + ); + +function commandError(input: { + readonly command: readonly string[]; + readonly message: string; + readonly exitCode: number | null; + readonly stderr?: string; +}): DesktopShellEnvironmentCommandError { + return new DesktopShellEnvironmentCommandError({ + command: input.command, + message: input.message, + exitCode: input.exitCode, + stderr: input.stderr ?? "", + }); +} - for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) { - try { - Object.assign(shellEnvironment, readEnvironment(shell, LOGIN_SHELL_ENV_NAMES)); - if (shellEnvironment.PATH) { - break; +const runCommandOnce = Effect.fn("desktop.shellEnvironment.runCommandOnce")(function* (input: { + readonly command: string; + readonly args: ReadonlyArray; + readonly shell?: boolean; +}): Effect.fn.Return< + string, + DesktopShellEnvironmentCommandError, + ChildProcessSpawner.ChildProcessSpawner | Scope.Scope +> { + const command = [input.command, ...input.args]; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner + .spawn( + ChildProcess.make(input.command, input.args, { + shell: input.shell ?? false, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + killSignal: "SIGTERM", + forceKillAfter: PROCESS_TERMINATE_GRACE, + }), + ) + .pipe( + Effect.mapError((cause) => + commandError({ + command, + message: cause instanceof Error ? cause.message : "Failed to spawn shell probe.", + exitCode: null, + }), + ), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectProcessOutput(child.stdout), + collectProcessOutput(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError((cause) => + commandError({ + command, + message: cause instanceof Error ? cause.message : "Failed to run shell probe.", + exitCode: null, + }), + ), + ); + + if (exitCode !== 0) { + return yield* commandError({ + command, + message: `Shell probe exited with code ${exitCode}.`, + exitCode, + stderr, + }); + } + + return stdout; +}); + +const runCommand = (input: { + readonly command: string; + readonly args: ReadonlyArray; + readonly shell?: boolean; + readonly timeout: Duration.Duration; +}): Effect.Effect< + string, + DesktopShellEnvironmentCommandError, + ChildProcessSpawner.ChildProcessSpawner | Scope.Scope +> => + runCommandOnce(input).pipe( + Effect.timeoutOption(input.timeout), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + commandError({ + command: [input.command, ...input.args], + message: `Shell probe timed out after ${Duration.format(input.timeout)}.`, + exitCode: null, + }), + ), + onSome: Effect.succeed, + }), + ), + ); + +const readLoginShellEnvironmentEffect = ( + shell: string, + names: ReadonlyArray, +): Effect.Effect< + EnvironmentPatch, + DesktopShellEnvironmentCommandError, + ChildProcessSpawner.ChildProcessSpawner +> => { + if (names.length === 0) { + return Effect.succeed({}); + } + + return runCommand({ + command: shell, + args: ["-ilc", buildEnvironmentCaptureCommand(names)], + timeout: LOGIN_SHELL_TIMEOUT, + }).pipe( + Effect.map((output) => extractEnvironment(output, names)), + Effect.scoped, + ); +}; + +const readLaunchctlPathEffect: Effect.Effect< + Option.Option, + never, + ChildProcessSpawner.ChildProcessSpawner +> = runCommand({ + command: "/bin/launchctl", + args: ["getenv", "PATH"], + timeout: LAUNCHCTL_TIMEOUT, +}).pipe( + Effect.map((output) => trimNonEmptyOption(output)), + Effect.catch(() => Effect.succeed(Option.none())), + Effect.scoped, +); + +const readWindowsShellEnvironmentEffect = ( + names: ReadonlyArray, + options: WindowsEnvironmentProbeOptions, +): Effect.Effect => { + if (names.length === 0) { + return Effect.succeed({}); + } + + const command = buildWindowsEnvironmentCaptureCommand(names); + const args = [ + "-NoLogo", + ...(options.loadProfile ? ([] as const) : (["-NoProfile"] as const)), + "-NonInteractive", + "-Command", + command, + ]; + + return Effect.gen(function* () { + for (const shell of WINDOWS_SHELL_CANDIDATES) { + const output = yield* runCommand({ + command: shell, + args, + shell: true, + timeout: LOGIN_SHELL_TIMEOUT, + }).pipe(Effect.option, Effect.scoped); + if (Option.isSome(output)) { + return extractEnvironment(output.value, names); + } + } + + return {}; + }); +}; + +function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray { + const rawValue = env.PATHEXT; + const fallback = [".COM", ".EXE", ".BAT", ".CMD"]; + if (!rawValue) return fallback; + + const parsed = rawValue + .split(WINDOWS_PATH_DELIMITER) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .map((entry) => (entry.startsWith(".") ? entry.toUpperCase() : `.${entry.toUpperCase()}`)); + return parsed.length > 0 ? Array.from(new Set(parsed)) : fallback; +} + +function resolveCommandCandidates(input: { + readonly command: string; + readonly platform: NodeJS.Platform; + readonly windowsPathExtensions: ReadonlyArray; +}): ReadonlyArray { + if (input.platform !== "win32") return [input.command]; + const extension = input.command.slice(input.command.lastIndexOf(".")).toUpperCase(); + + if (input.command.includes(".") && input.windowsPathExtensions.includes(extension)) { + const commandWithoutExtension = input.command.slice(0, -extension.length); + return Array.from( + new Set([ + input.command, + `${commandWithoutExtension}${extension}`, + `${commandWithoutExtension}${extension.toLowerCase()}`, + ]), + ); + } + + const candidates: string[] = []; + for (const candidateExtension of input.windowsPathExtensions) { + candidates.push(`${input.command}${candidateExtension}`); + candidates.push(`${input.command}${candidateExtension.toLowerCase()}`); + } + return Array.from(new Set(candidates)); +} + +function isPathCommand(command: string): boolean { + return command.includes("/") || command.includes("\\"); +} + +const isExecutableFile = Effect.fn("desktop.shellEnvironment.isExecutableFile")(function* ( + filePath: string, + platform: NodeJS.Platform, + windowsPathExtensions: ReadonlyArray, +): Effect.fn.Return { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stat = yield* fileSystem.stat(filePath).pipe(Effect.option); + if (Option.isNone(stat) || stat.value.type !== "File") { + return false; + } + + if (platform !== "win32") { + return yield* fileSystem.access(filePath, { ok: true }).pipe( + Effect.as(true), + Effect.catch(() => Effect.succeed(false)), + ); + } + + const extension = path.extname(filePath).toUpperCase(); + return extension.length > 0 && windowsPathExtensions.includes(extension); +}); + +const resolveCommandPathEffect = Effect.fn("desktop.shellEnvironment.resolveCommandPath")( + function* ( + command: string, + options: CommandAvailabilityOptions, + ): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const path = yield* Path.Path; + const windowsPathExtensions = + options.platform === "win32" ? resolveWindowsPathExtensions(options.env) : []; + const commandCandidates = resolveCommandCandidates({ + command, + platform: options.platform, + windowsPathExtensions, + }); + + if (isPathCommand(command)) { + for (const candidate of commandCandidates) { + if (yield* isExecutableFile(candidate, options.platform, windowsPathExtensions)) { + return Option.some(candidate); } - } catch (error) { - logWarning(`Failed to read login shell environment from ${shell}.`, error); } + return Option.none(); } - const launchctlPath = - platform === "darwin" && !shellEnvironment.PATH - ? (options.readLaunchctlPath ?? readPathFromLaunchctl)() - : undefined; - const mergedPath = mergePathEntries(shellEnvironment.PATH ?? launchctlPath, env.PATH, platform); - if (mergedPath) { - env.PATH = mergedPath; + const pathValue = readEnvPath(options.env); + if (Option.isNone(pathValue)) return Option.none(); + + const pathEntries = pathValue.value + .split(pathDelimiterForPlatform(options.platform)) + .map((entry) => stripWrappingQuotes(entry.trim())) + .filter((entry) => entry.length > 0); + + for (const pathEntry of pathEntries) { + for (const candidate of commandCandidates) { + const candidatePath = path.join(pathEntry, candidate); + if (yield* isExecutableFile(candidatePath, options.platform, windowsPathExtensions)) { + return Option.some(candidatePath); + } + } } - if (!env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) { - env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK; + return Option.none(); + }, +); + +const isWindowsCommandAvailableEffect = ( + command: string, + options: CommandAvailabilityOptions, +): Effect.Effect => + resolveCommandPathEffect(command, options).pipe(Effect.map(Option.isSome)); + +export const DesktopShellEnvironmentProbeLive = Layer.effect( + DesktopShellEnvironmentProbe, + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + return { + readLoginShellEnvironment: (shell, names) => + readLoginShellEnvironmentEffect(shell, names).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + ), + readLaunchctlPath: readLaunchctlPathEffect.pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + ), + readWindowsShellEnvironment: (names, options) => + readWindowsShellEnvironmentEffect(names, options).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + ), + isWindowsCommandAvailable: (command, options) => + isWindowsCommandAvailableEffect(command, options).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + } satisfies DesktopShellEnvironmentProbeShape; + }), +); + +export const DesktopShellEnvironmentConfigLive = Layer.effect( + DesktopShellEnvironmentConfig, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + return { + env: process.env, + platform: environment.platform, + userShell: Option.none(), + } satisfies DesktopShellEnvironmentConfigShape; + }), +); + +const applyEnvironmentPatch = (env: NodeJS.ProcessEnv, patch: EnvironmentPatch): void => { + for (const [key, value] of Object.entries(patch)) { + if (value !== undefined) { + env[key] = value; } + } +}; + +const readWindowsEnvironmentSafely = ( + probe: DesktopShellEnvironmentProbeShape, + names: ReadonlyArray, + options: WindowsEnvironmentProbeOptions, +): Effect.Effect => + probe.readWindowsShellEnvironment(names, options).pipe(Effect.catch(() => Effect.succeed({}))); + +const resolveWindowsEnvironmentEffect = Effect.fn( + "desktop.shellEnvironment.resolveWindowsEnvironment", +)(function* ( + env: NodeJS.ProcessEnv, +): Effect.fn.Return, never, DesktopShellEnvironmentProbe> { + const probe = yield* DesktopShellEnvironmentProbe; + const shellPath = yield* readWindowsEnvironmentSafely(probe, ["PATH"], { + loadProfile: false, + }).pipe(Effect.map((environment) => trimNonEmptyOption(environment.PATH))); + const mergedPath = mergePathValues(shellPath, readEnvPath(env), "win32"); + const knownCliPath = trimNonEmptyOption( + resolveKnownWindowsCliDirs(env).join(WINDOWS_PATH_DELIMITER), + ); + const baselinePath = mergePathValues(knownCliPath, mergedPath, "win32"); + const baselinePatch: Partial = Option.match(baselinePath, { + onNone: () => ({}), + onSome: (value) => ({ PATH: value }), + }); + const baselineEnv = mergeWindowsEnv(env, baselinePatch); + + const nodeAvailable = yield* probe + .isWindowsCommandAvailable("node", { platform: "win32", env: baselineEnv }) + .pipe(Effect.catch(() => Effect.succeed(false))); + if (nodeAvailable) { + return baselinePatch; + } + + const profiledEnvironment = yield* readWindowsEnvironmentSafely( + probe, + ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], + { loadProfile: true }, + ); + const profiledPath = mergePathValues( + trimNonEmptyOption(profiledEnvironment.PATH), + baselinePath, + "win32", + ); + const profiledPatch: Partial = { + ...Option.match(profiledPath, { + onNone: () => ({}), + onSome: (value) => ({ PATH: value }), + }), + ...(profiledEnvironment.FNM_DIR ? { FNM_DIR: profiledEnvironment.FNM_DIR } : {}), + ...(profiledEnvironment.FNM_MULTISHELL_PATH + ? { FNM_MULTISHELL_PATH: profiledEnvironment.FNM_MULTISHELL_PATH } + : {}), + }; + + return Object.keys(profiledPatch).length > 0 + ? { ...baselinePatch, ...profiledPatch } + : baselinePatch; +}); + +const syncPosixShellEnvironment = Effect.fn("desktop.shellEnvironment.syncPosix")(function* ( + config: DesktopShellEnvironmentConfigShape, +): Effect.fn.Return { + const probe = yield* DesktopShellEnvironmentProbe; + const shellEnvironment: EnvironmentPatch = {}; + + for (const shell of listLoginShellCandidates({ + platform: config.platform, + shell: config.env.SHELL, + userShell: config.userShell, + })) { + const result = yield* probe.readLoginShellEnvironment(shell, LOGIN_SHELL_ENV_NAMES).pipe( + Effect.option, + Effect.tap((environment) => + Option.isNone(environment) + ? Effect.logWarning("failed to read login shell environment", { shell }) + : Effect.void, + ), + ); - for (const name of [ - "HOMEBREW_PREFIX", - "HOMEBREW_CELLAR", - "HOMEBREW_REPOSITORY", - "XDG_CONFIG_HOME", - "XDG_DATA_HOME", - ] as const) { - if (!env[name] && shellEnvironment[name]) { - env[name] = shellEnvironment[name]; + if (Option.isSome(result)) { + Object.assign(shellEnvironment, result.value); + if (shellEnvironment.PATH) { + break; } } - } catch (error) { - logWarning("Failed to synchronize the desktop shell environment.", error); } -} + + const launchctlPath = + config.platform === "darwin" && !shellEnvironment.PATH + ? yield* probe.readLaunchctlPath.pipe( + Effect.catch(() => Effect.succeed(Option.none())), + ) + : Option.none(); + const mergedPath = mergePathValues( + trimNonEmptyOption(shellEnvironment.PATH).pipe(Option.orElse(() => launchctlPath)), + readEnvPath(config.env), + config.platform, + ); + if (Option.isSome(mergedPath)) { + config.env.PATH = mergedPath.value; + } + + if (!config.env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) { + config.env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK; + } + + for (const name of [ + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + ] as const) { + if (!config.env[name] && shellEnvironment[name]) { + config.env[name] = shellEnvironment[name]; + } + } +}); + +export const syncShellEnvironmentEffect: Effect.Effect< + void, + never, + DesktopShellEnvironmentConfig | DesktopShellEnvironmentProbe +> = Effect.gen(function* () { + const config = yield* DesktopShellEnvironmentConfig; + + yield* Effect.gen(function* () { + if (config.platform === "win32") { + applyEnvironmentPatch(config.env, yield* resolveWindowsEnvironmentEffect(config.env)); + return; + } + + if (config.platform !== "darwin" && config.platform !== "linux") { + return; + } + + yield* syncPosixShellEnvironment(config); + }); +}); + +export const DesktopShellEnvironmentLive = Layer.effect( + DesktopShellEnvironment, + Effect.gen(function* () { + const config = yield* DesktopShellEnvironmentConfig; + const probe = yield* DesktopShellEnvironmentProbe; + return { + sync: syncShellEnvironmentEffect.pipe( + Effect.provideService(DesktopShellEnvironmentConfig, config), + Effect.provideService(DesktopShellEnvironmentProbe, probe), + ), + } satisfies DesktopShellEnvironmentShape; + }), +); From 9e2d64ca82141ec5a7b0391973c82376f37a2d50 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 13:39:40 -0700 Subject: [PATCH 07/43] Refactor desktop IPC into shared handlers - Split desktop IPC channels and method registrations into dedicated modules - Wire client settings, saved environments, server exposure, updates, and window actions through the new IPC layer - Add desktop secret storage context for Electron-safe persistence Co-authored-by: codex --- .../src/electron/DesktopSecretStorage.ts | 10 + apps/desktop/src/ipc/DesktopIpc.ts | 194 +++++ apps/desktop/src/ipc/DesktopIpcHandlers.ts | 65 ++ apps/desktop/src/ipc/channels.ts | 35 + .../desktop/src/ipc/methods/clientSettings.ts | 30 + .../src/ipc/methods/savedEnvironments.ts | 102 +++ .../desktop/src/ipc/methods/serverExposure.ts | 84 ++ apps/desktop/src/ipc/methods/updates.ts | 92 +++ apps/desktop/src/ipc/methods/window.ts | 135 ++++ apps/desktop/src/main.ts | 764 +++++++----------- apps/desktop/src/preload.ts | 80 +- apps/desktop/src/sshEnvironment.ts | 23 +- packages/contracts/src/ipc.ts | 136 +++- 13 files changed, 1199 insertions(+), 551 deletions(-) create mode 100644 apps/desktop/src/electron/DesktopSecretStorage.ts create mode 100644 apps/desktop/src/ipc/DesktopIpc.ts create mode 100644 apps/desktop/src/ipc/DesktopIpcHandlers.ts create mode 100644 apps/desktop/src/ipc/channels.ts create mode 100644 apps/desktop/src/ipc/methods/clientSettings.ts create mode 100644 apps/desktop/src/ipc/methods/savedEnvironments.ts create mode 100644 apps/desktop/src/ipc/methods/serverExposure.ts create mode 100644 apps/desktop/src/ipc/methods/updates.ts create mode 100644 apps/desktop/src/ipc/methods/window.ts diff --git a/apps/desktop/src/electron/DesktopSecretStorage.ts b/apps/desktop/src/electron/DesktopSecretStorage.ts new file mode 100644 index 00000000000..cd39a8cacad --- /dev/null +++ b/apps/desktop/src/electron/DesktopSecretStorage.ts @@ -0,0 +1,10 @@ +import * as Context from "effect/Context"; + +import type { DesktopSecretStorage as ClientPersistenceSecretStorage } from "../clientPersistence.ts"; + +export interface DesktopSecretStorageShape extends ClientPersistenceSecretStorage {} + +export class DesktopSecretStorage extends Context.Service< + DesktopSecretStorage, + DesktopSecretStorageShape +>()("@t3tools/desktop/DesktopSecretStorage") {} diff --git a/apps/desktop/src/ipc/DesktopIpc.ts b/apps/desktop/src/ipc/DesktopIpc.ts new file mode 100644 index 00000000000..04ace682936 --- /dev/null +++ b/apps/desktop/src/ipc/DesktopIpc.ts @@ -0,0 +1,194 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Electron from "electron"; + +export interface DesktopIpcInvokeEvent {} + +export interface DesktopIpcSyncEvent { + returnValue: unknown; +} + +export type DesktopIpcHandleListener = ( + event: DesktopIpcInvokeEvent, + raw: unknown, +) => unknown | Promise; + +export type DesktopIpcSyncListener = (event: DesktopIpcSyncEvent) => void; + +export interface DesktopIpcMain { + removeHandler(channel: string): void; + handle(channel: string, listener: DesktopIpcHandleListener): void; + removeAllListeners(channel: string): void; + on(channel: string, listener: DesktopIpcSyncListener): void; +} + +export interface DesktopIpcMethod { + readonly channel: string; + readonly handler: (raw: unknown) => Effect.Effect; +} + +export interface DesktopSyncIpcMethod { + readonly channel: string; + readonly handler: () => Effect.Effect; +} + +export interface DesktopIpcShape { + readonly handle: ( + input: DesktopIpcMethod, + ) => Effect.Effect; + readonly handleSync: ( + input: DesktopSyncIpcMethod, + ) => Effect.Effect; +} + +export class DesktopIpc extends Context.Service()("t3/desktop/Ipc") {} + +export const make = (ipcMain: DesktopIpcMain): DesktopIpcShape => + DesktopIpc.of({ + handle: ({ channel, handler }: DesktopIpcMethod) => + Effect.gen(function* () { + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); + + yield* Effect.acquireRelease( + Effect.sync(() => { + ipcMain.removeHandler(channel); + ipcMain.handle(channel, (_event, raw) => runPromise(handler(raw))); + }), + () => Effect.sync(() => ipcMain.removeHandler(channel)), + ); + }), + + handleSync: ({ channel, handler }: DesktopSyncIpcMethod) => + Effect.gen(function* () { + const context = yield* Effect.context(); + const runSync = Effect.runSyncWith(context); + + yield* Effect.acquireRelease( + Effect.sync(() => { + ipcMain.removeAllListeners(channel); + ipcMain.on(channel, (event) => { + event.returnValue = runSync(handler()); + }); + }), + () => Effect.sync(() => ipcMain.removeAllListeners(channel)), + ); + }), + }); + +export const layer = Layer.succeed(DesktopIpc, make(Electron.ipcMain)); + +/** + * Convenience helpers for creating IPC methods + */ + +export interface DesktopIpcMethodRegistration< + Payload, + EncodedPayload, + Result, + EncodedResult, + E, + R, + PayloadDecodingServices = never, + PayloadEncodingServices = never, + ResultDecodingServices = never, + ResultEncodingServices = never, +> { + readonly channel: string; + readonly payload: Schema.Codec< + Payload, + EncodedPayload, + PayloadDecodingServices, + PayloadEncodingServices + >; + readonly result: Schema.Codec< + Result, + EncodedResult, + ResultDecodingServices, + ResultEncodingServices + >; + readonly handler: (input: Payload) => Effect.Effect; +} + +export const makeIpcMethod = < + Payload, + EncodedPayload, + Result, + EncodedResult, + E, + R, + PayloadDecodingServices = never, + PayloadEncodingServices = never, + ResultDecodingServices = never, + ResultEncodingServices = never, +>( + method: DesktopIpcMethodRegistration< + Payload, + EncodedPayload, + Result, + EncodedResult, + E, + R, + PayloadDecodingServices, + PayloadEncodingServices, + ResultDecodingServices, + ResultEncodingServices + >, +): DesktopIpcMethod< + E | Schema.SchemaError, + R | PayloadDecodingServices | ResultEncodingServices +> => { + const decode = Schema.decodeUnknownEffect(method.payload); + const encode = Schema.encodeUnknownEffect(method.result); + + return { + channel: method.channel, + handler: (raw) => decode(raw).pipe(Effect.flatMap(method.handler), Effect.flatMap(encode)), + }; +}; + +export interface DesktopSyncIpcMethodRegistration< + Result, + EncodedResult, + E, + R, + ResultDecodingServices = never, + ResultEncodingServices = never, +> { + readonly channel: string; + readonly result: Schema.Codec< + Result, + EncodedResult, + ResultDecodingServices, + ResultEncodingServices + >; + readonly handler: () => Effect.Effect; +} + +export const makeSyncIpcMethod = < + Result, + EncodedResult, + E, + R, + ResultDecodingServices = never, + ResultEncodingServices = never, +>( + method: DesktopSyncIpcMethodRegistration< + Result, + EncodedResult, + E, + R, + ResultDecodingServices, + ResultEncodingServices + >, +): DesktopSyncIpcMethod => { + const encode = Schema.encodeUnknownEffect(method.result); + + return { + channel: method.channel, + handler: () => method.handler().pipe(Effect.flatMap(encode)), + }; +}; diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts new file mode 100644 index 00000000000..d17c8bc4508 --- /dev/null +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -0,0 +1,65 @@ +import * as Effect from "effect/Effect"; + +import * as DesktopIpc from "./DesktopIpc.ts"; +import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; +import { + getSavedEnvironmentRegistry, + getSavedEnvironmentSecret, + removeSavedEnvironmentSecret, + setSavedEnvironmentRegistry, + setSavedEnvironmentSecret, +} from "./methods/savedEnvironments.ts"; +import { + getAdvertisedEndpoints, + getServerExposureState, + setServerExposureMode, + setTailscaleServeEnabled, +} from "./methods/serverExposure.ts"; +import { + checkForUpdate, + downloadUpdate, + getUpdateState, + installUpdate, + setUpdateChannel, +} from "./methods/updates.ts"; +import { + confirm, + getAppBranding, + getLocalEnvironmentBootstrap, + openExternal, + pickFolder, + setTheme, + showContextMenu, +} from "./methods/window.ts"; + +export const installDesktopIpcHandlers = Effect.gen(function* () { + const ipc = yield* DesktopIpc.DesktopIpc; + + yield* ipc.handleSync(getAppBranding); + yield* ipc.handleSync(getLocalEnvironmentBootstrap); + + yield* ipc.handle(getClientSettings); + yield* ipc.handle(setClientSettings); + yield* ipc.handle(getSavedEnvironmentRegistry); + yield* ipc.handle(setSavedEnvironmentRegistry); + yield* ipc.handle(getSavedEnvironmentSecret); + yield* ipc.handle(setSavedEnvironmentSecret); + yield* ipc.handle(removeSavedEnvironmentSecret); + + yield* ipc.handle(getServerExposureState); + yield* ipc.handle(setServerExposureMode); + yield* ipc.handle(setTailscaleServeEnabled); + yield* ipc.handle(getAdvertisedEndpoints); + + yield* ipc.handle(pickFolder); + yield* ipc.handle(confirm); + yield* ipc.handle(setTheme); + yield* ipc.handle(showContextMenu); + yield* ipc.handle(openExternal); + + yield* ipc.handle(getUpdateState); + yield* ipc.handle(setUpdateChannel); + yield* ipc.handle(downloadUpdate); + yield* ipc.handle(installUpdate); + yield* ipc.handle(checkForUpdate); +}); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts new file mode 100644 index 00000000000..2715b20cb36 --- /dev/null +++ b/apps/desktop/src/ipc/channels.ts @@ -0,0 +1,35 @@ +export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; +export const CONFIRM_CHANNEL = "desktop:confirm"; +export const SET_THEME_CHANNEL = "desktop:set-theme"; +export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; +export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; +export const MENU_ACTION_CHANNEL = "desktop:menu-action"; +export const UPDATE_STATE_CHANNEL = "desktop:update-state"; +export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; +export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; +export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; +export const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; +export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; +export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; +export const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; +export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; +export const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; +export const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry"; +export const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry"; +export const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret"; +export const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret"; +export const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; +export const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; +export const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; +export const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment"; +export const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor"; +export const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session"; +export const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state"; +export const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-token"; +export const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt"; +export const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt"; +export const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; +export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; +export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; +export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; +export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; diff --git a/apps/desktop/src/ipc/methods/clientSettings.ts b/apps/desktop/src/ipc/methods/clientSettings.ts new file mode 100644 index 00000000000..84be90d1615 --- /dev/null +++ b/apps/desktop/src/ipc/methods/clientSettings.ts @@ -0,0 +1,30 @@ +import { ClientSettingsSchema } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { readClientSettingsEffect, writeClientSettingsEffect } from "../../clientPersistence.ts"; +import { DesktopEnvironment } from "../../desktopEnvironment.ts"; +import { GET_CLIENT_SETTINGS_CHANNEL, SET_CLIENT_SETTINGS_CHANNEL } from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; + +export const getClientSettings = makeIpcMethod({ + channel: GET_CLIENT_SETTINGS_CHANNEL, + payload: Schema.Void, + result: Schema.NullOr(ClientSettingsSchema), + handler: () => + Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + return yield* readClientSettingsEffect(environment.clientSettingsPath); + }), +}); + +export const setClientSettings = makeIpcMethod({ + channel: SET_CLIENT_SETTINGS_CHANNEL, + payload: ClientSettingsSchema, + result: Schema.Void, + handler: (settings) => + Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + yield* writeClientSettingsEffect(environment.clientSettingsPath, settings); + }), +}); diff --git a/apps/desktop/src/ipc/methods/savedEnvironments.ts b/apps/desktop/src/ipc/methods/savedEnvironments.ts new file mode 100644 index 00000000000..a350f49bf31 --- /dev/null +++ b/apps/desktop/src/ipc/methods/savedEnvironments.ts @@ -0,0 +1,102 @@ +import { EnvironmentId, PersistedSavedEnvironmentRecordSchema } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { + readSavedEnvironmentRegistryEffect, + readSavedEnvironmentSecretEffect, + removeSavedEnvironmentSecretEffect, + writeSavedEnvironmentRegistryEffect, + writeSavedEnvironmentSecretEffect, +} from "../../clientPersistence.ts"; +import { DesktopEnvironment } from "../../desktopEnvironment.ts"; +import { DesktopSecretStorage } from "../../electron/DesktopSecretStorage.ts"; +import { + GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, + GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, + REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, + SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, + SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, +} from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; + +const SavedEnvironmentRegistryPayload = Schema.Array(PersistedSavedEnvironmentRecordSchema); +const NonBlankString = Schema.String.check( + Schema.makeFilter((value) => + value.trim().length > 0 ? undefined : "Expected a non-empty string", + ), +); + +const SetSavedEnvironmentSecretInput = Schema.Struct({ + environmentId: EnvironmentId, + secret: NonBlankString, +}); + +export const getSavedEnvironmentRegistry = makeIpcMethod({ + channel: GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, + payload: Schema.Void, + result: SavedEnvironmentRegistryPayload, + handler: () => + Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + return yield* readSavedEnvironmentRegistryEffect(environment.savedEnvironmentRegistryPath); + }), +}); + +export const setSavedEnvironmentRegistry = makeIpcMethod({ + channel: SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, + payload: SavedEnvironmentRegistryPayload, + result: Schema.Void, + handler: (records) => + Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + yield* writeSavedEnvironmentRegistryEffect(environment.savedEnvironmentRegistryPath, records); + }), +}); + +export const getSavedEnvironmentSecret = makeIpcMethod({ + channel: GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, + payload: EnvironmentId, + result: Schema.NullOr(Schema.String), + handler: (environmentId) => + Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + const secretStorage = yield* DesktopSecretStorage; + return yield* readSavedEnvironmentSecretEffect({ + registryPath: environment.savedEnvironmentRegistryPath, + environmentId, + secretStorage, + }); + }), +}); + +export const setSavedEnvironmentSecret = makeIpcMethod({ + channel: SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, + payload: SetSavedEnvironmentSecretInput, + result: Schema.Boolean, + handler: ({ environmentId, secret }) => + Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + const secretStorage = yield* DesktopSecretStorage; + return yield* writeSavedEnvironmentSecretEffect({ + registryPath: environment.savedEnvironmentRegistryPath, + environmentId, + secret, + secretStorage, + }); + }), +}); + +export const removeSavedEnvironmentSecret = makeIpcMethod({ + channel: REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, + payload: EnvironmentId, + result: Schema.Void, + handler: (environmentId) => + Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + yield* removeSavedEnvironmentSecretEffect({ + registryPath: environment.savedEnvironmentRegistryPath, + environmentId, + }); + }), +}); diff --git a/apps/desktop/src/ipc/methods/serverExposure.ts b/apps/desktop/src/ipc/methods/serverExposure.ts new file mode 100644 index 00000000000..94c4635e80d --- /dev/null +++ b/apps/desktop/src/ipc/methods/serverExposure.ts @@ -0,0 +1,84 @@ +import { + AdvertisedEndpoint, + DesktopServerExposureModeSchema, + DesktopServerExposureStateSchema, + type DesktopServerExposureMode, + type DesktopServerExposureState, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { DesktopShutdown } from "../../desktopShutdown.ts"; +import { + GET_ADVERTISED_ENDPOINTS_CHANNEL, + GET_SERVER_EXPOSURE_STATE_CHANNEL, + SET_SERVER_EXPOSURE_MODE_CHANNEL, + SET_TAILSCALE_SERVE_ENABLED_CHANNEL, +} from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; + +const SetTailscaleServeEnabledInput = Schema.Struct({ + enabled: Schema.Boolean, + port: Schema.optionalKey(Schema.Number), +}); + +export interface DesktopServerExposureIpcActionsShape { + readonly getState: Effect.Effect; + readonly setMode: ( + mode: DesktopServerExposureMode, + ) => Effect.Effect; + readonly setTailscaleServeEnabled: ( + input: typeof SetTailscaleServeEnabledInput.Type, + ) => Effect.Effect; + readonly getAdvertisedEndpoints: Effect.Effect; +} + +export class DesktopServerExposureIpcActions extends Context.Service< + DesktopServerExposureIpcActions, + DesktopServerExposureIpcActionsShape +>()("t3/desktop/Ipc/ServerExposure") {} + +export const getServerExposureState = makeIpcMethod({ + channel: GET_SERVER_EXPOSURE_STATE_CHANNEL, + payload: Schema.Void, + result: DesktopServerExposureStateSchema, + handler: () => + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposureIpcActions; + return yield* serverExposure.getState; + }), +}); + +export const setServerExposureMode = makeIpcMethod({ + channel: SET_SERVER_EXPOSURE_MODE_CHANNEL, + payload: DesktopServerExposureModeSchema, + result: DesktopServerExposureStateSchema, + handler: (mode) => + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposureIpcActions; + return yield* serverExposure.setMode(mode); + }), +}); + +export const setTailscaleServeEnabled = makeIpcMethod({ + channel: SET_TAILSCALE_SERVE_ENABLED_CHANNEL, + payload: SetTailscaleServeEnabledInput, + result: DesktopServerExposureStateSchema, + handler: (input) => + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposureIpcActions; + return yield* serverExposure.setTailscaleServeEnabled(input); + }), +}); + +export const getAdvertisedEndpoints = makeIpcMethod({ + channel: GET_ADVERTISED_ENDPOINTS_CHANNEL, + payload: Schema.Void, + result: Schema.Array(AdvertisedEndpoint), + handler: () => + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposureIpcActions; + return yield* serverExposure.getAdvertisedEndpoints; + }), +}); diff --git a/apps/desktop/src/ipc/methods/updates.ts b/apps/desktop/src/ipc/methods/updates.ts new file mode 100644 index 00000000000..3357ed1f766 --- /dev/null +++ b/apps/desktop/src/ipc/methods/updates.ts @@ -0,0 +1,92 @@ +import { + DesktopUpdateActionResultSchema, + DesktopUpdateChannelSchema, + DesktopUpdateCheckResultSchema, + DesktopUpdateStateSchema, + type DesktopUpdateActionResult, + type DesktopUpdateChannel, + type DesktopUpdateCheckResult, + type DesktopUpdateState, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { + UPDATE_CHECK_CHANNEL, + UPDATE_DOWNLOAD_CHANNEL, + UPDATE_GET_STATE_CHANNEL, + UPDATE_INSTALL_CHANNEL, + UPDATE_SET_CHANNEL_CHANNEL, +} from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; + +export interface DesktopUpdateIpcActionsShape { + readonly getState: Effect.Effect; + readonly setChannel: ( + channel: DesktopUpdateChannel, + ) => Effect.Effect; + readonly download: Effect.Effect; + readonly install: Effect.Effect; + readonly check: Effect.Effect; +} + +export class DesktopUpdateIpcActions extends Context.Service< + DesktopUpdateIpcActions, + DesktopUpdateIpcActionsShape +>()("t3/desktop/Ipc/Updates") {} + +export const getUpdateState = makeIpcMethod({ + channel: UPDATE_GET_STATE_CHANNEL, + payload: Schema.Void, + result: DesktopUpdateStateSchema, + handler: () => + Effect.gen(function* () { + const updates = yield* DesktopUpdateIpcActions; + return yield* updates.getState; + }), +}); + +export const setUpdateChannel = makeIpcMethod({ + channel: UPDATE_SET_CHANNEL_CHANNEL, + payload: DesktopUpdateChannelSchema, + result: DesktopUpdateStateSchema, + handler: (channel) => + Effect.gen(function* () { + const updates = yield* DesktopUpdateIpcActions; + return yield* updates.setChannel(channel); + }), +}); + +export const downloadUpdate = makeIpcMethod({ + channel: UPDATE_DOWNLOAD_CHANNEL, + payload: Schema.Void, + result: DesktopUpdateActionResultSchema, + handler: () => + Effect.gen(function* () { + const updates = yield* DesktopUpdateIpcActions; + return yield* updates.download; + }), +}); + +export const installUpdate = makeIpcMethod({ + channel: UPDATE_INSTALL_CHANNEL, + payload: Schema.Void, + result: DesktopUpdateActionResultSchema, + handler: () => + Effect.gen(function* () { + const updates = yield* DesktopUpdateIpcActions; + return yield* updates.install; + }), +}); + +export const checkForUpdate = makeIpcMethod({ + channel: UPDATE_CHECK_CHANNEL, + payload: Schema.Void, + result: DesktopUpdateCheckResultSchema, + handler: () => + Effect.gen(function* () { + const updates = yield* DesktopUpdateIpcActions; + return yield* updates.check; + }), +}); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts new file mode 100644 index 00000000000..7dc153d2526 --- /dev/null +++ b/apps/desktop/src/ipc/methods/window.ts @@ -0,0 +1,135 @@ +import { + ContextMenuItemSchema, + DesktopAppBrandingSchema, + DesktopEnvironmentBootstrapSchema, + DesktopThemeSchema, + PickFolderOptionsSchema, + type DesktopAppBranding, + type DesktopEnvironmentBootstrap, + type DesktopTheme, + type PickFolderOptions, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { + CONFIRM_CHANNEL, + CONTEXT_MENU_CHANNEL, + GET_APP_BRANDING_CHANNEL, + GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, + OPEN_EXTERNAL_CHANNEL, + PICK_FOLDER_CHANNEL, + SET_THEME_CHANNEL, +} from "../channels.ts"; +import { makeIpcMethod, makeSyncIpcMethod } from "../DesktopIpc.ts"; + +const ContextMenuPosition = Schema.Struct({ + x: Schema.Number, + y: Schema.Number, +}); + +const ContextMenuInput = Schema.Struct({ + items: Schema.Array(ContextMenuItemSchema), + position: Schema.optionalKey(ContextMenuPosition), +}); + +export interface DesktopWindowIpcActionsShape { + readonly getAppBranding: Effect.Effect; + readonly getLocalEnvironmentBootstrap: Effect.Effect; + readonly pickFolder: (options: PickFolderOptions | undefined) => Effect.Effect; + readonly confirm: (message: string) => Effect.Effect; + readonly setTheme: (theme: DesktopTheme) => Effect.Effect; + readonly showContextMenu: (input: typeof ContextMenuInput.Type) => Effect.Effect; + readonly openExternal: (url: string) => Effect.Effect; +} + +export class DesktopWindowIpcActions extends Context.Service< + DesktopWindowIpcActions, + DesktopWindowIpcActionsShape +>()("t3/desktop/Ipc/Window") {} + +export const getAppBranding = makeSyncIpcMethod({ + channel: GET_APP_BRANDING_CHANNEL, + result: Schema.NullOr(DesktopAppBrandingSchema), + handler: () => + Effect.gen(function* () { + const window = yield* DesktopWindowIpcActions; + return yield* window.getAppBranding; + }), +}); + +export const getLocalEnvironmentBootstrap = makeSyncIpcMethod({ + channel: GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, + result: Schema.NullOr(DesktopEnvironmentBootstrapSchema), + handler: () => + Effect.gen(function* () { + const window = yield* DesktopWindowIpcActions; + return yield* window.getLocalEnvironmentBootstrap; + }), +}); + +export const pickFolder = makeIpcMethod({ + channel: PICK_FOLDER_CHANNEL, + payload: Schema.UndefinedOr(PickFolderOptionsSchema), + result: Schema.NullOr(Schema.String), + handler: (options) => + Effect.gen(function* () { + const window = yield* DesktopWindowIpcActions; + return yield* window.pickFolder(options); + }), +}); + +export const confirm = makeIpcMethod({ + channel: CONFIRM_CHANNEL, + payload: Schema.String, + result: Schema.Boolean, + handler: (message) => + Effect.gen(function* () { + const window = yield* DesktopWindowIpcActions; + return yield* window.confirm(message); + }), +}); + +export const setTheme = makeIpcMethod({ + channel: SET_THEME_CHANNEL, + payload: DesktopThemeSchema, + result: Schema.Void, + handler: (theme) => + Effect.gen(function* () { + const window = yield* DesktopWindowIpcActions; + yield* window.setTheme(theme); + }), +}); + +export const showContextMenu = makeIpcMethod({ + channel: CONTEXT_MENU_CHANNEL, + payload: ContextMenuInput, + result: Schema.NullOr(Schema.String), + handler: (input) => + Effect.gen(function* () { + const window = yield* DesktopWindowIpcActions; + return yield* window.showContextMenu(input); + }), +}); + +export const openExternal = makeIpcMethod({ + channel: OPEN_EXTERNAL_CHANNEL, + payload: Schema.String, + result: Schema.Boolean, + handler: (url) => + Effect.gen(function* () { + const window = yield* DesktopWindowIpcActions; + return yield* window.openExternal(url); + }), +}); + +export const windowInvokeMethods = [ + pickFolder, + confirm, + setTheme, + showContextMenu, + openExternal, +] as const; + +export const windowSyncMethods = [getAppBranding, getLocalEnvironmentBootstrap] as const; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9870efbbeeb..e2937ec288d 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -36,15 +36,10 @@ import { import { autoUpdater } from "electron-updater"; import type { - ClientSettings, ContextMenuItem, - DesktopTheme, DesktopServerExposureMode, DesktopServerExposureState, DesktopUpdateChannel, - PersistedSavedEnvironmentRecord, - DesktopUpdateActionResult, - DesktopUpdateCheckResult, DesktopUpdateState, } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; @@ -61,15 +56,6 @@ import { setDesktopUpdateChannelPreference, writeDesktopSettingsEffect, } from "./desktopSettings.ts"; -import { - readClientSettingsEffect, - readSavedEnvironmentRegistryEffect, - readSavedEnvironmentSecretEffect, - removeSavedEnvironmentSecretEffect, - writeClientSettingsEffect, - writeSavedEnvironmentRegistryEffect, - writeSavedEnvironmentSecretEffect, -} from "./clientPersistence.ts"; import { showDesktopConfirmDialog } from "./confirmDialog.ts"; import { DesktopBackendConfiguration, @@ -94,7 +80,14 @@ import { makeDesktopEnvironment, type DesktopEnvironmentShape, } from "./desktopEnvironment.ts"; +import { DesktopSecretStorage } from "./electron/DesktopSecretStorage.ts"; import { DesktopShutdown, makeDesktopShutdown } from "./desktopShutdown.ts"; +import { MENU_ACTION_CHANNEL, UPDATE_STATE_CHANNEL } from "./ipc/channels.ts"; +import * as DesktopIpc from "./ipc/DesktopIpc.ts"; +import { installDesktopIpcHandlers } from "./ipc/DesktopIpcHandlers.ts"; +import { DesktopServerExposureIpcActions } from "./ipc/methods/serverExposure.ts"; +import { DesktopUpdateIpcActions } from "./ipc/methods/updates.ts"; +import { DesktopWindowIpcActions } from "./ipc/methods/window.ts"; import { resolveDesktopCoreAdvertisedEndpoints, resolveDesktopServerExposure, @@ -129,32 +122,6 @@ import { isArm64HostRunningIntelBuild } from "./runtimeArch.ts"; import { bindFirstRevealTrigger, type RevealSubscription } from "./windowReveal.ts"; import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; -const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; -const CONFIRM_CHANNEL = "desktop:confirm"; -const SET_THEME_CHANNEL = "desktop:set-theme"; -const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; -const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; -const MENU_ACTION_CHANNEL = "desktop:menu-action"; -const UPDATE_STATE_CHANNEL = "desktop:update-state"; -const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; -const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; -const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; -const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; -const UPDATE_CHECK_CHANNEL = "desktop:update-check"; -const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; -const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; -const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; -const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; -const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry"; -const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry"; -const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret"; -const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret"; -const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; -const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; -const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; -const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; -const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; - const DESKTOP_SCHEME = "t3"; const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i; const COMMIT_HASH_DISPLAY_LENGTH = 12; @@ -246,17 +213,6 @@ interface DesktopEffectRunner { type DesktopWindowBoundaryServices = DesktopEnvironment | DesktopSshEnvironmentBridge; type DesktopLifecycleBoundaryServices = DesktopShutdown | DesktopWindowBoundaryServices; -type DesktopIpcBoundaryServices = - | ChildProcessSpawner.ChildProcessSpawner - | HttpClient.HttpClient - | FileSystem.FileSystem - | EffectPath.Path - | DesktopShellEnvironment - | DesktopEnvironment - | DesktopBackendManager - | DesktopNetworkInterfacesService - | DesktopShutdown - | DesktopWindowBoundaryServices; function makeDesktopEffectRunner(context: Context.Context): DesktopEffectRunner { return (effect: Effect.Effect) => @@ -440,14 +396,6 @@ function getDesktopAdvertisedEndpoints() { }); } -function getDesktopSecretStorage() { - return { - isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(), - encryptString: (value: string) => safeStorage.encryptString(value), - decryptString: (value: Buffer) => safeStorage.decryptString(value), - } as const; -} - function resolveAdvertisedHostOverride(): string | undefined { const override = process.env.T3CODE_DESKTOP_LAN_HOST?.trim(); return override && override.length > 0 ? override : undefined; @@ -586,14 +534,6 @@ function getSafeExternalUrl(rawUrl: unknown): string | null { return parsedUrl.toString(); } -function getSafeTheme(rawTheme: unknown): DesktopTheme | null { - if (rawTheme === "light" || rawTheme === "dark" || rawTheme === "system") { - return rawTheme; - } - - return null; -} - function handleBackendReady( runEffect: DesktopEffectRunner, environment: DesktopEnvironmentShape, @@ -795,6 +735,262 @@ const desktopShellEnvironmentLayer = DesktopShellEnvironmentLive.pipe( ), ); +type DesktopServerExposureIpcActionServices = + | FileSystem.FileSystem + | EffectPath.Path + | DesktopEnvironment + | DesktopNetworkInterfacesService + | ChildProcessSpawner.ChildProcessSpawner + | HttpClient.HttpClient; + +const desktopServerExposureIpcActionsLayer = Layer.effect( + DesktopServerExposureIpcActions, + Effect.gen(function* () { + const context = yield* Effect.context(); + return DesktopServerExposureIpcActions.of({ + getState: Effect.sync(getDesktopServerExposureState), + setMode: (nextMode) => + Effect.gen(function* () { + if (nextMode === desktopServerExposureMode) { + return getDesktopServerExposureState(); + } + + const nextState = yield* applyDesktopServerExposureMode(nextMode, { + persist: true, + rejectIfUnavailable: true, + }); + yield* relaunchDesktopAppEffect(`serverExposureMode=${nextMode}`); + return nextState; + }).pipe(Effect.provide(context)), + setTailscaleServeEnabled: (input) => + Effect.gen(function* () { + const nextSettings = setDesktopTailscaleServePreference(desktopSettings, { + enabled: input.enabled, + ...(typeof input.port === "number" ? { port: input.port } : {}), + }); + if (nextSettings === desktopSettings) { + return getDesktopServerExposureState(); + } + return yield* applyDesktopTailscaleServeEnabled(nextSettings); + }).pipe(Effect.provide(context)), + getAdvertisedEndpoints: getDesktopAdvertisedEndpoints().pipe(Effect.provide(context)), + }); + }), +); + +type DesktopUpdateIpcActionServices = + | FileSystem.FileSystem + | EffectPath.Path + | DesktopEnvironment + | DesktopBackendManager; + +const desktopUpdateIpcActionsLayer = Layer.effect( + DesktopUpdateIpcActions, + Effect.gen(function* () { + const context = yield* Effect.context(); + return DesktopUpdateIpcActions.of({ + getState: Effect.sync(() => updateState), + setChannel: (nextChannel) => + Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + if (updateCheckInFlight || updateDownloadInFlight || updateInstallInFlight) { + return yield* Effect.fail( + new Error("Cannot change update tracks while an update action is in progress."), + ); + } + + desktopSettings = setDesktopUpdateChannelPreference(desktopSettings, nextChannel); + yield* writeDesktopSettingsEffect(environment.desktopSettingsPath, desktopSettings); + + if (nextChannel === updateState.channel) { + return updateState; + } + + const enabled = shouldEnableAutoUpdates(environment); + setUpdateState(createBaseUpdateState(nextChannel, enabled, environment)); + + if (!enabled || !updaterConfigured) { + return updateState; + } + + yield* applyAutoUpdaterChannel(nextChannel); + const allowDowngrade = autoUpdater.allowDowngrade; + autoUpdater.allowDowngrade = true; + yield* checkForUpdates("channel-change").pipe( + Effect.ensuring( + Effect.sync(() => { + autoUpdater.allowDowngrade = allowDowngrade; + }), + ), + ); + return updateState; + }).pipe(Effect.provide(context)), + download: Effect.gen(function* () { + const result = yield* downloadAvailableUpdate(); + return { + accepted: result.accepted, + completed: result.completed, + state: updateState, + }; + }).pipe(Effect.provide(context)), + install: Effect.gen(function* () { + if (isQuitting) { + return { + accepted: false, + completed: false, + state: updateState, + }; + } + const result = yield* installDownloadedUpdate(); + return { + accepted: result.accepted, + completed: result.completed, + state: updateState, + }; + }).pipe(Effect.provide(context)), + check: Effect.gen(function* () { + if (!updaterConfigured) { + return { + checked: false, + state: updateState, + }; + } + const checked = yield* checkForUpdates("web-ui"); + return { + checked, + state: updateState, + }; + }), + }); + }), +); + +const desktopWindowIpcActionsLayer = Layer.effect( + DesktopWindowIpcActions, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + return DesktopWindowIpcActions.of({ + getAppBranding: Effect.succeed(environment.branding), + getLocalEnvironmentBootstrap: Effect.sync(() => ({ + label: "Local environment", + httpBaseUrl: getBackendHttpUrlHref(), + wsBaseUrl: backendWsUrl || null, + ...(backendBootstrapToken ? { bootstrapToken: backendBootstrapToken } : {}), + })), + pickFolder: (options) => + Effect.promise(async () => { + const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; + const defaultPath = Option.getOrUndefined( + environment.resolvePickFolderDefaultPath(options), + ); + const openDialogOptions: OpenDialogOptions = { + properties: ["openDirectory", "createDirectory"], + ...(defaultPath ? { defaultPath } : {}), + }; + const result = owner + ? await dialog.showOpenDialog(owner, openDialogOptions) + : await dialog.showOpenDialog(openDialogOptions); + if (result.canceled) return null; + return result.filePaths[0] ?? null; + }), + confirm: (message) => + Effect.promise(() => + showDesktopConfirmDialog(message, BrowserWindow.getFocusedWindow() ?? mainWindow), + ), + setTheme: (theme) => + Effect.sync(() => { + nativeTheme.themeSource = theme; + }), + showContextMenu: ({ items, position }) => + Effect.promise( + () => + new Promise((resolve) => { + const normalizedItems = normalizeContextMenuItems(items); + if (normalizedItems.length === 0) { + resolve(null); + return; + } + + const popupPosition = + position && + Number.isFinite(position.x) && + Number.isFinite(position.y) && + position.x >= 0 && + position.y >= 0 + ? { + x: Math.floor(position.x), + y: Math.floor(position.y), + } + : null; + + const window = BrowserWindow.getFocusedWindow() ?? mainWindow; + if (!window) { + resolve(null); + return; + } + + const buildTemplate = ( + entries: readonly ContextMenuItem[], + ): MenuItemConstructorOptions[] => { + const template: MenuItemConstructorOptions[] = []; + let hasInsertedDestructiveSeparator = false; + for (const item of entries) { + if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { + template.push({ type: "separator" }); + hasInsertedDestructiveSeparator = true; + } + const itemOption: MenuItemConstructorOptions = { + label: item.label, + enabled: !item.disabled, + }; + if (item.children && item.children.length > 0) { + itemOption.submenu = buildTemplate(item.children); + } else { + itemOption.click = () => resolve(item.id); + } + if (item.destructive && (!item.children || item.children.length === 0)) { + const destructiveIcon = getDestructiveMenuIcon(); + if (destructiveIcon) { + itemOption.icon = destructiveIcon; + } + } + template.push(itemOption); + } + return template; + }; + + const menu = Menu.buildFromTemplate(buildTemplate(normalizedItems)); + menu.popup({ + window, + ...popupPosition, + callback: () => resolve(null), + }); + }), + ), + openExternal: (rawUrl) => { + const externalUrl = getSafeExternalUrl(rawUrl); + if (!externalUrl) { + return Effect.succeed(false); + } + + return Effect.promise(() => shell.openExternal(externalUrl)).pipe( + Effect.as(true), + Effect.catch(() => Effect.succeed(false)), + ); + }, + }); + }), +); + +const desktopSecretStorageLayer = Layer.succeed( + DesktopSecretStorage, + DesktopSecretStorage.of({ + isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(), + encryptString: (value) => safeStorage.encryptString(value), + decryptString: (value) => safeStorage.decryptString(value), + }), +); + const desktopBackendDependenciesLayer = Layer.mergeAll( NodeServices.layer, NodeHttpClient.layerUndici, @@ -804,16 +1000,25 @@ const desktopBackendDependenciesLayer = Layer.mergeAll( desktopBackendEventsLayer.pipe(Layer.provide(desktopBackendOutputLogLayer)), ); +const desktopBackendManagerLayer = DesktopBackendManagerLive.pipe( + Layer.provide(desktopBackendDependenciesLayer), +); + const desktopRuntimeLayer = Layer.mergeAll( desktopLoggerLayer, - DesktopBackendManagerLive.pipe(Layer.provide(desktopBackendDependenciesLayer)), NetService.layer, - NodeServices.layer, - NodeHttpClient.layerUndici, - DesktopNetworkInterfacesLive, desktopShellEnvironmentLayer, desktopSshEnvironmentLayer, + DesktopIpc.layer, + desktopServerExposureIpcActionsLayer, + desktopUpdateIpcActionsLayer, + desktopWindowIpcActionsLayer, + desktopSecretStorageLayer, ).pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(NodeHttpClient.layerUndici), + Layer.provideMerge(DesktopNetworkInterfacesLive), + Layer.provideMerge(desktopBackendManagerLayer), Layer.provideMerge(desktopSshEnvironmentBridgeLayer), Layer.provideMerge(desktopEnvironmentLayer), ); @@ -1851,425 +2056,10 @@ function quitFromSignal(signal: "SIGINT" | "SIGTERM", runEffect: DesktopEffectRu }); } -const syncIpcListenerChannels = [ - GET_APP_BRANDING_CHANNEL, - GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, -] as const; - -const handledIpcChannels = [ - GET_CLIENT_SETTINGS_CHANNEL, - SET_CLIENT_SETTINGS_CHANNEL, - GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, - SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, - GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, - SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, - REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, - GET_SERVER_EXPOSURE_STATE_CHANNEL, - SET_SERVER_EXPOSURE_MODE_CHANNEL, - SET_TAILSCALE_SERVE_ENABLED_CHANNEL, - GET_ADVERTISED_ENDPOINTS_CHANNEL, - PICK_FOLDER_CHANNEL, - CONFIRM_CHANNEL, - SET_THEME_CHANNEL, - CONTEXT_MENU_CHANNEL, - OPEN_EXTERNAL_CHANNEL, - UPDATE_GET_STATE_CHANNEL, - UPDATE_SET_CHANNEL_CHANNEL, - UPDATE_DOWNLOAD_CHANNEL, - UPDATE_INSTALL_CHANNEL, - UPDATE_CHECK_CHANNEL, -] as const; - -function clearDesktopIpcHandlers(): void { - for (const channel of syncIpcListenerChannels) { - ipcMain.removeAllListeners(channel); - } - for (const channel of handledIpcChannels) { - ipcMain.removeHandler(channel); - } -} - function registerIpcHandlers() { return Effect.gen(function* () { - const environment = yield* DesktopEnvironment; const desktopSshEnvironmentBridge = yield* DesktopSshEnvironmentBridge; - const context = yield* Effect.context(); - const runIpcEffect = makeDesktopEffectRunner(context); - - yield* Effect.acquireRelease( - Effect.sync(() => { - ipcMain.removeAllListeners(GET_APP_BRANDING_CHANNEL); - ipcMain.on(GET_APP_BRANDING_CHANNEL, (event) => { - event.returnValue = environment.branding; - }); - - ipcMain.removeAllListeners(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); - ipcMain.on(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, (event) => { - event.returnValue = { - label: "Local environment", - httpBaseUrl: getBackendHttpUrlHref(), - wsBaseUrl: backendWsUrl || null, - bootstrapToken: backendBootstrapToken || undefined, - } as const; - }); - - ipcMain.removeHandler(GET_CLIENT_SETTINGS_CHANNEL); - ipcMain.handle(GET_CLIENT_SETTINGS_CHANNEL, async () => - runIpcEffect(readClientSettingsEffect(environment.clientSettingsPath)), - ); - - ipcMain.removeHandler(SET_CLIENT_SETTINGS_CHANNEL); - ipcMain.handle(SET_CLIENT_SETTINGS_CHANNEL, async (_event, rawSettings: unknown) => { - if (typeof rawSettings !== "object" || rawSettings === null) { - throw new Error("Invalid client settings payload."); - } - - await runIpcEffect( - writeClientSettingsEffect( - environment.clientSettingsPath, - rawSettings as ClientSettings, - ), - ); - }); - - ipcMain.removeHandler(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL); - ipcMain.handle(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, async () => - runIpcEffect( - readSavedEnvironmentRegistryEffect(environment.savedEnvironmentRegistryPath), - ), - ); - - ipcMain.removeHandler(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL); - ipcMain.handle( - SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, - async (_event, rawRecords: unknown) => { - if (!Array.isArray(rawRecords)) { - throw new Error("Invalid saved environment registry payload."); - } - - await runIpcEffect( - writeSavedEnvironmentRegistryEffect( - environment.savedEnvironmentRegistryPath, - rawRecords as readonly PersistedSavedEnvironmentRecord[], - ), - ); - }, - ); - - ipcMain.removeHandler(GET_SAVED_ENVIRONMENT_SECRET_CHANNEL); - ipcMain.handle( - GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, - async (_event, rawEnvironmentId: unknown) => { - if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) { - return null; - } - - return runIpcEffect( - readSavedEnvironmentSecretEffect({ - registryPath: environment.savedEnvironmentRegistryPath, - environmentId: rawEnvironmentId, - secretStorage: getDesktopSecretStorage(), - }), - ); - }, - ); - - ipcMain.removeHandler(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL); - ipcMain.handle( - SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, - async (_event, rawEnvironmentId: unknown, rawSecret: unknown) => { - if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) { - throw new Error("Invalid saved environment id."); - } - if (typeof rawSecret !== "string" || rawSecret.trim().length === 0) { - throw new Error("Invalid saved environment secret."); - } - - return runIpcEffect( - writeSavedEnvironmentSecretEffect({ - registryPath: environment.savedEnvironmentRegistryPath, - environmentId: rawEnvironmentId, - secret: rawSecret, - secretStorage: getDesktopSecretStorage(), - }), - ); - }, - ); - - ipcMain.removeHandler(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL); - ipcMain.handle( - REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, - async (_event, rawEnvironmentId: unknown) => { - if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) { - return; - } - - await runIpcEffect( - removeSavedEnvironmentSecretEffect({ - registryPath: environment.savedEnvironmentRegistryPath, - environmentId: rawEnvironmentId, - }), - ); - }, - ); - - ipcMain.removeHandler(GET_SERVER_EXPOSURE_STATE_CHANNEL); - ipcMain.handle(GET_SERVER_EXPOSURE_STATE_CHANNEL, async () => - getDesktopServerExposureState(), - ); - - ipcMain.removeHandler(SET_SERVER_EXPOSURE_MODE_CHANNEL); - ipcMain.handle(SET_SERVER_EXPOSURE_MODE_CHANNEL, async (_event, rawMode: unknown) => { - if (rawMode !== "local-only" && rawMode !== "network-accessible") { - throw new Error("Invalid desktop server exposure input."); - } - - const nextMode = rawMode as DesktopServerExposureMode; - if (nextMode === desktopServerExposureMode) { - return getDesktopServerExposureState(); - } - - const nextState = await runIpcEffect( - applyDesktopServerExposureMode(nextMode, { - persist: true, - rejectIfUnavailable: true, - }), - ); - await runIpcEffect(relaunchDesktopAppEffect(`serverExposureMode=${nextMode}`)); - return nextState; - }); - - ipcMain.removeHandler(SET_TAILSCALE_SERVE_ENABLED_CHANNEL); - ipcMain.handle(SET_TAILSCALE_SERVE_ENABLED_CHANNEL, async (_event, rawInput: unknown) => { - if (typeof rawInput !== "object" || rawInput === null) { - throw new Error("Invalid Tailscale Serve input."); - } - const input = rawInput as { - readonly enabled?: unknown; - readonly port?: unknown; - }; - if (typeof input.enabled !== "boolean") { - throw new Error("Invalid Tailscale Serve input."); - } - const nextSettings = setDesktopTailscaleServePreference(desktopSettings, { - enabled: input.enabled, - ...(typeof input.port === "number" ? { port: input.port } : {}), - }); - if (nextSettings === desktopSettings) { - return getDesktopServerExposureState(); - } - return runIpcEffect(applyDesktopTailscaleServeEnabled(nextSettings)); - }); - - ipcMain.removeHandler(GET_ADVERTISED_ENDPOINTS_CHANNEL); - ipcMain.handle(GET_ADVERTISED_ENDPOINTS_CHANNEL, async () => - runIpcEffect(getDesktopAdvertisedEndpoints()), - ); - - ipcMain.removeHandler(PICK_FOLDER_CHANNEL); - ipcMain.handle(PICK_FOLDER_CHANNEL, async (_event, rawOptions: unknown) => { - const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; - const defaultPath = Option.getOrUndefined( - environment.resolvePickFolderDefaultPath(rawOptions), - ); - const openDialogOptions: OpenDialogOptions = { - properties: ["openDirectory", "createDirectory"], - ...(defaultPath ? { defaultPath } : {}), - }; - const result = owner - ? await dialog.showOpenDialog(owner, openDialogOptions) - : await dialog.showOpenDialog(openDialogOptions); - if (result.canceled) return null; - return result.filePaths[0] ?? null; - }); - - ipcMain.removeHandler(CONFIRM_CHANNEL); - ipcMain.handle(CONFIRM_CHANNEL, async (_event, message: unknown) => { - if (typeof message !== "string") { - return false; - } - - const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; - return showDesktopConfirmDialog(message, owner); - }); - - ipcMain.removeHandler(SET_THEME_CHANNEL); - ipcMain.handle(SET_THEME_CHANNEL, async (_event, rawTheme: unknown) => { - const theme = getSafeTheme(rawTheme); - if (!theme) { - return; - } - - nativeTheme.themeSource = theme; - }); - - ipcMain.removeHandler(CONTEXT_MENU_CHANNEL); - ipcMain.handle( - CONTEXT_MENU_CHANNEL, - async (_event, items: ContextMenuItem[], position?: { x: number; y: number }) => { - const normalizedItems = normalizeContextMenuItems(items); - if (normalizedItems.length === 0) { - return null; - } - - const popupPosition = - position && - Number.isFinite(position.x) && - Number.isFinite(position.y) && - position.x >= 0 && - position.y >= 0 - ? { - x: Math.floor(position.x), - y: Math.floor(position.y), - } - : null; - - const window = BrowserWindow.getFocusedWindow() ?? mainWindow; - if (!window) return null; - - return new Promise((resolve) => { - const buildTemplate = ( - entries: readonly ContextMenuItem[], - ): MenuItemConstructorOptions[] => { - const template: MenuItemConstructorOptions[] = []; - let hasInsertedDestructiveSeparator = false; - for (const item of entries) { - if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { - template.push({ type: "separator" }); - hasInsertedDestructiveSeparator = true; - } - const itemOption: MenuItemConstructorOptions = { - label: item.label, - enabled: !item.disabled, - }; - if (item.children && item.children.length > 0) { - itemOption.submenu = buildTemplate(item.children); - } else { - itemOption.click = () => resolve(item.id); - } - if (item.destructive && (!item.children || item.children.length === 0)) { - const destructiveIcon = getDestructiveMenuIcon(); - if (destructiveIcon) { - itemOption.icon = destructiveIcon; - } - } - template.push(itemOption); - } - return template; - }; - - const menu = Menu.buildFromTemplate(buildTemplate(normalizedItems)); - menu.popup({ - window, - ...popupPosition, - callback: () => resolve(null), - }); - }); - }, - ); - - ipcMain.removeHandler(OPEN_EXTERNAL_CHANNEL); - ipcMain.handle(OPEN_EXTERNAL_CHANNEL, async (_event, rawUrl: unknown) => { - const externalUrl = getSafeExternalUrl(rawUrl); - if (!externalUrl) { - return false; - } - - try { - await shell.openExternal(externalUrl); - return true; - } catch { - return false; - } - }); - - ipcMain.removeHandler(UPDATE_GET_STATE_CHANNEL); - ipcMain.handle(UPDATE_GET_STATE_CHANNEL, async () => updateState); - - ipcMain.removeHandler(UPDATE_SET_CHANNEL_CHANNEL); - ipcMain.handle(UPDATE_SET_CHANNEL_CHANNEL, async (_event, rawChannel: unknown) => { - if (rawChannel !== "latest" && rawChannel !== "nightly") { - throw new Error("Invalid desktop update channel input."); - } - if (updateCheckInFlight || updateDownloadInFlight || updateInstallInFlight) { - throw new Error("Cannot change update tracks while an update action is in progress."); - } - - const nextChannel = rawChannel as DesktopUpdateChannel; - - desktopSettings = setDesktopUpdateChannelPreference(desktopSettings, nextChannel); - await runIpcEffect( - writeDesktopSettingsEffect(environment.desktopSettingsPath, desktopSettings), - ); - - if (nextChannel === updateState.channel) { - return updateState; - } - - const enabled = shouldEnableAutoUpdates(environment); - setUpdateState(createBaseUpdateState(nextChannel, enabled, environment)); - - if (!enabled || !updaterConfigured) { - return updateState; - } - - applyAutoUpdaterChannel(nextChannel); - const allowDowngrade = autoUpdater.allowDowngrade; - // An explicit channel switch should allow the immediate nightly->stable rollback path. - autoUpdater.allowDowngrade = true; - try { - await runIpcEffect(checkForUpdates("channel-change")); - } finally { - autoUpdater.allowDowngrade = allowDowngrade; - } - return updateState; - }); - - ipcMain.removeHandler(UPDATE_DOWNLOAD_CHANNEL); - ipcMain.handle(UPDATE_DOWNLOAD_CHANNEL, async () => { - const result = await runIpcEffect(downloadAvailableUpdate()); - return { - accepted: result.accepted, - completed: result.completed, - state: updateState, - } satisfies DesktopUpdateActionResult; - }); - - ipcMain.removeHandler(UPDATE_INSTALL_CHANNEL); - ipcMain.handle(UPDATE_INSTALL_CHANNEL, async () => { - if (isQuitting) { - return { - accepted: false, - completed: false, - state: updateState, - } satisfies DesktopUpdateActionResult; - } - const result = await runIpcEffect(installDownloadedUpdate()); - return { - accepted: result.accepted, - completed: result.completed, - state: updateState, - } satisfies DesktopUpdateActionResult; - }); - - ipcMain.removeHandler(UPDATE_CHECK_CHANNEL); - ipcMain.handle(UPDATE_CHECK_CHANNEL, async () => { - if (!updaterConfigured) { - return { - checked: false, - state: updateState, - } satisfies DesktopUpdateCheckResult; - } - const checked = await runIpcEffect(checkForUpdates("web-ui")); - return { - checked, - state: updateState, - } satisfies DesktopUpdateCheckResult; - }); - }), - () => Effect.sync(clearDesktopIpcHandlers), - ).pipe(Effect.asVoid); - + yield* installDesktopIpcHandlers; yield* desktopSshEnvironmentBridge.registerIpcHandlers(ipcMain); }); } diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index b3b553fe214..800070e4fb8 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,41 +1,43 @@ import { contextBridge, ipcRenderer } from "electron"; import type { DesktopBridge } from "@t3tools/contracts"; -const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; -const CONFIRM_CHANNEL = "desktop:confirm"; -const SET_THEME_CHANNEL = "desktop:set-theme"; -const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; -const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; -const MENU_ACTION_CHANNEL = "desktop:menu-action"; -const UPDATE_STATE_CHANNEL = "desktop:update-state"; -const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; -const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; -const UPDATE_CHECK_CHANNEL = "desktop:update-check"; -const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; -const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; -const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; -const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; -const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; -const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; -const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry"; -const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry"; -const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret"; -const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret"; -const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; -const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; -const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; -const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment"; -const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor"; -const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session"; -const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state"; -const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-token"; -const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt"; -const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt"; -const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; -const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; -const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; -const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; -const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; +import { + BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, + CONFIRM_CHANNEL, + CONTEXT_MENU_CHANNEL, + DISCONNECT_SSH_ENVIRONMENT_CHANNEL, + DISCOVER_SSH_HOSTS_CHANNEL, + ENSURE_SSH_ENVIRONMENT_CHANNEL, + FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, + FETCH_SSH_SESSION_STATE_CHANNEL, + GET_ADVERTISED_ENDPOINTS_CHANNEL, + GET_APP_BRANDING_CHANNEL, + GET_CLIENT_SETTINGS_CHANNEL, + GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, + GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, + GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, + GET_SERVER_EXPOSURE_STATE_CHANNEL, + ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, + MENU_ACTION_CHANNEL, + OPEN_EXTERNAL_CHANNEL, + PICK_FOLDER_CHANNEL, + REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, + RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, + SET_CLIENT_SETTINGS_CHANNEL, + SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, + SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, + SET_SERVER_EXPOSURE_MODE_CHANNEL, + SET_TAILSCALE_SERVE_ENABLED_CHANNEL, + SET_THEME_CHANNEL, + SSH_PASSWORD_PROMPT_CANCELLED_RESULT, + SSH_PASSWORD_PROMPT_CHANNEL, + UPDATE_CHECK_CHANNEL, + UPDATE_DOWNLOAD_CHANNEL, + UPDATE_GET_STATE_CHANNEL, + UPDATE_INSTALL_CHANNEL, + UPDATE_SET_CHANNEL_CHANNEL, + UPDATE_STATE_CHANNEL, +} from "./ipc/channels.ts"; function unwrapEnsureSshEnvironmentResult(result: unknown) { if ( @@ -76,7 +78,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { getSavedEnvironmentSecret: (environmentId) => ipcRenderer.invoke(GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), setSavedEnvironmentSecret: (environmentId, secret) => - ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId, secret), + ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, { environmentId, secret }), removeSavedEnvironmentSecret: (environmentId) => ipcRenderer.invoke(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), discoverSshHosts: () => ipcRenderer.invoke(DISCOVER_SSH_HOSTS_CHANNEL), @@ -115,7 +117,11 @@ contextBridge.exposeInMainWorld("desktopBridge", { pickFolder: (options) => ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), - showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position), + showContextMenu: (items, position) => + ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, { + items, + ...(position === undefined ? {} : { position }), + }), openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { diff --git a/apps/desktop/src/sshEnvironment.ts b/apps/desktop/src/sshEnvironment.ts index 7e128a23e9c..e869baede04 100644 --- a/apps/desktop/src/sshEnvironment.ts +++ b/apps/desktop/src/sshEnvironment.ts @@ -40,19 +40,22 @@ import { import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { + BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, + DISCONNECT_SSH_ENVIRONMENT_CHANNEL, + DISCOVER_SSH_HOSTS_CHANNEL, + ENSURE_SSH_ENVIRONMENT_CHANNEL, + FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, + FETCH_SSH_SESSION_STATE_CHANNEL, + ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, + RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, + SSH_PASSWORD_PROMPT_CANCELLED_RESULT, + SSH_PASSWORD_PROMPT_CHANNEL, +} from "./ipc/channels.ts"; + export { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; -const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; -const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; -const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment"; -const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor"; -const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session"; -const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state"; -const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-token"; -const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt"; -const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt"; const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000; -const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; const SSH_HANDLED_IPC_CHANNELS = [ DISCOVER_SSH_HOSTS_CHANNEL, ENSURE_SSH_ENVIRONMENT_CHANNEL, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index eca3bb4e66b..8e1d96462b9 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -48,6 +48,7 @@ import type { TerminalWriteInput, } from "./terminal.ts"; import type { ServerRemoveKeybindingInput, ServerUpsertKeybindingInput } from "./server.ts"; +import * as Schema from "effect/Schema"; import type { ClientOrchestrationCommand, OrchestrationGetFullThreadDiffInput, @@ -58,13 +59,13 @@ import type { OrchestrationSubscribeThreadInput, OrchestrationThreadStreamItem, } from "./orchestration.ts"; -import type { EnvironmentId } from "./baseSchemas.ts"; +import { EnvironmentId } from "./baseSchemas.ts"; import type { AuthBearerBootstrapResult, AuthSessionState, AuthWebSocketTokenResult, } from "./auth.ts"; -import type { AdvertisedEndpoint } from "./remoteAccess.ts"; +import { AdvertisedEndpoint } from "./remoteAccess.ts"; import { EditorId } from "./editor.ts"; import type { ExecutionEnvironmentDescriptor } from "./environment.ts"; import type { ClientSettings, ServerSettings, ServerSettingsPatch } from "./settings.ts"; @@ -86,6 +87,26 @@ export interface ContextMenuItem { children?: readonly ContextMenuItem[]; } +export interface ContextMenuItemSchemaType { + readonly id: string; + readonly label: string; + readonly destructive?: boolean; + readonly disabled?: boolean; + readonly children?: readonly ContextMenuItemSchemaType[]; +} + +export const ContextMenuItemSchema: Schema.Codec = Schema.Struct({ + id: Schema.String, + label: Schema.String, + destructive: Schema.optionalKey(Schema.Boolean), + disabled: Schema.optionalKey(Schema.Boolean), + children: Schema.optionalKey( + Schema.Array( + Schema.suspend((): Schema.Codec => ContextMenuItemSchema), + ), + ), +}); + export type DesktopUpdateStatus = | "disabled" | "idle" @@ -101,18 +122,45 @@ export type DesktopTheme = "light" | "dark" | "system"; export type DesktopUpdateChannel = "latest" | "nightly"; export type DesktopAppStageLabel = "Alpha" | "Dev" | "Nightly"; +export const DesktopUpdateStatusSchema = Schema.Literals([ + "disabled", + "idle", + "checking", + "up-to-date", + "available", + "downloading", + "downloaded", + "error", +]); +export const DesktopRuntimeArchSchema = Schema.Literals(["arm64", "x64", "other"]); +export const DesktopThemeSchema = Schema.Literals(["light", "dark", "system"]); +export const DesktopUpdateChannelSchema = Schema.Literals(["latest", "nightly"]); +export const DesktopAppStageLabelSchema = Schema.Literals(["Alpha", "Dev", "Nightly"]); + export interface DesktopAppBranding { baseName: string; stageLabel: DesktopAppStageLabel; displayName: string; } +export const DesktopAppBrandingSchema = Schema.Struct({ + baseName: Schema.String, + stageLabel: DesktopAppStageLabelSchema, + displayName: Schema.String, +}); + export interface DesktopRuntimeInfo { hostArch: DesktopRuntimeArch; appArch: DesktopRuntimeArch; runningUnderArm64Translation: boolean; } +export const DesktopRuntimeInfoSchema = Schema.Struct({ + hostArch: DesktopRuntimeArchSchema, + appArch: DesktopRuntimeArchSchema, + runningUnderArm64Translation: Schema.Boolean, +}); + export interface DesktopUpdateState { enabled: boolean; status: DesktopUpdateStatus; @@ -130,17 +178,45 @@ export interface DesktopUpdateState { canRetry: boolean; } +export const DesktopUpdateStateSchema = Schema.Struct({ + enabled: Schema.Boolean, + status: DesktopUpdateStatusSchema, + channel: DesktopUpdateChannelSchema, + currentVersion: Schema.String, + hostArch: DesktopRuntimeArchSchema, + appArch: DesktopRuntimeArchSchema, + runningUnderArm64Translation: Schema.Boolean, + availableVersion: Schema.NullOr(Schema.String), + downloadedVersion: Schema.NullOr(Schema.String), + downloadPercent: Schema.NullOr(Schema.Number), + checkedAt: Schema.NullOr(Schema.String), + message: Schema.NullOr(Schema.String), + errorContext: Schema.NullOr(Schema.Literals(["check", "download", "install"])), + canRetry: Schema.Boolean, +}); + export interface DesktopUpdateActionResult { accepted: boolean; completed: boolean; state: DesktopUpdateState; } +export const DesktopUpdateActionResultSchema = Schema.Struct({ + accepted: Schema.Boolean, + completed: Schema.Boolean, + state: DesktopUpdateStateSchema, +}); + export interface DesktopUpdateCheckResult { checked: boolean; state: DesktopUpdateState; } +export const DesktopUpdateCheckResultSchema = Schema.Struct({ + checked: Schema.Boolean, + state: DesktopUpdateStateSchema, +}); + export interface DesktopEnvironmentBootstrap { label: string; httpBaseUrl: string | null; @@ -148,12 +224,20 @@ export interface DesktopEnvironmentBootstrap { bootstrapToken?: string; } -export interface DesktopSshEnvironmentTarget { - alias: string; - hostname: string; - username: string | null; - port: number | null; -} +export const DesktopEnvironmentBootstrapSchema = Schema.Struct({ + label: Schema.String, + httpBaseUrl: Schema.NullOr(Schema.String), + wsBaseUrl: Schema.NullOr(Schema.String), + bootstrapToken: Schema.optionalKey(Schema.String), +}); + +export const DesktopSshEnvironmentTargetSchema = Schema.Struct({ + alias: Schema.String, + hostname: Schema.String, + username: Schema.NullOr(Schema.String), + port: Schema.NullOr(Schema.Number), +}); +export type DesktopSshEnvironmentTarget = typeof DesktopSshEnvironmentTargetSchema.Type; export type DesktopSshHostSource = "ssh-config" | "known-hosts"; @@ -178,18 +262,24 @@ export interface DesktopSshPasswordPromptRequest { expiresAt: string; } -export interface PersistedSavedEnvironmentRecord { - environmentId: EnvironmentId; - label: string; - wsBaseUrl: string; - httpBaseUrl: string; - createdAt: string; - lastConnectedAt: string | null; - desktopSsh?: DesktopSshEnvironmentTarget; -} +export const PersistedSavedEnvironmentRecordSchema = Schema.Struct({ + environmentId: EnvironmentId, + label: Schema.String, + wsBaseUrl: Schema.String, + httpBaseUrl: Schema.String, + createdAt: Schema.String, + lastConnectedAt: Schema.NullOr(Schema.String), + desktopSsh: Schema.optionalKey(DesktopSshEnvironmentTargetSchema), +}); +export type PersistedSavedEnvironmentRecord = typeof PersistedSavedEnvironmentRecordSchema.Type; export type DesktopServerExposureMode = "local-only" | "network-accessible"; +export const DesktopServerExposureModeSchema = Schema.Literals([ + "local-only", + "network-accessible", +]); + export interface DesktopServerExposureState { mode: DesktopServerExposureMode; endpointUrl: string | null; @@ -198,10 +288,22 @@ export interface DesktopServerExposureState { tailscaleServePort: number; } +export const DesktopServerExposureStateSchema = Schema.Struct({ + mode: DesktopServerExposureModeSchema, + endpointUrl: Schema.NullOr(Schema.String), + advertisedHost: Schema.NullOr(Schema.String), + tailscaleServeEnabled: Schema.Boolean, + tailscaleServePort: Schema.Number, +}); + export interface PickFolderOptions { initialPath?: string | null; } +export const PickFolderOptionsSchema = Schema.Struct({ + initialPath: Schema.optionalKey(Schema.NullOr(Schema.String)), +}); + export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; From 0f5850dfb6719b2a9aebb76155050a3721204f6a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 14:00:54 -0700 Subject: [PATCH 08/43] Refactor desktop SSH IPC handlers - Move SSH IPC wiring into dedicated method module - Switch preload calls to structured payloads - Add typed SSH IPC contracts and cancellation handling Co-authored-by: codex --- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 19 ++ apps/desktop/src/ipc/channels.ts | 4 +- .../desktop/src/ipc/methods/sshEnvironment.ts | 144 +++++++++++ apps/desktop/src/main.ts | 11 +- apps/desktop/src/preload.ts | 15 +- apps/desktop/src/sshEnvironment.test.ts | 70 +++-- apps/desktop/src/sshEnvironment.ts | 244 ++---------------- packages/contracts/src/ipc.ts | 74 +++++- 8 files changed, 313 insertions(+), 268 deletions(-) create mode 100644 apps/desktop/src/ipc/methods/sshEnvironment.ts diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index d17c8bc4508..2b01630f6b0 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -15,6 +15,16 @@ import { setServerExposureMode, setTailscaleServeEnabled, } from "./methods/serverExposure.ts"; +import { + bootstrapSshBearerSession, + disconnectSshEnvironment, + discoverSshHosts, + ensureSshEnvironment, + fetchSshEnvironmentDescriptor, + fetchSshSessionState, + issueSshWebSocketToken, + resolveSshPasswordPrompt, +} from "./methods/sshEnvironment.ts"; import { checkForUpdate, downloadUpdate, @@ -46,6 +56,15 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { yield* ipc.handle(setSavedEnvironmentSecret); yield* ipc.handle(removeSavedEnvironmentSecret); + yield* ipc.handle(discoverSshHosts); + yield* ipc.handle(ensureSshEnvironment); + yield* ipc.handle(disconnectSshEnvironment); + yield* ipc.handle(fetchSshEnvironmentDescriptor); + yield* ipc.handle(bootstrapSshBearerSession); + yield* ipc.handle(fetchSshSessionState); + yield* ipc.handle(issueSshWebSocketToken); + yield* ipc.handle(resolveSshPasswordPrompt); + yield* ipc.handle(getServerExposureState); yield* ipc.handle(setServerExposureMode); yield* ipc.handle(setTailscaleServeEnabled); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 2715b20cb36..dc897d3846f 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -1,3 +1,5 @@ +import { DesktopSshPasswordPromptCancelledType } from "@t3tools/contracts"; + export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; export const CONFIRM_CHANNEL = "desktop:confirm"; export const SET_THEME_CHANNEL = "desktop:set-theme"; @@ -32,4 +34,4 @@ export const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-st export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; -export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; +export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = DesktopSshPasswordPromptCancelledType; diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts new file mode 100644 index 00000000000..aa5e3db7920 --- /dev/null +++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts @@ -0,0 +1,144 @@ +import { + AuthBearerBootstrapResult, + AuthSessionState, + AuthWebSocketTokenResult, + DesktopDiscoveredSshHostSchema, + DesktopSshBearerBootstrapInputSchema, + DesktopSshBearerRequestInputSchema, + DesktopSshEnvironmentEnsureInputSchema, + DesktopSshEnvironmentEnsureResultSchema, + DesktopSshEnvironmentTargetSchema, + DesktopSshHttpBaseUrlInputSchema, + DesktopSshPasswordPromptCancelledType, + DesktopSshPasswordPromptResolutionInputSchema, + ExecutionEnvironmentDescriptor, +} from "@t3tools/contracts"; +import { fetchLoopbackSshJson } from "@t3tools/ssh/tunnel"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { + BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, + DISCONNECT_SSH_ENVIRONMENT_CHANNEL, + DISCOVER_SSH_HOSTS_CHANNEL, + ENSURE_SSH_ENVIRONMENT_CHANNEL, + FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, + FETCH_SSH_SESSION_STATE_CHANNEL, + ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, + RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, +} from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; +import { + DesktopSshEnvironmentBridge, + DesktopSshEnvironmentManager, + isSshPasswordPromptCancellation, +} from "../../sshEnvironment.ts"; + +const decodeExecutionEnvironmentDescriptor = Schema.decodeUnknownEffect( + ExecutionEnvironmentDescriptor, +); +const decodeAuthBearerBootstrapResult = Schema.decodeUnknownEffect(AuthBearerBootstrapResult); +const decodeAuthSessionState = Schema.decodeUnknownEffect(AuthSessionState); +const decodeAuthWebSocketTokenResult = Schema.decodeUnknownEffect(AuthWebSocketTokenResult); + +export const discoverSshHosts = makeIpcMethod({ + channel: DISCOVER_SSH_HOSTS_CHANNEL, + payload: Schema.Void, + result: Schema.Array(DesktopDiscoveredSshHostSchema), + handler: () => + Effect.gen(function* () { + const manager = yield* DesktopSshEnvironmentManager; + return yield* manager.discoverHosts(); + }), +}); + +export const ensureSshEnvironment = makeIpcMethod({ + channel: ENSURE_SSH_ENVIRONMENT_CHANNEL, + payload: DesktopSshEnvironmentEnsureInputSchema, + result: DesktopSshEnvironmentEnsureResultSchema, + handler: ({ target, options }) => + Effect.gen(function* () { + const manager = yield* DesktopSshEnvironmentManager; + return yield* manager.ensureEnvironment(target, options).pipe( + Effect.catch((error) => + isSshPasswordPromptCancellation(error) + ? Effect.succeed({ + type: DesktopSshPasswordPromptCancelledType, + message: error.message, + }) + : Effect.fail(error), + ), + ); + }), +}); + +export const disconnectSshEnvironment = makeIpcMethod({ + channel: DISCONNECT_SSH_ENVIRONMENT_CHANNEL, + payload: DesktopSshEnvironmentTargetSchema, + result: Schema.Void, + handler: (target) => + Effect.gen(function* () { + const manager = yield* DesktopSshEnvironmentManager; + yield* manager.disconnectEnvironment(target); + }), +}); + +export const fetchSshEnvironmentDescriptor = makeIpcMethod({ + channel: FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, + payload: DesktopSshHttpBaseUrlInputSchema, + result: ExecutionEnvironmentDescriptor, + handler: ({ httpBaseUrl }) => + fetchLoopbackSshJson({ + httpBaseUrl, + pathname: "/.well-known/t3/environment", + }).pipe(Effect.flatMap(decodeExecutionEnvironmentDescriptor)), +}); + +export const bootstrapSshBearerSession = makeIpcMethod({ + channel: BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, + payload: DesktopSshBearerBootstrapInputSchema, + result: AuthBearerBootstrapResult, + handler: ({ httpBaseUrl, credential }) => + fetchLoopbackSshJson({ + httpBaseUrl, + pathname: "/api/auth/bootstrap/bearer", + method: "POST", + body: { credential }, + }).pipe(Effect.flatMap(decodeAuthBearerBootstrapResult)), +}); + +export const fetchSshSessionState = makeIpcMethod({ + channel: FETCH_SSH_SESSION_STATE_CHANNEL, + payload: DesktopSshBearerRequestInputSchema, + result: AuthSessionState, + handler: ({ httpBaseUrl, bearerToken }) => + fetchLoopbackSshJson({ + httpBaseUrl, + pathname: "/api/auth/session", + bearerToken, + }).pipe(Effect.flatMap(decodeAuthSessionState)), +}); + +export const issueSshWebSocketToken = makeIpcMethod({ + channel: ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, + payload: DesktopSshBearerRequestInputSchema, + result: AuthWebSocketTokenResult, + handler: ({ httpBaseUrl, bearerToken }) => + fetchLoopbackSshJson({ + httpBaseUrl, + pathname: "/api/auth/ws-token", + method: "POST", + bearerToken, + }).pipe(Effect.flatMap(decodeAuthWebSocketTokenResult)), +}); + +export const resolveSshPasswordPrompt = makeIpcMethod({ + channel: RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, + payload: DesktopSshPasswordPromptResolutionInputSchema, + result: Schema.Void, + handler: ({ requestId, password }) => + Effect.gen(function* () { + const bridge = yield* DesktopSshEnvironmentBridge; + yield* bridge.resolvePasswordPrompt(requestId, password); + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index e2937ec288d..6a5ce732359 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -25,7 +25,6 @@ import { type OpenDialogOptions, clipboard, dialog, - ipcMain, Menu, nativeImage, nativeTheme, @@ -2056,14 +2055,6 @@ function quitFromSignal(signal: "SIGINT" | "SIGTERM", runEffect: DesktopEffectRu }); } -function registerIpcHandlers() { - return Effect.gen(function* () { - const desktopSshEnvironmentBridge = yield* DesktopSshEnvironmentBridge; - yield* installDesktopIpcHandlers; - yield* desktopSshEnvironmentBridge.registerIpcHandlers(ipcMain); - }); -} - function getIconOption(): { icon: string } | Record { if (process.platform === "darwin") return {}; // macOS uses .icns from app bundle const ext = process.platform === "win32" ? "ico" : "png"; @@ -2288,7 +2279,7 @@ function bootstrap() { ); } - yield* registerIpcHandlers(); + yield* installDesktopIpcHandlers; yield* logDesktopInfo("bootstrap ipc handlers registered"); yield* startBackend(); yield* logDesktopInfo("bootstrap backend start requested"); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 800070e4fb8..62d0eca9c79 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -84,18 +84,21 @@ contextBridge.exposeInMainWorld("desktopBridge", { discoverSshHosts: () => ipcRenderer.invoke(DISCOVER_SSH_HOSTS_CHANNEL), ensureSshEnvironment: async (target, options) => unwrapEnsureSshEnvironmentResult( - await ipcRenderer.invoke(ENSURE_SSH_ENVIRONMENT_CHANNEL, target, options), + await ipcRenderer.invoke(ENSURE_SSH_ENVIRONMENT_CHANNEL, { + target, + ...(options === undefined ? {} : { options }), + }), ), disconnectSshEnvironment: (target) => ipcRenderer.invoke(DISCONNECT_SSH_ENVIRONMENT_CHANNEL, target), fetchSshEnvironmentDescriptor: (httpBaseUrl) => - ipcRenderer.invoke(FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, httpBaseUrl), + ipcRenderer.invoke(FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, { httpBaseUrl }), bootstrapSshBearerSession: (httpBaseUrl, credential) => - ipcRenderer.invoke(BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, httpBaseUrl, credential), + ipcRenderer.invoke(BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, { httpBaseUrl, credential }), fetchSshSessionState: (httpBaseUrl, bearerToken) => - ipcRenderer.invoke(FETCH_SSH_SESSION_STATE_CHANNEL, httpBaseUrl, bearerToken), + ipcRenderer.invoke(FETCH_SSH_SESSION_STATE_CHANNEL, { httpBaseUrl, bearerToken }), issueSshWebSocketToken: (httpBaseUrl, bearerToken) => - ipcRenderer.invoke(ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, httpBaseUrl, bearerToken), + ipcRenderer.invoke(ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, { httpBaseUrl, bearerToken }), onSshPasswordPrompt: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => { if (typeof request !== "object" || request === null) return; @@ -108,7 +111,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { }; }, resolveSshPasswordPrompt: (requestId, password) => - ipcRenderer.invoke(RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, requestId, password), + ipcRenderer.invoke(RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, { requestId, password }), getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL), setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), setTailscaleServeEnabled: (input) => diff --git a/apps/desktop/src/sshEnvironment.test.ts b/apps/desktop/src/sshEnvironment.test.ts index 3075d9d5a1a..afe01ad4886 100644 --- a/apps/desktop/src/sshEnvironment.test.ts +++ b/apps/desktop/src/sshEnvironment.test.ts @@ -1,17 +1,18 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { NetService } from "@t3tools/shared/Net"; import { SshPasswordPromptError } from "@t3tools/ssh/errors"; import { Effect, FileSystem, Layer, Path } from "effect"; import { - DesktopSshEnvironmentBridge, - type DesktopSshBridgeIpcMain, DesktopSshEnvironmentManager, discoverDesktopSshHostsEffect, isSshPasswordPromptCancellation, } from "./sshEnvironment.ts"; +import * as DesktopIpc from "./ipc/DesktopIpc.ts"; +import { SSH_PASSWORD_PROMPT_CANCELLED_RESULT } from "./ipc/channels.ts"; +import { discoverSshHosts, ensureSshEnvironment } from "./ipc/methods/sshEnvironment.ts"; function makeTempHomeDir() { return Effect.gen(function* () { @@ -20,22 +21,20 @@ function makeTempHomeDir() { }); } -class TestIpcMain implements DesktopSshBridgeIpcMain { - readonly handlers = new Map< - string, - (event: unknown, ...args: readonly unknown[]) => unknown | Promise - >(); +class TestIpcMain implements DesktopIpc.DesktopIpcMain { + readonly handlers = new Map(); removeHandler(channel: string): void { this.handlers.delete(channel); } - handle( - channel: string, - listener: (event: unknown, ...args: readonly unknown[]) => unknown | Promise, - ): void { + handle(channel: string, listener: DesktopIpc.DesktopIpcHandleListener): void { this.handlers.set(channel, listener); } + + removeAllListeners(): void {} + + on(): void {} } describe("sshEnvironment", () => { @@ -118,14 +117,14 @@ describe("sshEnvironment", () => { it.effect("runs SSH IPC handlers with the captured Effect context", () => Effect.gen(function* () { const ipcMain = new TestIpcMain(); - const bridge = yield* DesktopSshEnvironmentBridge; + const ipc = DesktopIpc.make(ipcMain); - yield* bridge.registerIpcHandlers(ipcMain); + yield* ipc.handle(discoverSshHosts); const discoverHosts = ipcMain.handlers.get("desktop:discover-ssh-hosts"); assert.ok(discoverHosts); - const hosts = yield* Effect.promise(() => Promise.resolve(discoverHosts({}))); + const hosts = yield* Effect.promise(() => Promise.resolve(discoverHosts({}, undefined))); assert.deepEqual(hosts, [ { alias: "devbox", @@ -138,7 +137,6 @@ describe("sshEnvironment", () => { }).pipe( Effect.provide( Layer.mergeAll( - DesktopSshEnvironmentBridge.layer({ getMainWindow: () => null }), Layer.succeed( DesktopSshEnvironmentManager, DesktopSshEnvironmentManager.of({ @@ -157,11 +155,49 @@ describe("sshEnvironment", () => { }), ), NodeServices.layer, + ), + ), + Effect.scoped, + ), + ); + + it.effect("encodes SSH password prompt cancellations as typed IPC results", () => + Effect.gen(function* () { + const result = yield* ensureSshEnvironment.handler({ + target: { + alias: "devbox", + hostname: "devbox.example.com", + username: null, + port: null, + }, + options: { issuePairingToken: true }, + }); + + assert.deepEqual(result, { + type: SSH_PASSWORD_PROMPT_CANCELLED_RESULT, + message: "SSH authentication timed out for devbox.", + }); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed( + DesktopSshEnvironmentManager, + DesktopSshEnvironmentManager.of({ + discoverHosts: () => Effect.die("unexpected discoverHosts"), + ensureEnvironment: () => + Effect.fail( + new SshPasswordPromptError({ + message: "SSH authentication timed out for devbox.", + }), + ), + disconnectEnvironment: () => Effect.die("unexpected disconnectEnvironment"), + }), + ), + NodeServices.layer, NodeHttpClient.layerUndici, NetService.layer, ), ), - Effect.scoped, ), ); }); diff --git a/apps/desktop/src/sshEnvironment.ts b/apps/desktop/src/sshEnvironment.ts index e869baede04..ed056cf52ef 100644 --- a/apps/desktop/src/sshEnvironment.ts +++ b/apps/desktop/src/sshEnvironment.ts @@ -1,13 +1,9 @@ import { NetService } from "@t3tools/shared/Net"; import type { - AuthBearerBootstrapResult, DesktopSshEnvironmentBootstrap, - AuthSessionState, - AuthWebSocketTokenResult, DesktopDiscoveredSshHost, DesktopSshEnvironmentTarget, DesktopSshPasswordPromptRequest, - ExecutionEnvironmentDescriptor, } from "@t3tools/contracts"; import { SshPasswordPrompt, @@ -16,11 +12,7 @@ import { } from "@t3tools/ssh/auth"; import { discoverSshHosts } from "@t3tools/ssh/config"; import { SshPasswordPromptError } from "@t3tools/ssh/errors"; -import { - fetchLoopbackSshJson, - SshEnvironmentManager, - type RemoteT3RunnerOptions, -} from "@t3tools/ssh/tunnel"; +import { SshEnvironmentManager, type RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; import { Cause, Context, @@ -40,32 +32,11 @@ import { import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { - BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, - DISCONNECT_SSH_ENVIRONMENT_CHANNEL, - DISCOVER_SSH_HOSTS_CHANNEL, - ENSURE_SSH_ENVIRONMENT_CHANNEL, - FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, - FETCH_SSH_SESSION_STATE_CHANNEL, - ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, - RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, - SSH_PASSWORD_PROMPT_CANCELLED_RESULT, - SSH_PASSWORD_PROMPT_CHANNEL, -} from "./ipc/channels.ts"; +import { SSH_PASSWORD_PROMPT_CHANNEL } from "./ipc/channels.ts"; export { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000; -const SSH_HANDLED_IPC_CHANNELS = [ - DISCOVER_SSH_HOSTS_CHANNEL, - ENSURE_SSH_ENVIRONMENT_CHANNEL, - DISCONNECT_SSH_ENVIRONMENT_CHANNEL, - FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, - BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, - FETCH_SSH_SESSION_STATE_CHANNEL, - ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, - RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, -] as const; interface DesktopSshEnvironmentManagerOptions { readonly passwordProvider?: ( @@ -79,7 +50,7 @@ export function discoverDesktopSshHostsEffect(input?: { readonly homeDir?: strin return discoverSshHosts(input ?? {}); } -type DesktopSshEnvironmentEffectContext = +export type DesktopSshEnvironmentEffectContext = | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path @@ -167,40 +138,6 @@ export class DesktopSshEnvironmentManager extends Context.Service< ); } -function getSafeDesktopSshTarget(rawTarget: unknown): DesktopSshEnvironmentTarget | null { - if (typeof rawTarget !== "object" || rawTarget === null) { - return null; - } - - const target = rawTarget as Partial; - if (typeof target.alias !== "string" || typeof target.hostname !== "string") { - return null; - } - if ( - target.username !== null && - target.username !== undefined && - typeof target.username !== "string" - ) { - return null; - } - if (target.port !== null && target.port !== undefined && !Number.isInteger(target.port)) { - return null; - } - - const alias = target.alias.trim(); - const hostname = target.hostname.trim(); - if (alias.length === 0 || hostname.length === 0) { - return null; - } - - return { - alias, - hostname, - username: target.username?.trim() || null, - port: target.port ?? null, - }; -} - /** Minimal subset of Electron's BrowserWindow used by the SSH bridge. */ export interface DesktopSshBridgeWindow { isDestroyed(): boolean; @@ -212,15 +149,6 @@ export interface DesktopSshBridgeWindow { }; } -/** Minimal subset of Electron's ipcMain used by the SSH bridge. */ -export interface DesktopSshBridgeIpcMain { - removeHandler(channel: string): void; - handle( - channel: string, - listener: (event: unknown, ...args: readonly unknown[]) => unknown | Promise, - ): void; -} - export interface DesktopSshEnvironmentBridgeOptions { readonly getMainWindow: () => DesktopSshBridgeWindow | null; readonly passwordPromptTimeoutMs?: number; @@ -242,26 +170,17 @@ export function isSshPasswordPromptCancellation(error: unknown): error is SshPas export interface DesktopSshEnvironmentBridgeShape { readonly installPasswordPromptScope: (scope: Scope.Closeable) => Effect.Effect; readonly passwordProvider: (request: SshPasswordRequest) => Effect.Effect; - readonly registerIpcHandlers: ( - ipcMain: DesktopSshBridgeIpcMain, - ) => Effect.Effect< - void, - never, - Scope.Scope | DesktopSshEnvironmentManager | DesktopSshEnvironmentEffectContext - >; + readonly resolvePasswordPrompt: ( + requestId: string, + password: string | null, + ) => Effect.Effect; readonly cancelPendingPasswordPromptsEffect: (reason: string) => Effect.Effect; readonly disposeEffect: () => Effect.Effect; } -function clearDesktopSshIpcHandlers(ipcMain: DesktopSshBridgeIpcMain): void { - for (const channel of SSH_HANDLED_IPC_CHANNELS) { - ipcMain.removeHandler(channel); - } -} - /** - * Wires the SSH environment manager to Electron IPC, owning the renderer-facing - * password prompt state so `main.ts` only needs to register, cancel, and dispose. + * Owns renderer-facing SSH password prompt state so the manager can request + * credentials without depending on Electron IPC details. */ function makeDesktopSshEnvironmentBridge( options: DesktopSshEnvironmentBridgeOptions, @@ -288,25 +207,22 @@ function makeDesktopSshEnvironmentBridge( }; const resolvePasswordPromptEffect = ( - rawRequestId: unknown, - rawPassword: unknown, + requestId: string, + password: string | null, ): Effect.Effect => { - if (typeof rawRequestId !== "string" || rawRequestId.trim().length === 0) { + if (requestId.trim().length === 0) { return Effect.fail(new Error("Invalid SSH password prompt id.")); } - if (rawPassword !== null && typeof rawPassword !== "string") { - return Effect.fail(new Error("Invalid SSH password prompt response.")); - } - const pending = pendingPrompts.get(rawRequestId); + const pending = pendingPrompts.get(requestId); if (!pending) { return Effect.fail(new Error("SSH password prompt expired. Try connecting again.")); } - pendingPrompts.delete(rawRequestId); + pendingPrompts.delete(requestId); return Fiber.interrupt(pending.timeoutFiber).pipe( Effect.ignore, - Effect.andThen(Deferred.succeed(pending.deferred, rawPassword)), + Effect.andThen(Deferred.succeed(pending.deferred, password)), Effect.asVoid, ); }; @@ -404,135 +320,7 @@ function makeDesktopSshEnvironmentBridge( passwordPromptScope = Option.some(scope); }), passwordProvider: requestPasswordFromRendererEffect, - registerIpcHandlers: (ipcMain) => - Effect.acquireRelease( - Effect.gen(function* () { - const context = yield* Effect.context< - DesktopSshEnvironmentManager | DesktopSshEnvironmentEffectContext - >(); - const runEffect = (effect: Effect.Effect): Promise => - Effect.runPromiseWith(context as unknown as Context.Context)(effect); - - yield* Effect.sync(() => { - clearDesktopSshIpcHandlers(ipcMain); - - ipcMain.handle(DISCOVER_SSH_HOSTS_CHANNEL, () => - runEffect( - Effect.gen(function* () { - const manager = yield* DesktopSshEnvironmentManager; - return yield* manager.discoverHosts(); - }), - ), - ); - - ipcMain.handle( - ENSURE_SSH_ENVIRONMENT_CHANNEL, - async (_event, rawTarget, rawOptions) => { - const target = getSafeDesktopSshTarget(rawTarget); - if (!target) { - throw new Error("Invalid desktop SSH target."); - } - - const issuePairingToken = - typeof rawOptions === "object" && - rawOptions !== null && - "issuePairingToken" in rawOptions && - (rawOptions as { issuePairingToken?: unknown }).issuePairingToken === true; - - try { - return await runEffect( - Effect.gen(function* () { - const manager = yield* DesktopSshEnvironmentManager; - return yield* manager.ensureEnvironment(target, { - issuePairingToken, - }); - }), - ); - } catch (error) { - if (isSshPasswordPromptCancellation(error)) { - return { - type: SSH_PASSWORD_PROMPT_CANCELLED_RESULT, - message: error.message, - }; - } - throw error; - } - }, - ); - - ipcMain.handle(DISCONNECT_SSH_ENVIRONMENT_CHANNEL, async (_event, rawTarget) => { - const target = getSafeDesktopSshTarget(rawTarget); - if (!target) { - throw new Error("Invalid desktop SSH target."); - } - - await runEffect( - Effect.gen(function* () { - const manager = yield* DesktopSshEnvironmentManager; - yield* manager.disconnectEnvironment(target); - }), - ); - }); - - ipcMain.handle( - FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, - async (_event, rawHttpBaseUrl) => - runEffect( - fetchLoopbackSshJson({ - httpBaseUrl: rawHttpBaseUrl, - pathname: "/.well-known/t3/environment", - }), - ), - ); - - ipcMain.handle( - BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, - async (_event, rawHttpBaseUrl, rawCredential) => - runEffect( - fetchLoopbackSshJson({ - httpBaseUrl: rawHttpBaseUrl, - pathname: "/api/auth/bootstrap/bearer", - method: "POST", - body: { credential: rawCredential }, - }), - ), - ); - - ipcMain.handle( - FETCH_SSH_SESSION_STATE_CHANNEL, - async (_event, rawHttpBaseUrl, rawBearerToken) => - runEffect( - fetchLoopbackSshJson({ - httpBaseUrl: rawHttpBaseUrl, - pathname: "/api/auth/session", - bearerToken: rawBearerToken, - }), - ), - ); - - ipcMain.handle( - ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, - async (_event, rawHttpBaseUrl, rawBearerToken) => - runEffect( - fetchLoopbackSshJson({ - httpBaseUrl: rawHttpBaseUrl, - pathname: "/api/auth/ws-token", - method: "POST", - bearerToken: rawBearerToken, - }), - ), - ); - - ipcMain.handle( - RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, - async (_event, rawRequestId, rawPassword) => { - await runEffect(resolvePasswordPromptEffect(rawRequestId, rawPassword)); - }, - ); - }); - }), - () => Effect.sync(() => clearDesktopSshIpcHandlers(ipcMain)), - ).pipe(Effect.asVoid), + resolvePasswordPrompt: resolvePasswordPromptEffect, cancelPendingPasswordPromptsEffect, disposeEffect: () => { if (disposed) return Effect.void; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 8e1d96462b9..abe8af5022f 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -60,14 +60,10 @@ import type { OrchestrationThreadStreamItem, } from "./orchestration.ts"; import { EnvironmentId } from "./baseSchemas.ts"; -import type { - AuthBearerBootstrapResult, - AuthSessionState, - AuthWebSocketTokenResult, -} from "./auth.ts"; +import { AuthBearerBootstrapResult, AuthSessionState, AuthWebSocketTokenResult } from "./auth.ts"; import { AdvertisedEndpoint } from "./remoteAccess.ts"; import { EditorId } from "./editor.ts"; -import type { ExecutionEnvironmentDescriptor } from "./environment.ts"; +import { ExecutionEnvironmentDescriptor } from "./environment.ts"; import type { ClientSettings, ServerSettings, ServerSettingsPatch } from "./settings.ts"; import type { SourceControlCloneRepositoryInput, @@ -240,11 +236,20 @@ export const DesktopSshEnvironmentTargetSchema = Schema.Struct({ export type DesktopSshEnvironmentTarget = typeof DesktopSshEnvironmentTargetSchema.Type; export type DesktopSshHostSource = "ssh-config" | "known-hosts"; +export const DesktopSshHostSourceSchema = Schema.Literals(["ssh-config", "known-hosts"]); export interface DesktopDiscoveredSshHost extends DesktopSshEnvironmentTarget { source: DesktopSshHostSource; } +export const DesktopDiscoveredSshHostSchema = Schema.Struct({ + alias: Schema.String, + hostname: Schema.String, + username: Schema.NullOr(Schema.String), + port: Schema.NullOr(Schema.Number), + source: DesktopSshHostSourceSchema, +}); + export interface DesktopSshEnvironmentBootstrap { target: DesktopSshEnvironmentTarget; httpBaseUrl: string; @@ -254,6 +259,15 @@ export interface DesktopSshEnvironmentBootstrap { remoteServerKind?: "external" | "managed"; } +export const DesktopSshEnvironmentBootstrapSchema = Schema.Struct({ + target: DesktopSshEnvironmentTargetSchema, + httpBaseUrl: Schema.String, + wsBaseUrl: Schema.String, + pairingToken: Schema.NullOr(Schema.String), + remotePort: Schema.optionalKey(Schema.Number), + remoteServerKind: Schema.optionalKey(Schema.Literals(["external", "managed"])), +}); + export interface DesktopSshPasswordPromptRequest { requestId: string; destination: string; @@ -262,6 +276,54 @@ export interface DesktopSshPasswordPromptRequest { expiresAt: string; } +export const DesktopSshPasswordPromptRequestSchema = Schema.Struct({ + requestId: Schema.String, + destination: Schema.String, + username: Schema.NullOr(Schema.String), + prompt: Schema.String, + expiresAt: Schema.String, +}); + +export const DesktopSshPasswordPromptCancelledType = "ssh-password-prompt-cancelled" as const; + +export const DesktopSshPasswordPromptCancelledResultSchema = Schema.Struct({ + type: Schema.Literal(DesktopSshPasswordPromptCancelledType), + message: Schema.String, +}); + +export const DesktopSshEnvironmentEnsureOptionsSchema = Schema.Struct({ + issuePairingToken: Schema.optionalKey(Schema.Boolean), +}); + +export const DesktopSshEnvironmentEnsureInputSchema = Schema.Struct({ + target: DesktopSshEnvironmentTargetSchema, + options: Schema.optionalKey(DesktopSshEnvironmentEnsureOptionsSchema), +}); + +export const DesktopSshEnvironmentEnsureResultSchema = Schema.Union([ + DesktopSshEnvironmentBootstrapSchema, + DesktopSshPasswordPromptCancelledResultSchema, +]); + +export const DesktopSshHttpBaseUrlInputSchema = Schema.Struct({ + httpBaseUrl: Schema.String, +}); + +export const DesktopSshBearerRequestInputSchema = Schema.Struct({ + httpBaseUrl: Schema.String, + bearerToken: Schema.String, +}); + +export const DesktopSshBearerBootstrapInputSchema = Schema.Struct({ + httpBaseUrl: Schema.String, + credential: Schema.String, +}); + +export const DesktopSshPasswordPromptResolutionInputSchema = Schema.Struct({ + requestId: Schema.String, + password: Schema.NullOr(Schema.String), +}); + export const PersistedSavedEnvironmentRecordSchema = Schema.Struct({ environmentId: EnvironmentId, label: Schema.String, From 8e162b5ff82b06f7c3e4458ae3744c4362ab95e3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 14:26:12 -0700 Subject: [PATCH 09/43] Centralize desktop window and quitting state - Move main window and quitting flags into Effect services - Route lifecycle, IPC, and update flows through shared desktop state - Reduce reliance on module-level mutable globals --- apps/desktop/src/electron/ElectronWindow.ts | 97 ++++ apps/desktop/src/ipc/DesktopIpc.ts | 4 - apps/desktop/src/main.ts | 505 +++++++++++--------- apps/desktop/src/main/DesktopState.ts | 21 + apps/desktop/src/sshEnvironment.ts | 4 +- 5 files changed, 410 insertions(+), 221 deletions(-) create mode 100644 apps/desktop/src/electron/ElectronWindow.ts create mode 100644 apps/desktop/src/main/DesktopState.ts diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts new file mode 100644 index 00000000000..c66a670c6e5 --- /dev/null +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -0,0 +1,97 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; + +import * as Electron from "electron"; + +export interface ElectronWindowShape { + readonly main: Effect.Effect>; + readonly currentMainOrFirst: Effect.Effect>; + readonly focusedMainOrFirst: Effect.Effect>; + readonly setMain: (window: Electron.BrowserWindow) => Effect.Effect; + readonly clearMain: (window?: Electron.BrowserWindow) => Effect.Effect; + readonly reveal: (window: Electron.BrowserWindow) => Effect.Effect; + readonly syncAllAppearance: ( + sync: (window: Electron.BrowserWindow) => void, + ) => Effect.Effect; +} + +export class ElectronWindow extends Context.Service()( + "t3/desktop/electron/Window", +) {} + +const make = Effect.gen(function* () { + const mainWindowRef = yield* Ref.make>(Option.none()); + + const liveMain = Ref.get(mainWindowRef).pipe( + Effect.map((window) => Option.filter(window, (value) => !value.isDestroyed())), + ); + + const currentMainOrFirst = Effect.gen(function* () { + const main = yield* liveMain; + if (Option.isSome(main)) { + return main; + } + + return Option.fromNullishOr(Electron.BrowserWindow.getAllWindows()[0] ?? null).pipe( + Option.filter((window) => !window.isDestroyed()), + ); + }); + + const focusedMainOrFirst = Effect.sync(() => + Option.fromNullishOr(Electron.BrowserWindow.getFocusedWindow() ?? null).pipe( + Option.filter((window) => !window.isDestroyed()), + ), + ).pipe( + Effect.flatMap((focused) => + Option.isSome(focused) ? Effect.succeed(focused) : currentMainOrFirst, + ), + ); + + return ElectronWindow.of({ + main: liveMain, + currentMainOrFirst, + focusedMainOrFirst, + setMain: (window) => Ref.set(mainWindowRef, Option.some(window)), + clearMain: (window) => + Ref.update(mainWindowRef, (current) => { + if (Option.isNone(current)) { + return current; + } + if (window !== undefined && current.value !== window) { + return current; + } + return Option.none(); + }), + reveal: (window) => + Effect.sync(() => { + if (window.isDestroyed()) { + return; + } + + if (window.isMinimized()) { + window.restore(); + } + + if (!window.isVisible()) { + window.show(); + } + + if (process.platform === "darwin") { + Electron.app.focus({ steal: true }); + } + + window.focus(); + }), + syncAllAppearance: (sync) => + Effect.sync(() => { + for (const window of Electron.BrowserWindow.getAllWindows()) { + sync(window); + } + }), + }); +}); + +export const layer = Layer.effect(ElectronWindow, make); diff --git a/apps/desktop/src/ipc/DesktopIpc.ts b/apps/desktop/src/ipc/DesktopIpc.ts index 04ace682936..2d83ba2550b 100644 --- a/apps/desktop/src/ipc/DesktopIpc.ts +++ b/apps/desktop/src/ipc/DesktopIpc.ts @@ -1,9 +1,7 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; -import * as Electron from "electron"; export interface DesktopIpcInvokeEvent {} @@ -79,8 +77,6 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpcShape => }), }); -export const layer = Layer.succeed(DesktopIpc, make(Electron.ipcMain)); - /** * Convenience helpers for creating IPC methods */ diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 6a5ce732359..4ac8f66d928 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -12,6 +12,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as EffectPath from "effect/Path"; import * as Random from "effect/Random"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import { HttpClient } from "effect/unstable/http"; @@ -79,10 +80,11 @@ import { makeDesktopEnvironment, type DesktopEnvironmentShape, } from "./desktopEnvironment.ts"; -import { DesktopSecretStorage } from "./electron/DesktopSecretStorage.ts"; +import * as DesktopSecretStorage from "./electron/DesktopSecretStorage.ts"; +import * as DesktopIpc from "./ipc/DesktopIpc.ts"; +import * as ElectronWindow from "./electron/ElectronWindow.ts"; import { DesktopShutdown, makeDesktopShutdown } from "./desktopShutdown.ts"; import { MENU_ACTION_CHANNEL, UPDATE_STATE_CHANNEL } from "./ipc/channels.ts"; -import * as DesktopIpc from "./ipc/DesktopIpc.ts"; import { installDesktopIpcHandlers } from "./ipc/DesktopIpcHandlers.ts"; import { DesktopServerExposureIpcActions } from "./ipc/methods/serverExposure.ts"; import { DesktopUpdateIpcActions } from "./ipc/methods/updates.ts"; @@ -120,6 +122,7 @@ import { import { isArm64HostRunningIntelBuild } from "./runtimeArch.ts"; import { bindFirstRevealTrigger, type RevealSubscription } from "./windowReveal.ts"; import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; +import * as DesktopState from "./main/DesktopState.ts"; const DESKTOP_SCHEME = "t3"; const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i; @@ -178,8 +181,6 @@ interface BackendObservabilitySettings { readonly otlpTracesUrl: string | undefined; readonly otlpMetricsUrl: string | undefined; } -let mainWindow: BrowserWindow | null = null; -let backendReady = false; let backendPort = 0; let backendBindHost = DESKTOP_LOOPBACK_HOST; let backendBootstrapToken = ""; @@ -187,7 +188,6 @@ let backendHttpUrl: Option.Option = Option.none(); let backendWsUrl = ""; let backendEndpointUrl: string | null = null; let backendAdvertisedHost: string | null = null; -let isQuitting = false; let desktopProtocolRegistered = false; let appUpdateYmlConfig: Option.Option> = Option.none(); let aboutCommitHashCache: Option.Option | undefined; @@ -210,7 +210,11 @@ interface DesktopEffectRunner { (effect: Effect.Effect): Promise; } -type DesktopWindowBoundaryServices = DesktopEnvironment | DesktopSshEnvironmentBridge; +type DesktopWindowBoundaryServices = + | DesktopEnvironment + | DesktopSshEnvironmentBridge + | DesktopState.DesktopState + | ElectronWindow.ElectronWindow; type DesktopLifecycleBoundaryServices = DesktopShutdown | DesktopWindowBoundaryServices; function makeDesktopEffectRunner(context: Context.Context): DesktopEffectRunner { @@ -465,7 +469,11 @@ function applyDesktopTailscaleServeEnabled( ): Effect.Effect< DesktopServerExposureState, unknown, - FileSystem.FileSystem | EffectPath.Path | DesktopEnvironment | DesktopShutdown + | FileSystem.FileSystem + | EffectPath.Path + | DesktopEnvironment + | DesktopShutdown + | DesktopState.DesktopState > { return Effect.gen(function* () { const environment = yield* DesktopEnvironment; @@ -482,16 +490,20 @@ function applyDesktopTailscaleServeEnabled( function relaunchDesktopAppEffect( reason: string, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { const environment = yield* DesktopEnvironment; - const context = yield* Effect.context(); + const state = yield* DesktopState.DesktopState; + const context = yield* Effect.context< + DesktopEnvironment | DesktopShutdown | DesktopState.DesktopState + >(); const runEffect = makeDesktopEffectRunner(context); yield* logDesktopInfo("desktop relaunch requested", { reason }); yield* Effect.sync(() => { setImmediate(() => { - isQuitting = true; - void runEffect(requestDesktopShutdownAndWait()).finally(() => { + void runEffect( + Ref.set(state.quitting, true).pipe(Effect.andThen(requestDesktopShutdownAndWait())), + ).finally(() => { if (environment.isDevelopment) { app.exit(75); return; @@ -536,23 +548,17 @@ function getSafeExternalUrl(rawUrl: unknown): string | null { function handleBackendReady( runEffect: DesktopEffectRunner, environment: DesktopEnvironmentShape, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { - yield* Effect.sync(() => { - backendReady = true; - }); + const state = yield* DesktopState.DesktopState; + const electronWindow = yield* ElectronWindow.ElectronWindow; + yield* Ref.set(state.backendReady, true); yield* logDesktopInfo("bootstrap backend ready", { source: "http" }); - const createdWindow = yield* Effect.sync(() => { - const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null; - if (environment.isDevelopment || existingWindow !== null) { - return false; - } - - mainWindow = createWindow(runEffect, environment); - return true; - }); - if (createdWindow) { + const existingWindow = yield* electronWindow.currentMainOrFirst; + if (!environment.isDevelopment && Option.isNone(existingWindow)) { + const window = createWindow(runEffect, environment, electronWindow); + yield* electronWindow.setMain(window); yield* logDesktopInfo("bootstrap main window created"); } }); @@ -561,11 +567,17 @@ function handleBackendReady( function createBackendWindowIfReady( runEffect: DesktopEffectRunner, environment: DesktopEnvironmentShape, -): void { - if (!backendReady) return; - const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null; - if (existingWindow !== null) return; - mainWindow = createWindow(runEffect, environment); +): Effect.Effect { + return Effect.gen(function* () { + const state = yield* DesktopState.DesktopState; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const backendReady = yield* Ref.get(state.backendReady); + if (!backendReady) return; + const existingWindow = yield* electronWindow.currentMainOrFirst; + if (Option.isSome(existingWindow)) return; + const window = createWindow(runEffect, environment, electronWindow); + yield* electronWindow.setMain(window); + }); } const resolveBackendStartConfig: Effect.Effect< @@ -650,28 +662,37 @@ const desktopBackendConfigurationLayer = Layer.effect( }; }), ); -const desktopSshEnvironmentBridgeLayer = DesktopSshEnvironmentBridge.layer({ - getMainWindow: () => mainWindow, -}); +const desktopSshEnvironmentBridgeLayer = Layer.unwrap( + Effect.gen(function* () { + const electronWindow = yield* ElectronWindow.ElectronWindow; + return DesktopSshEnvironmentBridge.layer({ + getMainWindow: electronWindow.main, + }); + }), +); const desktopBackendEventsLayer = Layer.effect( DesktopBackendEvents, Effect.gen(function* () { const environment = yield* DesktopEnvironment; const backendOutputLog = yield* DesktopBackendOutputLog; - const context = yield* Effect.context(); + const state = yield* DesktopState.DesktopState; + const context = yield* Effect.context< + | DesktopEnvironment + | DesktopSshEnvironmentBridge + | DesktopState.DesktopState + | ElectronWindow.ElectronWindow + >(); const runEffect = makeDesktopEffectRunner(context); return { - onStarting: Effect.sync(() => { - backendReady = false; - }), + onStarting: Ref.set(state.backendReady, false), onStarted: ({ pid, config }) => backendOutputLog.writeSessionBoundary({ phase: "START", runId: appRunId, details: `pid=${pid} port=${config.bootstrap.port} cwd=${config.cwd}`, }), - onReady: handleBackendReady(runEffect, environment), + onReady: handleBackendReady(runEffect, environment).pipe(Effect.provide(context)), onReadinessFailure: (error) => logDesktopWarning("backend readiness check failed during bootstrap", { error: formatErrorMessage(error), @@ -688,9 +709,7 @@ const desktopBackendEventsLayer = Layer.effect( details: `pid=${value} ${reason}`, }), }); - yield* Effect.sync(() => { - backendReady = false; - }); + yield* Ref.set(state.backendReady, false); }), onRestartScheduled: ({ reason, delay }) => logDesktopError("backend exited unexpectedly; restart scheduled", { @@ -738,6 +757,7 @@ type DesktopServerExposureIpcActionServices = | FileSystem.FileSystem | EffectPath.Path | DesktopEnvironment + | DesktopState.DesktopState | DesktopNetworkInterfacesService | ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient; @@ -781,12 +801,14 @@ type DesktopUpdateIpcActionServices = | FileSystem.FileSystem | EffectPath.Path | DesktopEnvironment - | DesktopBackendManager; + | DesktopBackendManager + | DesktopState.DesktopState; const desktopUpdateIpcActionsLayer = Layer.effect( DesktopUpdateIpcActions, Effect.gen(function* () { const context = yield* Effect.context(); + const state = yield* DesktopState.DesktopState; return DesktopUpdateIpcActions.of({ getState: Effect.sync(() => updateState), setChannel: (nextChannel) => @@ -833,7 +855,7 @@ const desktopUpdateIpcActionsLayer = Layer.effect( }; }).pipe(Effect.provide(context)), install: Effect.gen(function* () { - if (isQuitting) { + if (yield* Ref.get(state.quitting)) { return { accepted: false, completed: false, @@ -859,7 +881,7 @@ const desktopUpdateIpcActionsLayer = Layer.effect( checked, state: updateState, }; - }), + }).pipe(Effect.provide(context)), }); }), ); @@ -868,6 +890,7 @@ const desktopWindowIpcActionsLayer = Layer.effect( DesktopWindowIpcActions, Effect.gen(function* () { const environment = yield* DesktopEnvironment; + const electronWindow = yield* ElectronWindow.ElectronWindow; return DesktopWindowIpcActions.of({ getAppBranding: Effect.succeed(environment.branding), getLocalEnvironmentBootstrap: Effect.sync(() => ({ @@ -877,8 +900,8 @@ const desktopWindowIpcActionsLayer = Layer.effect( ...(backendBootstrapToken ? { bootstrapToken: backendBootstrapToken } : {}), })), pickFolder: (options) => - Effect.promise(async () => { - const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; + Effect.gen(function* () { + const owner = Option.getOrUndefined(yield* electronWindow.focusedMainOrFirst); const defaultPath = Option.getOrUndefined( environment.resolvePickFolderDefaultPath(options), ); @@ -886,86 +909,94 @@ const desktopWindowIpcActionsLayer = Layer.effect( properties: ["openDirectory", "createDirectory"], ...(defaultPath ? { defaultPath } : {}), }; - const result = owner - ? await dialog.showOpenDialog(owner, openDialogOptions) - : await dialog.showOpenDialog(openDialogOptions); + const result = yield* Effect.promise(() => + owner + ? dialog.showOpenDialog(owner, openDialogOptions) + : dialog.showOpenDialog(openDialogOptions), + ); if (result.canceled) return null; return result.filePaths[0] ?? null; }), confirm: (message) => - Effect.promise(() => - showDesktopConfirmDialog(message, BrowserWindow.getFocusedWindow() ?? mainWindow), - ), + Effect.gen(function* () { + const owner = Option.getOrUndefined(yield* electronWindow.focusedMainOrFirst); + return yield* Effect.promise(() => showDesktopConfirmDialog(message, owner ?? null)); + }), setTheme: (theme) => Effect.sync(() => { nativeTheme.themeSource = theme; }), showContextMenu: ({ items, position }) => - Effect.promise( - () => - new Promise((resolve) => { - const normalizedItems = normalizeContextMenuItems(items); - if (normalizedItems.length === 0) { - resolve(null); - return; - } - - const popupPosition = - position && - Number.isFinite(position.x) && - Number.isFinite(position.y) && - position.x >= 0 && - position.y >= 0 - ? { - x: Math.floor(position.x), - y: Math.floor(position.y), + Effect.gen(function* () { + const window = Option.getOrUndefined(yield* electronWindow.focusedMainOrFirst); + if (!window) { + return null; + } + + return yield* Effect.promise( + () => + new Promise((resolve) => { + const normalizedItems = normalizeContextMenuItems(items); + if (normalizedItems.length === 0) { + resolve(null); + return; + } + + const popupPosition = + position && + Number.isFinite(position.x) && + Number.isFinite(position.y) && + position.x >= 0 && + position.y >= 0 + ? { + x: Math.floor(position.x), + y: Math.floor(position.y), + } + : null; + + const buildTemplate = ( + entries: readonly ContextMenuItem[], + ): MenuItemConstructorOptions[] => { + const template: MenuItemConstructorOptions[] = []; + let hasInsertedDestructiveSeparator = false; + for (const item of entries) { + if ( + item.destructive && + !hasInsertedDestructiveSeparator && + template.length > 0 + ) { + template.push({ type: "separator" }); + hasInsertedDestructiveSeparator = true; } - : null; - - const window = BrowserWindow.getFocusedWindow() ?? mainWindow; - if (!window) { - resolve(null); - return; - } - - const buildTemplate = ( - entries: readonly ContextMenuItem[], - ): MenuItemConstructorOptions[] => { - const template: MenuItemConstructorOptions[] = []; - let hasInsertedDestructiveSeparator = false; - for (const item of entries) { - if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { - template.push({ type: "separator" }); - hasInsertedDestructiveSeparator = true; - } - const itemOption: MenuItemConstructorOptions = { - label: item.label, - enabled: !item.disabled, - }; - if (item.children && item.children.length > 0) { - itemOption.submenu = buildTemplate(item.children); - } else { - itemOption.click = () => resolve(item.id); - } - if (item.destructive && (!item.children || item.children.length === 0)) { - const destructiveIcon = getDestructiveMenuIcon(); - if (destructiveIcon) { - itemOption.icon = destructiveIcon; + const itemOption: MenuItemConstructorOptions = { + label: item.label, + enabled: !item.disabled, + }; + if (item.children && item.children.length > 0) { + itemOption.submenu = buildTemplate(item.children); + } else { + itemOption.click = () => resolve(item.id); } + if (item.destructive && (!item.children || item.children.length === 0)) { + const destructiveIcon = getDestructiveMenuIcon(); + if (destructiveIcon) { + itemOption.icon = destructiveIcon; + } + } + template.push(itemOption); } - template.push(itemOption); - } - return template; - }; - - const menu = Menu.buildFromTemplate(buildTemplate(normalizedItems)); - menu.popup({ - window, - ...popupPosition, - callback: () => resolve(null), - }); - }), - ), + return template; + }; + + const menu = Menu.buildFromTemplate(buildTemplate(normalizedItems)); + menu.popup({ + window, + ...popupPosition, + callback: () => resolve(null), + }); + }), + ); + }), openExternal: (rawUrl) => { const externalUrl = getSafeExternalUrl(rawUrl); if (!externalUrl) { @@ -982,8 +1013,8 @@ const desktopWindowIpcActionsLayer = Layer.effect( ); const desktopSecretStorageLayer = Layer.succeed( - DesktopSecretStorage, - DesktopSecretStorage.of({ + DesktopSecretStorage.DesktopSecretStorage, + DesktopSecretStorage.DesktopSecretStorage.of({ isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(), encryptString: (value) => safeStorage.encryptString(value), decryptString: (value) => safeStorage.decryptString(value), @@ -1003,12 +1034,16 @@ const desktopBackendManagerLayer = DesktopBackendManagerLive.pipe( Layer.provide(desktopBackendDependenciesLayer), ); +const desktopElectronWindowLayer = desktopSshEnvironmentBridgeLayer.pipe( + Layer.provideMerge(ElectronWindow.layer), +); + const desktopRuntimeLayer = Layer.mergeAll( desktopLoggerLayer, NetService.layer, desktopShellEnvironmentLayer, desktopSshEnvironmentLayer, - DesktopIpc.layer, + Layer.succeed(DesktopIpc.DesktopIpc, DesktopIpc.make(Electron.ipcMain)), desktopServerExposureIpcActionsLayer, desktopUpdateIpcActionsLayer, desktopWindowIpcActionsLayer, @@ -1018,7 +1053,7 @@ const desktopRuntimeLayer = Layer.mergeAll( Layer.provideMerge(NodeHttpClient.layerUndici), Layer.provideMerge(DesktopNetworkInterfacesLive), Layer.provideMerge(desktopBackendManagerLayer), - Layer.provideMerge(desktopSshEnvironmentBridgeLayer), + Layer.provideMerge(desktopElectronWindowLayer), Layer.provideMerge(desktopEnvironmentLayer), ); @@ -1279,9 +1314,10 @@ function isStaticAssetRequest(requestUrl: string, environment: DesktopEnvironmen function handleFatalStartupError( stage: string, error: unknown, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { const shutdown = yield* DesktopShutdown; + const state = yield* DesktopState.DesktopState; const message = formatErrorMessage(error); const detail = error instanceof Error && typeof error.stack === "string" ? `\n${error.stack}` : ""; @@ -1290,9 +1326,9 @@ function handleFatalStartupError( message, ...(detail.length > 0 ? { detail } : {}), }); + const wasQuitting = yield* Ref.getAndSet(state.quitting, true); yield* Effect.sync(() => { - if (!isQuitting) { - isQuitting = true; + if (!wasQuitting) { dialog.showErrorBox("T3 Code failed to start", `Stage: ${stage}\n${message}${detail}`); } }); @@ -1358,28 +1394,32 @@ function registerDesktopProtocol(): Effect.Effect< function dispatchMenuAction( action: string, - runEffect: DesktopEffectRunner, environment: DesktopEnvironmentShape, -): void { - const existingWindow = - BrowserWindow.getFocusedWindow() ?? mainWindow ?? BrowserWindow.getAllWindows()[0]; - const targetWindow = existingWindow ?? createWindow(runEffect, environment); - if (!existingWindow) { - mainWindow = targetWindow; - } +): Effect.Effect { + return Effect.gen(function* () { + const context = yield* Effect.context(); + const runEffect = makeDesktopEffectRunner(context); + const electronWindow = yield* ElectronWindow.ElectronWindow; + const existingWindow = yield* electronWindow.focusedMainOrFirst; + const targetWindow = + Option.getOrUndefined(existingWindow) ?? createWindow(runEffect, environment, electronWindow); + if (Option.isNone(existingWindow)) { + yield* electronWindow.setMain(targetWindow); + } - const send = () => { - if (targetWindow.isDestroyed()) return; - targetWindow.webContents.send(MENU_ACTION_CHANNEL, action); - revealWindow(targetWindow); - }; + const send = () => { + if (targetWindow.isDestroyed()) return; + targetWindow.webContents.send(MENU_ACTION_CHANNEL, action); + void runEffect(electronWindow.reveal(targetWindow)); + }; - if (targetWindow.webContents.isLoadingMainFrame()) { - targetWindow.webContents.once("did-finish-load", send); - return; - } + if (targetWindow.webContents.isLoadingMainFrame()) { + targetWindow.webContents.once("did-finish-load", send); + return; + } - send(); + send(); + }); } function handleCheckForUpdatesMenuClick( @@ -1411,7 +1451,14 @@ function handleCheckForUpdatesMenuClick( } if (!BrowserWindow.getAllWindows().length) { - mainWindow = createWindow(runEffect, environment); + void runEffect( + Effect.gen(function* () { + const electronWindow = yield* ElectronWindow.ElectronWindow; + yield* electronWindow.setMain(createWindow(runEffect, environment, electronWindow)); + yield* checkForUpdatesFromMenu(); + }), + ); + return; } void runEffect(checkForUpdatesFromMenu()); } @@ -1420,7 +1467,7 @@ function hasDesktopUpdateFeedConfig(): boolean { return Option.isSome(appUpdateYmlConfig) || Boolean(process.env.T3CODE_DESKTOP_MOCK_UPDATES); } -function checkForUpdatesFromMenu(): Effect.Effect { +function checkForUpdatesFromMenu(): Effect.Effect { return Effect.gen(function* () { yield* checkForUpdates("menu"); @@ -1467,7 +1514,9 @@ function configureApplicationMenu(): Effect.Effect dispatchMenuAction("open-settings", runEffect, environment), + click: () => { + void runEffect(dispatchMenuAction("open-settings", environment)); + }, }, { type: "separator" }, { role: "services" }, @@ -1491,7 +1540,9 @@ function configureApplicationMenu(): Effect.Effect dispatchMenuAction("open-settings", runEffect, environment), + click: () => { + void runEffect(dispatchMenuAction("open-settings", environment)); + }, }, { type: "separator" as const }, ]), @@ -1666,7 +1717,7 @@ function clearUpdatePollTimer(): Effect.Effect { }); } -function startUpdatePollers(): Effect.Effect { +function startUpdatePollers(): Effect.Effect { return Effect.gen(function* () { yield* clearUpdatePollTimer(); const parentScope = yield* Scope.Scope; @@ -1692,26 +1743,6 @@ function startUpdatePollers(): Effect.Effect { }); } -function revealWindow(window: BrowserWindow): void { - if (window.isDestroyed()) { - return; - } - - if (window.isMinimized()) { - window.restore(); - } - - if (!window.isVisible()) { - window.show(); - } - - if (process.platform === "darwin") { - app.focus({ steal: true }); - } - - window.focus(); -} - function emitUpdateState(): void { for (const window of BrowserWindow.getAllWindows()) { if (window.isDestroyed()) continue; @@ -1765,9 +1796,10 @@ function shouldEnableAutoUpdates(environment: DesktopEnvironmentShape): boolean ); } -function checkForUpdates(reason: string): Effect.Effect { +function checkForUpdates(reason: string): Effect.Effect { return Effect.gen(function* () { - if (isQuitting || !updaterConfigured || updateCheckInFlight) return false; + const state = yield* DesktopState.DesktopState; + if ((yield* Ref.get(state.quitting)) || !updaterConfigured || updateCheckInFlight) return false; if (updateState.status === "downloading" || updateState.status === "downloaded") { yield* logUpdaterInfo("skipping update check while update is active", { reason, @@ -1848,14 +1880,19 @@ function installDownloadedUpdate(): Effect.Effect< completed: boolean; }, never, - DesktopBackendManager + DesktopBackendManager | DesktopState.DesktopState > { return Effect.gen(function* () { - if (isQuitting || !updaterConfigured || updateState.status !== "downloaded") { + const state = yield* DesktopState.DesktopState; + if ( + (yield* Ref.get(state.quitting)) || + !updaterConfigured || + updateState.status !== "downloaded" + ) { return { accepted: false, completed: false }; } - isQuitting = true; + yield* Ref.set(state.quitting, true); updateInstallInFlight = true; yield* clearUpdatePollTimer(); @@ -1879,9 +1916,9 @@ function installDownloadedUpdate(): Effect.Effect< const message = formatErrorMessage(error); yield* Effect.sync(() => { updateInstallInFlight = false; - isQuitting = false; setUpdateState(reduceDesktopUpdateStateOnInstallFailure(updateState, message)); }); + yield* Ref.set(state.quitting, false); yield* logUpdaterError("failed to install update", { message }); return { accepted: true, completed: false }; }), @@ -1890,9 +1927,14 @@ function installDownloadedUpdate(): Effect.Effect< }); } -function configureAutoUpdater(): Effect.Effect { +function configureAutoUpdater(): Effect.Effect< + void, + never, + Scope.Scope | DesktopEnvironment | DesktopState.DesktopState +> { return Effect.gen(function* () { const environment = yield* DesktopEnvironment; + const state = yield* DesktopState.DesktopState; const context = yield* Effect.context(); const runEffect = makeDesktopEffectRunner(context); const githubToken = @@ -1969,7 +2011,7 @@ function configureAutoUpdater(): Effect.Effect { - if (isQuitting) return Effect.void; +function startBackend(): Effect.Effect< + void, + never, + DesktopBackendManager | DesktopState.DesktopState +> { return Effect.gen(function* () { + const state = yield* DesktopState.DesktopState; + if (yield* Ref.get(state.quitting)) return; const backendManager = yield* DesktopBackendManager; yield* backendManager.start; }).pipe( @@ -2044,14 +2091,19 @@ function requestDesktopShutdownAndWait(): Effect.Effect { - app.quit(); + Effect.gen(function* () { + const state = yield* DesktopState.DesktopState; + const wasQuitting = yield* Ref.getAndSet(state.quitting, true); + if (wasQuitting) return false; + yield* logDesktopInfo("process signal received", { signal }); + yield* requestDesktopShutdownAndWait(); + return true; + }), + ).then((shouldQuit) => { + if (shouldQuit) { + app.quit(); + } }); } @@ -2098,15 +2150,10 @@ function syncWindowAppearance(window: BrowserWindow): void { } } -function syncAllWindowAppearance(): void { - for (const window of BrowserWindow.getAllWindows()) { - syncWindowAppearance(window); - } -} - function createWindow( runEffect: DesktopEffectRunner, environment: DesktopEnvironmentShape, + electronWindow: ElectronWindow.ElectronWindowShape, ): BrowserWindow { const window = new BrowserWindow({ width: 1100, @@ -2201,7 +2248,9 @@ function createWindow( if (process.platform === "linux") { revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire)); } - bindFirstRevealTrigger(revealSubscribers, () => revealWindow(window)); + bindFirstRevealTrigger(revealSubscribers, () => { + void runEffect(electronWindow.reveal(window)); + }); if (environment.isDevelopment) { void window.loadURL(resolveDesktopDevServerUrl(environment)); @@ -2219,9 +2268,7 @@ function createWindow( ); }), ); - if (mainWindow === window) { - mainWindow = null; - } + void runEffect(electronWindow.clearMain(window)); }); return window; @@ -2230,6 +2277,7 @@ function createWindow( function bootstrap() { return Effect.gen(function* () { const environment = yield* DesktopEnvironment; + const electronWindow = yield* ElectronWindow.ElectronWindow; const context = yield* Effect.context(); const runEffect = makeDesktopEffectRunner(context); yield* logDesktopInfo("bootstrap start"); @@ -2285,7 +2333,7 @@ function bootstrap() { yield* logDesktopInfo("bootstrap backend start requested"); if (environment.isDevelopment) { - mainWindow = createWindow(runEffect, environment); + yield* electronWindow.setMain(createWindow(runEffect, environment, electronWindow)); yield* logDesktopInfo("bootstrap main window created"); } }); @@ -2297,39 +2345,61 @@ function handleBeforeQuit( allowQuit: () => boolean, markQuitAllowed: () => void, ): void { - isQuitting = true; - void runEffect(logDesktopInfo("before-quit received")); if (allowQuit()) { + void runEffect( + Effect.gen(function* () { + const state = yield* DesktopState.DesktopState; + yield* Ref.set(state.quitting, true); + yield* logDesktopInfo("before-quit received"); + }), + ); return; } event.preventDefault(); - void runEffect(requestDesktopShutdownAndWait()).finally(() => { + void runEffect( + Effect.gen(function* () { + const state = yield* DesktopState.DesktopState; + yield* Ref.set(state.quitting, true); + yield* logDesktopInfo("before-quit received"); + yield* requestDesktopShutdownAndWait(); + }), + ).finally(() => { markQuitAllowed(); app.quit(); }); } function handleActivate( - runEffect: DesktopEffectRunner, environment: DesktopEnvironmentShape, -): void { - const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0]; - if (existingWindow) { - revealWindow(existingWindow); - return; - } - if (environment.isDevelopment) { - mainWindow = createWindow(runEffect, environment); - return; - } - createBackendWindowIfReady(runEffect, environment); +): Effect.Effect { + return Effect.gen(function* () { + const context = yield* Effect.context(); + const runEffect = makeDesktopEffectRunner(context); + const electronWindow = yield* ElectronWindow.ElectronWindow; + const existingWindow = yield* electronWindow.currentMainOrFirst; + if (Option.isSome(existingWindow)) { + yield* electronWindow.reveal(existingWindow.value); + return; + } + if (environment.isDevelopment) { + const window = createWindow(runEffect, environment, electronWindow); + yield* electronWindow.setMain(window); + return; + } + yield* createBackendWindowIfReady(runEffect, environment); + }); } -function handleWindowAllClosed(): void { - if (process.platform !== "darwin" && !isQuitting) { - app.quit(); - } +function handleWindowAllClosed(): Effect.Effect { + return Effect.gen(function* () { + const state = yield* DesktopState.DesktopState; + if (process.platform !== "darwin" && !(yield* Ref.get(state.quitting))) { + yield* Effect.sync(() => { + app.quit(); + }); + } + }); } function registerDesktopLifecycleHandlers(): Effect.Effect< @@ -2339,11 +2409,12 @@ function registerDesktopLifecycleHandlers(): Effect.Effect< > { return Effect.gen(function* () { const environment = yield* DesktopEnvironment; + const electronWindow = yield* ElectronWindow.ElectronWindow; const context = yield* Effect.context(); const runEffect = makeDesktopEffectRunner(context); let quitAllowed = false; yield* addScopedListener(nativeTheme, "updated", () => { - syncAllWindowAppearance(); + void runEffect(electronWindow.syncAllAppearance(syncWindowAppearance)); }); yield* addScopedListener(app, "before-quit", (event: Electron.Event) => { handleBeforeQuit( @@ -2356,10 +2427,10 @@ function registerDesktopLifecycleHandlers(): Effect.Effect< ); }); yield* addScopedListener(app, "activate", () => { - handleActivate(runEffect, environment); + void runEffect(handleActivate(environment)); }); yield* addScopedListener(app, "window-all-closed", () => { - handleWindowAllClosed(); + void runEffect(handleWindowAllClosed()); }); if (process.platform !== "win32") { @@ -2444,4 +2515,8 @@ const program = Effect.scoped( ), ); -program.pipe(Effect.provide(desktopRuntimeLayer), NodeRuntime.runMain); +program.pipe( + Effect.provide(desktopRuntimeLayer), + Effect.provide(DesktopState.layer), + NodeRuntime.runMain, +); diff --git a/apps/desktop/src/main/DesktopState.ts b/apps/desktop/src/main/DesktopState.ts new file mode 100644 index 00000000000..43960ada65f --- /dev/null +++ b/apps/desktop/src/main/DesktopState.ts @@ -0,0 +1,21 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +export interface DesktopStateShape { + readonly backendReady: Ref.Ref; + readonly quitting: Ref.Ref; +} + +export class DesktopState extends Context.Service()( + "t3/desktop/State", +) {} + +export const layer = Layer.effect( + DesktopState, + Effect.all({ + backendReady: Ref.make(false), + quitting: Ref.make(false), + }), +); diff --git a/apps/desktop/src/sshEnvironment.ts b/apps/desktop/src/sshEnvironment.ts index ed056cf52ef..409e693bcd1 100644 --- a/apps/desktop/src/sshEnvironment.ts +++ b/apps/desktop/src/sshEnvironment.ts @@ -150,7 +150,7 @@ export interface DesktopSshBridgeWindow { } export interface DesktopSshEnvironmentBridgeOptions { - readonly getMainWindow: () => DesktopSshBridgeWindow | null; + readonly getMainWindow: Effect.Effect, never>; readonly passwordPromptTimeoutMs?: number; } @@ -236,7 +236,7 @@ function makeDesktopSshEnvironmentBridge( } return Effect.gen(function* () { - const window = options.getMainWindow(); + const window = Option.getOrUndefined(yield* options.getMainWindow); if (!window || window.isDestroyed()) { return yield* Effect.fail( new Error("T3 Code window is not available for SSH authentication."), From adfeee03f3e5b5a298bc16590119152f32cb833e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 14:48:07 -0700 Subject: [PATCH 10/43] Refactor desktop Electron IPC into shared services - Extract menu, dialog, shell, and theme helpers into layers - Add local environment bootstrap from backend config - Cover new desktop and backend behavior with tests Co-authored-by: codex --- .../desktop/src/desktopBackendManager.test.ts | 3 + apps/desktop/src/desktopBackendManager.ts | 6 + apps/desktop/src/electron/ElectronDialog.ts | 58 ++++ .../desktop/src/electron/ElectronMenu.test.ts | 93 +++++++ apps/desktop/src/electron/ElectronMenu.ts | 161 +++++++++++ .../src/electron/ElectronShell.test.ts | 78 ++++++ apps/desktop/src/electron/ElectronShell.ts | 50 ++++ apps/desktop/src/electron/ElectronTheme.ts | 25 ++ apps/desktop/src/electron/ElectronWindow.ts | 2 +- apps/desktop/src/ipc/methods/windowLive.ts | 61 ++++ apps/desktop/src/main.ts | 261 +++--------------- .../src/main/DesktopLocalEnvironment.test.ts | 89 ++++++ .../src/main/DesktopLocalEnvironment.ts | 48 ++++ 13 files changed, 717 insertions(+), 218 deletions(-) create mode 100644 apps/desktop/src/electron/ElectronDialog.ts create mode 100644 apps/desktop/src/electron/ElectronMenu.test.ts create mode 100644 apps/desktop/src/electron/ElectronMenu.ts create mode 100644 apps/desktop/src/electron/ElectronShell.test.ts create mode 100644 apps/desktop/src/electron/ElectronShell.ts create mode 100644 apps/desktop/src/electron/ElectronTheme.ts create mode 100644 apps/desktop/src/ipc/methods/windowLive.ts create mode 100644 apps/desktop/src/main/DesktopLocalEnvironment.test.ts create mode 100644 apps/desktop/src/main/DesktopLocalEnvironment.ts diff --git a/apps/desktop/src/desktopBackendManager.test.ts b/apps/desktop/src/desktopBackendManager.test.ts index 53d2ed28515..83a1dae57f7 100644 --- a/apps/desktop/src/desktopBackendManager.test.ts +++ b/apps/desktop/src/desktopBackendManager.test.ts @@ -105,8 +105,11 @@ describe("DesktopBackendManager", () => { yield* Effect.gen(function* () { const manager = yield* DesktopBackendManager; + assert.isTrue(Option.isNone(yield* manager.currentConfig)); + yield* manager.start; assert.equal(yield* Queue.take(startedPids), 123); + assert.deepEqual(yield* manager.currentConfig, Option.some(baseConfig)); const runningSnapshot = yield* manager.snapshot; assert.equal(runningSnapshot.ready, true); diff --git a/apps/desktop/src/desktopBackendManager.ts b/apps/desktop/src/desktopBackendManager.ts index 689d4329bfc..168d65c3459 100644 --- a/apps/desktop/src/desktopBackendManager.ts +++ b/apps/desktop/src/desktopBackendManager.ts @@ -111,6 +111,7 @@ export interface DesktopBackendManagerShape { readonly start: Effect.Effect; readonly stop: (options?: { readonly timeout?: Duration.Duration }) => Effect.Effect; readonly shutdown: Effect.Effect; + readonly currentConfig: Effect.Effect>; readonly snapshot: Effect.Effect; } @@ -129,6 +130,7 @@ interface ActiveBackendRun { interface BackendManagerState { readonly desiredRunning: boolean; readonly ready: boolean; + readonly config: Option.Option; readonly active: Option.Option; readonly restartAttempt: number; readonly restartFiber: Option.Option>; @@ -139,6 +141,7 @@ interface BackendManagerState { const initialState: BackendManagerState = { desiredRunning: false, ready: false, + config: Option.none(), active: Option.none(), restartAttempt: 0, restartFiber: Option.none(), @@ -200,6 +203,7 @@ export const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")( }), ), ); + const currentConfig = Ref.get(state).pipe(Effect.map((current) => current.config)); const cancelRestart = Effect.gen(function* () { const restartFiber = yield* Ref.modify(state, (current) => [ @@ -236,6 +240,7 @@ export const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")( ...latest, desiredRunning: true, ready: false, + config: Option.some(config), restartFiber: Option.none(), })); @@ -457,6 +462,7 @@ export const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")( start, stop, shutdown, + currentConfig, snapshot, }); }); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts new file mode 100644 index 00000000000..c8e734dfa24 --- /dev/null +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -0,0 +1,58 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as Electron from "electron"; + +import { showDesktopConfirmDialog } from "../confirmDialog.ts"; + +export interface ElectronDialogPickFolderInput { + readonly owner: Option.Option; + readonly defaultPath: Option.Option; +} + +export interface ElectronDialogConfirmInput { + readonly owner: Option.Option; + readonly message: string; +} + +export interface ElectronDialogShape { + readonly pickFolder: ( + input: ElectronDialogPickFolderInput, + ) => Effect.Effect>; + readonly confirm: (input: ElectronDialogConfirmInput) => Effect.Effect; +} + +export class ElectronDialog extends Context.Service()( + "t3/desktop/electron/Dialog", +) {} + +const make = ElectronDialog.of({ + pickFolder: (input) => + Effect.gen(function* () { + const openDialogOptions: Electron.OpenDialogOptions = Option.match(input.defaultPath, { + onNone: () => ({ + properties: ["openDirectory", "createDirectory"], + }), + onSome: (defaultPath) => ({ + properties: ["openDirectory", "createDirectory"], + defaultPath, + }), + }); + const result = yield* Option.match(input.owner, { + onNone: () => Effect.promise(() => Electron.dialog.showOpenDialog(openDialogOptions)), + onSome: (owner) => + Effect.promise(() => Electron.dialog.showOpenDialog(owner, openDialogOptions)), + }); + + if (result.canceled) { + return Option.none(); + } + return Option.fromNullishOr(result.filePaths[0]); + }), + confirm: (input) => + Effect.promise(() => showDesktopConfirmDialog(input.message, Option.getOrNull(input.owner))), +}); + +export const layer = Layer.succeed(ElectronDialog, make); diff --git a/apps/desktop/src/electron/ElectronMenu.test.ts b/apps/desktop/src/electron/ElectronMenu.test.ts new file mode 100644 index 00000000000..4d8fd90ffb4 --- /dev/null +++ b/apps/desktop/src/electron/ElectronMenu.test.ts @@ -0,0 +1,93 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import type * as Electron from "electron"; +import { beforeEach, vi } from "vitest"; + +const { buildFromTemplateMock, createFromNamedImageMock } = vi.hoisted(() => ({ + buildFromTemplateMock: vi.fn(), + createFromNamedImageMock: vi.fn(), +})); + +vi.mock("electron", () => ({ + Menu: { + buildFromTemplate: buildFromTemplateMock, + }, + nativeImage: { + createFromNamedImage: createFromNamedImageMock, + }, +})); + +import * as ElectronMenu from "./ElectronMenu.ts"; + +describe("ElectronMenu", () => { + beforeEach(() => { + buildFromTemplateMock.mockReset(); + createFromNamedImageMock.mockReset(); + }); + + it.effect("returns none without building a menu when there are no valid items", () => + Effect.gen(function* () { + const electronMenu = yield* ElectronMenu.ElectronMenu; + const selectedItemId = yield* electronMenu.showContextMenu({ + window: {} as Electron.BrowserWindow, + items: [], + position: Option.none(), + }); + + assert.isTrue(Option.isNone(selectedItemId)); + assert.equal(buildFromTemplateMock.mock.calls.length, 0); + }).pipe(Effect.provide(ElectronMenu.layer)), + ); + + it.effect("resolves with the clicked leaf item id", () => + Effect.gen(function* () { + buildFromTemplateMock.mockImplementation( + (template: Electron.MenuItemConstructorOptions[]) => ({ + popup: () => { + const firstItem = template[0]; + assert.isDefined(firstItem); + const click = firstItem.click; + if (!click) { + throw new Error("Expected menu item to have a click handler."); + } + click({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent); + }, + }), + ); + + const electronMenu = yield* ElectronMenu.ElectronMenu; + const selectedItemId = yield* electronMenu.showContextMenu({ + window: {} as Electron.BrowserWindow, + items: [{ id: "copy", label: "Copy" }], + position: Option.none(), + }); + + assert.equal(Option.getOrNull(selectedItemId), "copy"); + }).pipe(Effect.provide(ElectronMenu.layer)), + ); + + it.effect("resolves with none when the menu closes without a click", () => + Effect.gen(function* () { + buildFromTemplateMock.mockImplementation(() => ({ + popup: (options: Electron.PopupOptions) => { + options.callback?.(); + }, + })); + + const electronMenu = yield* ElectronMenu.ElectronMenu; + const selectedItemId = yield* electronMenu.showContextMenu({ + window: {} as Electron.BrowserWindow, + items: [{ id: "copy", label: "Copy" }], + position: Option.some({ x: 10.8, y: 20.2 }), + }); + + assert.isTrue(Option.isNone(selectedItemId)); + assert.deepEqual(buildFromTemplateMock.mock.calls[0]?.[0][0], { + label: "Copy", + enabled: true, + click: buildFromTemplateMock.mock.calls[0]?.[0][0].click, + }); + }).pipe(Effect.provide(ElectronMenu.layer)), + ); +}); diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts new file mode 100644 index 00000000000..bc04413c2d7 --- /dev/null +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -0,0 +1,161 @@ +import type { ContextMenuItem } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as Electron from "electron"; + +export interface ElectronMenuPosition { + readonly x: number; + readonly y: number; +} + +export interface ElectronMenuContextInput { + readonly window: Electron.BrowserWindow; + readonly items: readonly ContextMenuItem[]; + readonly position: Option.Option; +} + +export interface ElectronMenuShape { + readonly showContextMenu: ( + input: ElectronMenuContextInput, + ) => Effect.Effect>; +} + +export class ElectronMenu extends Context.Service()( + "t3/desktop/electron/Menu", +) {} + +function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextMenuItem[] { + const normalizedItems: ContextMenuItem[] = []; + + for (const sourceItem of source) { + if (typeof sourceItem.id !== "string" || typeof sourceItem.label !== "string") { + continue; + } + + const normalizedItem: ContextMenuItem = { + id: sourceItem.id, + label: sourceItem.label, + destructive: sourceItem.destructive === true, + disabled: sourceItem.disabled === true, + }; + + if (sourceItem.children) { + const normalizedChildren = normalizeContextMenuItems(sourceItem.children); + if (normalizedChildren.length === 0) { + continue; + } + normalizedItem.children = normalizedChildren; + } + + normalizedItems.push(normalizedItem); + } + + return normalizedItems; +} + +const normalizePosition = ( + position: Option.Option, +): Option.Option => + Option.filter( + position, + ({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0, + ).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) }))); + +export const layer = Layer.sync(ElectronMenu, () => { + let destructiveMenuIconCache: Option.Option | undefined; + + const getDestructiveMenuIcon = (): Option.Option => { + if (process.platform !== "darwin") { + return Option.none(); + } + if (destructiveMenuIconCache !== undefined) { + return destructiveMenuIconCache; + } + + try { + const icon = Electron.nativeImage.createFromNamedImage("trash").resize({ + width: 12, + height: 12, + }); + destructiveMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon); + } catch { + destructiveMenuIconCache = Option.none(); + } + + return destructiveMenuIconCache; + }; + + const buildTemplate = ( + entries: readonly ContextMenuItem[], + complete: (selectedItemId: Option.Option) => void, + ): Electron.MenuItemConstructorOptions[] => { + const template: Electron.MenuItemConstructorOptions[] = []; + let hasInsertedDestructiveSeparator = false; + + for (const item of entries) { + if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { + template.push({ type: "separator" }); + hasInsertedDestructiveSeparator = true; + } + + const itemOption: Electron.MenuItemConstructorOptions = { + label: item.label, + enabled: !item.disabled, + }; + if (item.children && item.children.length > 0) { + itemOption.submenu = buildTemplate(item.children, complete); + } else { + itemOption.click = () => complete(Option.some(item.id)); + } + if (item.destructive && (!item.children || item.children.length === 0)) { + const destructiveIcon = getDestructiveMenuIcon(); + if (Option.isSome(destructiveIcon)) { + itemOption.icon = destructiveIcon.value; + } + } + + template.push(itemOption); + } + + return template; + }; + + return ElectronMenu.of({ + showContextMenu: (input) => + Effect.callback>((resume) => { + const normalizedItems = normalizeContextMenuItems(input.items); + if (normalizedItems.length === 0) { + resume(Effect.succeed(Option.none())); + return; + } + + let completed = false; + const complete = (selectedItemId: Option.Option) => { + if (completed) { + return; + } + completed = true; + resume(Effect.succeed(selectedItemId)); + }; + + const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); + const popupPosition = normalizePosition(input.position); + const popupOptions = Option.match(popupPosition, { + onNone: (): Electron.PopupOptions => ({ + window: input.window, + callback: () => complete(Option.none()), + }), + onSome: (position): Electron.PopupOptions => ({ + window: input.window, + x: position.x, + y: position.y, + callback: () => complete(Option.none()), + }), + }); + menu.popup(popupOptions); + }), + }); +}); diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts new file mode 100644 index 00000000000..f34643c3873 --- /dev/null +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -0,0 +1,78 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { beforeEach, vi } from "vitest"; + +const { openExternalMock, writeTextMock } = vi.hoisted(() => ({ + openExternalMock: vi.fn(), + writeTextMock: vi.fn(), +})); + +vi.mock("electron", () => ({ + shell: { + openExternal: openExternalMock, + }, + clipboard: { + writeText: writeTextMock, + }, +})); + +import * as ElectronShell from "./ElectronShell.ts"; + +describe("ElectronShell", () => { + beforeEach(() => { + openExternalMock.mockReset(); + writeTextMock.mockReset(); + }); + + it("parses only safe external URLs", () => { + assert.equal( + Option.getOrNull(ElectronShell.parseSafeExternalUrl("https://example.com/path")), + "https://example.com/path", + ); + assert.isTrue(Option.isNone(ElectronShell.parseSafeExternalUrl("javascript:alert(1)"))); + assert.isTrue(Option.isNone(ElectronShell.parseSafeExternalUrl(42))); + }); + + it.effect("opens safe external URLs", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openExternal("https://example.com/path"); + + assert.equal(result, true); + assert.deepEqual(openExternalMock.mock.calls, [["https://example.com/path"]]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + + it.effect("does not open unsafe external URLs", () => + Effect.gen(function* () { + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openExternal("file:///etc/passwd"); + + assert.equal(result, false); + assert.equal(openExternalMock.mock.calls.length, 0); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + + it.effect("returns false when Electron rejects openExternal", () => + Effect.gen(function* () { + openExternalMock.mockRejectedValue(new Error("open failed")); + + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openExternal("https://example.com/path"); + + assert.equal(result, false); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + + it.effect("copies text through Electron clipboard", () => + Effect.gen(function* () { + const electronShell = yield* ElectronShell.ElectronShell; + yield* electronShell.copyText("https://example.com/path"); + + assert.deepEqual(writeTextMock.mock.calls, [["https://example.com/path"]]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); +}); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts new file mode 100644 index 00000000000..09826a95b09 --- /dev/null +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -0,0 +1,50 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as Electron from "electron"; + +const SAFE_EXTERNAL_PROTOCOLS = new Set(["http:", "https:"]); + +export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { + if (typeof rawUrl !== "string") { + return Option.none(); + } + + try { + const url = new URL(rawUrl); + return SAFE_EXTERNAL_PROTOCOLS.has(url.protocol) ? Option.some(url.href) : Option.none(); + } catch { + return Option.none(); + } +} + +export interface ElectronShellShape { + readonly openExternal: (rawUrl: unknown) => Effect.Effect; + readonly copyText: (text: string) => Effect.Effect; +} + +export class ElectronShell extends Context.Service()( + "t3/desktop/electron/Shell", +) {} + +const make = ElectronShell.of({ + openExternal: (rawUrl) => + Option.match(parseSafeExternalUrl(rawUrl), { + onNone: () => Effect.succeed(false), + onSome: (externalUrl) => + Effect.promise(() => + Electron.shell.openExternal(externalUrl).then( + () => true, + () => false, + ), + ), + }), + copyText: (text) => + Effect.sync(() => { + Electron.clipboard.writeText(text); + }), +}); + +export const layer = Layer.succeed(ElectronShell, make); diff --git a/apps/desktop/src/electron/ElectronTheme.ts b/apps/desktop/src/electron/ElectronTheme.ts new file mode 100644 index 00000000000..d3628c44b80 --- /dev/null +++ b/apps/desktop/src/electron/ElectronTheme.ts @@ -0,0 +1,25 @@ +import type { DesktopTheme } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as Electron from "electron"; + +export interface ElectronThemeShape { + readonly shouldUseDarkColors: Effect.Effect; + readonly setSource: (theme: DesktopTheme) => Effect.Effect; +} + +export class ElectronTheme extends Context.Service()( + "t3/desktop/electron/Theme", +) {} + +const make = ElectronTheme.of({ + shouldUseDarkColors: Effect.sync(() => Electron.nativeTheme.shouldUseDarkColors), + setSource: (theme) => + Effect.sync(() => { + Electron.nativeTheme.themeSource = theme; + }), +}); + +export const layer = Layer.succeed(ElectronTheme, make); diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index c66a670c6e5..e626d6c00b9 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -26,7 +26,7 @@ const make = Effect.gen(function* () { const mainWindowRef = yield* Ref.make>(Option.none()); const liveMain = Ref.get(mainWindowRef).pipe( - Effect.map((window) => Option.filter(window, (value) => !value.isDestroyed())), + Effect.map(Option.filter((value) => !value.isDestroyed())), ); const currentMainOrFirst = Effect.gen(function* () { diff --git a/apps/desktop/src/ipc/methods/windowLive.ts b/apps/desktop/src/ipc/methods/windowLive.ts new file mode 100644 index 00000000000..1684e2dcb6e --- /dev/null +++ b/apps/desktop/src/ipc/methods/windowLive.ts @@ -0,0 +1,61 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as DesktopEnvironment from "../../desktopEnvironment.ts"; +import * as ElectronDialog from "../../electron/ElectronDialog.ts"; +import * as ElectronMenu from "../../electron/ElectronMenu.ts"; +import * as ElectronShell from "../../electron/ElectronShell.ts"; +import * as ElectronTheme from "../../electron/ElectronTheme.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import * as DesktopLocalEnvironment from "../../main/DesktopLocalEnvironment.ts"; +import * as DesktopWindowIpc from "./window.ts"; + +export const layer = Layer.effect( + DesktopWindowIpc.DesktopWindowIpcActions, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const localEnvironment = yield* DesktopLocalEnvironment.DesktopLocalEnvironment; + const electronDialog = yield* ElectronDialog.ElectronDialog; + const electronMenu = yield* ElectronMenu.ElectronMenu; + const electronShell = yield* ElectronShell.ElectronShell; + const electronTheme = yield* ElectronTheme.ElectronTheme; + const electronWindow = yield* ElectronWindow.ElectronWindow; + + return DesktopWindowIpc.DesktopWindowIpcActions.of({ + getAppBranding: Effect.succeed(environment.branding), + getLocalEnvironmentBootstrap: localEnvironment.bootstrap.pipe(Effect.map(Option.getOrNull)), + pickFolder: (options) => + Effect.gen(function* () { + const selectedPath = yield* electronDialog.pickFolder({ + owner: yield* electronWindow.focusedMainOrFirst, + defaultPath: environment.resolvePickFolderDefaultPath(options), + }); + return Option.getOrNull(selectedPath); + }), + confirm: (message) => + Effect.gen(function* () { + return yield* electronDialog.confirm({ + owner: yield* electronWindow.focusedMainOrFirst, + message, + }); + }), + setTheme: (theme) => electronTheme.setSource(theme), + showContextMenu: ({ items, position }) => + Effect.gen(function* () { + const window = yield* electronWindow.focusedMainOrFirst; + if (Option.isNone(window)) { + return null; + } + + const selectedItemId = yield* electronMenu.showContextMenu({ + window: window.value, + items, + position: Option.fromNullishOr(position), + }); + return Option.getOrNull(selectedItemId); + }), + openExternal: (url) => electronShell.openExternal(url), + }); + }), +); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 4ac8f66d928..dce533ade83 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -23,20 +23,15 @@ import { BrowserWindow, type BrowserWindowConstructorOptions, type MenuItemConstructorOptions, - type OpenDialogOptions, - clipboard, dialog, Menu, - nativeImage, nativeTheme, protocol, safeStorage, - shell, } from "electron"; import { autoUpdater } from "electron-updater"; import type { - ContextMenuItem, DesktopServerExposureMode, DesktopServerExposureState, DesktopUpdateChannel, @@ -56,7 +51,6 @@ import { setDesktopUpdateChannelPreference, writeDesktopSettingsEffect, } from "./desktopSettings.ts"; -import { showDesktopConfirmDialog } from "./confirmDialog.ts"; import { DesktopBackendConfiguration, DesktopBackendEvents, @@ -81,6 +75,10 @@ import { type DesktopEnvironmentShape, } from "./desktopEnvironment.ts"; import * as DesktopSecretStorage from "./electron/DesktopSecretStorage.ts"; +import * as ElectronDialog from "./electron/ElectronDialog.ts"; +import * as ElectronMenu from "./electron/ElectronMenu.ts"; +import * as ElectronShell from "./electron/ElectronShell.ts"; +import * as ElectronTheme from "./electron/ElectronTheme.ts"; import * as DesktopIpc from "./ipc/DesktopIpc.ts"; import * as ElectronWindow from "./electron/ElectronWindow.ts"; import { DesktopShutdown, makeDesktopShutdown } from "./desktopShutdown.ts"; @@ -88,7 +86,7 @@ import { MENU_ACTION_CHANNEL, UPDATE_STATE_CHANNEL } from "./ipc/channels.ts"; import { installDesktopIpcHandlers } from "./ipc/DesktopIpcHandlers.ts"; import { DesktopServerExposureIpcActions } from "./ipc/methods/serverExposure.ts"; import { DesktopUpdateIpcActions } from "./ipc/methods/updates.ts"; -import { DesktopWindowIpcActions } from "./ipc/methods/window.ts"; +import * as DesktopWindowIpcActionsLive from "./ipc/methods/windowLive.ts"; import { resolveDesktopCoreAdvertisedEndpoints, resolveDesktopServerExposure, @@ -122,6 +120,7 @@ import { import { isArm64HostRunningIntelBuild } from "./runtimeArch.ts"; import { bindFirstRevealTrigger, type RevealSubscription } from "./windowReveal.ts"; import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; +import * as DesktopLocalEnvironment from "./main/DesktopLocalEnvironment.ts"; import * as DesktopState from "./main/DesktopState.ts"; const DESKTOP_SCHEME = "t3"; @@ -139,35 +138,6 @@ const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linu const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; -function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextMenuItem[] { - const normalizedItems: ContextMenuItem[] = []; - - for (const sourceItem of source) { - if (typeof sourceItem.id !== "string" || typeof sourceItem.label !== "string") { - continue; - } - - const normalizedItem: ContextMenuItem = { - id: sourceItem.id, - label: sourceItem.label, - destructive: sourceItem.destructive === true, - disabled: sourceItem.disabled === true, - }; - - if (sourceItem.children) { - const normalizedChildren = normalizeContextMenuItems(sourceItem.children); - if (normalizedChildren.length === 0) { - continue; - } - normalizedItem.children = normalizedChildren; - } - - normalizedItems.push(normalizedItem); - } - - return normalizedItems; -} - type WindowTitleBarOptions = Pick< BrowserWindowConstructorOptions, "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition" @@ -185,7 +155,6 @@ let backendPort = 0; let backendBindHost = DESKTOP_LOOPBACK_HOST; let backendBootstrapToken = ""; let backendHttpUrl: Option.Option = Option.none(); -let backendWsUrl = ""; let backendEndpointUrl: string | null = null; let backendAdvertisedHost: string | null = null; let desktopProtocolRegistered = false; @@ -204,8 +173,6 @@ let backendObservabilitySettings: BackendObservabilitySettings = { let desktopSettings = DEFAULT_DESKTOP_SETTINGS; let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode; -let destructiveMenuIconCache: Electron.NativeImage | null | undefined; - interface DesktopEffectRunner { (effect: Effect.Effect): Promise; } @@ -213,6 +180,7 @@ interface DesktopEffectRunner { type DesktopWindowBoundaryServices = | DesktopEnvironment | DesktopSshEnvironmentBridge + | ElectronShell.ElectronShell | DesktopState.DesktopState | ElectronWindow.ElectronWindow; type DesktopLifecycleBoundaryServices = DesktopShutdown | DesktopWindowBoundaryServices; @@ -452,7 +420,6 @@ function applyDesktopServerExposureMode( desktopSettings = setDesktopServerExposurePreference(desktopSettings, requestedMode); backendBindHost = exposure.bindHost; backendHttpUrl = Option.some(new URL(exposure.localHttpUrl)); - backendWsUrl = exposure.localWsUrl; backendEndpointUrl = exposure.endpointUrl; backendAdvertisedHost = exposure.advertisedHost; @@ -526,29 +493,14 @@ function formatErrorMessage(error: unknown): string { return String(error); } -function getSafeExternalUrl(rawUrl: unknown): string | null { - if (typeof rawUrl !== "string" || rawUrl.length === 0) { - return null; - } - - let parsedUrl: URL; - try { - parsedUrl = new URL(rawUrl); - } catch { - return null; - } - - if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") { - return null; - } - - return parsedUrl.toString(); -} - function handleBackendReady( runEffect: DesktopEffectRunner, environment: DesktopEnvironmentShape, -): Effect.Effect { +): Effect.Effect< + void, + never, + DesktopState.DesktopState | ElectronShell.ElectronShell | ElectronWindow.ElectronWindow +> { return Effect.gen(function* () { const state = yield* DesktopState.DesktopState; const electronWindow = yield* ElectronWindow.ElectronWindow; @@ -567,7 +519,11 @@ function handleBackendReady( function createBackendWindowIfReady( runEffect: DesktopEffectRunner, environment: DesktopEnvironmentShape, -): Effect.Effect { +): Effect.Effect< + void, + never, + DesktopState.DesktopState | ElectronShell.ElectronShell | ElectronWindow.ElectronWindow +> { return Effect.gen(function* () { const state = yield* DesktopState.DesktopState; const electronWindow = yield* ElectronWindow.ElectronWindow; @@ -680,6 +636,7 @@ const desktopBackendEventsLayer = Layer.effect( | DesktopEnvironment | DesktopSshEnvironmentBridge | DesktopState.DesktopState + | ElectronShell.ElectronShell | ElectronWindow.ElectronWindow >(); const runEffect = makeDesktopEffectRunner(context); @@ -886,132 +843,6 @@ const desktopUpdateIpcActionsLayer = Layer.effect( }), ); -const desktopWindowIpcActionsLayer = Layer.effect( - DesktopWindowIpcActions, - Effect.gen(function* () { - const environment = yield* DesktopEnvironment; - const electronWindow = yield* ElectronWindow.ElectronWindow; - return DesktopWindowIpcActions.of({ - getAppBranding: Effect.succeed(environment.branding), - getLocalEnvironmentBootstrap: Effect.sync(() => ({ - label: "Local environment", - httpBaseUrl: getBackendHttpUrlHref(), - wsBaseUrl: backendWsUrl || null, - ...(backendBootstrapToken ? { bootstrapToken: backendBootstrapToken } : {}), - })), - pickFolder: (options) => - Effect.gen(function* () { - const owner = Option.getOrUndefined(yield* electronWindow.focusedMainOrFirst); - const defaultPath = Option.getOrUndefined( - environment.resolvePickFolderDefaultPath(options), - ); - const openDialogOptions: OpenDialogOptions = { - properties: ["openDirectory", "createDirectory"], - ...(defaultPath ? { defaultPath } : {}), - }; - const result = yield* Effect.promise(() => - owner - ? dialog.showOpenDialog(owner, openDialogOptions) - : dialog.showOpenDialog(openDialogOptions), - ); - if (result.canceled) return null; - return result.filePaths[0] ?? null; - }), - confirm: (message) => - Effect.gen(function* () { - const owner = Option.getOrUndefined(yield* electronWindow.focusedMainOrFirst); - return yield* Effect.promise(() => showDesktopConfirmDialog(message, owner ?? null)); - }), - setTheme: (theme) => - Effect.sync(() => { - nativeTheme.themeSource = theme; - }), - showContextMenu: ({ items, position }) => - Effect.gen(function* () { - const window = Option.getOrUndefined(yield* electronWindow.focusedMainOrFirst); - if (!window) { - return null; - } - - return yield* Effect.promise( - () => - new Promise((resolve) => { - const normalizedItems = normalizeContextMenuItems(items); - if (normalizedItems.length === 0) { - resolve(null); - return; - } - - const popupPosition = - position && - Number.isFinite(position.x) && - Number.isFinite(position.y) && - position.x >= 0 && - position.y >= 0 - ? { - x: Math.floor(position.x), - y: Math.floor(position.y), - } - : null; - - const buildTemplate = ( - entries: readonly ContextMenuItem[], - ): MenuItemConstructorOptions[] => { - const template: MenuItemConstructorOptions[] = []; - let hasInsertedDestructiveSeparator = false; - for (const item of entries) { - if ( - item.destructive && - !hasInsertedDestructiveSeparator && - template.length > 0 - ) { - template.push({ type: "separator" }); - hasInsertedDestructiveSeparator = true; - } - const itemOption: MenuItemConstructorOptions = { - label: item.label, - enabled: !item.disabled, - }; - if (item.children && item.children.length > 0) { - itemOption.submenu = buildTemplate(item.children); - } else { - itemOption.click = () => resolve(item.id); - } - if (item.destructive && (!item.children || item.children.length === 0)) { - const destructiveIcon = getDestructiveMenuIcon(); - if (destructiveIcon) { - itemOption.icon = destructiveIcon; - } - } - template.push(itemOption); - } - return template; - }; - - const menu = Menu.buildFromTemplate(buildTemplate(normalizedItems)); - menu.popup({ - window, - ...popupPosition, - callback: () => resolve(null), - }); - }), - ); - }), - openExternal: (rawUrl) => { - const externalUrl = getSafeExternalUrl(rawUrl); - if (!externalUrl) { - return Effect.succeed(false); - } - - return Effect.promise(() => shell.openExternal(externalUrl)).pipe( - Effect.as(true), - Effect.catch(() => Effect.succeed(false)), - ); - }, - }); - }), -); - const desktopSecretStorageLayer = Layer.succeed( DesktopSecretStorage.DesktopSecretStorage, DesktopSecretStorage.DesktopSecretStorage.of({ @@ -1034,6 +865,10 @@ const desktopBackendManagerLayer = DesktopBackendManagerLive.pipe( Layer.provide(desktopBackendDependenciesLayer), ); +const desktopBackendRuntimeLayer = DesktopLocalEnvironment.layer.pipe( + Layer.provideMerge(desktopBackendManagerLayer), +); + const desktopElectronWindowLayer = desktopSshEnvironmentBridgeLayer.pipe( Layer.provideMerge(ElectronWindow.layer), ); @@ -1046,39 +881,21 @@ const desktopRuntimeLayer = Layer.mergeAll( Layer.succeed(DesktopIpc.DesktopIpc, DesktopIpc.make(Electron.ipcMain)), desktopServerExposureIpcActionsLayer, desktopUpdateIpcActionsLayer, - desktopWindowIpcActionsLayer, + DesktopWindowIpcActionsLive.layer, desktopSecretStorageLayer, ).pipe( Layer.provideMerge(NodeServices.layer), Layer.provideMerge(NodeHttpClient.layerUndici), Layer.provideMerge(DesktopNetworkInterfacesLive), - Layer.provideMerge(desktopBackendManagerLayer), + Layer.provideMerge(desktopBackendRuntimeLayer), Layer.provideMerge(desktopElectronWindowLayer), + Layer.provideMerge(ElectronDialog.layer), + Layer.provideMerge(ElectronMenu.layer), + Layer.provideMerge(ElectronShell.layer), + Layer.provideMerge(ElectronTheme.layer), Layer.provideMerge(desktopEnvironmentLayer), ); -function getDestructiveMenuIcon(): Electron.NativeImage | undefined { - if (process.platform !== "darwin") return undefined; - if (destructiveMenuIconCache !== undefined) { - return destructiveMenuIconCache ?? undefined; - } - try { - const icon = nativeImage.createFromNamedImage("trash").resize({ - width: 14, - height: 14, - }); - if (icon.isEmpty()) { - destructiveMenuIconCache = null; - return undefined; - } - icon.setTemplateImage(true); - destructiveMenuIconCache = icon; - return icon; - } catch { - destructiveMenuIconCache = null; - return undefined; - } -} let updatePollerScope: Option.Option = Option.none(); let updateCheckInFlight = false; let updateDownloadInFlight = false; @@ -2192,12 +2009,18 @@ function createWindow( menuTemplate.push({ type: "separator" }); } - const externalUrl = getSafeExternalUrl(params.linkURL); - if (externalUrl) { + if (Option.isSome(ElectronShell.parseSafeExternalUrl(params.linkURL))) { menuTemplate.push( { label: "Copy Link", - click: () => clipboard.writeText(params.linkURL), + click: () => { + void runEffect( + Effect.gen(function* () { + const electronShell = yield* ElectronShell.ElectronShell; + yield* electronShell.copyText(params.linkURL); + }), + ); + }, }, { type: "separator" }, ); @@ -2222,9 +2045,13 @@ function createWindow( }); window.webContents.setWindowOpenHandler(({ url }) => { - const externalUrl = getSafeExternalUrl(url); - if (externalUrl) { - void shell.openExternal(externalUrl); + if (Option.isSome(ElectronShell.parseSafeExternalUrl(url))) { + void runEffect( + Effect.gen(function* () { + const electronShell = yield* ElectronShell.ElectronShell; + yield* electronShell.openExternal(url); + }), + ); } return { action: "deny" }; }); diff --git a/apps/desktop/src/main/DesktopLocalEnvironment.test.ts b/apps/desktop/src/main/DesktopLocalEnvironment.test.ts new file mode 100644 index 00000000000..57eca501593 --- /dev/null +++ b/apps/desktop/src/main/DesktopLocalEnvironment.test.ts @@ -0,0 +1,89 @@ +import { assert, describe, it } from "@effect/vitest"; +import { Effect, Layer, Option } from "effect"; + +import * as DesktopBackendManager from "../desktopBackendManager.ts"; +import * as DesktopLocalEnvironment from "./DesktopLocalEnvironment.ts"; + +const backendConfig: DesktopBackendManager.DesktopBackendStartConfig = { + executablePath: "/electron", + entryPath: "/server/bin.mjs", + cwd: "/server", + env: { ELECTRON_RUN_AS_NODE: "1" }, + bootstrap: { + mode: "desktop", + noBrowser: true, + port: 3773, + t3Home: "/tmp/t3", + host: "127.0.0.1", + desktopBootstrapToken: "token", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }, + httpBaseUrl: new URL("http://127.0.0.1:3773"), + captureOutput: true, +}; + +const makeLayer = (currentConfig: Option.Option) => + DesktopLocalEnvironment.layer.pipe( + Layer.provide( + Layer.succeed( + DesktopBackendManager.DesktopBackendManager, + DesktopBackendManager.DesktopBackendManager.of({ + start: Effect.void, + stop: () => Effect.void, + shutdown: Effect.void, + currentConfig: Effect.succeed(currentConfig), + snapshot: Effect.succeed({ + desiredRunning: false, + ready: false, + activePid: Option.none(), + restartAttempt: 0, + restartScheduled: false, + shuttingDown: false, + }), + }), + ), + ), + ); + +describe("DesktopLocalEnvironment", () => { + it.effect("returns none before the backend config has been resolved", () => + Effect.gen(function* () { + const localEnvironment = yield* DesktopLocalEnvironment.DesktopLocalEnvironment; + + assert.isTrue(Option.isNone(yield* localEnvironment.bootstrap)); + }).pipe(Effect.provide(makeLayer(Option.none()))), + ); + + it.effect("derives the local bootstrap from the current backend config", () => + Effect.gen(function* () { + const localEnvironment = yield* DesktopLocalEnvironment.DesktopLocalEnvironment; + const bootstrap = yield* localEnvironment.bootstrap; + + assert.deepEqual(Option.getOrThrow(bootstrap), { + label: "Local environment", + httpBaseUrl: "http://127.0.0.1:3773/", + wsBaseUrl: "ws://127.0.0.1:3773/", + bootstrapToken: "token", + }); + }).pipe(Effect.provide(makeLayer(Option.some(backendConfig)))), + ); + + it.effect("uses wss when the backend base URL is https", () => + Effect.gen(function* () { + const localEnvironment = yield* DesktopLocalEnvironment.DesktopLocalEnvironment; + const bootstrap = yield* localEnvironment.bootstrap; + + assert.equal(Option.getOrThrow(bootstrap).wsBaseUrl, "wss://example.test/"); + }).pipe( + Effect.provide( + makeLayer( + Option.some({ + ...backendConfig, + httpBaseUrl: new URL("https://example.test"), + }), + ), + ), + ), + ); +}); diff --git a/apps/desktop/src/main/DesktopLocalEnvironment.ts b/apps/desktop/src/main/DesktopLocalEnvironment.ts new file mode 100644 index 00000000000..74c32d41e90 --- /dev/null +++ b/apps/desktop/src/main/DesktopLocalEnvironment.ts @@ -0,0 +1,48 @@ +import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as DesktopBackendManager from "../desktopBackendManager.ts"; + +export interface DesktopLocalEnvironmentShape { + readonly bootstrap: Effect.Effect>; +} + +export class DesktopLocalEnvironment extends Context.Service< + DesktopLocalEnvironment, + DesktopLocalEnvironmentShape +>()("t3/desktop/LocalEnvironment") {} + +function toWebSocketBaseUrl(httpBaseUrl: URL): string { + const url = new URL(httpBaseUrl.href); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.href; +} + +export const layer = Layer.effect( + DesktopLocalEnvironment, + Effect.gen(function* () { + const backendManager = yield* DesktopBackendManager.DesktopBackendManager; + + return DesktopLocalEnvironment.of({ + bootstrap: backendManager.currentConfig.pipe( + Effect.map( + // oxlint-disable-next-line oxc/no-map-spread + Option.map((config) => { + const bootstrap = config.bootstrap; + return { + label: "Local environment", + httpBaseUrl: config.httpBaseUrl.href, + wsBaseUrl: toWebSocketBaseUrl(config.httpBaseUrl), + ...(bootstrap.desktopBootstrapToken + ? { bootstrapToken: bootstrap.desktopBootstrapToken } + : {}), + }; + }), + ), + ), + }); + }), +); From 54611492ed40139895c97230ce7343e888e3e152 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 15:10:38 -0700 Subject: [PATCH 11/43] Refactor desktop Electron services - Extract app, protocol, and safeStorage wrappers into Effect services - Move desktop protocol and secret storage wiring out of main.ts - Add tests for Electron app and protocol behavior Co-authored-by: codex --- apps/desktop/src/desktopEnvironment.ts | 6 +- .../src/electron/DesktopSecretStorage.ts | 10 - apps/desktop/src/electron/ElectronApp.test.ts | 125 ++++++ apps/desktop/src/electron/ElectronApp.ts | 124 ++++++ apps/desktop/src/electron/ElectronDialog.ts | 9 + .../src/electron/ElectronProtocol.test.ts | 103 +++++ apps/desktop/src/electron/ElectronProtocol.ts | 246 ++++++++++ .../src/electron/ElectronSafeStorage.ts | 21 + .../src/ipc/methods/savedEnvironments.ts | 18 +- apps/desktop/src/main.ts | 421 +++++++----------- 10 files changed, 799 insertions(+), 284 deletions(-) delete mode 100644 apps/desktop/src/electron/DesktopSecretStorage.ts create mode 100644 apps/desktop/src/electron/ElectronApp.test.ts create mode 100644 apps/desktop/src/electron/ElectronApp.ts create mode 100644 apps/desktop/src/electron/ElectronProtocol.test.ts create mode 100644 apps/desktop/src/electron/ElectronProtocol.ts create mode 100644 apps/desktop/src/electron/ElectronSafeStorage.ts diff --git a/apps/desktop/src/desktopEnvironment.ts b/apps/desktop/src/desktopEnvironment.ts index cebec86cb8d..f969b9c3869 100644 --- a/apps/desktop/src/desktopEnvironment.ts +++ b/apps/desktop/src/desktopEnvironment.ts @@ -107,7 +107,7 @@ export const makeDesktopEnvironment = ( const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; const resourcesPath = input.resourcesPath; - const environment: DesktopEnvironmentShape = { + return DesktopEnvironment.of({ path, dirname: input.dirname, platform: input.platform, @@ -183,7 +183,5 @@ export const makeDesktopEnvironment = ( path.join(resourcesPath, fileName), ], developmentDockIconPath: path.join(rootDir, "assets", "dev", "blueprint-macos-1024.png"), - }; - - return environment; + }); }); diff --git a/apps/desktop/src/electron/DesktopSecretStorage.ts b/apps/desktop/src/electron/DesktopSecretStorage.ts deleted file mode 100644 index cd39a8cacad..00000000000 --- a/apps/desktop/src/electron/DesktopSecretStorage.ts +++ /dev/null @@ -1,10 +0,0 @@ -import * as Context from "effect/Context"; - -import type { DesktopSecretStorage as ClientPersistenceSecretStorage } from "../clientPersistence.ts"; - -export interface DesktopSecretStorageShape extends ClientPersistenceSecretStorage {} - -export class DesktopSecretStorage extends Context.Service< - DesktopSecretStorage, - DesktopSecretStorageShape ->()("@t3tools/desktop/DesktopSecretStorage") {} diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts new file mode 100644 index 00000000000..962a3fbdc1b --- /dev/null +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -0,0 +1,125 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { beforeEach, vi } from "vitest"; + +const { + appendSwitchMock, + exitMock, + getAppPathMock, + getVersionMock, + onMock, + quitMock, + relaunchMock, + removeListenerMock, + setAboutPanelOptionsMock, + setAppUserModelIdMock, + setDesktopNameMock, + setDockIconMock, + setNameMock, + setPathMock, + whenReadyMock, +} = vi.hoisted(() => ({ + appendSwitchMock: vi.fn(), + exitMock: vi.fn(), + getAppPathMock: vi.fn(() => "/app"), + getVersionMock: vi.fn(() => "1.2.3"), + onMock: vi.fn(), + quitMock: vi.fn(), + relaunchMock: vi.fn(), + removeListenerMock: vi.fn(), + setAboutPanelOptionsMock: vi.fn(), + setAppUserModelIdMock: vi.fn(), + setDesktopNameMock: vi.fn(), + setDockIconMock: vi.fn(), + setNameMock: vi.fn(), + setPathMock: vi.fn(), + whenReadyMock: vi.fn(() => Promise.resolve()), +})); + +vi.mock("electron", () => ({ + app: { + commandLine: { + appendSwitch: appendSwitchMock, + }, + dock: { + setIcon: setDockIconMock, + }, + getAppPath: getAppPathMock, + getVersion: getVersionMock, + isPackaged: true, + name: "T3 Code", + on: onMock, + quit: quitMock, + relaunch: relaunchMock, + removeListener: removeListenerMock, + runningUnderARM64Translation: false, + setAboutPanelOptions: setAboutPanelOptionsMock, + setAppUserModelId: setAppUserModelIdMock, + setDesktopName: setDesktopNameMock, + setName: setNameMock, + setPath: setPathMock, + whenReady: whenReadyMock, + exit: exitMock, + }, +})); + +import * as ElectronApp from "./ElectronApp.ts"; + +describe("ElectronApp", () => { + beforeEach(() => { + appendSwitchMock.mockClear(); + exitMock.mockClear(); + onMock.mockClear(); + quitMock.mockClear(); + relaunchMock.mockClear(); + removeListenerMock.mockClear(); + setPathMock.mockClear(); + }); + + it.effect("reads app metadata through the service", () => + Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + const metadata = yield* electronApp.metadata; + + assert.deepEqual(metadata, { + appVersion: "1.2.3", + appPath: "/app", + isPackaged: true, + resourcesPath: process.resourcesPath, + runningUnderArm64Translation: false, + }); + }).pipe(Effect.provide(ElectronApp.layer)), + ); + + it.effect("scopes app event listeners", () => + Effect.gen(function* () { + const listener = vi.fn(); + + yield* Effect.scoped( + Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + yield* electronApp.on("activate", listener); + }), + ); + + assert.deepEqual(onMock.mock.calls, [["activate", listener]]); + assert.deepEqual(removeListenerMock.mock.calls, [["activate", listener]]); + }).pipe(Effect.provide(ElectronApp.layer)), + ); + + it.effect("wraps app lifecycle operations", () => + Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + + yield* electronApp.setPath("userData", "/tmp/t3code"); + yield* electronApp.appendCommandLineSwitch("class", "t3code"); + yield* electronApp.relaunch({ execPath: "/electron", args: ["main.js"] }); + yield* electronApp.exit(0); + + assert.deepEqual(setPathMock.mock.calls, [["userData", "/tmp/t3code"]]); + assert.deepEqual(appendSwitchMock.mock.calls, [["class", "t3code"]]); + assert.deepEqual(relaunchMock.mock.calls, [[{ execPath: "/electron", args: ["main.js"] }]]); + assert.deepEqual(exitMock.mock.calls, [[0]]); + }).pipe(Effect.provide(ElectronApp.layer)), + ); +}); diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts new file mode 100644 index 00000000000..1dfec7e573a --- /dev/null +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -0,0 +1,124 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; + +import * as Electron from "electron"; + +export interface ElectronAppMetadata { + readonly appVersion: string; + readonly appPath: string; + readonly isPackaged: boolean; + readonly resourcesPath: string; + readonly runningUnderArm64Translation: boolean; +} + +export interface ElectronAppShape { + readonly metadata: Effect.Effect; + readonly name: Effect.Effect; + readonly whenReady: Effect.Effect; + readonly quit: Effect.Effect; + readonly exit: (code: number) => Effect.Effect; + readonly relaunch: (options: Electron.RelaunchOptions) => Effect.Effect; + readonly setPath: ( + name: Parameters[0], + path: string, + ) => Effect.Effect; + readonly setName: (name: string) => Effect.Effect; + readonly setAboutPanelOptions: ( + options: Electron.AboutPanelOptionsOptions, + ) => Effect.Effect; + readonly setAppUserModelId: (id: string) => Effect.Effect; + readonly setDesktopName: (desktopName: string) => Effect.Effect; + readonly setDockIcon: (iconPath: string) => Effect.Effect; + readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect; + readonly on: >( + eventName: string, + listener: (...args: Args) => void, + ) => Effect.Effect; +} + +export class ElectronApp extends Context.Service()( + "t3/desktop/electron/App", +) {} + +const addScopedAppListener = >( + eventName: string, + listener: (...args: Args) => void, +): Effect.Effect => { + const app = Electron.app as { + on: (eventName: string, listener: (...args: Array) => void) => unknown; + removeListener: (eventName: string, listener: (...args: Array) => void) => unknown; + }; + const untypedListener = listener as unknown as (...args: Array) => void; + return Effect.acquireRelease( + Effect.sync(() => { + app.on(eventName, untypedListener); + }), + () => + Effect.sync(() => { + app.removeListener(eventName, untypedListener); + }), + ).pipe(Effect.asVoid); +}; + +const make = ElectronApp.of({ + metadata: Effect.sync(() => ({ + appVersion: Electron.app.getVersion(), + appPath: Electron.app.getAppPath(), + isPackaged: Electron.app.isPackaged, + resourcesPath: process.resourcesPath, + runningUnderArm64Translation: Electron.app.runningUnderARM64Translation === true, + })), + name: Effect.sync(() => Electron.app.name), + whenReady: Effect.promise(() => Electron.app.whenReady()).pipe(Effect.asVoid), + quit: Effect.sync(() => { + Electron.app.quit(); + }), + exit: (code) => + Effect.sync(() => { + Electron.app.exit(code); + }), + relaunch: (options) => + Effect.sync(() => { + Electron.app.relaunch(options); + }), + setPath: (name, path) => + Effect.sync(() => { + Electron.app.setPath(name, path); + }), + setName: (name) => + Effect.sync(() => { + Electron.app.setName(name); + }), + setAboutPanelOptions: (options) => + Effect.sync(() => { + Electron.app.setAboutPanelOptions(options); + }), + setAppUserModelId: (id) => + Effect.sync(() => { + Electron.app.setAppUserModelId(id); + }), + setDesktopName: (desktopName) => + Effect.sync(() => { + const linuxApp = Electron.app as Electron.App & { + setDesktopName?: (desktopName: string) => void; + }; + linuxApp.setDesktopName?.(desktopName); + }), + setDockIcon: (iconPath) => + Effect.sync(() => { + Electron.app.dock?.setIcon(iconPath); + }), + appendCommandLineSwitch: (switchName, value) => + Effect.sync(() => { + if (value === undefined) { + Electron.app.commandLine.appendSwitch(switchName); + return; + } + Electron.app.commandLine.appendSwitch(switchName, value); + }), + on: addScopedAppListener, +}); + +export const layer = Layer.succeed(ElectronApp, make); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index c8e734dfa24..b0fee9cabf3 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -22,6 +22,10 @@ export interface ElectronDialogShape { input: ElectronDialogPickFolderInput, ) => Effect.Effect>; readonly confirm: (input: ElectronDialogConfirmInput) => Effect.Effect; + readonly showMessageBox: ( + options: Electron.MessageBoxOptions, + ) => Effect.Effect; + readonly showErrorBox: (title: string, content: string) => Effect.Effect; } export class ElectronDialog extends Context.Service()( @@ -53,6 +57,11 @@ const make = ElectronDialog.of({ }), confirm: (input) => Effect.promise(() => showDesktopConfirmDialog(input.message, Option.getOrNull(input.owner))), + showMessageBox: (options) => Effect.promise(() => Electron.dialog.showMessageBox(options)), + showErrorBox: (title, content) => + Effect.sync(() => { + Electron.dialog.showErrorBox(title, content); + }), }); export const layer = Layer.succeed(ElectronDialog, make); diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts new file mode 100644 index 00000000000..327053ddd60 --- /dev/null +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -0,0 +1,103 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import type * as Electron from "electron"; +import { beforeEach, vi } from "vitest"; + +const { registerFileProtocolMock, registerSchemesAsPrivilegedMock, unregisterProtocolMock } = + vi.hoisted(() => ({ + registerFileProtocolMock: vi.fn(), + registerSchemesAsPrivilegedMock: vi.fn(), + unregisterProtocolMock: vi.fn(), + })); + +vi.mock("electron", () => ({ + protocol: { + registerFileProtocol: registerFileProtocolMock, + registerSchemesAsPrivileged: registerSchemesAsPrivilegedMock, + unregisterProtocol: unregisterProtocolMock, + }, +})); + +import * as ElectronProtocol from "./ElectronProtocol.ts"; + +describe("ElectronProtocol", () => { + beforeEach(() => { + registerFileProtocolMock.mockReset(); + registerSchemesAsPrivilegedMock.mockReset(); + unregisterProtocolMock.mockReset(); + }); + + it("normalizes safe desktop protocol pathnames", () => { + assert.equal( + Option.getOrNull(ElectronProtocol.normalizeDesktopProtocolPathname("/settings/./general")), + "settings/general", + ); + assert.isTrue(Option.isNone(ElectronProtocol.normalizeDesktopProtocolPathname("/../secret"))); + }); + + it.effect("registers the desktop scheme privileges", () => + Effect.gen(function* () { + const electronProtocol = yield* ElectronProtocol.ElectronProtocol; + + yield* electronProtocol.registerDesktopSchemePrivileges; + + assert.deepEqual(registerSchemesAsPrivilegedMock.mock.calls, [ + [ + [ + { + scheme: "t3", + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + }, + }, + ], + ], + ]); + }).pipe(Effect.provide(ElectronProtocol.layer)), + ); + + it.effect("scopes registered file protocols", () => + Effect.gen(function* () { + let capturedHandler: + | (( + request: Electron.ProtocolRequest, + callback: (response: Electron.ProtocolResponse) => void, + ) => void) + | undefined; + + registerFileProtocolMock.mockImplementation((_scheme, handler) => { + capturedHandler = handler; + return true; + }); + + const response = yield* Effect.scoped( + Effect.gen(function* () { + const electronProtocol = yield* ElectronProtocol.ElectronProtocol; + yield* electronProtocol.registerFileProtocol({ + scheme: "t3", + handler: () => Effect.succeed({ path: "/app/index.html" }), + }); + + assert.isDefined(capturedHandler); + return yield* Effect.promise( + () => + new Promise((resolve) => { + capturedHandler?.({ url: "t3://app/" } as Electron.ProtocolRequest, resolve); + }), + ); + }), + ); + + assert.deepEqual(response, { path: "/app/index.html" }); + assert.deepEqual( + registerFileProtocolMock.mock.calls.map((call) => call[0]), + ["t3"], + ); + assert.deepEqual(unregisterProtocolMock.mock.calls, [["t3"]]); + }).pipe(Effect.provide(ElectronProtocol.layer)), + ); +}); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts new file mode 100644 index 00000000000..063e63e90df --- /dev/null +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -0,0 +1,246 @@ +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; + +import * as Electron from "electron"; + +import { DesktopEnvironment, type DesktopEnvironmentShape } from "../desktopEnvironment.ts"; + +export const DESKTOP_SCHEME = "t3"; + +export interface ElectronProtocolShape { + readonly registerDesktopSchemePrivileges: Effect.Effect; + readonly registerFileProtocol: (input: { + readonly scheme: string; + readonly handler: ( + request: Electron.ProtocolRequest, + ) => Effect.Effect; + readonly onFailure?: ( + request: Electron.ProtocolRequest, + cause: Cause.Cause, + ) => Electron.ProtocolResponse; + }) => Effect.Effect; + readonly registerDesktopFileProtocol: Effect.Effect< + void, + unknown, + FileSystem.FileSystem | DesktopEnvironment | Scope.Scope + >; +} + +export class ElectronProtocol extends Context.Service()( + "t3/desktop/electron/Protocol", +) {} + +export function normalizeDesktopProtocolPathname(rawPath: string): Option.Option { + const segments: string[] = []; + for (const segment of rawPath.split("/")) { + if (segment.length === 0 || segment === ".") { + continue; + } + if (segment === "..") { + return Option.none(); + } + segments.push(segment); + } + return Option.some(segments.join("/")); +} + +const registerDesktopSchemePrivileges = Effect.sync(() => { + Electron.protocol.registerSchemesAsPrivileged([ + { + scheme: DESKTOP_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + }, + }, + ]); +}); + +const resolveDesktopStaticDir: Effect.Effect< + Option.Option, + never, + FileSystem.FileSystem | DesktopEnvironment +> = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment; + const candidates = [ + environment.path.join(environment.appRoot, "apps/server/dist/client"), + environment.path.join(environment.appRoot, "apps/web/dist"), + ]; + for (const candidate of candidates) { + const hasIndex = yield* fileSystem + .exists(environment.path.join(candidate, "index.html")) + .pipe(Effect.orElseSucceed(() => false)); + if (hasIndex) { + return Option.some(candidate); + } + } + return Option.none(); +}); + +function resolveDesktopStaticPath( + staticRoot: string, + requestUrl: string, +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment; + const url = new URL(requestUrl); + const rawPath = decodeURIComponent(url.pathname); + const normalizedPath = normalizeDesktopProtocolPathname(rawPath); + if (Option.isNone(normalizedPath)) { + return environment.path.join(staticRoot, "index.html"); + } + + const requestedPath = normalizedPath.value.length > 0 ? normalizedPath.value : "index.html"; + const resolvedPath = environment.path.join(staticRoot, requestedPath); + + if (environment.path.extname(resolvedPath)) { + return resolvedPath; + } + + const nestedIndex = environment.path.join(resolvedPath, "index.html"); + const nestedIndexExists = yield* fileSystem + .exists(nestedIndex) + .pipe(Effect.orElseSucceed(() => false)); + if (nestedIndexExists) { + return nestedIndex; + } + + return environment.path.join(staticRoot, "index.html"); + }); +} + +function isStaticAssetRequest(requestUrl: string, environment: DesktopEnvironmentShape): boolean { + try { + const url = new URL(requestUrl); + return environment.path.extname(url.pathname).length > 0; + } catch { + return false; + } +} + +const make = Effect.gen(function* () { + const registeredProtocols = yield* Ref.make>(new Set()); + + const registerFileProtocol = ({ + scheme, + handler, + onFailure, + }: { + readonly scheme: string; + readonly handler: ( + request: Electron.ProtocolRequest, + ) => Effect.Effect; + readonly onFailure?: ( + request: Electron.ProtocolRequest, + cause: Cause.Cause, + ) => Electron.ProtocolResponse; + }): Effect.Effect => + Effect.gen(function* () { + const alreadyRegistered = yield* Ref.get(registeredProtocols).pipe( + Effect.map((protocols) => protocols.has(scheme)), + ); + if (alreadyRegistered) { + return; + } + + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); + + yield* Effect.acquireRelease( + Effect.try({ + try: () => { + const registered = Electron.protocol.registerFileProtocol( + scheme, + (request, callback) => { + const response = handler(request).pipe( + Effect.catchCause((cause) => + Effect.succeed(onFailure?.(request, cause) ?? ({ error: -2 } as const)), + ), + ); + + void runPromise(response).then(callback, () => callback({ error: -2 })); + }, + ); + if (!registered) { + throw new Error(`Failed to register ${scheme}: file protocol.`); + } + }, + catch: (error) => error, + }).pipe( + Effect.andThen( + Ref.update(registeredProtocols, (protocols) => new Set(protocols).add(scheme)), + ), + ), + () => + Effect.sync(() => { + Electron.protocol.unregisterProtocol(scheme); + }).pipe( + Effect.andThen( + Ref.update(registeredProtocols, (protocols) => { + const next = new Set(protocols); + next.delete(scheme); + return next; + }), + ), + ), + ); + }); + + const registerDesktopFileProtocol = Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + if (environment.isDevelopment) return; + + const staticRoot = yield* resolveDesktopStaticDir; + if (Option.isNone(staticRoot)) { + return yield* Effect.fail( + new Error("Desktop static bundle missing. Build apps/server (with bundled client) first."), + ); + } + + const staticRootResolved = environment.path.resolve(staticRoot.value); + const staticRootPrefix = `${staticRootResolved}${environment.path.sep}`; + const fallbackIndex = environment.path.join(staticRootResolved, "index.html"); + + yield* registerFileProtocol({ + scheme: DESKTOP_SCHEME, + handler: (request) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment; + const candidate = yield* resolveDesktopStaticPath(staticRootResolved, request.url); + const resolvedCandidate = environment.path.resolve(candidate); + const isInRoot = + resolvedCandidate === fallbackIndex || resolvedCandidate.startsWith(staticRootPrefix); + const isAssetRequest = isStaticAssetRequest(request.url, environment); + const exists = yield* fileSystem + .exists(resolvedCandidate) + .pipe(Effect.orElseSucceed(() => false)); + + if (!isInRoot || !exists) { + return isAssetRequest ? ({ error: -6 } as const) : ({ path: fallbackIndex } as const); + } + + return { path: resolvedCandidate } as const; + }), + onFailure: () => ({ path: fallbackIndex }), + }); + }); + + return ElectronProtocol.of({ + registerDesktopSchemePrivileges, + registerFileProtocol, + registerDesktopFileProtocol, + }); +}); + +export const layer = Layer.effect(ElectronProtocol, make); diff --git a/apps/desktop/src/electron/ElectronSafeStorage.ts b/apps/desktop/src/electron/ElectronSafeStorage.ts new file mode 100644 index 00000000000..087bc797a69 --- /dev/null +++ b/apps/desktop/src/electron/ElectronSafeStorage.ts @@ -0,0 +1,21 @@ +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; + +import * as Electron from "electron"; + +import type { DesktopSecretStorage as ClientPersistenceSecretStorage } from "../clientPersistence.ts"; + +export interface ElectronSafeStorageShape extends ClientPersistenceSecretStorage {} + +export class ElectronSafeStorage extends Context.Service< + ElectronSafeStorage, + ElectronSafeStorageShape +>()("@t3tools/desktop/ElectronSafeStorage") {} + +const make = ElectronSafeStorage.of({ + isEncryptionAvailable: () => Electron.safeStorage.isEncryptionAvailable(), + encryptString: (value) => Electron.safeStorage.encryptString(value), + decryptString: (value) => Electron.safeStorage.decryptString(value), +}); + +export const layer = Layer.succeed(ElectronSafeStorage, make); diff --git a/apps/desktop/src/ipc/methods/savedEnvironments.ts b/apps/desktop/src/ipc/methods/savedEnvironments.ts index a350f49bf31..24ec8dae507 100644 --- a/apps/desktop/src/ipc/methods/savedEnvironments.ts +++ b/apps/desktop/src/ipc/methods/savedEnvironments.ts @@ -9,8 +9,8 @@ import { writeSavedEnvironmentRegistryEffect, writeSavedEnvironmentSecretEffect, } from "../../clientPersistence.ts"; -import { DesktopEnvironment } from "../../desktopEnvironment.ts"; -import { DesktopSecretStorage } from "../../electron/DesktopSecretStorage.ts"; +import * as DesktopEnvironment from "../../desktopEnvironment.ts"; +import * as ElectronSafeStorage from "../../electron/ElectronSafeStorage.ts"; import { GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, @@ -38,7 +38,7 @@ export const getSavedEnvironmentRegistry = makeIpcMethod({ result: SavedEnvironmentRegistryPayload, handler: () => Effect.gen(function* () { - const environment = yield* DesktopEnvironment; + const environment = yield* DesktopEnvironment.DesktopEnvironment; return yield* readSavedEnvironmentRegistryEffect(environment.savedEnvironmentRegistryPath); }), }); @@ -49,7 +49,7 @@ export const setSavedEnvironmentRegistry = makeIpcMethod({ result: Schema.Void, handler: (records) => Effect.gen(function* () { - const environment = yield* DesktopEnvironment; + const environment = yield* DesktopEnvironment.DesktopEnvironment; yield* writeSavedEnvironmentRegistryEffect(environment.savedEnvironmentRegistryPath, records); }), }); @@ -60,8 +60,8 @@ export const getSavedEnvironmentSecret = makeIpcMethod({ result: Schema.NullOr(Schema.String), handler: (environmentId) => Effect.gen(function* () { - const environment = yield* DesktopEnvironment; - const secretStorage = yield* DesktopSecretStorage; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const secretStorage = yield* ElectronSafeStorage.ElectronSafeStorage; return yield* readSavedEnvironmentSecretEffect({ registryPath: environment.savedEnvironmentRegistryPath, environmentId, @@ -76,8 +76,8 @@ export const setSavedEnvironmentSecret = makeIpcMethod({ result: Schema.Boolean, handler: ({ environmentId, secret }) => Effect.gen(function* () { - const environment = yield* DesktopEnvironment; - const secretStorage = yield* DesktopSecretStorage; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const secretStorage = yield* ElectronSafeStorage.ElectronSafeStorage; return yield* writeSavedEnvironmentSecretEffect({ registryPath: environment.savedEnvironmentRegistryPath, environmentId, @@ -93,7 +93,7 @@ export const removeSavedEnvironmentSecret = makeIpcMethod({ result: Schema.Void, handler: (environmentId) => Effect.gen(function* () { - const environment = yield* DesktopEnvironment; + const environment = yield* DesktopEnvironment.DesktopEnvironment; yield* removeSavedEnvironmentSecretEffect({ registryPath: environment.savedEnvironmentRegistryPath, environmentId, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index dce533ade83..d56d6e18699 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -19,15 +19,12 @@ import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { - app, BrowserWindow, type BrowserWindowConstructorOptions, + ipcMain, type MenuItemConstructorOptions, - dialog, Menu, nativeTheme, - protocol, - safeStorage, } from "electron"; import { autoUpdater } from "electron-updater"; @@ -74,9 +71,11 @@ import { makeDesktopEnvironment, type DesktopEnvironmentShape, } from "./desktopEnvironment.ts"; -import * as DesktopSecretStorage from "./electron/DesktopSecretStorage.ts"; +import * as DesktopSecretStorage from "./electron/ElectronSafeStorage.ts"; +import * as ElectronApp from "./electron/ElectronApp.ts"; import * as ElectronDialog from "./electron/ElectronDialog.ts"; import * as ElectronMenu from "./electron/ElectronMenu.ts"; +import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; import * as ElectronShell from "./electron/ElectronShell.ts"; import * as ElectronTheme from "./electron/ElectronTheme.ts"; import * as DesktopIpc from "./ipc/DesktopIpc.ts"; @@ -123,7 +122,6 @@ import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider import * as DesktopLocalEnvironment from "./main/DesktopLocalEnvironment.ts"; import * as DesktopState from "./main/DesktopState.ts"; -const DESKTOP_SCHEME = "t3"; const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i; const COMMIT_HASH_DISPLAY_LENGTH = 12; const AppPackageMetadata = Schema.Struct({ @@ -144,9 +142,6 @@ type WindowTitleBarOptions = Pick< >; type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; -type LinuxDesktopNamedApp = Electron.App & { - setDesktopName?: (desktopName: string) => void; -}; interface BackendObservabilitySettings { readonly otlpTracesUrl: string | undefined; readonly otlpMetricsUrl: string | undefined; @@ -157,7 +152,6 @@ let backendBootstrapToken = ""; let backendHttpUrl: Option.Option = Option.none(); let backendEndpointUrl: string | null = null; let backendAdvertisedHost: string | null = null; -let desktopProtocolRegistered = false; let appUpdateYmlConfig: Option.Option> = Option.none(); let aboutCommitHashCache: Option.Option | undefined; let desktopIconPaths: Readonly>> = { @@ -180,10 +174,14 @@ interface DesktopEffectRunner { type DesktopWindowBoundaryServices = | DesktopEnvironment | DesktopSshEnvironmentBridge + | ElectronDialog.ElectronDialog | ElectronShell.ElectronShell | DesktopState.DesktopState | ElectronWindow.ElectronWindow; -type DesktopLifecycleBoundaryServices = DesktopShutdown | DesktopWindowBoundaryServices; +type DesktopLifecycleBoundaryServices = + | DesktopShutdown + | DesktopWindowBoundaryServices + | ElectronApp.ElectronApp; function makeDesktopEffectRunner(context: Context.Context): DesktopEffectRunner { return (effect: Effect.Effect) => @@ -438,6 +436,7 @@ function applyDesktopTailscaleServeEnabled( unknown, | FileSystem.FileSystem | EffectPath.Path + | ElectronApp.ElectronApp | DesktopEnvironment | DesktopShutdown | DesktopState.DesktopState @@ -457,12 +456,17 @@ function applyDesktopTailscaleServeEnabled( function relaunchDesktopAppEffect( reason: string, -): Effect.Effect { +): Effect.Effect< + void, + never, + ElectronApp.ElectronApp | DesktopEnvironment | DesktopShutdown | DesktopState.DesktopState +> { return Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; const environment = yield* DesktopEnvironment; const state = yield* DesktopState.DesktopState; const context = yield* Effect.context< - DesktopEnvironment | DesktopShutdown | DesktopState.DesktopState + ElectronApp.ElectronApp | DesktopEnvironment | DesktopShutdown | DesktopState.DesktopState >(); const runEffect = makeDesktopEffectRunner(context); yield* logDesktopInfo("desktop relaunch requested", { reason }); @@ -472,14 +476,17 @@ function relaunchDesktopAppEffect( Ref.set(state.quitting, true).pipe(Effect.andThen(requestDesktopShutdownAndWait())), ).finally(() => { if (environment.isDevelopment) { - app.exit(75); + void runEffect(electronApp.exit(75)); return; } - app.relaunch({ - execPath: process.execPath, - args: process.argv.slice(1), - }); - app.exit(0); + void runEffect( + electronApp + .relaunch({ + execPath: process.execPath, + args: process.argv.slice(1), + }) + .pipe(Effect.andThen(electronApp.exit(0))), + ); }); }); }); @@ -587,19 +594,19 @@ const randomHexString = (length: number): Effect.Effect => const desktopEnvironmentLayer = Layer.effect( DesktopEnvironment, - makeDesktopEnvironment({ - dirname: __dirname, - env: process.env, - cwd: process.cwd(), - platform: process.platform, - processArch: process.arch, - appVersion: app.getVersion(), - appPath: app.getAppPath(), - isPackaged: app.isPackaged, - resourcesPath: process.resourcesPath, - runningUnderArm64Translation: app.runningUnderARM64Translation === true, + Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + const metadata = yield* electronApp.metadata; + return yield* makeDesktopEnvironment({ + dirname: __dirname, + env: process.env, + cwd: process.cwd(), + platform: process.platform, + processArch: process.arch, + ...metadata, + }); }), -).pipe(Layer.provide(EffectPath.layer)); +).pipe(Layer.provide(Layer.mergeAll(EffectPath.layer, ElectronApp.layer))); const desktopLoggerLayer = DesktopLoggerLive.pipe(Layer.provide(NodeServices.layer)); @@ -713,6 +720,7 @@ const desktopShellEnvironmentLayer = DesktopShellEnvironmentLive.pipe( type DesktopServerExposureIpcActionServices = | FileSystem.FileSystem | EffectPath.Path + | ElectronApp.ElectronApp | DesktopEnvironment | DesktopState.DesktopState | DesktopNetworkInterfacesService @@ -843,15 +851,6 @@ const desktopUpdateIpcActionsLayer = Layer.effect( }), ); -const desktopSecretStorageLayer = Layer.succeed( - DesktopSecretStorage.DesktopSecretStorage, - DesktopSecretStorage.DesktopSecretStorage.of({ - isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(), - encryptString: (value) => safeStorage.encryptString(value), - decryptString: (value) => safeStorage.decryptString(value), - }), -); - const desktopBackendDependenciesLayer = Layer.mergeAll( NodeServices.layer, NodeHttpClient.layerUndici, @@ -878,19 +877,21 @@ const desktopRuntimeLayer = Layer.mergeAll( NetService.layer, desktopShellEnvironmentLayer, desktopSshEnvironmentLayer, - Layer.succeed(DesktopIpc.DesktopIpc, DesktopIpc.make(Electron.ipcMain)), + Layer.succeed(DesktopIpc.DesktopIpc, DesktopIpc.make(ipcMain)), desktopServerExposureIpcActionsLayer, desktopUpdateIpcActionsLayer, DesktopWindowIpcActionsLive.layer, - desktopSecretStorageLayer, + DesktopSecretStorage.layer, ).pipe( Layer.provideMerge(NodeServices.layer), Layer.provideMerge(NodeHttpClient.layerUndici), Layer.provideMerge(DesktopNetworkInterfacesLive), Layer.provideMerge(desktopBackendRuntimeLayer), Layer.provideMerge(desktopElectronWindowLayer), + Layer.provideMerge(ElectronApp.layer), Layer.provideMerge(ElectronDialog.layer), Layer.provideMerge(ElectronMenu.layer), + Layer.provideMerge(ElectronProtocol.layer), Layer.provideMerge(ElectronShell.layer), Layer.provideMerge(ElectronTheme.layer), Layer.provideMerge(desktopEnvironmentLayer), @@ -939,18 +940,6 @@ function addScopedListener>( ).pipe(Effect.asVoid); } -protocol.registerSchemesAsPrivileged([ - { - scheme: DESKTOP_SCHEME, - privileges: { - standard: true, - secure: true, - supportFetchAPI: true, - corsEnabled: true, - }, - }, -]); - function parseAppUpdateYml(raw: string): Option.Option> { // The YAML is simple key-value pairs — avoid pulling in a YAML parser by // doing a line-based parse (fields: provider, owner, repo, releaseType, ...). @@ -1048,93 +1037,22 @@ function resolveAboutCommitHash(): Effect.Effect< }); } -function resolveDesktopStaticDir(): Effect.Effect< - Option.Option, - never, - FileSystem.FileSystem | DesktopEnvironment -> { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const environment = yield* DesktopEnvironment; - const candidates = [ - environment.path.join(environment.appRoot, "apps/server/dist/client"), - environment.path.join(environment.appRoot, "apps/web/dist"), - ]; - for (const candidate of candidates) { - const hasIndex = yield* fileSystem - .exists(environment.path.join(candidate, "index.html")) - .pipe(Effect.orElseSucceed(() => false)); - if (hasIndex) { - return Option.some(candidate); - } - } - return Option.none(); - }); -} - -function normalizeDesktopProtocolPathname(rawPath: string): Option.Option { - const segments: string[] = []; - for (const segment of rawPath.split("/")) { - if (segment.length === 0 || segment === ".") { - continue; - } - if (segment === "..") { - return Option.none(); - } - segments.push(segment); - } - return Option.some(segments.join("/")); -} - -function resolveDesktopStaticPath( - staticRoot: string, - requestUrl: string, -): Effect.Effect { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const environment = yield* DesktopEnvironment; - const url = new URL(requestUrl); - const rawPath = decodeURIComponent(url.pathname); - const normalizedPath = normalizeDesktopProtocolPathname(rawPath); - if (Option.isNone(normalizedPath)) { - return environment.path.join(staticRoot, "index.html"); - } - - const requestedPath = normalizedPath.value.length > 0 ? normalizedPath.value : "index.html"; - const resolvedPath = environment.path.join(staticRoot, requestedPath); - - if (environment.path.extname(resolvedPath)) { - return resolvedPath; - } - - const nestedIndex = environment.path.join(resolvedPath, "index.html"); - const nestedIndexExists = yield* fileSystem - .exists(nestedIndex) - .pipe(Effect.orElseSucceed(() => false)); - if (nestedIndexExists) { - return nestedIndex; - } - - return environment.path.join(staticRoot, "index.html"); - }); -} - -function isStaticAssetRequest(requestUrl: string, environment: DesktopEnvironmentShape): boolean { - try { - const url = new URL(requestUrl); - return environment.path.extname(url.pathname).length > 0; - } catch { - return false; - } -} - function handleFatalStartupError( stage: string, error: unknown, -): Effect.Effect { +): Effect.Effect< + void, + never, + | DesktopShutdown + | DesktopState.DesktopState + | ElectronApp.ElectronApp + | ElectronDialog.ElectronDialog +> { return Effect.gen(function* () { const shutdown = yield* DesktopShutdown; const state = yield* DesktopState.DesktopState; + const electronApp = yield* ElectronApp.ElectronApp; + const electronDialog = yield* ElectronDialog.ElectronDialog; const message = formatErrorMessage(error); const detail = error instanceof Error && typeof error.stack === "string" ? `\n${error.stack}` : ""; @@ -1144,68 +1062,25 @@ function handleFatalStartupError( ...(detail.length > 0 ? { detail } : {}), }); const wasQuitting = yield* Ref.getAndSet(state.quitting, true); - yield* Effect.sync(() => { - if (!wasQuitting) { - dialog.showErrorBox("T3 Code failed to start", `Stage: ${stage}\n${message}${detail}`); - } - }); + if (!wasQuitting) { + yield* electronDialog.showErrorBox( + "T3 Code failed to start", + `Stage: ${stage}\n${message}${detail}`, + ); + } yield* shutdown.request; - yield* Effect.sync(() => { - app.quit(); - }); + yield* electronApp.quit; }); } function registerDesktopProtocol(): Effect.Effect< void, unknown, - FileSystem.FileSystem | DesktopEnvironment + FileSystem.FileSystem | DesktopEnvironment | ElectronProtocol.ElectronProtocol | Scope.Scope > { return Effect.gen(function* () { - const environment = yield* DesktopEnvironment; - if (environment.isDevelopment || desktopProtocolRegistered) return; - const context = yield* Effect.context(); - const runProtocolEffect = makeDesktopEffectRunner(context); - - const staticRoot = yield* resolveDesktopStaticDir(); - if (Option.isNone(staticRoot)) { - return yield* Effect.fail( - new Error("Desktop static bundle missing. Build apps/server (with bundled client) first."), - ); - } - - const staticRootResolved = environment.path.resolve(staticRoot.value); - const staticRootPrefix = `${staticRootResolved}${environment.path.sep}`; - const fallbackIndex = environment.path.join(staticRootResolved, "index.html"); - - yield* Effect.sync(() => { - protocol.registerFileProtocol(DESKTOP_SCHEME, (request, callback) => { - const resolution = Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const candidate = yield* resolveDesktopStaticPath(staticRootResolved, request.url); - const environment = yield* DesktopEnvironment; - const resolvedCandidate = environment.path.resolve(candidate); - const isInRoot = - resolvedCandidate === fallbackIndex || resolvedCandidate.startsWith(staticRootPrefix); - const isAssetRequest = isStaticAssetRequest(request.url, environment); - const exists = yield* fileSystem - .exists(resolvedCandidate) - .pipe(Effect.orElseSucceed(() => false)); - - if (!isInRoot || !exists) { - return isAssetRequest ? ({ error: -6 } as const) : ({ path: fallbackIndex } as const); - } - - return { path: resolvedCandidate } as const; - }).pipe(Effect.catch(() => Effect.succeed({ path: fallbackIndex } as const))); - - void runProtocolEffect(resolution).then(callback, () => { - callback({ path: fallbackIndex }); - }); - }); - - desktopProtocolRegistered = true; - }); + const electronProtocol = yield* ElectronProtocol.ElectronProtocol; + yield* electronProtocol.registerDesktopFileProtocol; }); } @@ -1245,7 +1120,7 @@ function handleCheckForUpdatesMenuClick( ): void { const disabledReason = getAutoUpdateDisabledReason({ isDevelopment: environment.isDevelopment, - isPackaged: app.isPackaged, + isPackaged: environment.isPackaged, platform: process.platform, appImage: process.env.APPIMAGE, disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1", @@ -1257,13 +1132,18 @@ function handleCheckForUpdatesMenuClick( disabledReason, }), ); - void dialog.showMessageBox({ - type: "info", - title: "Updates unavailable", - message: "Automatic updates are not available right now.", - detail: disabledReason, - buttons: ["OK"], - }); + void runEffect( + Effect.gen(function* () { + const electronDialog = yield* ElectronDialog.ElectronDialog; + yield* electronDialog.showMessageBox({ + type: "info", + title: "Updates unavailable", + message: "Automatic updates are not available right now.", + detail: disabledReason, + buttons: ["OK"], + }); + }), + ); return; } @@ -1284,43 +1164,52 @@ function hasDesktopUpdateFeedConfig(): boolean { return Option.isSome(appUpdateYmlConfig) || Boolean(process.env.T3CODE_DESKTOP_MOCK_UPDATES); } -function checkForUpdatesFromMenu(): Effect.Effect { +function checkForUpdatesFromMenu(): Effect.Effect< + void, + never, + DesktopState.DesktopState | ElectronDialog.ElectronDialog +> { return Effect.gen(function* () { + const electronDialog = yield* ElectronDialog.ElectronDialog; yield* checkForUpdates("menu"); if (updateState.status === "up-to-date") { - yield* Effect.promise(() => - dialog.showMessageBox({ - type: "info", - title: "You're up to date!", - message: `T3 Code ${updateState.currentVersion} is currently the newest version available.`, - buttons: ["OK"], - }), - ); + yield* electronDialog.showMessageBox({ + type: "info", + title: "You're up to date!", + message: `T3 Code ${updateState.currentVersion} is currently the newest version available.`, + buttons: ["OK"], + }); } else if (updateState.status === "error") { - yield* Effect.promise(() => - dialog.showMessageBox({ - type: "warning", - title: "Update check failed", - message: "Could not check for updates.", - detail: updateState.message ?? "An unknown error occurred. Please try again later.", - buttons: ["OK"], - }), - ); + yield* electronDialog.showMessageBox({ + type: "warning", + title: "Update check failed", + message: "Could not check for updates.", + detail: updateState.message ?? "An unknown error occurred. Please try again later.", + buttons: ["OK"], + }); } }); } -function configureApplicationMenu(): Effect.Effect { +function configureApplicationMenu(): Effect.Effect< + void, + never, + ElectronApp.ElectronApp | DesktopWindowBoundaryServices +> { return Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; const environment = yield* DesktopEnvironment; - const context = yield* Effect.context(); + const appName = yield* electronApp.name; + const context = yield* Effect.context< + ElectronApp.ElectronApp | DesktopWindowBoundaryServices + >(); const runEffect = makeDesktopEffectRunner(context); const template: MenuItemConstructorOptions[] = []; if (process.platform === "darwin") { template.push({ - label: app.name, + label: appName, submenu: [ { role: "about" }, { @@ -1492,34 +1381,33 @@ function resolveUserDataPath(): Effect.Effect< function configureAppIdentity(): Effect.Effect< void, never, - FileSystem.FileSystem | DesktopEnvironment + FileSystem.FileSystem | ElectronApp.ElectronApp | DesktopEnvironment > { return Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; const environment = yield* DesktopEnvironment; const commitHash = yield* resolveAboutCommitHash(); - yield* Effect.sync(() => { - app.setName(environment.displayName); - app.setAboutPanelOptions({ - applicationName: environment.displayName, - applicationVersion: environment.appVersion, - version: Option.getOrElse(commitHash, () => "unknown"), - }); + yield* electronApp.setName(environment.displayName); + yield* electronApp.setAboutPanelOptions({ + applicationName: environment.displayName, + applicationVersion: environment.appVersion, + version: Option.getOrElse(commitHash, () => "unknown"), + }); - if (process.platform === "win32") { - app.setAppUserModelId(environment.appUserModelId); - } + if (process.platform === "win32") { + yield* electronApp.setAppUserModelId(environment.appUserModelId); + } - if (process.platform === "linux") { - (app as LinuxDesktopNamedApp).setDesktopName?.(environment.linuxDesktopEntryName); - } + if (process.platform === "linux") { + yield* electronApp.setDesktopName(environment.linuxDesktopEntryName); + } - if (process.platform === "darwin" && app.dock) { - const iconPath = Option.getOrUndefined(desktopIconPaths.png); - if (iconPath) { - app.dock.setIcon(iconPath); - } - } - }); + if (process.platform === "darwin") { + yield* Option.match(desktopIconPaths.png, { + onNone: () => Effect.void, + onSome: electronApp.setDockIcon, + }); + } }); } @@ -1604,7 +1492,7 @@ function shouldEnableAutoUpdates(environment: DesktopEnvironmentShape): boolean return ( getAutoUpdateDisabledReason({ isDevelopment: environment.isDevelopment, - isPackaged: app.isPackaged, + isPackaged: environment.isPackaged, platform: process.platform, appImage: process.env.APPIMAGE, disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1", @@ -1910,18 +1798,15 @@ function requestDesktopShutdownAndWait(): Effect.Effect { - if (shouldQuit) { - app.quit(); - } - }); + ); } function getIconOption(): { icon: string } | Record { @@ -2193,7 +2078,12 @@ function handleBeforeQuit( }), ).finally(() => { markQuitAllowed(); - app.quit(); + void runEffect( + Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + yield* electronApp.quit; + }), + ); }); } @@ -2218,13 +2108,16 @@ function handleActivate( }); } -function handleWindowAllClosed(): Effect.Effect { +function handleWindowAllClosed(): Effect.Effect< + void, + never, + ElectronApp.ElectronApp | DesktopState.DesktopState +> { return Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; const state = yield* DesktopState.DesktopState; if (process.platform !== "darwin" && !(yield* Ref.get(state.quitting))) { - yield* Effect.sync(() => { - app.quit(); - }); + yield* electronApp.quit; } }); } @@ -2236,6 +2129,7 @@ function registerDesktopLifecycleHandlers(): Effect.Effect< > { return Effect.gen(function* () { const environment = yield* DesktopEnvironment; + const electronApp = yield* ElectronApp.ElectronApp; const electronWindow = yield* ElectronWindow.ElectronWindow; const context = yield* Effect.context(); const runEffect = makeDesktopEffectRunner(context); @@ -2243,7 +2137,7 @@ function registerDesktopLifecycleHandlers(): Effect.Effect< yield* addScopedListener(nativeTheme, "updated", () => { void runEffect(electronWindow.syncAllAppearance(syncWindowAppearance)); }); - yield* addScopedListener(app, "before-quit", (event: Electron.Event) => { + yield* electronApp.on("before-quit", (event: Electron.Event) => { handleBeforeQuit( event, runEffect, @@ -2253,10 +2147,10 @@ function registerDesktopLifecycleHandlers(): Effect.Effect< }, ); }); - yield* addScopedListener(app, "activate", () => { + yield* electronApp.on("activate", () => { void runEffect(handleActivate(environment)); }); - yield* addScopedListener(app, "window-all-closed", () => { + yield* electronApp.on("window-all-closed", () => { void runEffect(handleWindowAllClosed()); }); @@ -2277,13 +2171,20 @@ function fatalStartupCause(stage: string, cause: Cause.Cause) { ); } -const waitForElectronReady = Effect.promise(() => app.whenReady()).pipe(Effect.asVoid); +const waitForElectronReady = Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + yield* electronApp.whenReady; +}); const program = Effect.scoped( Effect.gen(function* () { const shutdown = yield* makeDesktopShutdown; yield* Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + const electronProtocol = yield* ElectronProtocol.ElectronProtocol; + yield* electronProtocol.registerDesktopSchemePrivileges; + const environment = yield* DesktopEnvironment; appRunId = (yield* Random.nextUUIDv4).replace(/-/g, "").slice(0, 12); const backendManager = yield* DesktopBackendManager; @@ -2300,11 +2201,9 @@ const program = Effect.scoped( yield* shellEnvironment.sync; const userDataPath = yield* resolveUserDataPath(); - yield* Effect.sync(() => { - // Must happen before Electron's ready event so Chromium profile data - // lands in the desktop-specific userData directory. - app.setPath("userData", userDataPath); - }); + // Must happen before Electron's ready event so Chromium profile data + // lands in the desktop-specific userData directory. + yield* electronApp.setPath("userData", userDataPath); appUpdateYmlConfig = yield* readAppUpdateYmlEffect(); yield* resolveDesktopIconPaths(); yield* logDesktopInfo("runtime logging configured", { logDir: environment.logDir }); @@ -2316,7 +2215,7 @@ const program = Effect.scoped( updateState = initialUpdateState(environment); if (process.platform === "linux") { - app.commandLine.appendSwitch("class", environment.linuxWmClass); + yield* electronApp.appendCommandLineSwitch("class", environment.linuxWmClass); } yield* configureAppIdentity(); From a90619e7f695e663cdf5a50fcef0f5c43a45e4f8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 15:42:32 -0700 Subject: [PATCH 12/43] Refactor desktop update handling into dedicated services - Move updater logic into DesktopUpdates and ElectronUpdater - Add shared desktop settings state and window broadcast helpers - Preserve update flow while simplifying main process orchestration --- .../src/electron/ElectronUpdater.test.ts | 89 +++ apps/desktop/src/electron/ElectronUpdater.ts | 97 +++ apps/desktop/src/electron/ElectronWindow.ts | 16 + apps/desktop/src/main.ts | 651 ++---------------- apps/desktop/src/main/DesktopErrors.ts | 3 + .../src/main/DesktopSettingsState.test.ts | 32 + apps/desktop/src/main/DesktopSettingsState.ts | 69 ++ apps/desktop/src/main/DesktopUpdates.test.ts | 259 +++++++ apps/desktop/src/main/DesktopUpdates.ts | 648 +++++++++++++++++ apps/desktop/src/sshEnvironment.ts | 2 +- packages/ssh/src/tunnel.ts | 8 +- 11 files changed, 1281 insertions(+), 593 deletions(-) create mode 100644 apps/desktop/src/electron/ElectronUpdater.test.ts create mode 100644 apps/desktop/src/electron/ElectronUpdater.ts create mode 100644 apps/desktop/src/main/DesktopErrors.ts create mode 100644 apps/desktop/src/main/DesktopSettingsState.test.ts create mode 100644 apps/desktop/src/main/DesktopSettingsState.ts create mode 100644 apps/desktop/src/main/DesktopUpdates.test.ts create mode 100644 apps/desktop/src/main/DesktopUpdates.ts diff --git a/apps/desktop/src/electron/ElectronUpdater.test.ts b/apps/desktop/src/electron/ElectronUpdater.test.ts new file mode 100644 index 00000000000..d5b0b2e9f99 --- /dev/null +++ b/apps/desktop/src/electron/ElectronUpdater.test.ts @@ -0,0 +1,89 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { beforeEach, vi } from "vitest"; + +const { autoUpdaterMock } = vi.hoisted(() => ({ + autoUpdaterMock: { + allowDowngrade: false, + allowPrerelease: false, + autoDownload: true, + autoInstallOnAppQuit: true, + channel: "latest", + disableDifferentialDownload: false, + checkForUpdates: vi.fn(() => Promise.resolve(null)), + downloadUpdate: vi.fn(() => Promise.resolve([])), + on: vi.fn(), + quitAndInstall: vi.fn(), + removeListener: vi.fn(), + setFeedURL: vi.fn(), + }, +})); + +vi.mock("electron-updater", () => ({ + autoUpdater: autoUpdaterMock, +})); + +import * as ElectronUpdater from "./ElectronUpdater.ts"; + +describe("ElectronUpdater", () => { + beforeEach(() => { + autoUpdaterMock.allowDowngrade = false; + autoUpdaterMock.allowPrerelease = false; + autoUpdaterMock.autoDownload = true; + autoUpdaterMock.autoInstallOnAppQuit = true; + autoUpdaterMock.channel = "latest"; + autoUpdaterMock.disableDifferentialDownload = false; + autoUpdaterMock.checkForUpdates.mockClear(); + autoUpdaterMock.downloadUpdate.mockClear(); + autoUpdaterMock.on.mockClear(); + autoUpdaterMock.quitAndInstall.mockClear(); + autoUpdaterMock.removeListener.mockClear(); + autoUpdaterMock.setFeedURL.mockClear(); + }); + + it.effect("wraps updater configuration and actions", () => + Effect.gen(function* () { + const updater = yield* ElectronUpdater.ElectronUpdater; + + yield* updater.setFeedURL({ provider: "generic", url: "http://127.0.0.1:3000" }); + yield* updater.setAutoDownload(false); + yield* updater.setAutoInstallOnAppQuit(false); + yield* updater.setChannel("nightly"); + yield* updater.setAllowPrerelease(true); + yield* updater.setAllowDowngrade(true); + yield* updater.setDisableDifferentialDownload(true); + yield* updater.checkForUpdates; + yield* updater.downloadUpdate; + yield* updater.quitAndInstall({ isSilent: true, isForceRunAfter: true }); + + assert.deepEqual(autoUpdaterMock.setFeedURL.mock.calls, [ + [{ provider: "generic", url: "http://127.0.0.1:3000" }], + ]); + assert.equal(autoUpdaterMock.autoDownload, false); + assert.equal(autoUpdaterMock.autoInstallOnAppQuit, false); + assert.equal(autoUpdaterMock.channel, "nightly"); + assert.equal(autoUpdaterMock.allowPrerelease, true); + assert.equal(autoUpdaterMock.allowDowngrade, true); + assert.equal(autoUpdaterMock.disableDifferentialDownload, true); + assert.equal(autoUpdaterMock.checkForUpdates.mock.calls.length, 1); + assert.equal(autoUpdaterMock.downloadUpdate.mock.calls.length, 1); + assert.deepEqual(autoUpdaterMock.quitAndInstall.mock.calls, [[true, true]]); + }).pipe(Effect.provide(ElectronUpdater.layer)), + ); + + it.effect("scopes updater event listeners", () => + Effect.gen(function* () { + const listener = vi.fn(); + + yield* Effect.scoped( + Effect.gen(function* () { + const updater = yield* ElectronUpdater.ElectronUpdater; + yield* updater.on("update-available", listener); + }), + ); + + assert.deepEqual(autoUpdaterMock.on.mock.calls, [["update-available", listener]]); + assert.deepEqual(autoUpdaterMock.removeListener.mock.calls, [["update-available", listener]]); + }).pipe(Effect.provide(ElectronUpdater.layer)), + ); +}); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts new file mode 100644 index 00000000000..71d61225136 --- /dev/null +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -0,0 +1,97 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; + +import { autoUpdater } from "electron-updater"; + +type AutoUpdater = typeof autoUpdater; + +export type ElectronUpdaterFeedUrl = Parameters[0]; + +export interface ElectronUpdaterShape { + readonly setFeedURL: (options: ElectronUpdaterFeedUrl) => Effect.Effect; + readonly setAutoDownload: (value: boolean) => Effect.Effect; + readonly setAutoInstallOnAppQuit: (value: boolean) => Effect.Effect; + readonly setChannel: (channel: string) => Effect.Effect; + readonly setAllowPrerelease: (value: boolean) => Effect.Effect; + readonly allowDowngrade: Effect.Effect; + readonly setAllowDowngrade: (value: boolean) => Effect.Effect; + readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect; + readonly checkForUpdates: Effect.Effect; + readonly downloadUpdate: Effect.Effect; + readonly quitAndInstall: (options: { + readonly isSilent: boolean; + readonly isForceRunAfter: boolean; + }) => Effect.Effect; + readonly on: >( + eventName: string, + listener: (...args: Args) => void, + ) => Effect.Effect; +} + +export class ElectronUpdater extends Context.Service()( + "t3/desktop/electron/Updater", +) {} + +const fromPromise = (evaluate: () => Promise): Effect.Effect => + Effect.callback((resume) => { + evaluate().then( + (value) => resume(Effect.succeed(value)), + (error: unknown) => resume(Effect.fail(error)), + ); + }); + +export const layer = Layer.succeed(ElectronUpdater, { + setFeedURL: (options) => + Effect.sync(() => { + autoUpdater.setFeedURL(options); + }), + setAutoDownload: (value) => + Effect.sync(() => { + autoUpdater.autoDownload = value; + }), + setAutoInstallOnAppQuit: (value) => + Effect.sync(() => { + autoUpdater.autoInstallOnAppQuit = value; + }), + setChannel: (channel) => + Effect.sync(() => { + autoUpdater.channel = channel; + }), + setAllowPrerelease: (value) => + Effect.sync(() => { + autoUpdater.allowPrerelease = value; + }), + allowDowngrade: Effect.sync(() => autoUpdater.allowDowngrade), + setAllowDowngrade: (value) => + Effect.sync(() => { + autoUpdater.allowDowngrade = value; + }), + setDisableDifferentialDownload: (value) => + Effect.sync(() => { + autoUpdater.disableDifferentialDownload = value; + }), + checkForUpdates: fromPromise(() => autoUpdater.checkForUpdates()).pipe(Effect.asVoid), + downloadUpdate: fromPromise(() => autoUpdater.downloadUpdate()).pipe(Effect.asVoid), + quitAndInstall: ({ isSilent, isForceRunAfter }) => + Effect.sync(() => { + autoUpdater.quitAndInstall(isSilent, isForceRunAfter); + }), + on: (eventName, listener) => { + const eventTarget = autoUpdater as unknown as { + on: (eventName: string, listener: (...args: Array) => void) => void; + removeListener: (eventName: string, listener: (...args: Array) => void) => void; + }; + const untypedListener = listener as unknown as (...args: Array) => void; + return Effect.acquireRelease( + Effect.sync(() => { + eventTarget.on(eventName, untypedListener); + }), + () => + Effect.sync(() => { + eventTarget.removeListener(eventName, untypedListener); + }), + ).pipe(Effect.asVoid); + }, +} satisfies ElectronUpdaterShape); diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index e626d6c00b9..d69e4ab2f0f 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -13,6 +13,8 @@ export interface ElectronWindowShape { readonly setMain: (window: Electron.BrowserWindow) => Effect.Effect; readonly clearMain: (window?: Electron.BrowserWindow) => Effect.Effect; readonly reveal: (window: Electron.BrowserWindow) => Effect.Effect; + readonly sendAll: (channel: string, ...args: readonly unknown[]) => Effect.Effect; + readonly destroyAll: Effect.Effect; readonly syncAllAppearance: ( sync: (window: Electron.BrowserWindow) => void, ) => Effect.Effect; @@ -85,6 +87,20 @@ const make = Effect.gen(function* () { window.focus(); }), + sendAll: (channel, ...args) => + Effect.sync(() => { + for (const window of Electron.BrowserWindow.getAllWindows()) { + if (window.isDestroyed()) { + continue; + } + window.webContents.send(channel, ...args); + } + }), + destroyAll: Effect.sync(() => { + for (const window of Electron.BrowserWindow.getAllWindows()) { + window.destroy(); + } + }), syncAllAppearance: (sync) => Effect.sync(() => { for (const window of Electron.BrowserWindow.getAllWindows()) { diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index d56d6e18699..8fe3a7167a3 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -3,10 +3,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; -import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -26,14 +24,8 @@ import { Menu, nativeTheme, } from "electron"; -import { autoUpdater } from "electron-updater"; -import type { - DesktopServerExposureMode, - DesktopServerExposureState, - DesktopUpdateChannel, - DesktopUpdateState, -} from "@t3tools/contracts"; +import type { DesktopServerExposureMode, DesktopServerExposureState } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; @@ -42,10 +34,8 @@ import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPortEffect } from ". import { type DesktopSettings, DEFAULT_DESKTOP_SETTINGS, - readDesktopSettingsEffect, setDesktopServerExposurePreference, setDesktopTailscaleServePreference, - setDesktopUpdateChannelPreference, writeDesktopSettingsEffect, } from "./desktopSettings.ts"; import { @@ -78,10 +68,11 @@ import * as ElectronMenu from "./electron/ElectronMenu.ts"; import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; import * as ElectronShell from "./electron/ElectronShell.ts"; import * as ElectronTheme from "./electron/ElectronTheme.ts"; +import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; import * as DesktopIpc from "./ipc/DesktopIpc.ts"; import * as ElectronWindow from "./electron/ElectronWindow.ts"; import { DesktopShutdown, makeDesktopShutdown } from "./desktopShutdown.ts"; -import { MENU_ACTION_CHANNEL, UPDATE_STATE_CHANNEL } from "./ipc/channels.ts"; +import { MENU_ACTION_CHANNEL } from "./ipc/channels.ts"; import { installDesktopIpcHandlers } from "./ipc/DesktopIpcHandlers.ts"; import { DesktopServerExposureIpcActions } from "./ipc/methods/serverExposure.ts"; import { DesktopUpdateIpcActions } from "./ipc/methods/updates.ts"; @@ -102,33 +93,19 @@ import { DesktopShellEnvironmentLive, DesktopShellEnvironmentProbeLive, } from "./syncShellEnvironment.ts"; -import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts"; -import { doesVersionMatchDesktopUpdateChannel } from "./updateChannels.ts"; -import { - createInitialDesktopUpdateState, - reduceDesktopUpdateStateOnCheckFailure, - reduceDesktopUpdateStateOnCheckStart, - reduceDesktopUpdateStateOnDownloadComplete, - reduceDesktopUpdateStateOnDownloadFailure, - reduceDesktopUpdateStateOnDownloadProgress, - reduceDesktopUpdateStateOnDownloadStart, - reduceDesktopUpdateStateOnInstallFailure, - reduceDesktopUpdateStateOnNoUpdate, - reduceDesktopUpdateStateOnUpdateAvailable, -} from "./updateMachine.ts"; -import { isArm64HostRunningIntelBuild } from "./runtimeArch.ts"; import { bindFirstRevealTrigger, type RevealSubscription } from "./windowReveal.ts"; import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; +import { formatErrorMessage } from "./main/DesktopErrors.ts"; import * as DesktopLocalEnvironment from "./main/DesktopLocalEnvironment.ts"; +import * as DesktopSettingsState from "./main/DesktopSettingsState.ts"; import * as DesktopState from "./main/DesktopState.ts"; +import * as DesktopUpdates from "./main/DesktopUpdates.ts"; const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i; const COMMIT_HASH_DISPLAY_LENGTH = 12; const AppPackageMetadata = Schema.Struct({ t3codeCommitHash: Schema.optional(Schema.String), }); -const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000; -const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const; const TITLEBAR_HEIGHT = 40; @@ -141,7 +118,6 @@ type WindowTitleBarOptions = Pick< "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition" >; -type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; interface BackendObservabilitySettings { readonly otlpTracesUrl: string | undefined; readonly otlpMetricsUrl: string | undefined; @@ -152,7 +128,6 @@ let backendBootstrapToken = ""; let backendHttpUrl: Option.Option = Option.none(); let backendEndpointUrl: string | null = null; let backendAdvertisedHost: string | null = null; -let appUpdateYmlConfig: Option.Option> = Option.none(); let aboutCommitHashCache: Option.Option | undefined; let desktopIconPaths: Readonly>> = { ico: Option.none(), @@ -177,6 +152,7 @@ type DesktopWindowBoundaryServices = | ElectronDialog.ElectronDialog | ElectronShell.ElectronShell | DesktopState.DesktopState + | DesktopUpdates.DesktopUpdates | ElectronWindow.ElectronWindow; type DesktopLifecycleBoundaryServices = | DesktopShutdown @@ -202,17 +178,6 @@ function getBackendHttpUrlHref(): string | null { }); } -const initialUpdateState = (environment: DesktopEnvironmentShape): DesktopUpdateState => - createInitialDesktopUpdateState( - environment.appVersion, - environment.runtimeInfo, - desktopSettings.updateChannel, - ); - -function nowIsoTimestamp(): string { - return DateTime.formatIso(DateTime.nowUnsafe()); -} - const withDesktopLogAnnotations = ( effect: Effect.Effect, annotations?: Record, @@ -249,15 +214,6 @@ const logUpdaterInfo = ( ...annotations, }); -const logUpdaterError = ( - message: string, - annotations?: Record, -): Effect.Effect => - withDesktopLogAnnotations(Effect.logError(message), { - component: "desktop-updater", - ...annotations, - }); - function readPersistedBackendObservabilitySettings(): Effect.Effect< BackendObservabilitySettings, never, @@ -493,13 +449,6 @@ function relaunchDesktopAppEffect( }); } -function formatErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - return String(error); -} - function handleBackendReady( runEffect: DesktopEffectRunner, environment: DesktopEnvironmentShape, @@ -581,8 +530,6 @@ const resolveBackendStartConfig: Effect.Effect< }; }); -const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso)); - const randomHexString = (length: number): Effect.Effect => Effect.gen(function* () { let value = ""; @@ -684,7 +631,10 @@ const desktopBackendEventsLayer = Layer.effect( }), ); -function resolveDesktopSshCliRunner(environment: DesktopEnvironmentShape): RemoteT3RunnerOptions { +function resolveDesktopSshCliRunner( + environment: DesktopEnvironmentShape, + settings: DesktopSettings, +): RemoteT3RunnerOptions { const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath); if (environment.isDevelopment && devRemoteEntryPath !== undefined) { return { nodeScriptPath: devRemoteEntryPath }; @@ -692,7 +642,7 @@ function resolveDesktopSshCliRunner(environment: DesktopEnvironmentShape): Remot return { packageSpec: resolveRemoteT3CliPackageSpec({ appVersion: environment.appVersion, - updateChannel: desktopSettings.updateChannel, + updateChannel: settings.updateChannel, isDevelopment: environment.isDevelopment, }), }; @@ -701,8 +651,11 @@ function resolveDesktopSshCliRunner(environment: DesktopEnvironmentShape): Remot const desktopSshEnvironmentLayer = Layer.unwrap( Effect.gen(function* () { const environment = yield* DesktopEnvironment; + const settingsState = yield* DesktopSettingsState.DesktopSettingsState; return DesktopSshEnvironmentManager.layer({ - resolveCliRunner: () => resolveDesktopSshCliRunner(environment), + resolveCliRunner: settingsState.get.pipe( + Effect.map((settings) => resolveDesktopSshCliRunner(environment, settings)), + ), }); }), ); @@ -762,94 +715,21 @@ const desktopServerExposureIpcActionsLayer = Layer.effect( }), ); -type DesktopUpdateIpcActionServices = - | FileSystem.FileSystem - | EffectPath.Path - | DesktopEnvironment - | DesktopBackendManager - | DesktopState.DesktopState; +const desktopUpdatesLayer = DesktopUpdates.layer.pipe(Layer.provideMerge(ElectronUpdater.layer)); const desktopUpdateIpcActionsLayer = Layer.effect( DesktopUpdateIpcActions, Effect.gen(function* () { - const context = yield* Effect.context(); - const state = yield* DesktopState.DesktopState; + const updates = yield* DesktopUpdates.DesktopUpdates; return DesktopUpdateIpcActions.of({ - getState: Effect.sync(() => updateState), - setChannel: (nextChannel) => - Effect.gen(function* () { - const environment = yield* DesktopEnvironment; - if (updateCheckInFlight || updateDownloadInFlight || updateInstallInFlight) { - return yield* Effect.fail( - new Error("Cannot change update tracks while an update action is in progress."), - ); - } - - desktopSettings = setDesktopUpdateChannelPreference(desktopSettings, nextChannel); - yield* writeDesktopSettingsEffect(environment.desktopSettingsPath, desktopSettings); - - if (nextChannel === updateState.channel) { - return updateState; - } - - const enabled = shouldEnableAutoUpdates(environment); - setUpdateState(createBaseUpdateState(nextChannel, enabled, environment)); - - if (!enabled || !updaterConfigured) { - return updateState; - } - - yield* applyAutoUpdaterChannel(nextChannel); - const allowDowngrade = autoUpdater.allowDowngrade; - autoUpdater.allowDowngrade = true; - yield* checkForUpdates("channel-change").pipe( - Effect.ensuring( - Effect.sync(() => { - autoUpdater.allowDowngrade = allowDowngrade; - }), - ), - ); - return updateState; - }).pipe(Effect.provide(context)), - download: Effect.gen(function* () { - const result = yield* downloadAvailableUpdate(); - return { - accepted: result.accepted, - completed: result.completed, - state: updateState, - }; - }).pipe(Effect.provide(context)), - install: Effect.gen(function* () { - if (yield* Ref.get(state.quitting)) { - return { - accepted: false, - completed: false, - state: updateState, - }; - } - const result = yield* installDownloadedUpdate(); - return { - accepted: result.accepted, - completed: result.completed, - state: updateState, - }; - }).pipe(Effect.provide(context)), - check: Effect.gen(function* () { - if (!updaterConfigured) { - return { - checked: false, - state: updateState, - }; - } - const checked = yield* checkForUpdates("web-ui"); - return { - checked, - state: updateState, - }; - }).pipe(Effect.provide(context)), + getState: updates.getState, + setChannel: updates.setChannel, + download: updates.download, + install: updates.install, + check: updates.check("web-ui"), }); }), -); +).pipe(Layer.provideMerge(desktopUpdatesLayer)); const desktopBackendDependenciesLayer = Layer.mergeAll( NodeServices.layer, @@ -894,31 +774,10 @@ const desktopRuntimeLayer = Layer.mergeAll( Layer.provideMerge(ElectronProtocol.layer), Layer.provideMerge(ElectronShell.layer), Layer.provideMerge(ElectronTheme.layer), + Layer.provideMerge(DesktopSettingsState.layer), Layer.provideMerge(desktopEnvironmentLayer), ); -let updatePollerScope: Option.Option = Option.none(); -let updateCheckInFlight = false; -let updateDownloadInFlight = false; -let updateInstallInFlight = false; -let updaterConfigured = false; -let updateState: DesktopUpdateState = createInitialDesktopUpdateState( - "0.0.0", - { - hostArch: "other", - appArch: "other", - runningUnderArm64Translation: false, - }, - DEFAULT_DESKTOP_SETTINGS.updateChannel, -); - -function resolveUpdaterErrorContext(): DesktopUpdateErrorContext { - if (updateInstallInFlight) return "install"; - if (updateDownloadInFlight) return "download"; - if (updateCheckInFlight) return "check"; - return updateState.errorContext; -} - function addScopedListener>( target: unknown, eventName: string, @@ -940,36 +799,6 @@ function addScopedListener>( ).pipe(Effect.asVoid); } -function parseAppUpdateYml(raw: string): Option.Option> { - // The YAML is simple key-value pairs — avoid pulling in a YAML parser by - // doing a line-based parse (fields: provider, owner, repo, releaseType, ...). - const entries: Record = {}; - for (const line of raw.split("\n")) { - const match = line.match(/^(\w+):\s*(.+)$/); - if (match?.[1] && match[2]) entries[match[1]] = match[2].trim(); - } - return entries.provider ? Option.some(entries) : Option.none(); -} - -/** Read the baked-in app-update.yml config (if applicable). */ -function readAppUpdateYmlEffect(): Effect.Effect< - Option.Option>, - never, - FileSystem.FileSystem | DesktopEnvironment -> { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const environment = yield* DesktopEnvironment; - const raw = yield* fileSystem - .readFileString(environment.appUpdateYmlPath, "utf-8") - .pipe(Effect.option); - return Option.match(raw, { - onNone: () => Option.none>(), - onSome: parseAppUpdateYml, - }); - }); -} - function normalizeCommitHash(value: unknown): string | null { if (typeof value !== "string") { return null; @@ -1118,60 +947,45 @@ function handleCheckForUpdatesMenuClick( runEffect: DesktopEffectRunner, environment: DesktopEnvironmentShape, ): void { - const disabledReason = getAutoUpdateDisabledReason({ - isDevelopment: environment.isDevelopment, - isPackaged: environment.isPackaged, - platform: process.platform, - appImage: process.env.APPIMAGE, - disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1", - hasUpdateFeedConfig: hasDesktopUpdateFeedConfig(), - }); - if (disabledReason) { - void runEffect( - logUpdaterInfo("manual update check requested, but updates are disabled", { - disabledReason, - }), - ); - void runEffect( - Effect.gen(function* () { - const electronDialog = yield* ElectronDialog.ElectronDialog; + void runEffect( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + const electronDialog = yield* ElectronDialog.ElectronDialog; + const disabledReason = yield* updates.disabledReason; + if (Option.isSome(disabledReason)) { + yield* logUpdaterInfo("manual update check requested, but updates are disabled", { + disabledReason: disabledReason.value, + }); yield* electronDialog.showMessageBox({ type: "info", title: "Updates unavailable", message: "Automatic updates are not available right now.", - detail: disabledReason, + detail: disabledReason.value, buttons: ["OK"], }); - }), - ); - return; - } + return; + } - if (!BrowserWindow.getAllWindows().length) { - void runEffect( - Effect.gen(function* () { - const electronWindow = yield* ElectronWindow.ElectronWindow; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const existingWindow = yield* electronWindow.currentMainOrFirst; + if (Option.isNone(existingWindow)) { yield* electronWindow.setMain(createWindow(runEffect, environment, electronWindow)); - yield* checkForUpdatesFromMenu(); - }), - ); - return; - } - void runEffect(checkForUpdatesFromMenu()); -} - -function hasDesktopUpdateFeedConfig(): boolean { - return Option.isSome(appUpdateYmlConfig) || Boolean(process.env.T3CODE_DESKTOP_MOCK_UPDATES); + } + yield* checkForUpdatesFromMenu(); + }), + ); } function checkForUpdatesFromMenu(): Effect.Effect< void, never, - DesktopState.DesktopState | ElectronDialog.ElectronDialog + DesktopUpdates.DesktopUpdates | ElectronDialog.ElectronDialog > { return Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; const electronDialog = yield* ElectronDialog.ElectronDialog; - yield* checkForUpdates("menu"); + const result = yield* updates.check("menu"); + const updateState = result.state; if (updateState.status === "up-to-date") { yield* electronDialog.showMessageBox({ @@ -1411,351 +1225,6 @@ function configureAppIdentity(): Effect.Effect< }); } -function clearUpdatePollTimer(): Effect.Effect { - return Effect.gen(function* () { - const scope = updatePollerScope; - updatePollerScope = Option.none(); - yield* Option.match(scope, { - onNone: () => Effect.void, - onSome: (value) => Scope.close(value, Exit.void).pipe(Effect.ignore), - }); - }); -} - -function startUpdatePollers(): Effect.Effect { - return Effect.gen(function* () { - yield* clearUpdatePollTimer(); - const parentScope = yield* Scope.Scope; - const scope = yield* Scope.make("sequential"); - updatePollerScope = Option.some(scope); - yield* Scope.addFinalizer(parentScope, Scope.close(scope, Exit.void).pipe(Effect.ignore)); - - yield* Effect.sleep(Duration.millis(AUTO_UPDATE_STARTUP_DELAY_MS)).pipe( - Effect.andThen(checkForUpdates("startup")), - Effect.catchCause((cause) => - logUpdaterError("startup update check failed", { cause: Cause.pretty(cause) }), - ), - Effect.forkIn(scope), - ); - yield* Effect.sleep(Duration.millis(AUTO_UPDATE_POLL_INTERVAL_MS)).pipe( - Effect.andThen(checkForUpdates("poll")), - Effect.forever, - Effect.catchCause((cause) => - logUpdaterError("poll update check failed", { cause: Cause.pretty(cause) }), - ), - Effect.forkIn(scope), - ); - }); -} - -function emitUpdateState(): void { - for (const window of BrowserWindow.getAllWindows()) { - if (window.isDestroyed()) continue; - window.webContents.send(UPDATE_STATE_CHANNEL, updateState); - } -} - -function setUpdateState(patch: Partial): void { - updateState = { ...updateState, ...patch }; - emitUpdateState(); -} - -function createBaseUpdateState( - channel: DesktopUpdateChannel, - enabled: boolean, - environment: DesktopEnvironmentShape, -): DesktopUpdateState { - return { - ...createInitialDesktopUpdateState(environment.appVersion, environment.runtimeInfo, channel), - enabled, - status: enabled ? "idle" : "disabled", - }; -} - -function applyAutoUpdaterChannel(channel: DesktopUpdateChannel): Effect.Effect { - return Effect.gen(function* () { - const allowsPrerelease = channel === "nightly"; - yield* Effect.sync(() => { - autoUpdater.channel = channel; - autoUpdater.allowPrerelease = allowsPrerelease; - autoUpdater.allowDowngrade = allowsPrerelease; - }); - yield* logUpdaterInfo("using update channel", { - channel, - allowPrerelease: allowsPrerelease, - allowDowngrade: allowsPrerelease, - }); - }); -} - -function shouldEnableAutoUpdates(environment: DesktopEnvironmentShape): boolean { - return ( - getAutoUpdateDisabledReason({ - isDevelopment: environment.isDevelopment, - isPackaged: environment.isPackaged, - platform: process.platform, - appImage: process.env.APPIMAGE, - disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1", - hasUpdateFeedConfig: hasDesktopUpdateFeedConfig(), - }) === null - ); -} - -function checkForUpdates(reason: string): Effect.Effect { - return Effect.gen(function* () { - const state = yield* DesktopState.DesktopState; - if ((yield* Ref.get(state.quitting)) || !updaterConfigured || updateCheckInFlight) return false; - if (updateState.status === "downloading" || updateState.status === "downloaded") { - yield* logUpdaterInfo("skipping update check while update is active", { - reason, - status: updateState.status, - }); - return false; - } - - updateCheckInFlight = true; - const checkedAt = yield* currentIsoTimestamp; - setUpdateState(reduceDesktopUpdateStateOnCheckStart(updateState, checkedAt)); - yield* logUpdaterInfo("checking for updates", { reason }); - - return yield* Effect.promise(() => autoUpdater.checkForUpdates()).pipe( - Effect.as(true), - Effect.catch((error: unknown) => - Effect.gen(function* () { - const failedAt = yield* currentIsoTimestamp; - const message = formatErrorMessage(error); - setUpdateState(reduceDesktopUpdateStateOnCheckFailure(updateState, message, failedAt)); - yield* logUpdaterError("failed to check for updates", { message }); - return true; - }), - ), - Effect.ensuring( - Effect.sync(() => { - updateCheckInFlight = false; - }), - ), - ); - }); -} - -function downloadAvailableUpdate(): Effect.Effect< - { - accepted: boolean; - completed: boolean; - }, - never, - DesktopEnvironment -> { - return Effect.gen(function* () { - const environment = yield* DesktopEnvironment; - if (!updaterConfigured || updateDownloadInFlight || updateState.status !== "available") { - return { accepted: false, completed: false }; - } - - updateDownloadInFlight = true; - setUpdateState(reduceDesktopUpdateStateOnDownloadStart(updateState)); - autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(environment.runtimeInfo); - yield* logUpdaterInfo("downloading update"); - - return yield* Effect.promise(() => autoUpdater.downloadUpdate()).pipe( - Effect.as({ accepted: true, completed: true }), - Effect.catch((error: unknown) => - Effect.sync(() => { - const message = formatErrorMessage(error); - setUpdateState(reduceDesktopUpdateStateOnDownloadFailure(updateState, message)); - return { accepted: true, completed: false }; - }).pipe( - Effect.tap(() => - logUpdaterError("failed to download update", { message: formatErrorMessage(error) }), - ), - ), - ), - Effect.ensuring( - Effect.sync(() => { - updateDownloadInFlight = false; - }), - ), - ); - }); -} - -function installDownloadedUpdate(): Effect.Effect< - { - accepted: boolean; - completed: boolean; - }, - never, - DesktopBackendManager | DesktopState.DesktopState -> { - return Effect.gen(function* () { - const state = yield* DesktopState.DesktopState; - if ( - (yield* Ref.get(state.quitting)) || - !updaterConfigured || - updateState.status !== "downloaded" - ) { - return { accepted: false, completed: false }; - } - - yield* Ref.set(state.quitting, true); - updateInstallInFlight = true; - yield* clearUpdatePollTimer(); - - return yield* Effect.gen(function* () { - const backendManager = yield* DesktopBackendManager; - yield* backendManager.stop({ timeout: Duration.seconds(5) }); - yield* Effect.sync(() => { - // Destroy all windows before launching the NSIS installer to avoid the installer finding live windows it needs to close. - for (const win of BrowserWindow.getAllWindows()) { - win.destroy(); - } - // `quitAndInstall()` only starts the handoff to the updater. The actual - // install may still fail asynchronously, so keep the action incomplete - // until we either quit or receive an updater error. - autoUpdater.quitAndInstall(true, true); - }); - return { accepted: true, completed: false }; - }).pipe( - Effect.catch((error: unknown) => - Effect.gen(function* () { - const message = formatErrorMessage(error); - yield* Effect.sync(() => { - updateInstallInFlight = false; - setUpdateState(reduceDesktopUpdateStateOnInstallFailure(updateState, message)); - }); - yield* Ref.set(state.quitting, false); - yield* logUpdaterError("failed to install update", { message }); - return { accepted: true, completed: false }; - }), - ), - ); - }); -} - -function configureAutoUpdater(): Effect.Effect< - void, - never, - Scope.Scope | DesktopEnvironment | DesktopState.DesktopState -> { - return Effect.gen(function* () { - const environment = yield* DesktopEnvironment; - const state = yield* DesktopState.DesktopState; - const context = yield* Effect.context(); - const runEffect = makeDesktopEffectRunner(context); - const githubToken = - process.env.T3CODE_DESKTOP_UPDATE_GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim() || ""; - if (githubToken) { - // When a token is provided, re-configure the feed with `private: true` so - // electron-updater uses the GitHub API (api.github.com) instead of the - // public Atom feed (github.com/…/releases.atom) which rejects Bearer auth. - const appUpdateYml = Option.getOrUndefined(appUpdateYmlConfig); - if (appUpdateYml?.provider === "github") { - autoUpdater.setFeedURL({ - ...appUpdateYml, - provider: "github" as const, - private: true, - token: githubToken, - }); - } - } - - if (process.env.T3CODE_DESKTOP_MOCK_UPDATES) { - autoUpdater.setFeedURL({ - provider: "generic", - url: `http://localhost:${process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT ?? 3000}`, - }); - } - - const enabled = shouldEnableAutoUpdates(environment); - setUpdateState(createBaseUpdateState(desktopSettings.updateChannel, enabled, environment)); - if (!enabled) { - return; - } - updaterConfigured = true; - - autoUpdater.autoDownload = false; - autoUpdater.autoInstallOnAppQuit = false; - yield* applyAutoUpdaterChannel(desktopSettings.updateChannel); - autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(environment.runtimeInfo); - let lastLoggedDownloadMilestone = -1; - - if (isArm64HostRunningIntelBuild(environment.runtimeInfo)) { - yield* logUpdaterInfo( - "Apple Silicon host detected while running Intel build; updates will switch to arm64 packages", - ); - } - - yield* addScopedListener(autoUpdater, "checking-for-update", () => { - void runEffect(logUpdaterInfo("looking for updates")); - }); - yield* addScopedListener(autoUpdater, "update-available", (info: { version: string }) => { - if (!doesVersionMatchDesktopUpdateChannel(info.version, updateState.channel)) { - void runEffect( - logUpdaterInfo("ignoring update that does not match selected channel", { - version: info.version, - channel: updateState.channel, - }), - ); - setUpdateState(reduceDesktopUpdateStateOnNoUpdate(updateState, nowIsoTimestamp())); - lastLoggedDownloadMilestone = -1; - return; - } - - setUpdateState( - reduceDesktopUpdateStateOnUpdateAvailable(updateState, info.version, nowIsoTimestamp()), - ); - lastLoggedDownloadMilestone = -1; - void runEffect(logUpdaterInfo("update available", { version: info.version })); - }); - yield* addScopedListener(autoUpdater, "update-not-available", () => { - setUpdateState(reduceDesktopUpdateStateOnNoUpdate(updateState, nowIsoTimestamp())); - lastLoggedDownloadMilestone = -1; - void runEffect(logUpdaterInfo("no updates available")); - }); - yield* addScopedListener(autoUpdater, "error", (error: unknown) => { - const message = formatErrorMessage(error); - if (updateInstallInFlight) { - updateInstallInFlight = false; - void runEffect(Ref.set(state.quitting, false)); - setUpdateState(reduceDesktopUpdateStateOnInstallFailure(updateState, message)); - void runEffect(logUpdaterError("updater error", { message })); - return; - } - if (!updateCheckInFlight && !updateDownloadInFlight) { - setUpdateState({ - status: "error", - message, - checkedAt: nowIsoTimestamp(), - downloadPercent: null, - errorContext: resolveUpdaterErrorContext(), - canRetry: updateState.availableVersion !== null || updateState.downloadedVersion !== null, - }); - } - void runEffect(logUpdaterError("updater error", { message })); - }); - yield* addScopedListener(autoUpdater, "download-progress", (progress: { percent: number }) => { - const percent = Math.floor(progress.percent); - if ( - shouldBroadcastDownloadProgress(updateState, progress.percent) || - updateState.message !== null - ) { - setUpdateState(reduceDesktopUpdateStateOnDownloadProgress(updateState, progress.percent)); - } - const milestone = percent - (percent % 10); - if (milestone > lastLoggedDownloadMilestone) { - lastLoggedDownloadMilestone = milestone; - void runEffect(logUpdaterInfo("download progress", { percent })); - } - }); - yield* addScopedListener(autoUpdater, "update-downloaded", (info: { version: string }) => { - setUpdateState(reduceDesktopUpdateStateOnDownloadComplete(updateState, info.version)); - void runEffect(logUpdaterInfo("update downloaded", { version: info.version })); - }); - - yield* startUpdatePollers(); - }); -} - function startBackend(): Effect.Effect< void, never, @@ -1778,11 +1247,11 @@ function startBackend(): Effect.Effect< function closeDesktopResourcesWithManager( backendManager: DesktopBackendManagerShape, desktopSshEnvironmentBridge: DesktopSshEnvironmentBridgeShape, + updates: DesktopUpdates.DesktopUpdatesShape, ): Effect.Effect { return Effect.gen(function* () { yield* backendManager.shutdown; - updateInstallInFlight = false; - yield* clearUpdatePollTimer(); + yield* updates.shutdown; yield* desktopSshEnvironmentBridge.disposeEffect().pipe(Effect.ignore); }); } @@ -1947,7 +1416,12 @@ function createWindow( }); window.webContents.on("did-finish-load", () => { window.setTitle(environment.displayName); - emitUpdateState(); + void runEffect( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.emitState; + }), + ); }); // On Linux/Wayland with `show: false`, Electron's `ready-to-show` only @@ -2189,12 +1663,14 @@ const program = Effect.scoped( appRunId = (yield* Random.nextUUIDv4).replace(/-/g, "").slice(0, 12); const backendManager = yield* DesktopBackendManager; const shellEnvironment = yield* DesktopShellEnvironment; + const settingsState = yield* DesktopSettingsState.DesktopSettingsState; + const updates = yield* DesktopUpdates.DesktopUpdates; const desktopSshEnvironmentBridge = yield* DesktopSshEnvironmentBridge; const sshPasswordPromptScope = yield* Scope.make("sequential"); yield* desktopSshEnvironmentBridge.installPasswordPromptScope(sshPasswordPromptScope); yield* Scope.addFinalizer( yield* Scope.Scope, - closeDesktopResourcesWithManager(backendManager, desktopSshEnvironmentBridge).pipe( + closeDesktopResourcesWithManager(backendManager, desktopSshEnvironmentBridge, updates).pipe( Effect.ensuring(shutdown.markComplete), ), ); @@ -2204,15 +1680,10 @@ const program = Effect.scoped( // Must happen before Electron's ready event so Chromium profile data // lands in the desktop-specific userData directory. yield* electronApp.setPath("userData", userDataPath); - appUpdateYmlConfig = yield* readAppUpdateYmlEffect(); yield* resolveDesktopIconPaths(); yield* logDesktopInfo("runtime logging configured", { logDir: environment.logDir }); - desktopSettings = yield* readDesktopSettingsEffect( - environment.desktopSettingsPath, - environment.appVersion, - ); + desktopSettings = yield* settingsState.load; desktopServerExposureMode = desktopSettings.serverExposureMode; - updateState = initialUpdateState(environment); if (process.platform === "linux") { yield* electronApp.appendCommandLineSwitch("class", environment.linuxWmClass); @@ -2228,7 +1699,7 @@ const program = Effect.scoped( yield* configureAppIdentity(); yield* configureApplicationMenu(); yield* registerDesktopProtocol(); - yield* configureAutoUpdater(); + yield* updates.configure; yield* bootstrap().pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); yield* shutdown.awaitRequest; }).pipe(Effect.provideService(DesktopShutdown, shutdown)); diff --git a/apps/desktop/src/main/DesktopErrors.ts b/apps/desktop/src/main/DesktopErrors.ts new file mode 100644 index 00000000000..485da565c71 --- /dev/null +++ b/apps/desktop/src/main/DesktopErrors.ts @@ -0,0 +1,3 @@ +export function formatErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/desktop/src/main/DesktopSettingsState.test.ts b/apps/desktop/src/main/DesktopSettingsState.test.ts new file mode 100644 index 00000000000..ac12492219e --- /dev/null +++ b/apps/desktop/src/main/DesktopSettingsState.test.ts @@ -0,0 +1,32 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { DEFAULT_DESKTOP_SETTINGS } from "../desktopSettings.ts"; +import * as DesktopSettingsState from "./DesktopSettingsState.ts"; + +describe("DesktopSettingsState", () => { + it.effect("updates settings through effectful ref operations", () => + Effect.gen(function* () { + const settingsState = yield* DesktopSettingsState.DesktopSettingsState; + + assert.deepEqual(yield* settingsState.get, DEFAULT_DESKTOP_SETTINGS); + + const settings = { + ...DEFAULT_DESKTOP_SETTINGS, + updateChannel: "nightly" as const, + updateChannelConfiguredByUser: true, + }; + yield* settingsState.set(settings); + + assert.deepEqual(yield* settingsState.get, settings); + + const updated = yield* settingsState.update((current) => ({ + ...current, + updateChannel: "latest", + })); + + assert.equal(updated.updateChannel, "latest"); + assert.deepEqual(yield* settingsState.get, updated); + }).pipe(Effect.provide(DesktopSettingsState.layer)), + ); +}); diff --git a/apps/desktop/src/main/DesktopSettingsState.ts b/apps/desktop/src/main/DesktopSettingsState.ts new file mode 100644 index 00000000000..b0998865bee --- /dev/null +++ b/apps/desktop/src/main/DesktopSettingsState.ts @@ -0,0 +1,69 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as SynchronizedRef from "effect/SynchronizedRef"; + +import { + type DesktopSettings, + DEFAULT_DESKTOP_SETTINGS, + readDesktopSettingsEffect, + writeDesktopSettingsEffect, +} from "../desktopSettings.ts"; +import { DesktopEnvironment } from "../desktopEnvironment.ts"; + +export interface DesktopSettingsStateShape { + readonly get: Effect.Effect; + readonly set: (settings: DesktopSettings) => Effect.Effect; + readonly load: Effect.Effect; + readonly update: ( + f: (settings: DesktopSettings) => DesktopSettings, + ) => Effect.Effect; + readonly updatePersisted: ( + f: (settings: DesktopSettings) => DesktopSettings, + ) => Effect.Effect< + DesktopSettings, + unknown, + FileSystem.FileSystem | Path.Path | DesktopEnvironment + >; +} + +export class DesktopSettingsState extends Context.Service< + DesktopSettingsState, + DesktopSettingsStateShape +>()("t3/desktop/SettingsState") {} + +export const layer = Layer.effect( + DesktopSettingsState, + Effect.gen(function* () { + const settingsRef = yield* SynchronizedRef.make(DEFAULT_DESKTOP_SETTINGS); + + const update = (f: (settings: DesktopSettings) => DesktopSettings) => + SynchronizedRef.updateAndGet(settingsRef, f); + + return DesktopSettingsState.of({ + get: SynchronizedRef.get(settingsRef), + set: (settings) => SynchronizedRef.set(settingsRef, settings), + load: Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + const settings = yield* readDesktopSettingsEffect( + environment.desktopSettingsPath, + environment.appVersion, + ); + return yield* SynchronizedRef.setAndGet(settingsRef, settings); + }), + update, + updatePersisted: (f) => + Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + return yield* SynchronizedRef.modifyEffect(settingsRef, (settings) => { + const nextSettings = f(settings); + return writeDesktopSettingsEffect(environment.desktopSettingsPath, nextSettings).pipe( + Effect.as([nextSettings, nextSettings] as const), + ); + }); + }), + }); + }), +); diff --git a/apps/desktop/src/main/DesktopUpdates.test.ts b/apps/desktop/src/main/DesktopUpdates.test.ts new file mode 100644 index 00000000000..c31191c3f8e --- /dev/null +++ b/apps/desktop/src/main/DesktopUpdates.test.ts @@ -0,0 +1,259 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import type { DesktopUpdateState } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as EffectPath from "effect/Path"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { TestClock } from "effect/testing"; +import { afterEach, beforeEach } from "vitest"; + +import { DesktopBackendManager } from "../desktopBackendManager.ts"; +import { makeDesktopEnvironment, DesktopEnvironment } from "../desktopEnvironment.ts"; +import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import { DEFAULT_DESKTOP_SETTINGS } from "../desktopSettings.ts"; +import * as DesktopSettingsState from "./DesktopSettingsState.ts"; +import * as DesktopState from "./DesktopState.ts"; +import * as DesktopUpdates from "./DesktopUpdates.ts"; + +const originalMockUpdates = process.env.T3CODE_DESKTOP_MOCK_UPDATES; +const originalMockUpdatePort = process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT; + +interface UpdatesHarness { + readonly layer: Layer.Layer< + DesktopUpdates.DesktopUpdates | DesktopSettingsState.DesktopSettingsState + >; + readonly checkCount: () => number; + readonly feedUrls: () => readonly ElectronUpdater.ElectronUpdaterFeedUrl[]; + readonly listenerCount: () => number; + readonly sentStates: readonly DesktopUpdateState[]; + readonly emit: (eventName: string, payload?: unknown) => void; +} + +const flushCallbacks = Effect.callback((resume) => { + setImmediate(() => resume(Effect.void)); +}); + +function makeHarness(): UpdatesHarness { + let checkCount = 0; + let allowDowngrade = false; + const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = []; + const listeners = new Map void>>(); + const sentStates: DesktopUpdateState[] = []; + + const addListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => { + const eventListeners = listeners.get(eventName) ?? new Set(); + eventListeners.add(listener); + listeners.set(eventName, eventListeners); + }; + + const removeListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => { + const eventListeners = listeners.get(eventName); + if (!eventListeners) { + return; + } + eventListeners.delete(listener); + if (eventListeners.size === 0) { + listeners.delete(eventName); + } + }; + + const updaterLayer = Layer.succeed(ElectronUpdater.ElectronUpdater, { + setFeedURL: (options) => + Effect.sync(() => { + feedUrls.push(options); + }), + setAutoDownload: () => Effect.void, + setAutoInstallOnAppQuit: () => Effect.void, + setChannel: () => Effect.void, + setAllowPrerelease: () => Effect.void, + allowDowngrade: Effect.sync(() => allowDowngrade), + setAllowDowngrade: (value) => + Effect.sync(() => { + allowDowngrade = value; + }), + setDisableDifferentialDownload: () => Effect.void, + checkForUpdates: Effect.sync(() => { + checkCount += 1; + }), + downloadUpdate: Effect.void, + quitAndInstall: () => Effect.void, + on: (eventName, listener) => + Effect.acquireRelease( + Effect.sync(() => { + addListener(eventName, listener as unknown as (...args: readonly unknown[]) => void); + }), + () => + Effect.sync(() => { + removeListener(eventName, listener as unknown as (...args: readonly unknown[]) => void); + }), + ).pipe(Effect.asVoid), + } satisfies ElectronUpdater.ElectronUpdaterShape); + + const windowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { + main: Effect.succeed(Option.none()), + currentMainOrFirst: Effect.succeed(Option.none()), + focusedMainOrFirst: Effect.succeed(Option.none()), + setMain: () => Effect.void, + clearMain: () => Effect.void, + reveal: () => Effect.void, + sendAll: (_channel, state) => + Effect.sync(() => { + sentStates.push(state as DesktopUpdateState); + }), + destroyAll: Effect.void, + syncAllAppearance: () => Effect.void, + } satisfies ElectronWindow.ElectronWindowShape); + + const backendLayer = Layer.succeed(DesktopBackendManager, { + start: Effect.void, + stop: () => Effect.void, + shutdown: Effect.void, + currentConfig: Effect.succeed(Option.none()), + snapshot: Effect.succeed({ + desiredRunning: false, + ready: false, + activePid: Option.none(), + restartAttempt: 0, + restartScheduled: false, + shuttingDown: false, + }), + }); + + const environmentLayer = Layer.effect( + DesktopEnvironment, + makeDesktopEnvironment({ + dirname: "/repo/apps/desktop/src", + env: { T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}` }, + cwd: "/repo", + platform: "darwin", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }), + ).pipe(Layer.provide(EffectPath.layer)); + + const layer = DesktopUpdates.layer.pipe( + Layer.provideMerge(updaterLayer), + Layer.provideMerge(windowLayer), + Layer.provideMerge(backendLayer), + Layer.provideMerge(DesktopState.layer), + Layer.provideMerge(DesktopSettingsState.layer), + Layer.provideMerge(environmentLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return { + layer, + checkCount: () => checkCount, + feedUrls: () => feedUrls, + listenerCount: () => + Array.from(listeners.values()).reduce( + (total, eventListeners) => total + eventListeners.size, + 0, + ), + sentStates, + emit: (eventName, payload) => { + for (const listener of listeners.get(eventName) ?? []) { + listener(payload); + } + }, + }; +} + +describe("DesktopUpdates", () => { + beforeEach(() => { + process.env.T3CODE_DESKTOP_MOCK_UPDATES = "1"; + process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT = "4141"; + }); + + afterEach(() => { + if (originalMockUpdates === undefined) { + delete process.env.T3CODE_DESKTOP_MOCK_UPDATES; + } else { + process.env.T3CODE_DESKTOP_MOCK_UPDATES = originalMockUpdates; + } + + if (originalMockUpdatePort === undefined) { + delete process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT; + } else { + process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT = originalMockUpdatePort; + } + }); + + it.effect("configures the updater and runs startup checks on the test clock", () => { + const harness = makeHarness(); + + return Effect.gen(function* () { + yield* Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + const state = yield* updates.getState; + assert.equal(state.enabled, true); + assert.equal(state.status, "idle"); + assert.deepEqual(harness.feedUrls(), [ + { provider: "generic", url: "http://localhost:4141" }, + ]); + assert.equal(harness.listenerCount(), 6); + assert.equal(harness.checkCount(), 0); + + yield* TestClock.adjust(Duration.millis(15_000)); + assert.equal(harness.checkCount(), 1); + }), + ); + + assert.equal(harness.listenerCount(), 0); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("updates and broadcasts state from updater events", () => { + const harness = makeHarness(); + + return Effect.gen(function* () { + yield* Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + harness.emit("update-available", { version: "1.2.4" }); + yield* flushCallbacks; + + const state = yield* updates.getState; + assert.equal(state.status, "available"); + assert.equal(state.availableVersion, "1.2.4"); + assert.isNotNull(state.checkedAt); + assert.equal(harness.sentStates.at(-1)?.status, "available"); + }), + ); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("persists channel changes through the settings service", () => { + const harness = makeHarness(); + + return Effect.gen(function* () { + yield* Effect.scoped( + Effect.gen(function* () { + const settingsState = yield* DesktopSettingsState.DesktopSettingsState; + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* settingsState.set(DEFAULT_DESKTOP_SETTINGS); + yield* updates.configure; + + const state = yield* updates.setChannel("nightly"); + const settings = yield* settingsState.get; + + assert.equal(state.channel, "nightly"); + assert.equal(settings.updateChannel, "nightly"); + assert.equal(settings.updateChannelConfiguredByUser, true); + }), + ); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); +}); diff --git a/apps/desktop/src/main/DesktopUpdates.ts b/apps/desktop/src/main/DesktopUpdates.ts new file mode 100644 index 00000000000..e3143a0cc9a --- /dev/null +++ b/apps/desktop/src/main/DesktopUpdates.ts @@ -0,0 +1,648 @@ +import type { + DesktopUpdateActionResult, + DesktopUpdateChannel, + DesktopUpdateCheckResult, + DesktopUpdateState, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; + +import { DesktopBackendManager } from "../desktopBackendManager.ts"; +import { type DesktopSettings, setDesktopUpdateChannelPreference } from "../desktopSettings.ts"; +import { DesktopEnvironment, type DesktopEnvironmentShape } from "../desktopEnvironment.ts"; +import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import { UPDATE_STATE_CHANNEL } from "../ipc/channels.ts"; +import { isArm64HostRunningIntelBuild } from "../runtimeArch.ts"; +import { doesVersionMatchDesktopUpdateChannel } from "../updateChannels.ts"; +import { + createInitialDesktopUpdateState, + reduceDesktopUpdateStateOnCheckFailure, + reduceDesktopUpdateStateOnCheckStart, + reduceDesktopUpdateStateOnDownloadComplete, + reduceDesktopUpdateStateOnDownloadFailure, + reduceDesktopUpdateStateOnDownloadProgress, + reduceDesktopUpdateStateOnDownloadStart, + reduceDesktopUpdateStateOnInstallFailure, + reduceDesktopUpdateStateOnNoUpdate, + reduceDesktopUpdateStateOnUpdateAvailable, +} from "../updateMachine.ts"; +import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "../updateState.ts"; +import { formatErrorMessage } from "./DesktopErrors.ts"; +import { DesktopSettingsState } from "./DesktopSettingsState.ts"; +import * as DesktopState from "./DesktopState.ts"; + +const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000; +const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; + +const AppUpdateYmlConfig = Schema.Record(Schema.String, Schema.String); +type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type; + +const UpdateInfo = Schema.Struct({ + version: Schema.String, +}); + +const DownloadProgressInfo = Schema.Struct({ + percent: Schema.Number, +}); + +const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + +export interface DesktopUpdatesShape { + readonly getState: Effect.Effect; + readonly emitState: Effect.Effect; + readonly disabledReason: Effect.Effect>; + readonly configure: Effect.Effect; + readonly setChannel: ( + channel: DesktopUpdateChannel, + ) => Effect.Effect; + readonly check: (reason: string) => Effect.Effect; + readonly download: Effect.Effect; + readonly install: Effect.Effect; + readonly shutdown: Effect.Effect; +} + +export class DesktopUpdates extends Context.Service()( + "t3/desktop/Updates", +) {} + +const withUpdaterLogAnnotations = ( + effect: Effect.Effect, + annotations?: Record, +): Effect.Effect => + effect.pipe( + Effect.annotateLogs({ + scope: "desktop", + component: "desktop-updater", + ...annotations, + }), + ); + +const logUpdaterInfo = ( + message: string, + annotations?: Record, +): Effect.Effect => withUpdaterLogAnnotations(Effect.logInfo(message), annotations); + +const logUpdaterWarning = ( + message: string, + annotations?: Record, +): Effect.Effect => withUpdaterLogAnnotations(Effect.logWarning(message), annotations); + +const logUpdaterError = ( + message: string, + annotations?: Record, +): Effect.Effect => withUpdaterLogAnnotations(Effect.logError(message), annotations); + +function parseAppUpdateYml(raw: string): Effect.Effect> { + const entries: Record = {}; + for (const line of raw.split("\n")) { + const match = line.match(/^(\w+):\s*(.+)$/); + if (match?.[1] && match[2]) { + entries[match[1]] = match[2].trim(); + } + } + + return Schema.decodeUnknownEffect(AppUpdateYmlConfig)(entries).pipe( + Effect.map((config) => (config.provider ? Option.some(config) : Option.none())), + Effect.catch(() => Effect.succeed(Option.none())), + ); +} + +function createBaseUpdateState( + channel: DesktopUpdateChannel, + enabled: boolean, + environment: DesktopEnvironmentShape, +): DesktopUpdateState { + return { + ...createInitialDesktopUpdateState(environment.appVersion, environment.runtimeInfo, channel), + enabled, + status: enabled ? "idle" : "disabled", + }; +} + +function getCanRetryFromState(state: DesktopUpdateState): boolean { + return state.availableVersion !== null || state.downloadedVersion !== null; +} + +const make = Effect.gen(function* () { + const backendManager = yield* DesktopBackendManager; + const desktopState = yield* DesktopState.DesktopState; + const electronUpdater = yield* ElectronUpdater.ElectronUpdater; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const environment = yield* DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const settingsState = yield* DesktopSettingsState; + const updatePersistedSettings = ( + f: Parameters[0], + ): Effect.Effect => + settingsState + .updatePersisted(f) + .pipe( + Effect.provideService(DesktopEnvironment, environment), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + + const appUpdateYmlConfigRef = yield* Ref.make>(Option.none()); + const updatePollerScopeRef = yield* Ref.make>(Option.none()); + const updateCheckInFlightRef = yield* Ref.make(false); + const updateDownloadInFlightRef = yield* Ref.make(false); + const updateInstallInFlightRef = yield* Ref.make(false); + const updaterConfiguredRef = yield* Ref.make(false); + const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1); + const updateStateRef = yield* Ref.make( + createInitialDesktopUpdateState( + environment.appVersion, + environment.runtimeInfo, + environment.defaultDesktopSettings.updateChannel, + ), + ); + + const emitState = Ref.get(updateStateRef).pipe( + Effect.flatMap((state) => electronWindow.sendAll(UPDATE_STATE_CHANNEL, state)), + ); + + const setState = (state: DesktopUpdateState): Effect.Effect => + Ref.set(updateStateRef, state).pipe(Effect.andThen(emitState)); + + const updateState = ( + f: (state: DesktopUpdateState) => DesktopUpdateState, + ): Effect.Effect => + Ref.get(updateStateRef).pipe( + Effect.flatMap((state) => { + const nextState = f(state); + return setState(nextState).pipe(Effect.as(nextState)); + }), + ); + + const readAppUpdateYml = fileSystem.readFileString(environment.appUpdateYmlPath, "utf-8").pipe( + Effect.option, + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(Option.none()), + onSome: parseAppUpdateYml, + }), + ), + ); + + const hasUpdateFeedConfig = Ref.get(appUpdateYmlConfigRef).pipe( + Effect.map( + (appUpdateYmlConfig) => + Option.isSome(appUpdateYmlConfig) || Boolean(process.env.T3CODE_DESKTOP_MOCK_UPDATES), + ), + ); + + const resolveDisabledReason = Effect.gen(function* () { + const hasFeedConfig = yield* hasUpdateFeedConfig; + return Option.fromNullishOr( + getAutoUpdateDisabledReason({ + isDevelopment: environment.isDevelopment, + isPackaged: environment.isPackaged, + platform: process.platform, + appImage: process.env.APPIMAGE, + disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1", + hasUpdateFeedConfig: hasFeedConfig, + }), + ); + }); + + const resolveUpdaterErrorContext = Effect.gen(function* () { + if (yield* Ref.get(updateInstallInFlightRef)) return "install" as const; + if (yield* Ref.get(updateDownloadInFlightRef)) return "download" as const; + if (yield* Ref.get(updateCheckInFlightRef)) return "check" as const; + return (yield* Ref.get(updateStateRef)).errorContext; + }); + + const clearUpdatePollTimer = Effect.gen(function* () { + const scope = yield* Ref.getAndSet(updatePollerScopeRef, Option.none()); + yield* Option.match(scope, { + onNone: () => Effect.void, + onSome: (value) => Scope.close(value, Exit.void).pipe(Effect.ignore), + }); + }); + + const applyAutoUpdaterChannel = (channel: DesktopUpdateChannel): Effect.Effect => + Effect.gen(function* () { + const allowsPrerelease = channel === "nightly"; + yield* electronUpdater.setChannel(channel); + yield* electronUpdater.setAllowPrerelease(allowsPrerelease); + yield* electronUpdater.setAllowDowngrade(allowsPrerelease); + yield* logUpdaterInfo("using update channel", { + channel, + allowPrerelease: allowsPrerelease, + allowDowngrade: allowsPrerelease, + }); + }); + + const shouldEnableAutoUpdates = resolveDisabledReason.pipe(Effect.map(Option.isNone)); + + const checkForUpdates = (reason: string): Effect.Effect => + Effect.gen(function* () { + if (yield* Ref.get(desktopState.quitting)) return false; + if (!(yield* Ref.get(updaterConfiguredRef))) return false; + if (yield* Ref.get(updateCheckInFlightRef)) return false; + + const state = yield* Ref.get(updateStateRef); + if (state.status === "downloading" || state.status === "downloaded") { + yield* logUpdaterInfo("skipping update check while update is active", { + reason, + status: state.status, + }); + return false; + } + + yield* Ref.set(updateCheckInFlightRef, true); + const checkedAt = yield* currentIsoTimestamp; + yield* setState(reduceDesktopUpdateStateOnCheckStart(state, checkedAt)); + yield* logUpdaterInfo("checking for updates", { reason }); + + return yield* electronUpdater.checkForUpdates.pipe( + Effect.as(true), + Effect.catch((error: unknown) => + Effect.gen(function* () { + const failedAt = yield* currentIsoTimestamp; + const message = formatErrorMessage(error); + yield* updateState((current) => + reduceDesktopUpdateStateOnCheckFailure(current, message, failedAt), + ); + yield* logUpdaterError("failed to check for updates", { message }); + return true; + }), + ), + Effect.ensuring(Ref.set(updateCheckInFlightRef, false)), + ); + }); + + const downloadAvailableUpdate = Effect.gen(function* () { + const state = yield* Ref.get(updateStateRef); + if ( + !(yield* Ref.get(updaterConfiguredRef)) || + (yield* Ref.get(updateDownloadInFlightRef)) || + state.status !== "available" + ) { + return { accepted: false, completed: false }; + } + + yield* Ref.set(updateDownloadInFlightRef, true); + yield* setState(reduceDesktopUpdateStateOnDownloadStart(state)); + yield* electronUpdater.setDisableDifferentialDownload( + isArm64HostRunningIntelBuild(environment.runtimeInfo), + ); + yield* logUpdaterInfo("downloading update"); + + return yield* electronUpdater.downloadUpdate.pipe( + Effect.as({ accepted: true, completed: true }), + Effect.catch((error: unknown) => + Effect.gen(function* () { + const message = formatErrorMessage(error); + yield* updateState((current) => + reduceDesktopUpdateStateOnDownloadFailure(current, message), + ); + yield* logUpdaterError("failed to download update", { message }); + return { accepted: true, completed: false }; + }), + ), + Effect.ensuring(Ref.set(updateDownloadInFlightRef, false)), + ); + }); + + const installDownloadedUpdate = Effect.gen(function* () { + const state = yield* Ref.get(updateStateRef); + if ( + (yield* Ref.get(desktopState.quitting)) || + !(yield* Ref.get(updaterConfiguredRef)) || + state.status !== "downloaded" + ) { + return { accepted: false, completed: false }; + } + + yield* Ref.set(desktopState.quitting, true); + yield* Ref.set(updateInstallInFlightRef, true); + yield* clearUpdatePollTimer; + + return yield* Effect.gen(function* () { + yield* backendManager.stop({ timeout: Duration.seconds(5) }); + yield* electronWindow.destroyAll; + yield* electronUpdater.quitAndInstall({ + isSilent: true, + isForceRunAfter: true, + }); + return { accepted: true, completed: false }; + }).pipe( + Effect.catch((error: unknown) => + Effect.gen(function* () { + const message = formatErrorMessage(error); + yield* Ref.set(updateInstallInFlightRef, false); + yield* updateState((current) => + reduceDesktopUpdateStateOnInstallFailure(current, message), + ); + yield* Ref.set(desktopState.quitting, false); + yield* logUpdaterError("failed to install update", { message }); + return { accepted: true, completed: false }; + }), + ), + ); + }); + + const startUpdatePollers: Effect.Effect = Effect.gen(function* () { + yield* clearUpdatePollTimer; + const parentScope = yield* Scope.Scope; + const scope = yield* Scope.make("sequential"); + yield* Ref.set(updatePollerScopeRef, Option.some(scope)); + yield* Scope.addFinalizer(parentScope, Scope.close(scope, Exit.void)); + + yield* Effect.sleep(Duration.millis(AUTO_UPDATE_STARTUP_DELAY_MS)).pipe( + Effect.andThen(checkForUpdates("startup")), + Effect.catchCause((cause) => + logUpdaterError("startup update check failed", { cause: Cause.pretty(cause) }), + ), + Effect.forkIn(scope), + ); + yield* Effect.sleep(Duration.millis(AUTO_UPDATE_POLL_INTERVAL_MS)).pipe( + Effect.andThen(checkForUpdates("poll")), + Effect.forever, + Effect.catchCause((cause) => + logUpdaterError("poll update check failed", { cause: Cause.pretty(cause) }), + ), + Effect.forkIn(scope), + ); + }); + + const handleUpdateAvailable = (raw: unknown) => + Schema.decodeUnknownEffect(UpdateInfo)(raw).pipe( + Effect.flatMap((info) => + Effect.gen(function* () { + const state = yield* Ref.get(updateStateRef); + if (!doesVersionMatchDesktopUpdateChannel(info.version, state.channel)) { + yield* logUpdaterInfo("ignoring update that does not match selected channel", { + version: info.version, + channel: state.channel, + }); + const checkedAt = yield* currentIsoTimestamp; + yield* setState(reduceDesktopUpdateStateOnNoUpdate(state, checkedAt)); + yield* Ref.set(lastLoggedDownloadMilestoneRef, -1); + return; + } + + const checkedAt = yield* currentIsoTimestamp; + yield* setState( + reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt), + ); + yield* Ref.set(lastLoggedDownloadMilestoneRef, -1); + yield* logUpdaterInfo("update available", { version: info.version }); + }), + ), + Effect.catchCause((cause) => + logUpdaterWarning("ignored malformed update-available event", { + cause: Cause.pretty(cause), + }), + ), + ); + + const handleUpdateNotAvailable = Effect.gen(function* () { + const checkedAt = yield* currentIsoTimestamp; + const state = yield* Ref.get(updateStateRef); + yield* setState(reduceDesktopUpdateStateOnNoUpdate(state, checkedAt)); + yield* Ref.set(lastLoggedDownloadMilestoneRef, -1); + yield* logUpdaterInfo("no updates available"); + }); + + const handleUpdaterError = (error: unknown) => + Effect.gen(function* () { + const message = formatErrorMessage(error); + if (yield* Ref.get(updateInstallInFlightRef)) { + yield* Ref.set(updateInstallInFlightRef, false); + yield* Ref.set(desktopState.quitting, false); + yield* updateState((current) => reduceDesktopUpdateStateOnInstallFailure(current, message)); + yield* logUpdaterError("updater error", { message }); + return; + } + + if ( + !(yield* Ref.get(updateCheckInFlightRef)) && + !(yield* Ref.get(updateDownloadInFlightRef)) + ) { + const errorContext = yield* resolveUpdaterErrorContext; + const checkedAt = yield* currentIsoTimestamp; + yield* updateState((current) => ({ + ...current, + status: "error", + message, + checkedAt, + downloadPercent: null, + errorContext, + canRetry: getCanRetryFromState(current), + })); + } + + yield* logUpdaterError("updater error", { message }); + }); + + const handleDownloadProgress = (raw: unknown) => + Schema.decodeUnknownEffect(DownloadProgressInfo)(raw).pipe( + Effect.flatMap((progress) => + Effect.gen(function* () { + const state = yield* Ref.get(updateStateRef); + const percent = Math.floor(progress.percent); + if (shouldBroadcastDownloadProgress(state, progress.percent) || state.message !== null) { + yield* setState(reduceDesktopUpdateStateOnDownloadProgress(state, progress.percent)); + } + const milestone = percent - (percent % 10); + const lastLoggedMilestone = yield* Ref.get(lastLoggedDownloadMilestoneRef); + if (milestone > lastLoggedMilestone) { + yield* Ref.set(lastLoggedDownloadMilestoneRef, milestone); + yield* logUpdaterInfo("download progress", { percent }); + } + }), + ), + Effect.catchCause((cause) => + logUpdaterWarning("ignored malformed download-progress event", { + cause: Cause.pretty(cause), + }), + ), + ); + + const handleUpdateDownloaded = (raw: unknown) => + Schema.decodeUnknownEffect(UpdateInfo)(raw).pipe( + Effect.flatMap((info) => + Effect.gen(function* () { + const state = yield* Ref.get(updateStateRef); + yield* setState(reduceDesktopUpdateStateOnDownloadComplete(state, info.version)); + yield* logUpdaterInfo("update downloaded", { version: info.version }); + }), + ), + Effect.catchCause((cause) => + logUpdaterWarning("ignored malformed update-downloaded event", { + cause: Cause.pretty(cause), + }), + ), + ); + + return DesktopUpdates.of({ + getState: Ref.get(updateStateRef), + emitState, + disabledReason: resolveDisabledReason, + configure: Effect.gen(function* () { + const context = yield* Effect.context(); + const runEffect = (effect: Effect.Effect) => { + void Effect.runPromiseWith(context)(effect); + }; + + const appUpdateYmlConfig = yield* readAppUpdateYml; + yield* Ref.set(appUpdateYmlConfigRef, appUpdateYmlConfig); + + const githubToken = + process.env.T3CODE_DESKTOP_UPDATE_GITHUB_TOKEN?.trim() || + process.env.GH_TOKEN?.trim() || + ""; + if (githubToken) { + const config = Option.getOrUndefined(appUpdateYmlConfig); + if (config?.provider === "github") { + yield* electronUpdater.setFeedURL({ + ...config, + provider: "github", + private: true, + token: githubToken, + } as ElectronUpdater.ElectronUpdaterFeedUrl); + } + } + + if (process.env.T3CODE_DESKTOP_MOCK_UPDATES) { + yield* electronUpdater.setFeedURL({ + provider: "generic", + url: `http://localhost:${process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT ?? 3000}`, + } as ElectronUpdater.ElectronUpdaterFeedUrl); + } + + const settings = yield* settingsState.get; + const enabled = yield* shouldEnableAutoUpdates; + yield* setState(createBaseUpdateState(settings.updateChannel, enabled, environment)); + if (!enabled) { + return; + } + yield* Ref.set(updaterConfiguredRef, true); + + yield* electronUpdater.setAutoDownload(false); + yield* electronUpdater.setAutoInstallOnAppQuit(false); + yield* applyAutoUpdaterChannel(settings.updateChannel); + yield* electronUpdater.setDisableDifferentialDownload( + isArm64HostRunningIntelBuild(environment.runtimeInfo), + ); + + if (isArm64HostRunningIntelBuild(environment.runtimeInfo)) { + yield* logUpdaterInfo( + "Apple Silicon host detected while running Intel build; updates will switch to arm64 packages", + ); + } + + yield* electronUpdater.on("checking-for-update", () => { + runEffect(logUpdaterInfo("looking for updates")); + }); + yield* electronUpdater.on("update-available", (info: unknown) => { + runEffect(handleUpdateAvailable(info)); + }); + yield* electronUpdater.on("update-not-available", () => { + runEffect(handleUpdateNotAvailable); + }); + yield* electronUpdater.on("error", (error: unknown) => { + runEffect(handleUpdaterError(error)); + }); + yield* electronUpdater.on("download-progress", (progress: unknown) => { + runEffect(handleDownloadProgress(progress)); + }); + yield* electronUpdater.on("update-downloaded", (info: unknown) => { + runEffect(handleUpdateDownloaded(info)); + }); + + yield* startUpdatePollers; + }), + setChannel: (nextChannel) => + Effect.gen(function* () { + if ( + (yield* Ref.get(updateCheckInFlightRef)) || + (yield* Ref.get(updateDownloadInFlightRef)) || + (yield* Ref.get(updateInstallInFlightRef)) + ) { + return yield* Effect.fail( + new Error("Cannot change update tracks while an update action is in progress."), + ); + } + + yield* updatePersistedSettings((settings) => + setDesktopUpdateChannelPreference(settings, nextChannel), + ); + + const state = yield* Ref.get(updateStateRef); + if (nextChannel === state.channel) { + return state; + } + + const enabled = yield* shouldEnableAutoUpdates; + yield* setState(createBaseUpdateState(nextChannel, enabled, environment)); + + if (!enabled || !(yield* Ref.get(updaterConfiguredRef))) { + return yield* Ref.get(updateStateRef); + } + + yield* applyAutoUpdaterChannel(nextChannel); + const allowDowngrade = yield* electronUpdater.allowDowngrade; + yield* electronUpdater.setAllowDowngrade(true); + yield* checkForUpdates("channel-change").pipe( + Effect.ensuring(electronUpdater.setAllowDowngrade(allowDowngrade)), + ); + return yield* Ref.get(updateStateRef); + }), + check: (reason) => + Effect.gen(function* () { + if (!(yield* Ref.get(updaterConfiguredRef))) { + return { + checked: false, + state: yield* Ref.get(updateStateRef), + }; + } + const checked = yield* checkForUpdates(reason); + return { + checked, + state: yield* Ref.get(updateStateRef), + }; + }), + download: Effect.gen(function* () { + const result = yield* downloadAvailableUpdate; + return { + accepted: result.accepted, + completed: result.completed, + state: yield* Ref.get(updateStateRef), + }; + }), + install: Effect.gen(function* () { + if (yield* Ref.get(desktopState.quitting)) { + return { + accepted: false, + completed: false, + state: yield* Ref.get(updateStateRef), + }; + } + const result = yield* installDownloadedUpdate; + return { + accepted: result.accepted, + completed: result.completed, + state: yield* Ref.get(updateStateRef), + }; + }), + shutdown: Ref.set(updateInstallInFlightRef, false).pipe(Effect.andThen(clearUpdatePollTimer)), + }); +}); + +export const layer = Layer.effect(DesktopUpdates, make); diff --git a/apps/desktop/src/sshEnvironment.ts b/apps/desktop/src/sshEnvironment.ts index 409e693bcd1..2d2f665100e 100644 --- a/apps/desktop/src/sshEnvironment.ts +++ b/apps/desktop/src/sshEnvironment.ts @@ -43,7 +43,7 @@ interface DesktopSshEnvironmentManagerOptions { request: SshPasswordRequest, ) => Effect.Effect; readonly resolveCliPackageSpec?: () => string; - readonly resolveCliRunner?: () => RemoteT3RunnerOptions; + readonly resolveCliRunner?: Effect.Effect; } export function discoverDesktopSshHostsEffect(input?: { readonly homeDir?: string }) { diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 302341d071e..a4b110c1947 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -64,7 +64,7 @@ export interface RemoteT3RunnerOptions { export interface SshEnvironmentManagerOptions { readonly resolveCliPackageSpec?: () => string; - readonly resolveCliRunner?: () => RemoteT3RunnerOptions; + readonly resolveCliRunner?: Effect.Effect; } interface SshTunnelEntry { @@ -1502,7 +1502,11 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma }); const packageSpec = options.resolveCliPackageSpec?.(); const runner = - options.resolveCliRunner?.() ?? (packageSpec === undefined ? undefined : { packageSpec }); + options.resolveCliRunner === undefined + ? packageSpec === undefined + ? undefined + : { packageSpec } + : yield* options.resolveCliRunner; yield* Effect.logDebug("ssh.environment.runner.resolved", { ...sshTargetLogFields(resolvedTarget), ...sshRunnerLogFields(runner), From d9435a0b7fe2063af6f91a5af17af521eeea01d9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 16:04:30 -0700 Subject: [PATCH 13/43] Refactor desktop server exposure into scoped service - Move exposure state, persistence, and endpoint resolution into a dedicated main service - Wire backend startup and window creation through the new service - Add tests for fallback, persistence, and advertised endpoints --- apps/desktop/src/desktopNetworkInterfaces.ts | 11 +- .../desktop/src/ipc/methods/serverExposure.ts | 16 +- apps/desktop/src/main.ts | 318 +++++---------- .../src/main/DesktopServerExposure.test.ts | 248 ++++++++++++ .../desktop/src/main/DesktopServerExposure.ts | 366 ++++++++++++++++++ apps/desktop/src/main/DesktopSettingsState.ts | 39 +- apps/desktop/src/main/DesktopUpdates.ts | 6 +- apps/desktop/src/serverExposure.ts | 4 +- 8 files changed, 766 insertions(+), 242 deletions(-) create mode 100644 apps/desktop/src/main/DesktopServerExposure.test.ts create mode 100644 apps/desktop/src/main/DesktopServerExposure.ts diff --git a/apps/desktop/src/desktopNetworkInterfaces.ts b/apps/desktop/src/desktopNetworkInterfaces.ts index 68c484d863b..870b5eff10e 100644 --- a/apps/desktop/src/desktopNetworkInterfaces.ts +++ b/apps/desktop/src/desktopNetworkInterfaces.ts @@ -1,4 +1,4 @@ -import * as OS from "node:os"; +import * as NodeOS from "node:os"; import { Context, Effect, Layer } from "effect"; @@ -13,6 +13,9 @@ export class DesktopNetworkInterfacesService extends Context.Service< DesktopNetworkInterfacesServiceShape >()("t3/desktop/NetworkInterfaces") {} -export const DesktopNetworkInterfacesLive = Layer.succeed(DesktopNetworkInterfacesService, { - read: Effect.sync(() => OS.networkInterfaces()), -} satisfies DesktopNetworkInterfacesServiceShape); +export const layer = Layer.succeed( + DesktopNetworkInterfacesService, + DesktopNetworkInterfacesService.of({ + read: Effect.sync(() => NodeOS.networkInterfaces()), + }), +); diff --git a/apps/desktop/src/ipc/methods/serverExposure.ts b/apps/desktop/src/ipc/methods/serverExposure.ts index 94c4635e80d..4bf9fcac518 100644 --- a/apps/desktop/src/ipc/methods/serverExposure.ts +++ b/apps/desktop/src/ipc/methods/serverExposure.ts @@ -17,6 +17,10 @@ import { SET_TAILSCALE_SERVE_ENABLED_CHANNEL, } from "../channels.ts"; import { makeIpcMethod } from "../DesktopIpc.ts"; +import type { + DesktopServerExposurePersistenceError, + DesktopServerExposureSetModeError, +} from "../../main/DesktopServerExposure.ts"; const SetTailscaleServeEnabledInput = Schema.Struct({ enabled: Schema.Boolean, @@ -27,10 +31,18 @@ export interface DesktopServerExposureIpcActionsShape { readonly getState: Effect.Effect; readonly setMode: ( mode: DesktopServerExposureMode, - ) => Effect.Effect; + ) => Effect.Effect< + DesktopServerExposureState, + DesktopServerExposureSetModeError, + DesktopShutdown + >; readonly setTailscaleServeEnabled: ( input: typeof SetTailscaleServeEnabledInput.Type, - ) => Effect.Effect; + ) => Effect.Effect< + DesktopServerExposureState, + DesktopServerExposurePersistenceError, + DesktopShutdown + >; readonly getAdvertisedEndpoints: Effect.Effect; } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 8fe3a7167a3..470a188cec5 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -13,8 +13,6 @@ import * as Random from "effect/Random"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; -import { HttpClient } from "effect/unstable/http"; -import { ChildProcessSpawner } from "effect/unstable/process"; import { BrowserWindow, @@ -25,19 +23,12 @@ import { nativeTheme, } from "electron"; -import type { DesktopServerExposureMode, DesktopServerExposureState } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPortEffect } from "./backendPort.ts"; -import { - type DesktopSettings, - DEFAULT_DESKTOP_SETTINGS, - setDesktopServerExposurePreference, - setDesktopTailscaleServePreference, - writeDesktopSettingsEffect, -} from "./desktopSettings.ts"; +import { type DesktopSettings } from "./desktopSettings.ts"; import { DesktopBackendConfiguration, DesktopBackendEvents, @@ -47,10 +38,7 @@ import { type DesktopBackendManagerShape, type DesktopBackendStartConfig, } from "./desktopBackendManager.ts"; -import { - DesktopNetworkInterfacesLive, - DesktopNetworkInterfacesService, -} from "./desktopNetworkInterfaces.ts"; +import * as DesktopNetworkInterfaces from "./desktopNetworkInterfaces.ts"; import { DesktopBackendOutputLog, DesktopBackendOutputLogLive, @@ -77,10 +65,6 @@ import { installDesktopIpcHandlers } from "./ipc/DesktopIpcHandlers.ts"; import { DesktopServerExposureIpcActions } from "./ipc/methods/serverExposure.ts"; import { DesktopUpdateIpcActions } from "./ipc/methods/updates.ts"; import * as DesktopWindowIpcActionsLive from "./ipc/methods/windowLive.ts"; -import { - resolveDesktopCoreAdvertisedEndpoints, - resolveDesktopServerExposure, -} from "./serverExposure.ts"; import { DesktopSshEnvironmentBridge, DesktopSshEnvironmentManager, @@ -94,9 +78,9 @@ import { DesktopShellEnvironmentProbeLive, } from "./syncShellEnvironment.ts"; import { bindFirstRevealTrigger, type RevealSubscription } from "./windowReveal.ts"; -import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; import { formatErrorMessage } from "./main/DesktopErrors.ts"; import * as DesktopLocalEnvironment from "./main/DesktopLocalEnvironment.ts"; +import * as DesktopServerExposure from "./main/DesktopServerExposure.ts"; import * as DesktopSettingsState from "./main/DesktopSettingsState.ts"; import * as DesktopState from "./main/DesktopState.ts"; import * as DesktopUpdates from "./main/DesktopUpdates.ts"; @@ -106,8 +90,6 @@ const COMMIT_HASH_DISPLAY_LENGTH = 12; const AppPackageMetadata = Schema.Struct({ t3codeCommitHash: Schema.optional(Schema.String), }); -const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; -const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const; const TITLEBAR_HEIGHT = 40; const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; @@ -122,12 +104,7 @@ interface BackendObservabilitySettings { readonly otlpTracesUrl: string | undefined; readonly otlpMetricsUrl: string | undefined; } -let backendPort = 0; -let backendBindHost = DESKTOP_LOOPBACK_HOST; let backendBootstrapToken = ""; -let backendHttpUrl: Option.Option = Option.none(); -let backendEndpointUrl: string | null = null; -let backendAdvertisedHost: string | null = null; let aboutCommitHashCache: Option.Option | undefined; let desktopIconPaths: Readonly>> = { ico: Option.none(), @@ -139,8 +116,6 @@ let backendObservabilitySettings: BackendObservabilitySettings = { otlpTracesUrl: undefined, otlpMetricsUrl: undefined, }; -let desktopSettings = DEFAULT_DESKTOP_SETTINGS; -let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode; interface DesktopEffectRunner { (effect: Effect.Effect): Promise; @@ -153,7 +128,8 @@ type DesktopWindowBoundaryServices = | ElectronShell.ElectronShell | DesktopState.DesktopState | DesktopUpdates.DesktopUpdates - | ElectronWindow.ElectronWindow; + | ElectronWindow.ElectronWindow + | DesktopServerExposure.DesktopServerExposure; type DesktopLifecycleBoundaryServices = | DesktopShutdown | DesktopWindowBoundaryServices @@ -164,20 +140,6 @@ function makeDesktopEffectRunner(context: Context.Context): DesktopEffectR Effect.runPromiseWith(context as unknown as Context.Context)(effect); } -function requireBackendHttpUrl(): URL { - return Option.getOrThrowWith( - backendHttpUrl, - () => new Error("Desktop backend HTTP URL has not been resolved."), - ); -} - -function getBackendHttpUrlHref(): string | null { - return Option.match(backendHttpUrl, { - onNone: () => null, - onSome: (url) => url.href, - }); -} - const withDesktopLogAnnotations = ( effect: Effect.Effect, annotations?: Record, @@ -287,129 +249,6 @@ function backendChildEnv(): NodeJS.ProcessEnv { return env; } -function getDesktopServerExposureState(): DesktopServerExposureState { - return { - mode: desktopServerExposureMode, - endpointUrl: backendEndpointUrl, - advertisedHost: backendAdvertisedHost, - tailscaleServeEnabled: desktopSettings.tailscaleServeEnabled, - tailscaleServePort: desktopSettings.tailscaleServePort, - }; -} - -function getDesktopAdvertisedEndpoints() { - return Effect.gen(function* () { - const networkInterfaces = yield* (yield* DesktopNetworkInterfacesService).read; - const exposure = resolveDesktopServerExposure({ - mode: desktopServerExposureMode, - port: backendPort, - networkInterfaces, - ...(backendAdvertisedHost ? { advertisedHostOverride: backendAdvertisedHost } : {}), - }); - const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({ - port: backendPort, - exposure, - customHttpsEndpointUrls: resolveCustomHttpsEndpointUrls(), - }); - const tailscaleEndpoints = yield* resolveTailscaleAdvertisedEndpoints({ - port: backendPort, - serveEnabled: desktopSettings.tailscaleServeEnabled, - servePort: desktopSettings.tailscaleServePort, - networkInterfaces, - }); - return [...coreEndpoints, ...tailscaleEndpoints]; - }); -} - -function resolveAdvertisedHostOverride(): string | undefined { - const override = process.env.T3CODE_DESKTOP_LAN_HOST?.trim(); - return override && override.length > 0 ? override : undefined; -} - -function resolveCustomHttpsEndpointUrls(): readonly string[] { - return (process.env.T3CODE_DESKTOP_HTTPS_ENDPOINTS ?? "") - .split(",") - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); -} - -function applyDesktopServerExposureMode( - mode: DesktopServerExposureMode, - options?: { - readonly persist?: boolean; - readonly rejectIfUnavailable?: boolean; - }, -): Effect.Effect< - DesktopServerExposureState, - unknown, - FileSystem.FileSystem | EffectPath.Path | DesktopEnvironment | DesktopNetworkInterfacesService -> { - return Effect.gen(function* () { - const environment = yield* DesktopEnvironment; - const networkInterfaces = yield* (yield* DesktopNetworkInterfacesService).read; - const advertisedHostOverride = resolveAdvertisedHostOverride(); - const requestedMode = mode; - let exposure = resolveDesktopServerExposure({ - mode, - port: backendPort, - networkInterfaces, - ...(advertisedHostOverride ? { advertisedHostOverride } : {}), - }); - - if (requestedMode === "network-accessible" && exposure.endpointUrl === null) { - if (options?.rejectIfUnavailable) { - return yield* Effect.fail( - new Error("No reachable network address is available for this desktop right now."), - ); - } - exposure = resolveDesktopServerExposure({ - mode: "local-only", - port: backendPort, - networkInterfaces, - ...(advertisedHostOverride ? { advertisedHostOverride } : {}), - }); - } - - desktopServerExposureMode = exposure.mode; - desktopSettings = setDesktopServerExposurePreference(desktopSettings, requestedMode); - backendBindHost = exposure.bindHost; - backendHttpUrl = Option.some(new URL(exposure.localHttpUrl)); - backendEndpointUrl = exposure.endpointUrl; - backendAdvertisedHost = exposure.advertisedHost; - - if (options?.persist) { - yield* writeDesktopSettingsEffect(environment.desktopSettingsPath, desktopSettings); - } - - return getDesktopServerExposureState(); - }); -} - -function applyDesktopTailscaleServeEnabled( - nextSettings: DesktopSettings, -): Effect.Effect< - DesktopServerExposureState, - unknown, - | FileSystem.FileSystem - | EffectPath.Path - | ElectronApp.ElectronApp - | DesktopEnvironment - | DesktopShutdown - | DesktopState.DesktopState -> { - return Effect.gen(function* () { - const environment = yield* DesktopEnvironment; - desktopSettings = nextSettings; - yield* writeDesktopSettingsEffect(environment.desktopSettingsPath, desktopSettings); - yield* relaunchDesktopAppEffect( - desktopSettings.tailscaleServeEnabled - ? "tailscale-serve-enabled" - : "tailscale-serve-disabled", - ); - return getDesktopServerExposureState(); - }); -} - function relaunchDesktopAppEffect( reason: string, ): Effect.Effect< @@ -455,17 +294,27 @@ function handleBackendReady( ): Effect.Effect< void, never, - DesktopState.DesktopState | ElectronShell.ElectronShell | ElectronWindow.ElectronWindow + | DesktopState.DesktopState + | ElectronShell.ElectronShell + | ElectronWindow.ElectronWindow + | DesktopServerExposure.DesktopServerExposure > { return Effect.gen(function* () { const state = yield* DesktopState.DesktopState; const electronWindow = yield* ElectronWindow.ElectronWindow; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; yield* Ref.set(state.backendReady, true); yield* logDesktopInfo("bootstrap backend ready", { source: "http" }); const existingWindow = yield* electronWindow.currentMainOrFirst; if (!environment.isDevelopment && Option.isNone(existingWindow)) { - const window = createWindow(runEffect, environment, electronWindow); + const backendConfig = yield* serverExposure.backendConfig; + const window = createWindow( + runEffect, + environment, + electronWindow, + backendConfig.httpBaseUrl, + ); yield* electronWindow.setMain(window); yield* logDesktopInfo("bootstrap main window created"); } @@ -478,16 +327,21 @@ function createBackendWindowIfReady( ): Effect.Effect< void, never, - DesktopState.DesktopState | ElectronShell.ElectronShell | ElectronWindow.ElectronWindow + | DesktopState.DesktopState + | ElectronShell.ElectronShell + | ElectronWindow.ElectronWindow + | DesktopServerExposure.DesktopServerExposure > { return Effect.gen(function* () { const state = yield* DesktopState.DesktopState; const electronWindow = yield* ElectronWindow.ElectronWindow; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; const backendReady = yield* Ref.get(state.backendReady); if (!backendReady) return; const existingWindow = yield* electronWindow.currentMainOrFirst; if (Option.isSome(existingWindow)) return; - const window = createWindow(runEffect, environment, electronWindow); + const backendConfig = yield* serverExposure.backendConfig; + const window = createWindow(runEffect, environment, electronWindow, backendConfig.httpBaseUrl); yield* electronWindow.setMain(window); }); } @@ -495,9 +349,11 @@ function createBackendWindowIfReady( const resolveBackendStartConfig: Effect.Effect< DesktopBackendStartConfig, never, - FileSystem.FileSystem | DesktopEnvironment + FileSystem.FileSystem | DesktopEnvironment | DesktopServerExposure.DesktopServerExposure > = Effect.gen(function* () { const environment = yield* DesktopEnvironment; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const backendExposure = yield* serverExposure.backendConfig; backendObservabilitySettings = yield* readPersistedBackendObservabilitySettings(); const captureBackendLogs = !environment.isDevelopment; @@ -512,12 +368,12 @@ const resolveBackendStartConfig: Effect.Effect< bootstrap: { mode: "desktop", noBrowser: true, - port: backendPort, + port: backendExposure.port, t3Home: environment.baseDir, - host: backendBindHost, + host: backendExposure.bindHost, desktopBootstrapToken: backendBootstrapToken, - tailscaleServeEnabled: desktopSettings.tailscaleServeEnabled, - tailscaleServePort: desktopSettings.tailscaleServePort, + tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, + tailscaleServePort: backendExposure.tailscaleServePort, ...(backendObservabilitySettings.otlpTracesUrl ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl } : {}), @@ -525,7 +381,7 @@ const resolveBackendStartConfig: Effect.Effect< ? { otlpMetricsUrl: backendObservabilitySettings.otlpMetricsUrl } : {}), }, - httpBaseUrl: requireBackendHttpUrl(), + httpBaseUrl: backendExposure.httpBaseUrl, captureOutput: captureBackendLogs, }; }); @@ -565,9 +421,11 @@ const desktopBackendConfigurationLayer = Layer.effect( DesktopBackendConfiguration, Effect.gen(function* () { const environment = yield* DesktopEnvironment; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; return { resolve: resolveBackendStartConfig.pipe( Effect.provideService(DesktopEnvironment, environment), + Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), ), }; }), @@ -588,6 +446,7 @@ const desktopBackendEventsLayer = Layer.effect( const state = yield* DesktopState.DesktopState; const context = yield* Effect.context< | DesktopEnvironment + | DesktopServerExposure.DesktopServerExposure | DesktopSshEnvironmentBridge | DesktopState.DesktopState | ElectronShell.ElectronShell @@ -670,47 +529,47 @@ const desktopShellEnvironmentLayer = DesktopShellEnvironmentLive.pipe( ), ); +const desktopServerExposureLayer = DesktopServerExposure.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(NodeHttpClient.layerUndici), + Layer.provideMerge(DesktopNetworkInterfaces.layer), + Layer.provideMerge(DesktopSettingsState.layer), + Layer.provideMerge(desktopEnvironmentLayer), +); + type DesktopServerExposureIpcActionServices = - | FileSystem.FileSystem - | EffectPath.Path | ElectronApp.ElectronApp | DesktopEnvironment - | DesktopState.DesktopState - | DesktopNetworkInterfacesService - | ChildProcessSpawner.ChildProcessSpawner - | HttpClient.HttpClient; + | DesktopState.DesktopState; const desktopServerExposureIpcActionsLayer = Layer.effect( DesktopServerExposureIpcActions, Effect.gen(function* () { const context = yield* Effect.context(); + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; return DesktopServerExposureIpcActions.of({ - getState: Effect.sync(getDesktopServerExposureState), + getState: serverExposure.getState, setMode: (nextMode) => Effect.gen(function* () { - if (nextMode === desktopServerExposureMode) { - return getDesktopServerExposureState(); + const change = yield* serverExposure.setMode(nextMode); + if (change.requiresRelaunch) { + yield* relaunchDesktopAppEffect(`serverExposureMode=${nextMode}`); } - - const nextState = yield* applyDesktopServerExposureMode(nextMode, { - persist: true, - rejectIfUnavailable: true, - }); - yield* relaunchDesktopAppEffect(`serverExposureMode=${nextMode}`); - return nextState; + return change.state; }).pipe(Effect.provide(context)), setTailscaleServeEnabled: (input) => Effect.gen(function* () { - const nextSettings = setDesktopTailscaleServePreference(desktopSettings, { - enabled: input.enabled, - ...(typeof input.port === "number" ? { port: input.port } : {}), - }); - if (nextSettings === desktopSettings) { - return getDesktopServerExposureState(); + const change = yield* serverExposure.setTailscaleServeEnabled(input); + if (change.requiresRelaunch) { + yield* relaunchDesktopAppEffect( + change.state.tailscaleServeEnabled + ? "tailscale-serve-enabled" + : "tailscale-serve-disabled", + ); } - return yield* applyDesktopTailscaleServeEnabled(nextSettings); + return change.state; }).pipe(Effect.provide(context)), - getAdvertisedEndpoints: getDesktopAdvertisedEndpoints().pipe(Effect.provide(context)), + getAdvertisedEndpoints: serverExposure.getAdvertisedEndpoints, }); }), ); @@ -746,6 +605,7 @@ const desktopBackendManagerLayer = DesktopBackendManagerLive.pipe( const desktopBackendRuntimeLayer = DesktopLocalEnvironment.layer.pipe( Layer.provideMerge(desktopBackendManagerLayer), + Layer.provideMerge(desktopServerExposureLayer), ); const desktopElectronWindowLayer = desktopSshEnvironmentBridgeLayer.pipe( @@ -765,7 +625,7 @@ const desktopRuntimeLayer = Layer.mergeAll( ).pipe( Layer.provideMerge(NodeServices.layer), Layer.provideMerge(NodeHttpClient.layerUndici), - Layer.provideMerge(DesktopNetworkInterfacesLive), + Layer.provideMerge(DesktopNetworkInterfaces.layer), Layer.provideMerge(desktopBackendRuntimeLayer), Layer.provideMerge(desktopElectronWindowLayer), Layer.provideMerge(ElectronApp.layer), @@ -774,7 +634,6 @@ const desktopRuntimeLayer = Layer.mergeAll( Layer.provideMerge(ElectronProtocol.layer), Layer.provideMerge(ElectronShell.layer), Layer.provideMerge(ElectronTheme.layer), - Layer.provideMerge(DesktopSettingsState.layer), Layer.provideMerge(desktopEnvironmentLayer), ); @@ -921,9 +780,12 @@ function dispatchMenuAction( const context = yield* Effect.context(); const runEffect = makeDesktopEffectRunner(context); const electronWindow = yield* ElectronWindow.ElectronWindow; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const backendConfig = yield* serverExposure.backendConfig; const existingWindow = yield* electronWindow.focusedMainOrFirst; const targetWindow = - Option.getOrUndefined(existingWindow) ?? createWindow(runEffect, environment, electronWindow); + Option.getOrUndefined(existingWindow) ?? + createWindow(runEffect, environment, electronWindow, backendConfig.httpBaseUrl); if (Option.isNone(existingWindow)) { yield* electronWindow.setMain(targetWindow); } @@ -967,9 +829,13 @@ function handleCheckForUpdatesMenuClick( } const electronWindow = yield* ElectronWindow.ElectronWindow; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const backendConfig = yield* serverExposure.backendConfig; const existingWindow = yield* electronWindow.currentMainOrFirst; if (Option.isNone(existingWindow)) { - yield* electronWindow.setMain(createWindow(runEffect, environment, electronWindow)); + yield* electronWindow.setMain( + createWindow(runEffect, environment, electronWindow, backendConfig.httpBaseUrl), + ); } yield* checkForUpdatesFromMenu(); }), @@ -1325,6 +1191,7 @@ function createWindow( runEffect: DesktopEffectRunner, environment: DesktopEnvironmentShape, electronWindow: ElectronWindow.ElectronWindowShape, + backendHttpUrl: URL, ): BrowserWindow { const window = new BrowserWindow({ width: 1100, @@ -1442,7 +1309,7 @@ function createWindow( void window.loadURL(resolveDesktopDevServerUrl(environment)); window.webContents.openDevTools({ mode: "detach" }); } else { - void window.loadURL(requireBackendHttpUrl().href); + void window.loadURL(backendHttpUrl.href); } window.on("closed", () => { @@ -1464,6 +1331,8 @@ function bootstrap() { return Effect.gen(function* () { const environment = yield* DesktopEnvironment; const electronWindow = yield* ElectronWindow.ElectronWindow; + const settingsState = yield* DesktopSettingsState.DesktopSettingsState; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; const context = yield* Effect.context(); const runEffect = makeDesktopEffectRunner(context); yield* logDesktopInfo("bootstrap start"); @@ -1472,12 +1341,12 @@ function bootstrap() { return yield* Effect.fail(new Error("T3CODE_PORT is required in desktop development.")); } - backendPort = + const backendPort = configuredBackendPort ?? (yield* resolveDesktopBackendPortEffect({ - host: DESKTOP_LOOPBACK_HOST, + host: DesktopServerExposure.DESKTOP_LOOPBACK_HOST, startPort: DEFAULT_DESKTOP_BACKEND_PORT, - requiredHosts: DESKTOP_REQUIRED_PORT_PROBE_HOSTS, + requiredHosts: DesktopServerExposure.DESKTOP_REQUIRED_PORT_PROBE_HOSTS, })); yield* logDesktopInfo( configuredBackendPort === undefined @@ -1489,25 +1358,22 @@ function bootstrap() { }, ); backendBootstrapToken = yield* randomHexString(48); - if (desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode) { + const settings = yield* settingsState.get; + if (settings.serverExposureMode !== environment.defaultDesktopSettings.serverExposureMode) { yield* logDesktopInfo("bootstrap restoring persisted server exposure mode", { - mode: desktopSettings.serverExposureMode, + mode: settings.serverExposureMode, }); } - const serverExposureState = yield* applyDesktopServerExposureMode( - desktopSettings.serverExposureMode, - { - persist: desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode, - }, - ); + const serverExposureState = yield* serverExposure.configureFromSettings({ port: backendPort }); + const backendConfig = yield* serverExposure.backendConfig; yield* logDesktopInfo("bootstrap resolved backend endpoint", { - baseUrl: getBackendHttpUrlHref(), + baseUrl: backendConfig.httpBaseUrl.href, }); if (serverExposureState.endpointUrl) { yield* logDesktopInfo("bootstrap enabled network access", { endpointUrl: serverExposureState.endpointUrl, }); - } else if (desktopSettings.serverExposureMode === "network-accessible") { + } else if (settings.serverExposureMode === "network-accessible") { yield* logDesktopWarning( "bootstrap fell back to local-only because no advertised network host was available", ); @@ -1519,7 +1385,9 @@ function bootstrap() { yield* logDesktopInfo("bootstrap backend start requested"); if (environment.isDevelopment) { - yield* electronWindow.setMain(createWindow(runEffect, environment, electronWindow)); + yield* electronWindow.setMain( + createWindow(runEffect, environment, electronWindow, backendConfig.httpBaseUrl), + ); yield* logDesktopInfo("bootstrap main window created"); } }); @@ -1568,13 +1436,20 @@ function handleActivate( const context = yield* Effect.context(); const runEffect = makeDesktopEffectRunner(context); const electronWindow = yield* ElectronWindow.ElectronWindow; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; const existingWindow = yield* electronWindow.currentMainOrFirst; if (Option.isSome(existingWindow)) { yield* electronWindow.reveal(existingWindow.value); return; } if (environment.isDevelopment) { - const window = createWindow(runEffect, environment, electronWindow); + const backendConfig = yield* serverExposure.backendConfig; + const window = createWindow( + runEffect, + environment, + electronWindow, + backendConfig.httpBaseUrl, + ); yield* electronWindow.setMain(window); return; } @@ -1682,8 +1557,7 @@ const program = Effect.scoped( yield* electronApp.setPath("userData", userDataPath); yield* resolveDesktopIconPaths(); yield* logDesktopInfo("runtime logging configured", { logDir: environment.logDir }); - desktopSettings = yield* settingsState.load; - desktopServerExposureMode = desktopSettings.serverExposureMode; + yield* settingsState.load; if (process.platform === "linux") { yield* electronApp.appendCommandLineSwitch("class", environment.linuxWmClass); diff --git a/apps/desktop/src/main/DesktopServerExposure.test.ts b/apps/desktop/src/main/DesktopServerExposure.test.ts new file mode 100644 index 00000000000..3db3c1d89bf --- /dev/null +++ b/apps/desktop/src/main/DesktopServerExposure.test.ts @@ -0,0 +1,248 @@ +import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; +import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; +import * as NodePath from "@effect/platform-node/NodePath"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as EffectPath from "effect/Path"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { DEFAULT_DESKTOP_SETTINGS, readDesktopSettingsEffect } from "../desktopSettings.ts"; +import { makeDesktopEnvironment, DesktopEnvironment } from "../desktopEnvironment.ts"; +import { DesktopNetworkInterfacesService } from "../desktopNetworkInterfaces.ts"; +import type { DesktopNetworkInterfaces } from "../serverExposure.ts"; +import * as DesktopServerExposure from "./DesktopServerExposure.ts"; +import * as DesktopSettingsState from "./DesktopSettingsState.ts"; + +const encoder = new TextEncoder(); + +const emptyNetworkInterfaces: DesktopNetworkInterfaces = {}; +const lanNetworkInterfaces: DesktopNetworkInterfaces = { + en0: [ + { + address: "192.168.1.20", + family: "IPv4", + internal: false, + }, + ], +}; + +const tailnetNetworkInterfaces: DesktopNetworkInterfaces = { + tailscale0: [ + { + address: "100.90.1.2", + family: "IPv4", + internal: false, + }, + ], +}; + +function mockSpawnerLayer(statusJson = "{}") { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(encoder.encode(statusJson)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ), + ), + ); +} + +function makeEnvironment(baseDir: string) { + return makeDesktopEnvironment({ + dirname: "/repo/apps/desktop/src", + env: { T3CODE_HOME: baseDir }, + cwd: "/repo", + platform: "darwin", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }); +} + +function makeLayer(input: { + readonly baseDir: string; + readonly networkInterfaces?: DesktopNetworkInterfaces; +}) { + const environmentLayer = Layer.effect(DesktopEnvironment, makeEnvironment(input.baseDir)).pipe( + Layer.provide(EffectPath.layer), + ); + const networkLayer = Layer.succeed(DesktopNetworkInterfacesService, { + read: Effect.succeed(input.networkInterfaces ?? emptyNetworkInterfaces), + }); + + return DesktopServerExposure.layer.pipe( + Layer.provideMerge(DesktopSettingsState.layer), + Layer.provideMerge(NodeFileSystem.layer), + Layer.provideMerge(NodePath.layer), + Layer.provideMerge(NodeHttpClient.layerUndici), + Layer.provideMerge(mockSpawnerLayer()), + Layer.provideMerge(networkLayer), + Layer.provideMerge(environmentLayer), + ); +} + +const withHarness = ( + networkInterfaces: DesktopNetworkInterfaces, + effect: Effect.Effect< + A, + E, + | R + | DesktopEnvironment + | FileSystem.FileSystem + | DesktopServerExposure.DesktopServerExposure + | DesktopSettingsState.DesktopSettingsState + >, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-server-exposure-test-", + }); + return yield* effect.pipe(Effect.provide(makeLayer({ baseDir, networkInterfaces }))); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); + +describe("DesktopServerExposure", () => { + it.effect("falls back to local-only without losing the requested network preference", () => + withHarness( + emptyNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const settingsState = yield* DesktopSettingsState.DesktopSettingsState; + + yield* settingsState.set({ + ...DEFAULT_DESKTOP_SETTINGS, + serverExposureMode: "network-accessible", + }); + + const state = yield* serverExposure.configureFromSettings({ port: 4173 }); + assert.equal(state.mode, "local-only"); + assert.equal(state.endpointUrl, null); + assert.equal((yield* settingsState.get).serverExposureMode, "network-accessible"); + + const backendConfig = yield* serverExposure.backendConfig; + assert.equal(backendConfig.bindHost, "127.0.0.1"); + assert.equal(backendConfig.httpBaseUrl.href, "http://127.0.0.1:4173/"); + }), + ), + ); + + it.effect("returns a typed error when network access is explicitly unavailable", () => + withHarness( + emptyNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 4173 }); + + const error = yield* serverExposure.setMode("network-accessible").pipe(Effect.flip); + assert.ok(error._tag === "DesktopServerExposureNoNetworkAddressError"); + assert.equal(error.port, 4173); + }), + ), + ); + + it.effect("persists network-accessible mode and updates backend binding state", () => + withHarness( + lanNetworkInterfaces, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const settingsState = yield* DesktopSettingsState.DesktopSettingsState; + + yield* settingsState.load; + yield* serverExposure.configureFromSettings({ port: 4173 }); + + const change = yield* serverExposure.setMode("network-accessible"); + assert.equal(change.requiresRelaunch, true); + assert.deepEqual(change.state, { + mode: "network-accessible", + endpointUrl: "http://192.168.1.20:4173", + advertisedHost: "192.168.1.20", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }); + + const backendConfig = yield* serverExposure.backendConfig; + assert.equal(backendConfig.bindHost, "0.0.0.0"); + assert.equal(backendConfig.httpBaseUrl.href, "http://127.0.0.1:4173/"); + + const persisted = yield* readDesktopSettingsEffect( + environment.desktopSettingsPath, + environment.appVersion, + ); + assert.equal(persisted.serverExposureMode, "network-accessible"); + }), + ), + ); + + it.effect("persists tailscale serve preferences atomically and reports no-op updates", () => + withHarness( + emptyNetworkInterfaces, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const settingsState = yield* DesktopSettingsState.DesktopSettingsState; + + yield* settingsState.load; + yield* serverExposure.configureFromSettings({ port: 4173 }); + + const changed = yield* serverExposure.setTailscaleServeEnabled({ + enabled: true, + port: 8443, + }); + assert.equal(changed.requiresRelaunch, true); + assert.equal(changed.state.tailscaleServeEnabled, true); + assert.equal(changed.state.tailscaleServePort, 8443); + + const unchanged = yield* serverExposure.setTailscaleServeEnabled({ + enabled: true, + port: 8443, + }); + assert.equal(unchanged.requiresRelaunch, false); + + const persisted = yield* readDesktopSettingsEffect( + environment.desktopSettingsPath, + environment.appVersion, + ); + assert.equal(persisted.tailscaleServeEnabled, true); + assert.equal(persisted.tailscaleServePort, 8443); + }), + ), + ); + + it.effect("resolves advertised endpoints from the scoped runtime state", () => + withHarness( + { ...lanNetworkInterfaces, ...tailnetNetworkInterfaces }, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 4173 }); + yield* serverExposure.setMode("network-accessible"); + + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + assert.deepEqual( + endpoints.map((endpoint) => endpoint.httpBaseUrl), + ["http://127.0.0.1:4173/", "http://192.168.1.20:4173/", "http://100.90.1.2:4173/"], + ); + }), + ), + ); +}); diff --git a/apps/desktop/src/main/DesktopServerExposure.ts b/apps/desktop/src/main/DesktopServerExposure.ts new file mode 100644 index 00000000000..c7cb31243ab --- /dev/null +++ b/apps/desktop/src/main/DesktopServerExposure.ts @@ -0,0 +1,366 @@ +import type { + AdvertisedEndpoint, + DesktopServerExposureMode, + DesktopServerExposureState, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + DEFAULT_DESKTOP_SETTINGS, + type DesktopSettings, + setDesktopServerExposurePreference, + setDesktopTailscaleServePreference, +} from "../desktopSettings.ts"; +import * as DesktopEnvironment from "../desktopEnvironment.ts"; +import * as DesktopNetwork from "../desktopNetworkInterfaces.ts"; +import { + DESKTOP_LOOPBACK_HOST, + resolveDesktopCoreAdvertisedEndpoints, + resolveDesktopServerExposure, + type DesktopNetworkInterfaces, + type DesktopServerExposure as ResolvedDesktopServerExposure, +} from "../serverExposure.ts"; +import { resolveTailscaleAdvertisedEndpoints } from "../tailscaleEndpointProvider.ts"; +import * as DesktopSettingsState from "./DesktopSettingsState.ts"; + +export { DESKTOP_LOOPBACK_HOST } from "../serverExposure.ts"; +export const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const; + +type DesktopServerExposurePersistenceOperation = "server-exposure-mode" | "tailscale-serve"; + +export class DesktopServerExposureNoNetworkAddressError extends Data.TaggedError( + "DesktopServerExposureNoNetworkAddressError", +)<{ + readonly port: number; +}> { + override get message() { + return `No reachable network address is available for desktop network access on port ${this.port}.`; + } +} + +export class DesktopServerExposurePersistenceError extends Data.TaggedError( + "DesktopServerExposurePersistenceError", +)<{ + readonly operation: DesktopServerExposurePersistenceOperation; + readonly cause: DesktopSettingsState.DesktopSettingsPersistenceError; +}> { + override get message() { + return `Failed to persist desktop ${this.operation} settings.`; + } +} + +export type DesktopServerExposureSetModeError = + | DesktopServerExposureNoNetworkAddressError + | DesktopServerExposurePersistenceError; + +export type DesktopServerExposureError = DesktopServerExposureSetModeError; + +export interface DesktopServerExposureBackendConfig { + readonly port: number; + readonly bindHost: string; + readonly httpBaseUrl: URL; + readonly tailscaleServeEnabled: boolean; + readonly tailscaleServePort: number; +} + +export interface DesktopServerExposureChange { + readonly state: DesktopServerExposureState; + readonly requiresRelaunch: boolean; +} + +export interface DesktopServerExposureShape { + readonly getState: Effect.Effect; + readonly backendConfig: Effect.Effect; + readonly configureFromSettings: (input: { + readonly port: number; + }) => Effect.Effect; + readonly setMode: ( + mode: DesktopServerExposureMode, + ) => Effect.Effect; + readonly setTailscaleServeEnabled: (input: { + readonly enabled: boolean; + readonly port?: number; + }) => Effect.Effect; + readonly getAdvertisedEndpoints: Effect.Effect; +} + +export class DesktopServerExposure extends Context.Service< + DesktopServerExposure, + DesktopServerExposureShape +>()("t3/desktop/ServerExposure") {} + +interface RuntimeState { + readonly requestedMode: DesktopServerExposureMode; + readonly mode: DesktopServerExposureMode; + readonly port: number; + readonly bindHost: string; + readonly localHttpUrl: string; + readonly localWsUrl: string; + readonly httpBaseUrl: URL; + readonly endpointUrl: Option.Option; + readonly advertisedHost: Option.Option; + readonly tailscaleServeEnabled: boolean; + readonly tailscaleServePort: number; +} + +interface ResolvedRuntimeState { + readonly state: RuntimeState; + readonly unavailable: boolean; +} + +const initialRuntimeState = (): RuntimeState => + runtimeStateFromResolvedExposure({ + requestedMode: DEFAULT_DESKTOP_SETTINGS.serverExposureMode, + settings: DEFAULT_DESKTOP_SETTINGS, + exposure: resolveDesktopServerExposure({ + mode: DEFAULT_DESKTOP_SETTINGS.serverExposureMode, + port: 0, + networkInterfaces: {}, + }), + port: 0, + }); + +const toContractState = (state: RuntimeState): DesktopServerExposureState => ({ + mode: state.mode, + endpointUrl: Option.getOrNull(state.endpointUrl), + advertisedHost: Option.getOrNull(state.advertisedHost), + tailscaleServeEnabled: state.tailscaleServeEnabled, + tailscaleServePort: state.tailscaleServePort, +}); + +const toBackendConfig = (state: RuntimeState): DesktopServerExposureBackendConfig => ({ + port: state.port, + bindHost: state.bindHost, + httpBaseUrl: state.httpBaseUrl, + tailscaleServeEnabled: state.tailscaleServeEnabled, + tailscaleServePort: state.tailscaleServePort, +}); + +const toResolvedExposure = (state: RuntimeState): ResolvedDesktopServerExposure => ({ + mode: state.mode, + bindHost: state.bindHost, + localHttpUrl: state.localHttpUrl, + localWsUrl: state.localWsUrl, + endpointUrl: Option.getOrNull(state.endpointUrl), + advertisedHost: Option.getOrNull(state.advertisedHost), +}); + +const resolveAdvertisedHostOverride = (): Option.Option => { + const override = process.env.T3CODE_DESKTOP_LAN_HOST?.trim(); + return override && override.length > 0 ? Option.some(override) : Option.none(); +}; + +const resolveCustomHttpsEndpointUrls = (): readonly string[] => + (process.env.T3CODE_DESKTOP_HTTPS_ENDPOINTS ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + +function runtimeStateFromResolvedExposure(input: { + readonly requestedMode: DesktopServerExposureMode; + readonly settings: DesktopSettings; + readonly exposure: ResolvedDesktopServerExposure; + readonly port: number; +}): RuntimeState { + return { + requestedMode: input.requestedMode, + mode: input.exposure.mode, + port: input.port, + bindHost: input.exposure.bindHost, + localHttpUrl: input.exposure.localHttpUrl, + localWsUrl: input.exposure.localWsUrl, + httpBaseUrl: new URL(input.exposure.localHttpUrl), + endpointUrl: Option.fromNullishOr(input.exposure.endpointUrl), + advertisedHost: Option.fromNullishOr(input.exposure.advertisedHost), + tailscaleServeEnabled: input.settings.tailscaleServeEnabled, + tailscaleServePort: input.settings.tailscaleServePort, + }; +} + +function resolveRuntimeState(input: { + readonly requestedMode: DesktopServerExposureMode; + readonly settings: DesktopSettings; + readonly port: number; + readonly networkInterfaces: DesktopNetworkInterfaces; +}): ResolvedRuntimeState { + const advertisedHostOverride = Option.getOrUndefined(resolveAdvertisedHostOverride()); + const requestedExposure = resolveDesktopServerExposure({ + mode: input.requestedMode, + port: input.port, + networkInterfaces: input.networkInterfaces, + ...(advertisedHostOverride ? { advertisedHostOverride } : {}), + }); + const unavailable = + input.requestedMode === "network-accessible" && requestedExposure.endpointUrl === null; + const exposure = unavailable + ? resolveDesktopServerExposure({ + mode: "local-only", + port: input.port, + networkInterfaces: input.networkInterfaces, + ...(advertisedHostOverride ? { advertisedHostOverride } : {}), + }) + : requestedExposure; + + return { + state: runtimeStateFromResolvedExposure({ + requestedMode: input.requestedMode, + settings: input.settings, + exposure, + port: input.port, + }), + unavailable, + }; +} + +const requiresBackendRelaunch = (previous: RuntimeState, next: RuntimeState): boolean => + previous.port !== next.port || + previous.bindHost !== next.bindHost || + previous.localHttpUrl !== next.localHttpUrl; + +const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const networkInterfaces = yield* DesktopNetwork.DesktopNetworkInterfacesService; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const settingsState = yield* DesktopSettingsState.DesktopSettingsState; + const stateRef = yield* Ref.make(initialRuntimeState()); + + const persistSettings = ( + operation: DesktopServerExposurePersistenceOperation, + effect: Effect.Effect< + A, + DesktopSettingsState.DesktopSettingsPersistenceError, + FileSystem.FileSystem | Path.Path | DesktopEnvironment.DesktopEnvironment + >, + ) => + effect.pipe( + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.mapError((cause) => new DesktopServerExposurePersistenceError({ operation, cause })), + ); + + const readNetworkInterfaces = networkInterfaces.read; + + const getState = Ref.get(stateRef).pipe(Effect.map(toContractState)); + const backendConfig = Ref.get(stateRef).pipe(Effect.map(toBackendConfig)); + + const configureFromSettings = ({ port }: { readonly port: number }) => + Effect.gen(function* () { + const settings = yield* settingsState.get; + const currentNetworkInterfaces = yield* readNetworkInterfaces; + const resolved = resolveRuntimeState({ + requestedMode: settings.serverExposureMode, + settings, + port, + networkInterfaces: currentNetworkInterfaces, + }); + yield* Ref.set(stateRef, resolved.state); + return toContractState(resolved.state); + }); + + const setMode = (mode: DesktopServerExposureMode) => + Effect.gen(function* () { + const previous = yield* Ref.get(stateRef); + const currentSettings = yield* settingsState.get; + const nextSettings = setDesktopServerExposurePreference(currentSettings, mode); + const currentNetworkInterfaces = yield* readNetworkInterfaces; + const resolved = resolveRuntimeState({ + requestedMode: mode, + settings: nextSettings, + port: previous.port, + networkInterfaces: currentNetworkInterfaces, + }); + + if (resolved.unavailable) { + return yield* Effect.fail( + new DesktopServerExposureNoNetworkAddressError({ port: previous.port }), + ); + } + + if (nextSettings !== currentSettings) { + yield* persistSettings( + "server-exposure-mode", + settingsState.updatePersisted((settings) => + setDesktopServerExposurePreference(settings, mode), + ), + ); + } + + yield* Ref.set(stateRef, resolved.state); + return { + state: toContractState(resolved.state), + requiresRelaunch: requiresBackendRelaunch(previous, resolved.state), + }; + }); + + const setTailscaleServeEnabled = (input: { readonly enabled: boolean; readonly port?: number }) => + Effect.gen(function* () { + const result = yield* persistSettings( + "tailscale-serve", + settingsState.modifyPersisted((settings) => { + const nextSettings = setDesktopTailscaleServePreference(settings, input); + return [ + { + changed: nextSettings !== settings, + settings: nextSettings, + }, + nextSettings, + ] as const; + }), + ); + + const nextState = yield* Ref.updateAndGet(stateRef, (current) => ({ + ...current, + tailscaleServeEnabled: result.settings.tailscaleServeEnabled, + tailscaleServePort: result.settings.tailscaleServePort, + })); + + return { + state: toContractState(nextState), + requiresRelaunch: result.changed, + }; + }); + + const getAdvertisedEndpoints = Effect.gen(function* () { + const state = yield* Ref.get(stateRef); + const currentNetworkInterfaces = yield* readNetworkInterfaces; + const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({ + port: state.port, + exposure: toResolvedExposure(state), + customHttpsEndpointUrls: resolveCustomHttpsEndpointUrls(), + }); + const tailscaleEndpoints = yield* resolveTailscaleAdvertisedEndpoints({ + port: state.port, + serveEnabled: state.tailscaleServeEnabled, + servePort: state.tailscaleServePort, + networkInterfaces: currentNetworkInterfaces, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + return [...coreEndpoints, ...tailscaleEndpoints]; + }); + + return DesktopServerExposure.of({ + getState, + backendConfig, + configureFromSettings, + setMode, + setTailscaleServeEnabled, + getAdvertisedEndpoints, + }); +}); + +export const layer = Layer.effect(DesktopServerExposure, make); diff --git a/apps/desktop/src/main/DesktopSettingsState.ts b/apps/desktop/src/main/DesktopSettingsState.ts index b0998865bee..30b4596e749 100644 --- a/apps/desktop/src/main/DesktopSettingsState.ts +++ b/apps/desktop/src/main/DesktopSettingsState.ts @@ -3,6 +3,8 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; import * as SynchronizedRef from "effect/SynchronizedRef"; import { @@ -13,6 +15,8 @@ import { } from "../desktopSettings.ts"; import { DesktopEnvironment } from "../desktopEnvironment.ts"; +export type DesktopSettingsPersistenceError = PlatformError.PlatformError | Schema.SchemaError; + export interface DesktopSettingsStateShape { readonly get: Effect.Effect; readonly set: (settings: DesktopSettings) => Effect.Effect; @@ -24,7 +28,14 @@ export interface DesktopSettingsStateShape { f: (settings: DesktopSettings) => DesktopSettings, ) => Effect.Effect< DesktopSettings, - unknown, + DesktopSettingsPersistenceError, + FileSystem.FileSystem | Path.Path | DesktopEnvironment + >; + readonly modifyPersisted: ( + f: (settings: DesktopSettings) => readonly [A, DesktopSettings], + ) => Effect.Effect< + A, + DesktopSettingsPersistenceError, FileSystem.FileSystem | Path.Path | DesktopEnvironment >; } @@ -41,6 +52,20 @@ export const layer = Layer.effect( const update = (f: (settings: DesktopSettings) => DesktopSettings) => SynchronizedRef.updateAndGet(settingsRef, f); + const modifyPersisted = (f: (settings: DesktopSettings) => readonly [A, DesktopSettings]) => + Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + return yield* SynchronizedRef.modifyEffect(settingsRef, (settings) => { + const [result, nextSettings] = f(settings); + if (nextSettings === settings) { + return Effect.succeed([result, settings] as const); + } + + return writeDesktopSettingsEffect(environment.desktopSettingsPath, nextSettings).pipe( + Effect.as([result, nextSettings] as const), + ); + }); + }); return DesktopSettingsState.of({ get: SynchronizedRef.get(settingsRef), @@ -55,15 +80,11 @@ export const layer = Layer.effect( }), update, updatePersisted: (f) => - Effect.gen(function* () { - const environment = yield* DesktopEnvironment; - return yield* SynchronizedRef.modifyEffect(settingsRef, (settings) => { - const nextSettings = f(settings); - return writeDesktopSettingsEffect(environment.desktopSettingsPath, nextSettings).pipe( - Effect.as([nextSettings, nextSettings] as const), - ); - }); + modifyPersisted((settings) => { + const nextSettings = f(settings); + return [nextSettings, nextSettings] as const; }), + modifyPersisted, }); }), ); diff --git a/apps/desktop/src/main/DesktopUpdates.ts b/apps/desktop/src/main/DesktopUpdates.ts index e3143a0cc9a..977e7e75c47 100644 --- a/apps/desktop/src/main/DesktopUpdates.ts +++ b/apps/desktop/src/main/DesktopUpdates.ts @@ -40,7 +40,7 @@ import { } from "../updateMachine.ts"; import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "../updateState.ts"; import { formatErrorMessage } from "./DesktopErrors.ts"; -import { DesktopSettingsState } from "./DesktopSettingsState.ts"; +import * as DesktopSettingsState from "./DesktopSettingsState.ts"; import * as DesktopState from "./DesktopState.ts"; const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000; @@ -143,10 +143,10 @@ const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const settingsState = yield* DesktopSettingsState; + const settingsState = yield* DesktopSettingsState.DesktopSettingsState; const updatePersistedSettings = ( f: Parameters[0], - ): Effect.Effect => + ): Effect.Effect => settingsState .updatePersisted(f) .pipe( diff --git a/apps/desktop/src/serverExposure.ts b/apps/desktop/src/serverExposure.ts index d2352591831..fbf06d391db 100644 --- a/apps/desktop/src/serverExposure.ts +++ b/apps/desktop/src/serverExposure.ts @@ -8,8 +8,8 @@ import type { DesktopServerExposureMode, } from "@t3tools/contracts"; -const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; -const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; +export const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; +export const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; export interface DesktopNetworkInterfaceInfo { readonly address: string; From 3f82d8652e1790e08d9f53e500767cd2b37809b2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 16:24:41 -0700 Subject: [PATCH 14/43] Split desktop SSH handling into dedicated services - Move SSH environment, password prompts, and remote API logic into main-process modules - Preserve prompt cancellation and timeout handling in dedicated tests --- .../desktop/src/ipc/methods/sshEnvironment.ts | 77 ++-- apps/desktop/src/main.ts | 51 +-- .../desktop/src/main/DesktopSshEnvironment.ts | 147 ++++++++ .../main/DesktopSshPasswordPrompts.test.ts | 143 +++++++ .../src/main/DesktopSshPasswordPrompts.ts | 351 +++++++++++++++++ .../src/main/DesktopSshRemoteApi.test.ts | 79 ++++ apps/desktop/src/main/DesktopSshRemoteApi.ts | 120 ++++++ apps/desktop/src/sshEnvironment.test.ts | 31 +- apps/desktop/src/sshEnvironment.ts | 352 ------------------ 9 files changed, 900 insertions(+), 451 deletions(-) create mode 100644 apps/desktop/src/main/DesktopSshEnvironment.ts create mode 100644 apps/desktop/src/main/DesktopSshPasswordPrompts.test.ts create mode 100644 apps/desktop/src/main/DesktopSshPasswordPrompts.ts create mode 100644 apps/desktop/src/main/DesktopSshRemoteApi.test.ts create mode 100644 apps/desktop/src/main/DesktopSshRemoteApi.ts delete mode 100644 apps/desktop/src/sshEnvironment.ts diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts index aa5e3db7920..74407b9586c 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts @@ -1,7 +1,4 @@ import { - AuthBearerBootstrapResult, - AuthSessionState, - AuthWebSocketTokenResult, DesktopDiscoveredSshHostSchema, DesktopSshBearerBootstrapInputSchema, DesktopSshBearerRequestInputSchema, @@ -12,8 +9,10 @@ import { DesktopSshPasswordPromptCancelledType, DesktopSshPasswordPromptResolutionInputSchema, ExecutionEnvironmentDescriptor, + AuthBearerBootstrapResult, + AuthSessionState, + AuthWebSocketTokenResult, } from "@t3tools/contracts"; -import { fetchLoopbackSshJson } from "@t3tools/ssh/tunnel"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -28,18 +27,9 @@ import { RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, } from "../channels.ts"; import { makeIpcMethod } from "../DesktopIpc.ts"; -import { - DesktopSshEnvironmentBridge, - DesktopSshEnvironmentManager, - isSshPasswordPromptCancellation, -} from "../../sshEnvironment.ts"; - -const decodeExecutionEnvironmentDescriptor = Schema.decodeUnknownEffect( - ExecutionEnvironmentDescriptor, -); -const decodeAuthBearerBootstrapResult = Schema.decodeUnknownEffect(AuthBearerBootstrapResult); -const decodeAuthSessionState = Schema.decodeUnknownEffect(AuthSessionState); -const decodeAuthWebSocketTokenResult = Schema.decodeUnknownEffect(AuthWebSocketTokenResult); +import * as DesktopSshEnvironment from "../../main/DesktopSshEnvironment.ts"; +import * as DesktopSshPasswordPrompts from "../../main/DesktopSshPasswordPrompts.ts"; +import * as DesktopSshRemoteApi from "../../main/DesktopSshRemoteApi.ts"; export const discoverSshHosts = makeIpcMethod({ channel: DISCOVER_SSH_HOSTS_CHANNEL, @@ -47,8 +37,8 @@ export const discoverSshHosts = makeIpcMethod({ result: Schema.Array(DesktopDiscoveredSshHostSchema), handler: () => Effect.gen(function* () { - const manager = yield* DesktopSshEnvironmentManager; - return yield* manager.discoverHosts(); + const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; + return yield* sshEnvironment.discoverHosts(); }), }); @@ -58,10 +48,10 @@ export const ensureSshEnvironment = makeIpcMethod({ result: DesktopSshEnvironmentEnsureResultSchema, handler: ({ target, options }) => Effect.gen(function* () { - const manager = yield* DesktopSshEnvironmentManager; - return yield* manager.ensureEnvironment(target, options).pipe( + const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; + return yield* sshEnvironment.ensureEnvironment(target, options).pipe( Effect.catch((error) => - isSshPasswordPromptCancellation(error) + DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation(error) ? Effect.succeed({ type: DesktopSshPasswordPromptCancelledType, message: error.message, @@ -78,8 +68,8 @@ export const disconnectSshEnvironment = makeIpcMethod({ result: Schema.Void, handler: (target) => Effect.gen(function* () { - const manager = yield* DesktopSshEnvironmentManager; - yield* manager.disconnectEnvironment(target); + const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; + yield* sshEnvironment.disconnectEnvironment(target); }), }); @@ -88,10 +78,10 @@ export const fetchSshEnvironmentDescriptor = makeIpcMethod({ payload: DesktopSshHttpBaseUrlInputSchema, result: ExecutionEnvironmentDescriptor, handler: ({ httpBaseUrl }) => - fetchLoopbackSshJson({ - httpBaseUrl, - pathname: "/.well-known/t3/environment", - }).pipe(Effect.flatMap(decodeExecutionEnvironmentDescriptor)), + Effect.gen(function* () { + const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; + return yield* remoteApi.fetchEnvironmentDescriptor({ httpBaseUrl }); + }), }); export const bootstrapSshBearerSession = makeIpcMethod({ @@ -99,12 +89,10 @@ export const bootstrapSshBearerSession = makeIpcMethod({ payload: DesktopSshBearerBootstrapInputSchema, result: AuthBearerBootstrapResult, handler: ({ httpBaseUrl, credential }) => - fetchLoopbackSshJson({ - httpBaseUrl, - pathname: "/api/auth/bootstrap/bearer", - method: "POST", - body: { credential }, - }).pipe(Effect.flatMap(decodeAuthBearerBootstrapResult)), + Effect.gen(function* () { + const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; + return yield* remoteApi.bootstrapBearerSession({ httpBaseUrl, credential }); + }), }); export const fetchSshSessionState = makeIpcMethod({ @@ -112,11 +100,10 @@ export const fetchSshSessionState = makeIpcMethod({ payload: DesktopSshBearerRequestInputSchema, result: AuthSessionState, handler: ({ httpBaseUrl, bearerToken }) => - fetchLoopbackSshJson({ - httpBaseUrl, - pathname: "/api/auth/session", - bearerToken, - }).pipe(Effect.flatMap(decodeAuthSessionState)), + Effect.gen(function* () { + const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; + return yield* remoteApi.fetchSessionState({ httpBaseUrl, bearerToken }); + }), }); export const issueSshWebSocketToken = makeIpcMethod({ @@ -124,12 +111,10 @@ export const issueSshWebSocketToken = makeIpcMethod({ payload: DesktopSshBearerRequestInputSchema, result: AuthWebSocketTokenResult, handler: ({ httpBaseUrl, bearerToken }) => - fetchLoopbackSshJson({ - httpBaseUrl, - pathname: "/api/auth/ws-token", - method: "POST", - bearerToken, - }).pipe(Effect.flatMap(decodeAuthWebSocketTokenResult)), + Effect.gen(function* () { + const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; + return yield* remoteApi.issueWebSocketToken({ httpBaseUrl, bearerToken }); + }), }); export const resolveSshPasswordPrompt = makeIpcMethod({ @@ -138,7 +123,7 @@ export const resolveSshPasswordPrompt = makeIpcMethod({ result: Schema.Void, handler: ({ requestId, password }) => Effect.gen(function* () { - const bridge = yield* DesktopSshEnvironmentBridge; - yield* bridge.resolvePasswordPrompt(requestId, password); + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; + yield* prompts.resolve({ requestId, password }); }), }); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 470a188cec5..de09b4f722b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -26,6 +26,7 @@ import { import * as NetService from "@t3tools/shared/Net"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; +import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPortEffect } from "./backendPort.ts"; import { type DesktopSettings } from "./desktopSettings.ts"; @@ -65,12 +66,6 @@ import { installDesktopIpcHandlers } from "./ipc/DesktopIpcHandlers.ts"; import { DesktopServerExposureIpcActions } from "./ipc/methods/serverExposure.ts"; import { DesktopUpdateIpcActions } from "./ipc/methods/updates.ts"; import * as DesktopWindowIpcActionsLive from "./ipc/methods/windowLive.ts"; -import { - DesktopSshEnvironmentBridge, - DesktopSshEnvironmentManager, - type DesktopSshEnvironmentBridgeShape, - resolveRemoteT3CliPackageSpec, -} from "./sshEnvironment.ts"; import { DesktopShellEnvironment, DesktopShellEnvironmentConfigLive, @@ -82,6 +77,9 @@ import { formatErrorMessage } from "./main/DesktopErrors.ts"; import * as DesktopLocalEnvironment from "./main/DesktopLocalEnvironment.ts"; import * as DesktopServerExposure from "./main/DesktopServerExposure.ts"; import * as DesktopSettingsState from "./main/DesktopSettingsState.ts"; +import * as DesktopSshEnvironment from "./main/DesktopSshEnvironment.ts"; +import * as DesktopSshPasswordPrompts from "./main/DesktopSshPasswordPrompts.ts"; +import * as DesktopSshRemoteApi from "./main/DesktopSshRemoteApi.ts"; import * as DesktopState from "./main/DesktopState.ts"; import * as DesktopUpdates from "./main/DesktopUpdates.ts"; @@ -123,7 +121,6 @@ interface DesktopEffectRunner { type DesktopWindowBoundaryServices = | DesktopEnvironment - | DesktopSshEnvironmentBridge | ElectronDialog.ElectronDialog | ElectronShell.ElectronShell | DesktopState.DesktopState @@ -430,14 +427,6 @@ const desktopBackendConfigurationLayer = Layer.effect( }; }), ); -const desktopSshEnvironmentBridgeLayer = Layer.unwrap( - Effect.gen(function* () { - const electronWindow = yield* ElectronWindow.ElectronWindow; - return DesktopSshEnvironmentBridge.layer({ - getMainWindow: electronWindow.main, - }); - }), -); const desktopBackendEventsLayer = Layer.effect( DesktopBackendEvents, Effect.gen(function* () { @@ -447,7 +436,6 @@ const desktopBackendEventsLayer = Layer.effect( const context = yield* Effect.context< | DesktopEnvironment | DesktopServerExposure.DesktopServerExposure - | DesktopSshEnvironmentBridge | DesktopState.DesktopState | ElectronShell.ElectronShell | ElectronWindow.ElectronWindow @@ -511,7 +499,7 @@ const desktopSshEnvironmentLayer = Layer.unwrap( Effect.gen(function* () { const environment = yield* DesktopEnvironment; const settingsState = yield* DesktopSettingsState.DesktopSettingsState; - return DesktopSshEnvironmentManager.layer({ + return DesktopSshEnvironment.layer({ resolveCliRunner: settingsState.get.pipe( Effect.map((settings) => resolveDesktopSshCliRunner(environment, settings)), ), @@ -519,6 +507,11 @@ const desktopSshEnvironmentLayer = Layer.unwrap( }), ); +const desktopSshRuntimeLayer = Layer.mergeAll( + desktopSshEnvironmentLayer, + DesktopSshRemoteApi.layer, +).pipe(Layer.provideMerge(DesktopSshPasswordPrompts.layer()), Layer.provideMerge(NetService.layer)); + const desktopShellEnvironmentProbeLayer = DesktopShellEnvironmentProbeLive.pipe( Layer.provide(NodeServices.layer), ); @@ -608,15 +601,10 @@ const desktopBackendRuntimeLayer = DesktopLocalEnvironment.layer.pipe( Layer.provideMerge(desktopServerExposureLayer), ); -const desktopElectronWindowLayer = desktopSshEnvironmentBridgeLayer.pipe( - Layer.provideMerge(ElectronWindow.layer), -); - const desktopRuntimeLayer = Layer.mergeAll( desktopLoggerLayer, - NetService.layer, desktopShellEnvironmentLayer, - desktopSshEnvironmentLayer, + desktopSshRuntimeLayer, Layer.succeed(DesktopIpc.DesktopIpc, DesktopIpc.make(ipcMain)), desktopServerExposureIpcActionsLayer, desktopUpdateIpcActionsLayer, @@ -627,7 +615,7 @@ const desktopRuntimeLayer = Layer.mergeAll( Layer.provideMerge(NodeHttpClient.layerUndici), Layer.provideMerge(DesktopNetworkInterfaces.layer), Layer.provideMerge(desktopBackendRuntimeLayer), - Layer.provideMerge(desktopElectronWindowLayer), + Layer.provideMerge(ElectronWindow.layer), Layer.provideMerge(ElectronApp.layer), Layer.provideMerge(ElectronDialog.layer), Layer.provideMerge(ElectronMenu.layer), @@ -1112,13 +1100,11 @@ function startBackend(): Effect.Effect< function closeDesktopResourcesWithManager( backendManager: DesktopBackendManagerShape, - desktopSshEnvironmentBridge: DesktopSshEnvironmentBridgeShape, updates: DesktopUpdates.DesktopUpdatesShape, ): Effect.Effect { return Effect.gen(function* () { yield* backendManager.shutdown; yield* updates.shutdown; - yield* desktopSshEnvironmentBridge.disposeEffect().pipe(Effect.ignore); }); } @@ -1313,14 +1299,6 @@ function createWindow( } window.on("closed", () => { - void runEffect( - Effect.gen(function* () { - const desktopSshEnvironmentBridge = yield* DesktopSshEnvironmentBridge; - yield* desktopSshEnvironmentBridge.cancelPendingPasswordPromptsEffect( - "SSH authentication was cancelled because the app window closed.", - ); - }), - ); void runEffect(electronWindow.clearMain(window)); }); @@ -1540,12 +1518,9 @@ const program = Effect.scoped( const shellEnvironment = yield* DesktopShellEnvironment; const settingsState = yield* DesktopSettingsState.DesktopSettingsState; const updates = yield* DesktopUpdates.DesktopUpdates; - const desktopSshEnvironmentBridge = yield* DesktopSshEnvironmentBridge; - const sshPasswordPromptScope = yield* Scope.make("sequential"); - yield* desktopSshEnvironmentBridge.installPasswordPromptScope(sshPasswordPromptScope); yield* Scope.addFinalizer( yield* Scope.Scope, - closeDesktopResourcesWithManager(backendManager, desktopSshEnvironmentBridge, updates).pipe( + closeDesktopResourcesWithManager(backendManager, updates).pipe( Effect.ensuring(shutdown.markComplete), ), ); diff --git a/apps/desktop/src/main/DesktopSshEnvironment.ts b/apps/desktop/src/main/DesktopSshEnvironment.ts new file mode 100644 index 00000000000..1c36b299708 --- /dev/null +++ b/apps/desktop/src/main/DesktopSshEnvironment.ts @@ -0,0 +1,147 @@ +import type { + DesktopDiscoveredSshHost, + DesktopSshEnvironmentBootstrap, + DesktopSshEnvironmentTarget, +} from "@t3tools/contracts"; +import { type NetError, NetService } from "@t3tools/shared/Net"; +import { + SshPasswordPrompt, + type SshPasswordPromptShape, + type SshPasswordRequest, +} from "@t3tools/ssh/auth"; +import { discoverSshHosts } from "@t3tools/ssh/config"; +import { + SshCommandError, + SshHostDiscoveryError, + SshInvalidTargetError, + SshLaunchError, + SshPairingError, + SshPasswordPromptError, + SshReadinessError, +} from "@t3tools/ssh/errors"; +import { SshEnvironmentManager, type RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; + +export { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; + +export type DesktopSshEnvironmentRuntimeServices = + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | Path.Path + | HttpClient.HttpClient + | NetService; + +export type DesktopSshEnvironmentOperationError = + | SshCommandError + | SshInvalidTargetError + | SshLaunchError + | SshPairingError + | SshReadinessError + | SshPasswordPromptError + | NetError; + +export type DesktopSshEnvironmentDiscoverError = SshHostDiscoveryError; + +export type DesktopSshEnvironmentError = + | DesktopSshEnvironmentDiscoverError + | DesktopSshEnvironmentOperationError; + +export interface DesktopSshEnvironmentShape { + readonly discoverHosts: (input?: { + readonly homeDir?: string; + }) => Effect.Effect; + readonly ensureEnvironment: ( + target: DesktopSshEnvironmentTarget, + options?: { readonly issuePairingToken?: boolean }, + ) => Effect.Effect; + readonly disconnectEnvironment: ( + target: DesktopSshEnvironmentTarget, + ) => Effect.Effect; +} + +export class DesktopSshEnvironment extends Context.Service< + DesktopSshEnvironment, + DesktopSshEnvironmentShape +>()("t3/desktop/SshEnvironment") {} + +export interface DesktopSshEnvironmentLayerOptions { + readonly resolveCliPackageSpec?: () => string; + readonly resolveCliRunner?: Effect.Effect; +} + +export function discoverDesktopSshHostsEffect(input?: { readonly homeDir?: string }) { + return discoverSshHosts(input ?? {}); +} + +export function isDesktopSshPasswordPromptCancellation( + error: unknown, +): error is SshPasswordPromptError { + return ( + error instanceof SshPasswordPromptError && + DesktopSshPasswordPrompts.isDesktopSshPasswordPromptCancellation(error.cause) + ); +} + +const makePasswordPrompt = ( + prompts: DesktopSshPasswordPrompts.DesktopSshPasswordPromptsShape, +): SshPasswordPromptShape => ({ + isAvailable: true, + request: (request: SshPasswordRequest) => + prompts.request(request).pipe( + Effect.mapError( + (cause) => + new SshPasswordPromptError({ + message: cause.message, + cause, + }), + ), + ), +}); + +const make = Effect.gen(function* () { + const manager = yield* SshEnvironmentManager; + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; + const runtimeContext = yield* Effect.context(); + const passwordPrompt = SshPasswordPrompt.of(makePasswordPrompt(prompts)); + + return DesktopSshEnvironment.of({ + discoverHosts: (input) => + discoverDesktopSshHostsEffect(input).pipe(Effect.provide(runtimeContext)), + ensureEnvironment: (target, ensureOptions) => + manager + .ensureEnvironment(target, ensureOptions) + .pipe( + Effect.provideService(SshPasswordPrompt, passwordPrompt), + Effect.provide(runtimeContext), + ), + disconnectEnvironment: (target) => + manager + .disconnectEnvironment(target) + .pipe( + Effect.provideService(SshPasswordPrompt, passwordPrompt), + Effect.provide(runtimeContext), + ), + }); +}); + +export const layer = (options: DesktopSshEnvironmentLayerOptions = {}) => + Layer.effect(DesktopSshEnvironment, make).pipe( + Layer.provide( + SshEnvironmentManager.layer({ + ...(options.resolveCliPackageSpec === undefined + ? {} + : { resolveCliPackageSpec: options.resolveCliPackageSpec }), + ...(options.resolveCliRunner === undefined + ? {} + : { resolveCliRunner: options.resolveCliRunner }), + }), + ), + ); diff --git a/apps/desktop/src/main/DesktopSshPasswordPrompts.test.ts b/apps/desktop/src/main/DesktopSshPasswordPrompts.test.ts new file mode 100644 index 00000000000..14ca8fdc48e --- /dev/null +++ b/apps/desktop/src/main/DesktopSshPasswordPrompts.test.ts @@ -0,0 +1,143 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { TestClock } from "effect/testing"; +import type * as Electron from "electron"; + +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import { SSH_PASSWORD_PROMPT_CHANNEL } from "../ipc/channels.ts"; +import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; + +interface SentMessage { + readonly channel: string; + readonly args: readonly unknown[]; +} + +function makeTestWindow() { + const listeners = new Map void>>(); + const sentMessages: SentMessage[] = []; + let destroyed = false; + let minimized = true; + let restored = false; + let focused = false; + + const window = { + isDestroyed: () => destroyed, + isMinimized: () => minimized, + restore: () => { + restored = true; + minimized = false; + }, + focus: () => { + focused = true; + }, + once: (eventName: string, listener: () => void) => { + const eventListeners = listeners.get(eventName) ?? new Set<() => void>(); + eventListeners.add(listener); + listeners.set(eventName, eventListeners); + }, + removeListener: (eventName: string, listener: () => void) => { + listeners.get(eventName)?.delete(listener); + }, + webContents: { + send: (channel: string, ...args: readonly unknown[]) => { + sentMessages.push({ channel, args }); + }, + }, + }; + + return { + window, + sentMessages, + isRestored: () => restored, + isFocused: () => focused, + close: () => { + destroyed = true; + const closedListeners = [...(listeners.get("closed") ?? [])]; + listeners.delete("closed"); + for (const listener of closedListeners) { + listener(); + } + }, + }; +} + +function makeElectronWindowLayer(window: ReturnType["window"]) { + return Layer.succeed( + ElectronWindow.ElectronWindow, + ElectronWindow.ElectronWindow.of({ + main: Effect.succeed(Option.some(window as Electron.BrowserWindow)), + currentMainOrFirst: Effect.succeed(Option.some(window as Electron.BrowserWindow)), + focusedMainOrFirst: Effect.succeed(Option.some(window as Electron.BrowserWindow)), + setMain: () => Effect.void, + clearMain: () => Effect.void, + reveal: () => Effect.void, + sendAll: () => Effect.void, + destroyAll: Effect.void, + syncAllAppearance: () => Effect.void, + }), + ); +} + +function makeLayer(window: ReturnType["window"]) { + return DesktopSshPasswordPrompts.layer({ passwordPromptTimeoutMs: 1_000 }).pipe( + Layer.provide(makeElectronWindowLayer(window)), + Layer.provideMerge(TestClock.layer()), + ); +} + +describe("DesktopSshPasswordPrompts", () => { + it.effect("sends renderer prompts and resolves them by request id", () => { + const testWindow = makeTestWindow(); + + return Effect.gen(function* () { + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; + const fiber = yield* prompts + .request({ + destination: "devbox", + username: "julius", + prompt: "Enter the SSH password.", + attempt: 1, + }) + .pipe(Effect.forkScoped); + + yield* Effect.yieldNow; + assert.equal(testWindow.sentMessages.length, 1); + const sent = testWindow.sentMessages[0]; + assert.ok(sent); + assert.equal(sent.channel, SSH_PASSWORD_PROMPT_CHANNEL); + const request = sent.args[0] as { readonly requestId: string; readonly destination: string }; + assert.equal(request.destination, "devbox"); + assert.equal(testWindow.isRestored(), true); + assert.equal(testWindow.isFocused(), true); + + yield* prompts.resolve({ requestId: request.requestId, password: "secret" }); + assert.equal(yield* Fiber.join(fiber), "secret"); + }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped); + }); + + it.effect("times out pending renderer prompts with a typed error", () => { + const testWindow = makeTestWindow(); + + return Effect.gen(function* () { + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; + const fiber = yield* prompts + .request({ + destination: "devbox", + username: null, + prompt: "Enter the SSH password.", + attempt: 1, + }) + .pipe(Effect.forkScoped); + + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(1_000)); + const error = yield* Fiber.join(fiber).pipe(Effect.flip); + assert.instanceOf(error, DesktopSshPasswordPrompts.DesktopSshPromptTimedOutError); + assert.equal(error.destination, "devbox"); + }).pipe(Effect.provide(makeLayer(testWindow.window)), Effect.scoped); + }); +}); diff --git a/apps/desktop/src/main/DesktopSshPasswordPrompts.ts b/apps/desktop/src/main/DesktopSshPasswordPrompts.ts new file mode 100644 index 00000000000..34bd4d3f7d1 --- /dev/null +++ b/apps/desktop/src/main/DesktopSshPasswordPrompts.ts @@ -0,0 +1,351 @@ +import type { DesktopSshPasswordPromptRequest } from "@t3tools/contracts"; +import { DesktopSshPasswordPromptResolutionInputSchema } from "@t3tools/contracts"; +import type { SshPasswordRequest } from "@t3tools/ssh/auth"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Random from "effect/Random"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; + +import { SSH_PASSWORD_PROMPT_CHANNEL } from "../ipc/channels.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; + +const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000; +const WINDOW_UNAVAILABLE_MESSAGE = "T3 Code window is not available for SSH authentication."; + +type DesktopSshPasswordPromptResolutionInput = + typeof DesktopSshPasswordPromptResolutionInputSchema.Type; + +export class DesktopSshPromptUnavailableError extends Data.TaggedError( + "DesktopSshPromptUnavailableError", +)<{ + readonly reason: string; +}> { + override get message() { + return this.reason; + } +} + +export class DesktopSshPromptWindowUnavailableError extends Data.TaggedError( + "DesktopSshPromptWindowUnavailableError", +)<{ + readonly destination: string; +}> { + override get message() { + return WINDOW_UNAVAILABLE_MESSAGE; + } +} + +export class DesktopSshPromptSendError extends Data.TaggedError("DesktopSshPromptSendError")<{ + readonly requestId: string; + readonly destination: string; + readonly cause: unknown; +}> { + override get message() { + return WINDOW_UNAVAILABLE_MESSAGE; + } +} + +export class DesktopSshPromptTimedOutError extends Data.TaggedError( + "DesktopSshPromptTimedOutError", +)<{ + readonly requestId: string; + readonly destination: string; +}> { + override get message() { + return `SSH authentication timed out for ${this.destination}.`; + } +} + +export class DesktopSshPromptCancelledError extends Data.TaggedError( + "DesktopSshPromptCancelledError", +)<{ + readonly requestId: string; + readonly destination: string; + readonly reason: string; +}> { + override get message() { + return this.reason; + } +} + +export class DesktopSshPromptInvalidRequestIdError extends Data.TaggedError( + "DesktopSshPromptInvalidRequestIdError", +)<{ + readonly requestId: string; +}> { + override get message() { + return "Invalid SSH password prompt id."; + } +} + +export class DesktopSshPromptExpiredError extends Data.TaggedError("DesktopSshPromptExpiredError")<{ + readonly requestId: string; +}> { + override get message() { + return "SSH password prompt expired. Try connecting again."; + } +} + +export type DesktopSshPasswordPromptRequestError = + | DesktopSshPromptUnavailableError + | DesktopSshPromptWindowUnavailableError + | DesktopSshPromptSendError + | DesktopSshPromptTimedOutError + | DesktopSshPromptCancelledError; + +export type DesktopSshPasswordPromptResolveError = + | DesktopSshPromptInvalidRequestIdError + | DesktopSshPromptExpiredError; + +export type DesktopSshPasswordPromptError = + | DesktopSshPasswordPromptRequestError + | DesktopSshPasswordPromptResolveError; + +export function isDesktopSshPasswordPromptCancellation( + error: unknown, +): error is DesktopSshPromptCancelledError | DesktopSshPromptTimedOutError { + return ( + error instanceof DesktopSshPromptCancelledError || + error instanceof DesktopSshPromptTimedOutError + ); +} + +export interface DesktopSshPasswordPromptsShape { + readonly request: ( + request: SshPasswordRequest, + ) => Effect.Effect; + readonly resolve: ( + input: DesktopSshPasswordPromptResolutionInput, + ) => Effect.Effect; + readonly cancelPending: (reason: string) => Effect.Effect; +} + +export class DesktopSshPasswordPrompts extends Context.Service< + DesktopSshPasswordPrompts, + DesktopSshPasswordPromptsShape +>()("t3/desktop/SshPasswordPrompts") {} + +interface PendingSshPasswordPrompt { + readonly requestId: string; + readonly destination: string; + readonly deferred: Deferred.Deferred; + readonly timeoutFiber: Fiber.Fiber; +} + +interface LayerOptions { + readonly passwordPromptTimeoutMs?: number; +} + +const removePending = ( + pendingRef: Ref.Ref>, + requestId: string, +) => + Ref.modify(pendingRef, (pending) => { + const entry = pending.get(requestId); + if (entry === undefined) { + return [Option.none(), pending] as const; + } + + const nextPending = new Map(pending); + nextPending.delete(requestId); + return [Option.some(entry), nextPending] as const; + }); + +const interruptTimeout = (pending: PendingSshPasswordPrompt) => + Fiber.interrupt(pending.timeoutFiber).pipe(Effect.ignore); + +const failPending = ( + pending: PendingSshPasswordPrompt, + error: DesktopSshPasswordPromptRequestError, +) => + interruptTimeout(pending).pipe( + Effect.andThen(Deferred.fail(pending.deferred, error)), + Effect.asVoid, + ); + +const make = (options: LayerOptions = {}) => + Effect.gen(function* () { + const serviceScope = yield* Scope.Scope; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const pendingRef = yield* Ref.make(new Map()); + const passwordPromptTimeoutMs = + options.passwordPromptTimeoutMs ?? DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS; + + const cancelPending = (reason: string): Effect.Effect => + Ref.getAndSet(pendingRef, new Map()).pipe( + Effect.flatMap((pending) => + Effect.forEach( + pending.values(), + (entry) => + failPending( + entry, + new DesktopSshPromptCancelledError({ + requestId: entry.requestId, + destination: entry.destination, + reason, + }), + ), + { discard: true }, + ), + ), + Effect.asVoid, + ); + + yield* Scope.addFinalizer( + serviceScope, + cancelPending("SSH password prompt service stopped.").pipe(Effect.ignore), + ); + + const resolve = ( + input: DesktopSshPasswordPromptResolutionInput, + ): Effect.Effect => + Effect.gen(function* () { + const requestId = input.requestId.trim(); + if (requestId.length === 0) { + return yield* new DesktopSshPromptInvalidRequestIdError({ requestId: input.requestId }); + } + + const pending = yield* removePending(pendingRef, requestId); + if (Option.isNone(pending)) { + return yield* new DesktopSshPromptExpiredError({ requestId }); + } + + const entry = pending.value; + if (input.password === null) { + yield* failPending( + entry, + new DesktopSshPromptCancelledError({ + requestId, + destination: entry.destination, + reason: `SSH authentication cancelled for ${entry.destination}.`, + }), + ); + return; + } + + yield* interruptTimeout(entry); + yield* Deferred.succeed(entry.deferred, input.password).pipe(Effect.asVoid); + }); + + const request = ( + input: SshPasswordRequest, + ): Effect.Effect => + Effect.gen(function* () { + const window = yield* electronWindow.main; + if (Option.isNone(window) || window.value.isDestroyed()) { + return yield* new DesktopSshPromptWindowUnavailableError({ + destination: input.destination, + }); + } + + const requestId = yield* Random.nextUUIDv4; + const now = yield* DateTime.now; + const expiresAt = DateTime.formatIso( + DateTime.add(now, { milliseconds: passwordPromptTimeoutMs }), + ); + const promptRequest: DesktopSshPasswordPromptRequest = { + requestId, + destination: input.destination, + username: input.username, + prompt: input.prompt, + expiresAt, + }; + const deferred = yield* Deferred.make(); + const timeoutFiber = yield* Effect.sleep(Duration.millis(passwordPromptTimeoutMs)).pipe( + Effect.andThen(removePending(pendingRef, requestId)), + Effect.flatMap((pending) => + Option.match(pending, { + onNone: () => Effect.void, + onSome: (entry) => + Deferred.fail( + entry.deferred, + new DesktopSshPromptTimedOutError({ + requestId, + destination: input.destination, + }), + ).pipe(Effect.asVoid), + }), + ), + Effect.forkIn(serviceScope), + ); + const pending: PendingSshPasswordPrompt = { + requestId, + destination: input.destination, + deferred, + timeoutFiber, + }; + yield* Ref.update(pendingRef, (entries) => new Map(entries).set(requestId, pending)); + + const cancelOnWindowClosed = () => { + Effect.runFork( + removePending(pendingRef, requestId).pipe( + Effect.flatMap((entry) => + Option.match(entry, { + onNone: () => Effect.void, + onSome: (pending) => + failPending( + pending, + new DesktopSshPromptCancelledError({ + requestId, + destination: input.destination, + reason: "SSH authentication was cancelled because the app window closed.", + }), + ), + }), + ), + ), + ); + }; + const cleanup = Effect.sync(() => { + if (!window.value.isDestroyed()) { + window.value.removeListener("closed", cancelOnWindowClosed); + } + }).pipe( + Effect.andThen(removePending(pendingRef, requestId)), + Effect.andThen(interruptTimeout(pending)), + ); + + return yield* Effect.try({ + try: () => { + if (window.value.isDestroyed()) { + throw new Error(WINDOW_UNAVAILABLE_MESSAGE); + } + window.value.once("closed", cancelOnWindowClosed); + window.value.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, promptRequest); + if (window.value.isDestroyed()) { + throw new Error(WINDOW_UNAVAILABLE_MESSAGE); + } + if (window.value.isMinimized()) { + window.value.restore(); + } + if (window.value.isDestroyed()) { + throw new Error(WINDOW_UNAVAILABLE_MESSAGE); + } + window.value.focus(); + }, + catch: (cause) => + new DesktopSshPromptSendError({ + requestId, + destination: input.destination, + cause, + }), + }).pipe(Effect.andThen(Deferred.await(deferred)), Effect.ensuring(cleanup)); + }); + + return DesktopSshPasswordPrompts.of({ + request, + resolve, + cancelPending, + }); + }); + +export const layer = (options: LayerOptions = {}) => + Layer.effect(DesktopSshPasswordPrompts, make(options)); diff --git a/apps/desktop/src/main/DesktopSshRemoteApi.test.ts b/apps/desktop/src/main/DesktopSshRemoteApi.test.ts new file mode 100644 index 00000000000..8b6798d38cb --- /dev/null +++ b/apps/desktop/src/main/DesktopSshRemoteApi.test.ts @@ -0,0 +1,79 @@ +import { assert, describe, it } from "@effect/vitest"; +import { SshHttpBridgeError } from "@t3tools/ssh/errors"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import * as DesktopSshRemoteApi from "./DesktopSshRemoteApi.ts"; + +function jsonResponse(request: HttpClientRequest.HttpClientRequest, body: unknown, status = 200) { + return HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), + ); +} + +function makeLayer( + handler: ( + request: HttpClientRequest.HttpClientRequest, + ) => Effect.Effect, +) { + return DesktopSshRemoteApi.layer.pipe( + Layer.provide( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => handler(request)), + ), + ), + ); +} + +describe("DesktopSshRemoteApi", () => { + it.effect("fetches and decodes the remote environment descriptor", () => { + const requestUrls: string[] = []; + const layer = makeLayer((request) => + Effect.sync(() => { + requestUrls.push(request.url); + return jsonResponse(request, { + environmentId: "remote-env", + label: "Remote Devbox", + platform: { os: "linux", arch: "x64" }, + serverVersion: "1.2.3", + capabilities: { repositoryIdentity: true }, + }); + }), + ); + + return Effect.gen(function* () { + const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; + const descriptor = yield* remoteApi.fetchEnvironmentDescriptor({ + httpBaseUrl: "http://127.0.0.1:41773/", + }); + + assert.equal(descriptor.label, "Remote Devbox"); + assert.deepEqual(requestUrls, ["http://127.0.0.1:41773/.well-known/t3/environment"]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("wraps schema decode failures in a typed remote api error", () => { + const layer = makeLayer((request) => + Effect.succeed(jsonResponse(request, { environmentId: "remote-env" })), + ); + + return Effect.gen(function* () { + const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; + const error = yield* remoteApi + .fetchEnvironmentDescriptor({ + httpBaseUrl: "http://127.0.0.1:41773/", + }) + .pipe(Effect.flip); + + assert.instanceOf(error, DesktopSshRemoteApi.DesktopSshRemoteApiError); + assert.equal(error.operation, "fetch-environment-descriptor"); + assert.equal(error.cause instanceof SshHttpBridgeError, false); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/desktop/src/main/DesktopSshRemoteApi.ts b/apps/desktop/src/main/DesktopSshRemoteApi.ts new file mode 100644 index 00000000000..898686d7ca0 --- /dev/null +++ b/apps/desktop/src/main/DesktopSshRemoteApi.ts @@ -0,0 +1,120 @@ +import { + AuthBearerBootstrapResult, + AuthSessionState, + AuthWebSocketTokenResult, + type AuthBearerBootstrapResult as AuthBearerBootstrapResultType, + type AuthSessionState as AuthSessionStateType, + type AuthWebSocketTokenResult as AuthWebSocketTokenResultType, + ExecutionEnvironmentDescriptor, + type ExecutionEnvironmentDescriptor as ExecutionEnvironmentDescriptorType, +} from "@t3tools/contracts"; +import { SshHttpBridgeError } from "@t3tools/ssh/errors"; +import { fetchLoopbackSshJson } from "@t3tools/ssh/tunnel"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/unstable/http"; + +export type DesktopSshRemoteApiOperation = + | "fetch-environment-descriptor" + | "bootstrap-bearer-session" + | "fetch-session-state" + | "issue-websocket-token"; + +export class DesktopSshRemoteApiError extends Data.TaggedError("DesktopSshRemoteApiError")<{ + readonly operation: DesktopSshRemoteApiOperation; + readonly cause: SshHttpBridgeError | Schema.SchemaError; +}> { + override get message() { + return `SSH remote API request failed during ${this.operation}.`; + } +} + +export interface DesktopSshRemoteApiShape { + readonly fetchEnvironmentDescriptor: (input: { + readonly httpBaseUrl: string; + }) => Effect.Effect; + readonly bootstrapBearerSession: (input: { + readonly httpBaseUrl: string; + readonly credential: string; + }) => Effect.Effect; + readonly fetchSessionState: (input: { + readonly httpBaseUrl: string; + readonly bearerToken: string; + }) => Effect.Effect; + readonly issueWebSocketToken: (input: { + readonly httpBaseUrl: string; + readonly bearerToken: string; + }) => Effect.Effect; +} + +export class DesktopSshRemoteApi extends Context.Service< + DesktopSshRemoteApi, + DesktopSshRemoteApiShape +>()("t3/desktop/SshRemoteApi") {} + +const decodeExecutionEnvironmentDescriptor = Schema.decodeUnknownEffect( + ExecutionEnvironmentDescriptor, +); +const decodeAuthBearerBootstrapResult = Schema.decodeUnknownEffect(AuthBearerBootstrapResult); +const decodeAuthSessionState = Schema.decodeUnknownEffect(AuthSessionState); +const decodeAuthWebSocketTokenResult = Schema.decodeUnknownEffect(AuthWebSocketTokenResult); + +const mapError = + (operation: DesktopSshRemoteApiOperation) => + (cause: SshHttpBridgeError | Schema.SchemaError): DesktopSshRemoteApiError => + new DesktopSshRemoteApiError({ operation, cause }); + +const make = Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const provideHttpClient = (effect: Effect.Effect) => + effect.pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); + + return DesktopSshRemoteApi.of({ + fetchEnvironmentDescriptor: ({ httpBaseUrl }) => + fetchLoopbackSshJson({ + httpBaseUrl, + pathname: "/.well-known/t3/environment", + }).pipe( + Effect.flatMap(decodeExecutionEnvironmentDescriptor), + Effect.mapError(mapError("fetch-environment-descriptor")), + provideHttpClient, + ), + bootstrapBearerSession: ({ httpBaseUrl, credential }) => + fetchLoopbackSshJson({ + httpBaseUrl, + pathname: "/api/auth/bootstrap/bearer", + method: "POST", + body: { credential }, + }).pipe( + Effect.flatMap(decodeAuthBearerBootstrapResult), + Effect.mapError(mapError("bootstrap-bearer-session")), + provideHttpClient, + ), + fetchSessionState: ({ httpBaseUrl, bearerToken }) => + fetchLoopbackSshJson({ + httpBaseUrl, + pathname: "/api/auth/session", + bearerToken, + }).pipe( + Effect.flatMap(decodeAuthSessionState), + Effect.mapError(mapError("fetch-session-state")), + provideHttpClient, + ), + issueWebSocketToken: ({ httpBaseUrl, bearerToken }) => + fetchLoopbackSshJson({ + httpBaseUrl, + pathname: "/api/auth/ws-token", + method: "POST", + bearerToken, + }).pipe( + Effect.flatMap(decodeAuthWebSocketTokenResult), + Effect.mapError(mapError("issue-websocket-token")), + provideHttpClient, + ), + }); +}); + +export const layer = Layer.effect(DesktopSshRemoteApi, make); diff --git a/apps/desktop/src/sshEnvironment.test.ts b/apps/desktop/src/sshEnvironment.test.ts index afe01ad4886..4e97e937fe5 100644 --- a/apps/desktop/src/sshEnvironment.test.ts +++ b/apps/desktop/src/sshEnvironment.test.ts @@ -1,15 +1,10 @@ -import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; -import { NetService } from "@t3tools/shared/Net"; import { SshPasswordPromptError } from "@t3tools/ssh/errors"; import { Effect, FileSystem, Layer, Path } from "effect"; -import { - DesktopSshEnvironmentManager, - discoverDesktopSshHostsEffect, - isSshPasswordPromptCancellation, -} from "./sshEnvironment.ts"; +import * as DesktopSshEnvironment from "./main/DesktopSshEnvironment.ts"; +import * as DesktopSshPasswordPrompts from "./main/DesktopSshPasswordPrompts.ts"; import * as DesktopIpc from "./ipc/DesktopIpc.ts"; import { SSH_PASSWORD_PROMPT_CANCELLED_RESULT } from "./ipc/channels.ts"; import { discoverSshHosts, ensureSshEnvironment } from "./ipc/methods/sshEnvironment.ts"; @@ -40,9 +35,13 @@ class TestIpcMain implements DesktopIpc.DesktopIpcMain { describe("sshEnvironment", () => { it("treats password prompt timeouts as cancellable authentication prompts", () => { assert.equal( - isSshPasswordPromptCancellation( + DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation( new SshPasswordPromptError({ message: "SSH authentication timed out for devbox.", + cause: new DesktopSshPasswordPrompts.DesktopSshPromptTimedOutError({ + requestId: "prompt-1", + destination: "devbox", + }), }), ), true, @@ -80,7 +79,7 @@ describe("sshEnvironment", () => { ].join("\n"), ); - const hosts = yield* discoverDesktopSshHostsEffect({ homeDir }); + const hosts = yield* DesktopSshEnvironment.discoverDesktopSshHostsEffect({ homeDir }); assert.deepEqual(hosts, [ { alias: "bastion.example.com", @@ -138,8 +137,8 @@ describe("sshEnvironment", () => { Effect.provide( Layer.mergeAll( Layer.succeed( - DesktopSshEnvironmentManager, - DesktopSshEnvironmentManager.of({ + DesktopSshEnvironment.DesktopSshEnvironment, + DesktopSshEnvironment.DesktopSshEnvironment.of({ discoverHosts: () => Effect.succeed([ { @@ -181,21 +180,23 @@ describe("sshEnvironment", () => { Effect.provide( Layer.mergeAll( Layer.succeed( - DesktopSshEnvironmentManager, - DesktopSshEnvironmentManager.of({ + DesktopSshEnvironment.DesktopSshEnvironment, + DesktopSshEnvironment.DesktopSshEnvironment.of({ discoverHosts: () => Effect.die("unexpected discoverHosts"), ensureEnvironment: () => Effect.fail( new SshPasswordPromptError({ message: "SSH authentication timed out for devbox.", + cause: new DesktopSshPasswordPrompts.DesktopSshPromptTimedOutError({ + requestId: "prompt-1", + destination: "devbox", + }), }), ), disconnectEnvironment: () => Effect.die("unexpected disconnectEnvironment"), }), ), NodeServices.layer, - NodeHttpClient.layerUndici, - NetService.layer, ), ), ), diff --git a/apps/desktop/src/sshEnvironment.ts b/apps/desktop/src/sshEnvironment.ts deleted file mode 100644 index 2d2f665100e..00000000000 --- a/apps/desktop/src/sshEnvironment.ts +++ /dev/null @@ -1,352 +0,0 @@ -import { NetService } from "@t3tools/shared/Net"; -import type { - DesktopSshEnvironmentBootstrap, - DesktopDiscoveredSshHost, - DesktopSshEnvironmentTarget, - DesktopSshPasswordPromptRequest, -} from "@t3tools/contracts"; -import { - SshPasswordPrompt, - type SshPasswordPromptShape, - type SshPasswordRequest, -} from "@t3tools/ssh/auth"; -import { discoverSshHosts } from "@t3tools/ssh/config"; -import { SshPasswordPromptError } from "@t3tools/ssh/errors"; -import { SshEnvironmentManager, type RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; -import { - Cause, - Context, - DateTime, - Deferred, - Duration, - Effect, - Exit, - FileSystem, - Fiber, - Layer, - Option, - Path, - Random, - Scope, -} from "effect"; -import { HttpClient } from "effect/unstable/http"; -import { ChildProcessSpawner } from "effect/unstable/process"; - -import { SSH_PASSWORD_PROMPT_CHANNEL } from "./ipc/channels.ts"; - -export { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; - -const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000; - -interface DesktopSshEnvironmentManagerOptions { - readonly passwordProvider?: ( - request: SshPasswordRequest, - ) => Effect.Effect; - readonly resolveCliPackageSpec?: () => string; - readonly resolveCliRunner?: Effect.Effect; -} - -export function discoverDesktopSshHostsEffect(input?: { readonly homeDir?: string }) { - return discoverSshHosts(input ?? {}); -} - -export type DesktopSshEnvironmentEffectContext = - | ChildProcessSpawner.ChildProcessSpawner - | FileSystem.FileSystem - | Path.Path - | HttpClient.HttpClient - | NetService; - -export interface DesktopSshEnvironmentManagerShape { - readonly discoverHosts: (input?: { - readonly homeDir?: string; - }) => Effect.Effect< - readonly DesktopDiscoveredSshHost[], - unknown, - FileSystem.FileSystem | Path.Path - >; - readonly ensureEnvironment: ( - target: DesktopSshEnvironmentTarget, - options?: { readonly issuePairingToken?: boolean }, - ) => Effect.Effect; - readonly disconnectEnvironment: ( - target: DesktopSshEnvironmentTarget, - ) => Effect.Effect; -} - -function makeDesktopSshPasswordPrompt( - passwordProvider: DesktopSshEnvironmentManagerOptions["passwordProvider"], -): SshPasswordPromptShape { - return { - isAvailable: passwordProvider !== undefined, - request: (request) => { - if (!passwordProvider) { - return Effect.succeed(null); - } - - return passwordProvider(request).pipe( - Effect.catchCause((cause) => - Effect.fail( - new SshPasswordPromptError({ - message: "SSH password prompt failed.", - cause: Cause.squash(cause), - }), - ), - ), - ); - }, - }; -} - -const makeDesktopSshEnvironmentManager = Effect.fn("desktop.ssh.manager.make")(function* ( - options: DesktopSshEnvironmentManagerOptions = {}, -) { - const manager = yield* SshEnvironmentManager; - const bridge = yield* DesktopSshEnvironmentBridge; - const passwordPrompt = SshPasswordPrompt.of( - makeDesktopSshPasswordPrompt(options.passwordProvider ?? bridge.passwordProvider), - ); - const withPasswordPrompt = ( - effect: Effect.Effect, - ): Effect.Effect> => - effect.pipe(Effect.provideService(SshPasswordPrompt, passwordPrompt)); - - return DesktopSshEnvironmentManager.of({ - discoverHosts: discoverDesktopSshHostsEffect, - ensureEnvironment: (target, ensureOptions) => - withPasswordPrompt(manager.ensureEnvironment(target, ensureOptions)), - disconnectEnvironment: (target) => withPasswordPrompt(manager.disconnectEnvironment(target)), - }); -}); - -export class DesktopSshEnvironmentManager extends Context.Service< - DesktopSshEnvironmentManager, - DesktopSshEnvironmentManagerShape ->()("@t3tools/desktop/DesktopSshEnvironmentManager") { - static readonly layer = (options: DesktopSshEnvironmentManagerOptions = {}) => - Layer.effect(DesktopSshEnvironmentManager, makeDesktopSshEnvironmentManager(options)).pipe( - Layer.provide( - SshEnvironmentManager.layer({ - ...(options.resolveCliPackageSpec === undefined - ? {} - : { resolveCliPackageSpec: options.resolveCliPackageSpec }), - ...(options.resolveCliRunner === undefined - ? {} - : { resolveCliRunner: options.resolveCliRunner }), - }), - ), - ); -} - -/** Minimal subset of Electron's BrowserWindow used by the SSH bridge. */ -export interface DesktopSshBridgeWindow { - isDestroyed(): boolean; - isMinimized(): boolean; - restore(): void; - focus(): void; - readonly webContents: { - send(channel: string, ...args: readonly unknown[]): void; - }; -} - -export interface DesktopSshEnvironmentBridgeOptions { - readonly getMainWindow: Effect.Effect, never>; - readonly passwordPromptTimeoutMs?: number; -} - -interface PendingSshPasswordPrompt { - readonly deferred: Deferred.Deferred; - readonly timeoutFiber: Fiber.Fiber; -} - -export function isSshPasswordPromptCancellation(error: unknown): error is SshPasswordPromptError { - const message = error instanceof SshPasswordPromptError ? error.message.toLowerCase() : ""; - return ( - error instanceof SshPasswordPromptError && - (message.includes("cancelled") || message.includes("timed out")) - ); -} - -export interface DesktopSshEnvironmentBridgeShape { - readonly installPasswordPromptScope: (scope: Scope.Closeable) => Effect.Effect; - readonly passwordProvider: (request: SshPasswordRequest) => Effect.Effect; - readonly resolvePasswordPrompt: ( - requestId: string, - password: string | null, - ) => Effect.Effect; - readonly cancelPendingPasswordPromptsEffect: (reason: string) => Effect.Effect; - readonly disposeEffect: () => Effect.Effect; -} - -/** - * Owns renderer-facing SSH password prompt state so the manager can request - * credentials without depending on Electron IPC details. - */ -function makeDesktopSshEnvironmentBridge( - options: DesktopSshEnvironmentBridgeOptions, -): DesktopSshEnvironmentBridgeShape { - let passwordPromptScope: Option.Option = Option.none(); - const pendingPrompts = new Map(); - const passwordPromptTimeoutMs = - options.passwordPromptTimeoutMs ?? DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS; - let disposed = false; - - const cancelPendingPasswordPromptsEffect = (reason: string): Effect.Effect => { - const prompts = Array.from(pendingPrompts); - pendingPrompts.clear(); - return Effect.forEach( - prompts, - ([, pending]) => - Fiber.interrupt(pending.timeoutFiber).pipe( - Effect.ignore, - Effect.andThen(Deferred.fail(pending.deferred, new Error(reason))), - Effect.asVoid, - ), - { discard: true }, - ).pipe(Effect.asVoid); - }; - - const resolvePasswordPromptEffect = ( - requestId: string, - password: string | null, - ): Effect.Effect => { - if (requestId.trim().length === 0) { - return Effect.fail(new Error("Invalid SSH password prompt id.")); - } - - const pending = pendingPrompts.get(requestId); - if (!pending) { - return Effect.fail(new Error("SSH password prompt expired. Try connecting again.")); - } - - pendingPrompts.delete(requestId); - return Fiber.interrupt(pending.timeoutFiber).pipe( - Effect.ignore, - Effect.andThen(Deferred.succeed(pending.deferred, password)), - Effect.asVoid, - ); - }; - - const requestPasswordFromRendererEffect = ( - input: SshPasswordRequest, - ): Effect.Effect => { - const scope = Option.getOrUndefined(passwordPromptScope); - if (scope === undefined) { - return Effect.fail(new Error("SSH password prompt scope has not been initialized.")); - } - - return Effect.gen(function* () { - const window = Option.getOrUndefined(yield* options.getMainWindow); - if (!window || window.isDestroyed()) { - return yield* Effect.fail( - new Error("T3 Code window is not available for SSH authentication."), - ); - } - - const requestId = yield* Random.nextUUIDv4; - const now = yield* DateTime.now; - const request: DesktopSshPasswordPromptRequest = { - requestId, - destination: input.destination, - username: input.username, - prompt: input.prompt, - expiresAt: DateTime.formatIso(DateTime.add(now, { milliseconds: passwordPromptTimeoutMs })), - }; - const deferred = yield* Deferred.make(); - const timeoutFiber = yield* Effect.sleep(Duration.millis(passwordPromptTimeoutMs)).pipe( - Effect.andThen( - Effect.sync(() => { - pendingPrompts.delete(request.requestId); - }), - ), - Effect.andThen( - Deferred.fail( - deferred, - new Error(`SSH authentication timed out for ${input.destination}.`), - ), - ), - Effect.asVoid, - Effect.forkIn(scope), - ); - - pendingPrompts.set(request.requestId, { deferred, timeoutFiber }); - - yield* Effect.try({ - try: () => { - if (window.isDestroyed()) { - throw new Error("T3 Code window is not available for SSH authentication."); - } - window.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, request); - if (window.isDestroyed()) { - throw new Error("T3 Code window is not available for SSH authentication."); - } - if (window.isMinimized()) { - window.restore(); - } - if (window.isDestroyed()) { - throw new Error("T3 Code window is not available for SSH authentication."); - } - window.focus(); - }, - catch: (error) => - error instanceof Error - ? error - : new Error("T3 Code window is not available for SSH authentication."), - }).pipe( - Effect.catch((error) => - Effect.fail(error).pipe( - Effect.ensuring( - Effect.sync(() => { - pendingPrompts.delete(request.requestId); - }).pipe(Effect.andThen(Fiber.interrupt(timeoutFiber).pipe(Effect.ignore))), - ), - ), - ), - ); - - return yield* Deferred.await(deferred).pipe( - Effect.ensuring( - Effect.sync(() => { - pendingPrompts.delete(request.requestId); - }).pipe(Effect.andThen(Fiber.interrupt(timeoutFiber).pipe(Effect.ignore))), - ), - ); - }); - }; - - return { - installPasswordPromptScope: (scope) => - Effect.sync(() => { - passwordPromptScope = Option.some(scope); - }), - passwordProvider: requestPasswordFromRendererEffect, - resolvePasswordPrompt: resolvePasswordPromptEffect, - cancelPendingPasswordPromptsEffect, - disposeEffect: () => { - if (disposed) return Effect.void; - disposed = true; - const scope = passwordPromptScope; - passwordPromptScope = Option.none(); - return cancelPendingPasswordPromptsEffect("SSH environment bridge disposed.").pipe( - Effect.andThen( - Option.match(scope, { - onNone: () => Effect.void, - onSome: (scope) => Scope.close(scope, Exit.void), - }), - ), - Effect.ignore, - ); - }, - }; -} - -export class DesktopSshEnvironmentBridge extends Context.Service< - DesktopSshEnvironmentBridge, - DesktopSshEnvironmentBridgeShape ->()("@t3tools/desktop/DesktopSshEnvironmentBridge") { - static readonly layer = (options: DesktopSshEnvironmentBridgeOptions) => - Layer.succeed( - DesktopSshEnvironmentBridge, - DesktopSshEnvironmentBridge.of(makeDesktopSshEnvironmentBridge(options)), - ); -} From 0f9fb93152da198fdec0b24208b26dfd5317aa71 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 17:47:59 -0700 Subject: [PATCH 15/43] Refactor desktop window, theme, and updater services - Split lifecycle and asset handling into dedicated modules - Add typed Electron updater and window creation errors - Scope native theme and menu popup helpers with tests --- .../desktop/src/electron/ElectronMenu.test.ts | 17 + apps/desktop/src/electron/ElectronMenu.ts | 13 + .../src/electron/ElectronTheme.test.ts | 63 ++ apps/desktop/src/electron/ElectronTheme.ts | 17 +- .../src/electron/ElectronUpdater.test.ts | 20 + apps/desktop/src/electron/ElectronUpdater.ts | 86 ++- apps/desktop/src/electron/ElectronWindow.ts | 34 +- apps/desktop/src/ipc/methods/updates.ts | 3 +- apps/desktop/src/main.ts | 637 ++---------------- apps/desktop/src/main/DesktopAssets.ts | 83 +++ apps/desktop/src/main/DesktopLifecycle.ts | 174 +++++ .../main/DesktopSshPasswordPrompts.test.ts | 1 + apps/desktop/src/main/DesktopUpdates.test.ts | 48 +- apps/desktop/src/main/DesktopUpdates.ts | 87 ++- apps/desktop/src/main/DesktopWindow.ts | 351 ++++++++++ 15 files changed, 1002 insertions(+), 632 deletions(-) create mode 100644 apps/desktop/src/electron/ElectronTheme.test.ts create mode 100644 apps/desktop/src/main/DesktopAssets.ts create mode 100644 apps/desktop/src/main/DesktopLifecycle.ts create mode 100644 apps/desktop/src/main/DesktopWindow.ts diff --git a/apps/desktop/src/electron/ElectronMenu.test.ts b/apps/desktop/src/electron/ElectronMenu.test.ts index 4d8fd90ffb4..e3767d8a293 100644 --- a/apps/desktop/src/electron/ElectronMenu.test.ts +++ b/apps/desktop/src/electron/ElectronMenu.test.ts @@ -67,6 +67,23 @@ describe("ElectronMenu", () => { }).pipe(Effect.provide(ElectronMenu.layer)), ); + it.effect("pops up a native menu template", () => + Effect.gen(function* () { + const popupMock = vi.fn(); + const window = {} as Electron.BrowserWindow; + buildFromTemplateMock.mockReturnValue({ popup: popupMock }); + + const electronMenu = yield* ElectronMenu.ElectronMenu; + yield* electronMenu.popupTemplate({ + window, + template: [{ role: "copy" }], + }); + + assert.deepEqual(buildFromTemplateMock.mock.calls[0]?.[0], [{ role: "copy" }]); + assert.deepEqual(popupMock.mock.calls, [[{ window }]]); + }).pipe(Effect.provide(ElectronMenu.layer)), + ); + it.effect("resolves with none when the menu closes without a click", () => Effect.gen(function* () { buildFromTemplateMock.mockImplementation(() => ({ diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index bc04413c2d7..8ea21017787 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -17,10 +17,16 @@ export interface ElectronMenuContextInput { readonly position: Option.Option; } +export interface ElectronMenuTemplateInput { + readonly window: Electron.BrowserWindow; + readonly template: readonly Electron.MenuItemConstructorOptions[]; +} + export interface ElectronMenuShape { readonly showContextMenu: ( input: ElectronMenuContextInput, ) => Effect.Effect>; + readonly popupTemplate: (input: ElectronMenuTemplateInput) => Effect.Effect; } export class ElectronMenu extends Context.Service()( @@ -124,6 +130,13 @@ export const layer = Layer.sync(ElectronMenu, () => { }; return ElectronMenu.of({ + popupTemplate: (input) => { + if (input.template.length === 0) { + return Effect.void; + } + Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window }); + return Effect.void; + }, showContextMenu: (input) => Effect.callback>((resume) => { const normalizedItems = normalizeContextMenuItems(input.items); diff --git a/apps/desktop/src/electron/ElectronTheme.test.ts b/apps/desktop/src/electron/ElectronTheme.test.ts new file mode 100644 index 00000000000..73682669765 --- /dev/null +++ b/apps/desktop/src/electron/ElectronTheme.test.ts @@ -0,0 +1,63 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { beforeEach, vi } from "vitest"; + +const { onMock, removeListenerMock, themeState } = vi.hoisted(() => ({ + onMock: vi.fn(), + removeListenerMock: vi.fn(), + themeState: { + shouldUseDarkColors: true, + themeSource: "system", + }, +})); + +vi.mock("electron", () => ({ + nativeTheme: { + get shouldUseDarkColors() { + return themeState.shouldUseDarkColors; + }, + set themeSource(value: string) { + themeState.themeSource = value; + }, + on: onMock, + removeListener: removeListenerMock, + }, +})); + +import * as ElectronTheme from "./ElectronTheme.ts"; + +describe("ElectronTheme", () => { + beforeEach(() => { + onMock.mockClear(); + removeListenerMock.mockClear(); + themeState.shouldUseDarkColors = true; + themeState.themeSource = "system"; + }); + + it.effect("reads and writes native theme state", () => + Effect.gen(function* () { + const electronTheme = yield* ElectronTheme.ElectronTheme; + + assert.isTrue(yield* electronTheme.shouldUseDarkColors); + yield* electronTheme.setSource("dark"); + + assert.equal(themeState.themeSource, "dark"); + }).pipe(Effect.provide(ElectronTheme.layer)), + ); + + it.effect("scopes native theme update listeners", () => + Effect.gen(function* () { + const listener = vi.fn(); + + yield* Effect.scoped( + Effect.gen(function* () { + const electronTheme = yield* ElectronTheme.ElectronTheme; + yield* electronTheme.onUpdated(listener); + }), + ); + + assert.deepEqual(onMock.mock.calls, [["updated", listener]]); + assert.deepEqual(removeListenerMock.mock.calls, [["updated", listener]]); + }).pipe(Effect.provide(ElectronTheme.layer)), + ); +}); diff --git a/apps/desktop/src/electron/ElectronTheme.ts b/apps/desktop/src/electron/ElectronTheme.ts index d3628c44b80..ecf1f98dade 100644 --- a/apps/desktop/src/electron/ElectronTheme.ts +++ b/apps/desktop/src/electron/ElectronTheme.ts @@ -2,12 +2,14 @@ import type { DesktopTheme } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; import * as Electron from "electron"; export interface ElectronThemeShape { readonly shouldUseDarkColors: Effect.Effect; readonly setSource: (theme: DesktopTheme) => Effect.Effect; + readonly onUpdated: (listener: () => void) => Effect.Effect; } export class ElectronTheme extends Context.Service()( @@ -17,9 +19,22 @@ export class ElectronTheme extends Context.Service Electron.nativeTheme.shouldUseDarkColors), setSource: (theme) => - Effect.sync(() => { + Effect.suspend(() => { Electron.nativeTheme.themeSource = theme; + return Effect.void; }), + onUpdated: (listener) => + Effect.acquireRelease( + Effect.suspend(() => { + Electron.nativeTheme.on("updated", listener); + return Effect.void; + }), + () => + Effect.suspend(() => { + Electron.nativeTheme.removeListener("updated", listener); + return Effect.void; + }), + ), }); export const layer = Layer.succeed(ElectronTheme, make); diff --git a/apps/desktop/src/electron/ElectronUpdater.test.ts b/apps/desktop/src/electron/ElectronUpdater.test.ts index d5b0b2e9f99..f45d6fd3eab 100644 --- a/apps/desktop/src/electron/ElectronUpdater.test.ts +++ b/apps/desktop/src/electron/ElectronUpdater.test.ts @@ -1,4 +1,5 @@ import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import { beforeEach, vi } from "vitest"; @@ -34,7 +35,9 @@ describe("ElectronUpdater", () => { autoUpdaterMock.channel = "latest"; autoUpdaterMock.disableDifferentialDownload = false; autoUpdaterMock.checkForUpdates.mockClear(); + autoUpdaterMock.checkForUpdates.mockImplementation(() => Promise.resolve(null)); autoUpdaterMock.downloadUpdate.mockClear(); + autoUpdaterMock.downloadUpdate.mockImplementation(() => Promise.resolve([])); autoUpdaterMock.on.mockClear(); autoUpdaterMock.quitAndInstall.mockClear(); autoUpdaterMock.removeListener.mockClear(); @@ -86,4 +89,21 @@ describe("ElectronUpdater", () => { assert.deepEqual(autoUpdaterMock.removeListener.mock.calls, [["update-available", listener]]); }).pipe(Effect.provide(ElectronUpdater.layer)), ); + + it.effect("wraps rejected update checks in the method-specific typed error", () => + Effect.gen(function* () { + const cause = new Error("network unavailable"); + autoUpdaterMock.checkForUpdates.mockImplementationOnce(() => Promise.reject(cause)); + const updater = yield* ElectronUpdater.ElectronUpdater; + + const exit = yield* Effect.exit(updater.checkForUpdates); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, ElectronUpdater.ElectronUpdaterCheckForUpdatesError); + assert.equal(error.cause, cause); + } + }).pipe(Effect.provide(ElectronUpdater.layer)), + ); }); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index 71d61225136..ad8afbcdfc3 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -1,4 +1,5 @@ import * as Context from "effect/Context"; +import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Scope from "effect/Scope"; @@ -9,6 +10,41 @@ type AutoUpdater = typeof autoUpdater; export type ElectronUpdaterFeedUrl = Parameters[0]; +export class ElectronUpdaterCheckForUpdatesError extends Data.TaggedError( + "ElectronUpdaterCheckForUpdatesError", +)<{ + readonly cause: unknown; +}> { + override get message() { + return "Electron updater failed to check for updates."; + } +} + +export class ElectronUpdaterDownloadUpdateError extends Data.TaggedError( + "ElectronUpdaterDownloadUpdateError", +)<{ + readonly cause: unknown; +}> { + override get message() { + return "Electron updater failed to download the update."; + } +} + +export class ElectronUpdaterQuitAndInstallError extends Data.TaggedError( + "ElectronUpdaterQuitAndInstallError", +)<{ + readonly cause: unknown; +}> { + override get message() { + return "Electron updater failed to quit and install the update."; + } +} + +export type ElectronUpdaterError = + | ElectronUpdaterCheckForUpdatesError + | ElectronUpdaterDownloadUpdateError + | ElectronUpdaterQuitAndInstallError; + export interface ElectronUpdaterShape { readonly setFeedURL: (options: ElectronUpdaterFeedUrl) => Effect.Effect; readonly setAutoDownload: (value: boolean) => Effect.Effect; @@ -18,12 +54,12 @@ export interface ElectronUpdaterShape { readonly allowDowngrade: Effect.Effect; readonly setAllowDowngrade: (value: boolean) => Effect.Effect; readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect; - readonly checkForUpdates: Effect.Effect; - readonly downloadUpdate: Effect.Effect; + readonly checkForUpdates: Effect.Effect; + readonly downloadUpdate: Effect.Effect; readonly quitAndInstall: (options: { readonly isSilent: boolean; readonly isForceRunAfter: boolean; - }) => Effect.Effect; + }) => Effect.Effect; readonly on: >( eventName: string, listener: (...args: Args) => void, @@ -34,49 +70,55 @@ export class ElectronUpdater extends Context.Service(evaluate: () => Promise): Effect.Effect => - Effect.callback((resume) => { - evaluate().then( - (value) => resume(Effect.succeed(value)), - (error: unknown) => resume(Effect.fail(error)), - ); - }); - export const layer = Layer.succeed(ElectronUpdater, { setFeedURL: (options) => - Effect.sync(() => { + Effect.suspend(() => { autoUpdater.setFeedURL(options); + return Effect.void; }), setAutoDownload: (value) => - Effect.sync(() => { + Effect.suspend(() => { autoUpdater.autoDownload = value; + return Effect.void; }), setAutoInstallOnAppQuit: (value) => - Effect.sync(() => { + Effect.suspend(() => { autoUpdater.autoInstallOnAppQuit = value; + return Effect.void; }), setChannel: (channel) => - Effect.sync(() => { + Effect.suspend(() => { autoUpdater.channel = channel; + return Effect.void; }), setAllowPrerelease: (value) => - Effect.sync(() => { + Effect.suspend(() => { autoUpdater.allowPrerelease = value; + return Effect.void; }), allowDowngrade: Effect.sync(() => autoUpdater.allowDowngrade), setAllowDowngrade: (value) => - Effect.sync(() => { + Effect.suspend(() => { autoUpdater.allowDowngrade = value; + return Effect.void; }), setDisableDifferentialDownload: (value) => - Effect.sync(() => { + Effect.suspend(() => { autoUpdater.disableDifferentialDownload = value; + return Effect.void; }), - checkForUpdates: fromPromise(() => autoUpdater.checkForUpdates()).pipe(Effect.asVoid), - downloadUpdate: fromPromise(() => autoUpdater.downloadUpdate()).pipe(Effect.asVoid), + checkForUpdates: Effect.tryPromise({ + try: () => autoUpdater.checkForUpdates(), + catch: (cause) => new ElectronUpdaterCheckForUpdatesError({ cause }), + }).pipe(Effect.asVoid), + downloadUpdate: Effect.tryPromise({ + try: () => autoUpdater.downloadUpdate(), + catch: (cause) => new ElectronUpdaterDownloadUpdateError({ cause }), + }).pipe(Effect.asVoid), quitAndInstall: ({ isSilent, isForceRunAfter }) => - Effect.sync(() => { - autoUpdater.quitAndInstall(isSilent, isForceRunAfter); + Effect.try({ + try: () => autoUpdater.quitAndInstall(isSilent, isForceRunAfter), + catch: (cause) => new ElectronUpdaterQuitAndInstallError({ cause }), }), on: (eventName, listener) => { const eventTarget = autoUpdater as unknown as { diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index d69e4ab2f0f..b353c922ca3 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -1,4 +1,5 @@ import * as Context from "effect/Context"; +import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -6,18 +7,29 @@ import * as Ref from "effect/Ref"; import * as Electron from "electron"; +export class ElectronWindowCreateError extends Data.TaggedError("ElectronWindowCreateError")<{ + readonly cause: unknown; +}> { + override get message() { + return "Failed to create Electron BrowserWindow."; + } +} + export interface ElectronWindowShape { + readonly create: ( + options: Electron.BrowserWindowConstructorOptions, + ) => Effect.Effect; readonly main: Effect.Effect>; readonly currentMainOrFirst: Effect.Effect>; readonly focusedMainOrFirst: Effect.Effect>; readonly setMain: (window: Electron.BrowserWindow) => Effect.Effect; - readonly clearMain: (window?: Electron.BrowserWindow) => Effect.Effect; + readonly clearMain: (window: Option.Option) => Effect.Effect; readonly reveal: (window: Electron.BrowserWindow) => Effect.Effect; readonly sendAll: (channel: string, ...args: readonly unknown[]) => Effect.Effect; readonly destroyAll: Effect.Effect; - readonly syncAllAppearance: ( - sync: (window: Electron.BrowserWindow) => void, - ) => Effect.Effect; + readonly syncAllAppearance: ( + sync: (window: Electron.BrowserWindow) => Effect.Effect, + ) => Effect.Effect; } export class ElectronWindow extends Context.Service()( @@ -53,6 +65,11 @@ const make = Effect.gen(function* () { ); return ElectronWindow.of({ + create: (options) => + Effect.try({ + try: () => new Electron.BrowserWindow(options), + catch: (cause) => new ElectronWindowCreateError({ cause }), + }), main: liveMain, currentMainOrFirst, focusedMainOrFirst, @@ -62,7 +79,7 @@ const make = Effect.gen(function* () { if (Option.isNone(current)) { return current; } - if (window !== undefined && current.value !== window) { + if (Option.isSome(window) && current.value !== window.value) { return current; } return Option.none(); @@ -102,9 +119,10 @@ const make = Effect.gen(function* () { } }), syncAllAppearance: (sync) => - Effect.sync(() => { - for (const window of Electron.BrowserWindow.getAllWindows()) { - sync(window); + Effect.gen(function* () { + const windows = Electron.BrowserWindow.getAllWindows(); + for (const window of windows) { + yield* sync(window); } }), }); diff --git a/apps/desktop/src/ipc/methods/updates.ts b/apps/desktop/src/ipc/methods/updates.ts index 3357ed1f766..1f977f4a76a 100644 --- a/apps/desktop/src/ipc/methods/updates.ts +++ b/apps/desktop/src/ipc/methods/updates.ts @@ -20,12 +20,13 @@ import { UPDATE_SET_CHANNEL_CHANNEL, } from "../channels.ts"; import { makeIpcMethod } from "../DesktopIpc.ts"; +import type * as DesktopUpdates from "../../main/DesktopUpdates.ts"; export interface DesktopUpdateIpcActionsShape { readonly getState: Effect.Effect; readonly setChannel: ( channel: DesktopUpdateChannel, - ) => Effect.Effect; + ) => Effect.Effect; readonly download: Effect.Effect; readonly install: Effect.Effect; readonly check: Effect.Effect; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index de09b4f722b..23b7531eada 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -14,14 +14,7 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; -import { - BrowserWindow, - type BrowserWindowConstructorOptions, - ipcMain, - type MenuItemConstructorOptions, - Menu, - nativeTheme, -} from "electron"; +import { ipcMain, type MenuItemConstructorOptions, Menu } from "electron"; import * as NetService from "@t3tools/shared/Net"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; @@ -61,7 +54,6 @@ import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; import * as DesktopIpc from "./ipc/DesktopIpc.ts"; import * as ElectronWindow from "./electron/ElectronWindow.ts"; import { DesktopShutdown, makeDesktopShutdown } from "./desktopShutdown.ts"; -import { MENU_ACTION_CHANNEL } from "./ipc/channels.ts"; import { installDesktopIpcHandlers } from "./ipc/DesktopIpcHandlers.ts"; import { DesktopServerExposureIpcActions } from "./ipc/methods/serverExposure.ts"; import { DesktopUpdateIpcActions } from "./ipc/methods/updates.ts"; @@ -72,8 +64,9 @@ import { DesktopShellEnvironmentLive, DesktopShellEnvironmentProbeLive, } from "./syncShellEnvironment.ts"; -import { bindFirstRevealTrigger, type RevealSubscription } from "./windowReveal.ts"; +import * as DesktopAssets from "./main/DesktopAssets.ts"; import { formatErrorMessage } from "./main/DesktopErrors.ts"; +import * as DesktopLifecycle from "./main/DesktopLifecycle.ts"; import * as DesktopLocalEnvironment from "./main/DesktopLocalEnvironment.ts"; import * as DesktopServerExposure from "./main/DesktopServerExposure.ts"; import * as DesktopSettingsState from "./main/DesktopSettingsState.ts"; @@ -82,33 +75,19 @@ import * as DesktopSshPasswordPrompts from "./main/DesktopSshPasswordPrompts.ts" import * as DesktopSshRemoteApi from "./main/DesktopSshRemoteApi.ts"; import * as DesktopState from "./main/DesktopState.ts"; import * as DesktopUpdates from "./main/DesktopUpdates.ts"; +import * as DesktopWindow from "./main/DesktopWindow.ts"; const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i; const COMMIT_HASH_DISPLAY_LENGTH = 12; const AppPackageMetadata = Schema.Struct({ t3codeCommitHash: Schema.optional(Schema.String), }); -const TITLEBAR_HEIGHT = 40; -const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux -const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; -const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; - -type WindowTitleBarOptions = Pick< - BrowserWindowConstructorOptions, - "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition" ->; - interface BackendObservabilitySettings { readonly otlpTracesUrl: string | undefined; readonly otlpMetricsUrl: string | undefined; } let backendBootstrapToken = ""; let aboutCommitHashCache: Option.Option | undefined; -let desktopIconPaths: Readonly>> = { - ico: Option.none(), - icns: Option.none(), - png: Option.none(), -}; let appRunId = "startup"; let backendObservabilitySettings: BackendObservabilitySettings = { otlpTracesUrl: undefined, @@ -120,17 +99,9 @@ interface DesktopEffectRunner { } type DesktopWindowBoundaryServices = - | DesktopEnvironment | ElectronDialog.ElectronDialog - | ElectronShell.ElectronShell - | DesktopState.DesktopState | DesktopUpdates.DesktopUpdates - | ElectronWindow.ElectronWindow - | DesktopServerExposure.DesktopServerExposure; -type DesktopLifecycleBoundaryServices = - | DesktopShutdown - | DesktopWindowBoundaryServices - | ElectronApp.ElectronApp; + | DesktopWindow.DesktopWindow; function makeDesktopEffectRunner(context: Context.Context): DesktopEffectRunner { return (effect: Effect.Effect) => @@ -222,15 +193,6 @@ function resolveConfiguredDesktopBackendPort(rawPort: string | undefined): numbe return parsedPort; } -function resolveDesktopDevServerUrl(environment: DesktopEnvironmentShape): string { - const devServerUrl = Option.getOrUndefined(environment.devServerUrl); - if (devServerUrl === undefined) { - throw new Error("VITE_DEV_SERVER_URL is required in desktop development."); - } - - return devServerUrl; -} - function backendChildEnv(): NodeJS.ProcessEnv { const env = { ...process.env }; delete env.T3CODE_PORT; @@ -285,64 +247,6 @@ function relaunchDesktopAppEffect( }); } -function handleBackendReady( - runEffect: DesktopEffectRunner, - environment: DesktopEnvironmentShape, -): Effect.Effect< - void, - never, - | DesktopState.DesktopState - | ElectronShell.ElectronShell - | ElectronWindow.ElectronWindow - | DesktopServerExposure.DesktopServerExposure -> { - return Effect.gen(function* () { - const state = yield* DesktopState.DesktopState; - const electronWindow = yield* ElectronWindow.ElectronWindow; - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - yield* Ref.set(state.backendReady, true); - yield* logDesktopInfo("bootstrap backend ready", { source: "http" }); - - const existingWindow = yield* electronWindow.currentMainOrFirst; - if (!environment.isDevelopment && Option.isNone(existingWindow)) { - const backendConfig = yield* serverExposure.backendConfig; - const window = createWindow( - runEffect, - environment, - electronWindow, - backendConfig.httpBaseUrl, - ); - yield* electronWindow.setMain(window); - yield* logDesktopInfo("bootstrap main window created"); - } - }); -} - -function createBackendWindowIfReady( - runEffect: DesktopEffectRunner, - environment: DesktopEnvironmentShape, -): Effect.Effect< - void, - never, - | DesktopState.DesktopState - | ElectronShell.ElectronShell - | ElectronWindow.ElectronWindow - | DesktopServerExposure.DesktopServerExposure -> { - return Effect.gen(function* () { - const state = yield* DesktopState.DesktopState; - const electronWindow = yield* ElectronWindow.ElectronWindow; - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - const backendReady = yield* Ref.get(state.backendReady); - if (!backendReady) return; - const existingWindow = yield* electronWindow.currentMainOrFirst; - if (Option.isSome(existingWindow)) return; - const backendConfig = yield* serverExposure.backendConfig; - const window = createWindow(runEffect, environment, electronWindow, backendConfig.httpBaseUrl); - yield* electronWindow.setMain(window); - }); -} - const resolveBackendStartConfig: Effect.Effect< DesktopBackendStartConfig, never, @@ -430,17 +334,9 @@ const desktopBackendConfigurationLayer = Layer.effect( const desktopBackendEventsLayer = Layer.effect( DesktopBackendEvents, Effect.gen(function* () { - const environment = yield* DesktopEnvironment; const backendOutputLog = yield* DesktopBackendOutputLog; + const desktopWindow = yield* DesktopWindow.DesktopWindow; const state = yield* DesktopState.DesktopState; - const context = yield* Effect.context< - | DesktopEnvironment - | DesktopServerExposure.DesktopServerExposure - | DesktopState.DesktopState - | ElectronShell.ElectronShell - | ElectronWindow.ElectronWindow - >(); - const runEffect = makeDesktopEffectRunner(context); return { onStarting: Ref.set(state.backendReady, false), @@ -450,7 +346,13 @@ const desktopBackendEventsLayer = Layer.effect( runId: appRunId, details: `pid=${pid} port=${config.bootstrap.port} cwd=${config.cwd}`, }), - onReady: handleBackendReady(runEffect, environment).pipe(Effect.provide(context)), + onReady: desktopWindow.handleBackendReady.pipe( + Effect.catch((error) => + logDesktopError("failed to open main window after backend readiness", { + message: error.message, + }), + ), + ), onReadinessFailure: (error) => logDesktopWarning("backend readiness check failed during bootstrap", { error: formatErrorMessage(error), @@ -569,6 +471,10 @@ const desktopServerExposureIpcActionsLayer = Layer.effect( const desktopUpdatesLayer = DesktopUpdates.layer.pipe(Layer.provideMerge(ElectronUpdater.layer)); +const desktopAssetsLayer = DesktopAssets.layer; + +const desktopWindowLayer = DesktopWindow.layer.pipe(Layer.provideMerge(desktopAssetsLayer)); + const desktopUpdateIpcActionsLayer = Layer.effect( DesktopUpdateIpcActions, Effect.gen(function* () { @@ -589,7 +495,10 @@ const desktopBackendDependenciesLayer = Layer.mergeAll( NetService.layer, DesktopBackendProcessRunnerLive, desktopBackendConfigurationLayer, - desktopBackendEventsLayer.pipe(Layer.provide(desktopBackendOutputLogLayer)), + desktopBackendEventsLayer.pipe( + Layer.provide(desktopBackendOutputLogLayer), + Layer.provide(desktopWindowLayer), + ), ); const desktopBackendManagerLayer = DesktopBackendManagerLive.pipe( @@ -605,6 +514,8 @@ const desktopRuntimeLayer = Layer.mergeAll( desktopLoggerLayer, desktopShellEnvironmentLayer, desktopSshRuntimeLayer, + DesktopLifecycle.layer, + desktopWindowLayer, Layer.succeed(DesktopIpc.DesktopIpc, DesktopIpc.make(ipcMain)), desktopServerExposureIpcActionsLayer, desktopUpdateIpcActionsLayer, @@ -625,27 +536,6 @@ const desktopRuntimeLayer = Layer.mergeAll( Layer.provideMerge(desktopEnvironmentLayer), ); -function addScopedListener>( - target: unknown, - eventName: string, - listener: (...args: Args) => void, -): Effect.Effect { - const eventTarget = target as { - on: (eventName: string, listener: (...args: Array) => void) => unknown; - removeListener: (eventName: string, listener: (...args: Array) => void) => unknown; - }; - const untypedListener = listener as unknown as (...args: Array) => void; - return Effect.acquireRelease( - Effect.sync(() => { - eventTarget.on(eventName, untypedListener); - }), - () => - Effect.sync(() => { - eventTarget.removeListener(eventName, untypedListener); - }), - ).pipe(Effect.asVoid); -} - function normalizeCommitHash(value: unknown): string | null { if (typeof value !== "string") { return null; @@ -762,74 +652,42 @@ function registerDesktopProtocol(): Effect.Effect< function dispatchMenuAction( action: string, - environment: DesktopEnvironmentShape, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { - const context = yield* Effect.context(); - const runEffect = makeDesktopEffectRunner(context); - const electronWindow = yield* ElectronWindow.ElectronWindow; - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - const backendConfig = yield* serverExposure.backendConfig; - const existingWindow = yield* electronWindow.focusedMainOrFirst; - const targetWindow = - Option.getOrUndefined(existingWindow) ?? - createWindow(runEffect, environment, electronWindow, backendConfig.httpBaseUrl); - if (Option.isNone(existingWindow)) { - yield* electronWindow.setMain(targetWindow); - } - - const send = () => { - if (targetWindow.isDestroyed()) return; - targetWindow.webContents.send(MENU_ACTION_CHANNEL, action); - void runEffect(electronWindow.reveal(targetWindow)); - }; + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.dispatchMenuAction(action); + }); +} - if (targetWindow.webContents.isLoadingMainFrame()) { - targetWindow.webContents.once("did-finish-load", send); +function handleCheckForUpdatesMenuClick(): Effect.Effect< + void, + DesktopWindow.DesktopWindowError, + DesktopUpdates.DesktopUpdates | ElectronDialog.ElectronDialog | DesktopWindow.DesktopWindow +> { + return Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + const electronDialog = yield* ElectronDialog.ElectronDialog; + const disabledReason = yield* updates.disabledReason; + if (Option.isSome(disabledReason)) { + yield* logUpdaterInfo("manual update check requested, but updates are disabled", { + disabledReason: disabledReason.value, + }); + yield* electronDialog.showMessageBox({ + type: "info", + title: "Updates unavailable", + message: "Automatic updates are not available right now.", + detail: disabledReason.value, + buttons: ["OK"], + }); return; } - send(); + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.ensureMain; + yield* checkForUpdatesFromMenu(); }); } -function handleCheckForUpdatesMenuClick( - runEffect: DesktopEffectRunner, - environment: DesktopEnvironmentShape, -): void { - void runEffect( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - const electronDialog = yield* ElectronDialog.ElectronDialog; - const disabledReason = yield* updates.disabledReason; - if (Option.isSome(disabledReason)) { - yield* logUpdaterInfo("manual update check requested, but updates are disabled", { - disabledReason: disabledReason.value, - }); - yield* electronDialog.showMessageBox({ - type: "info", - title: "Updates unavailable", - message: "Automatic updates are not available right now.", - detail: disabledReason.value, - buttons: ["OK"], - }); - return; - } - - const electronWindow = yield* ElectronWindow.ElectronWindow; - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - const backendConfig = yield* serverExposure.backendConfig; - const existingWindow = yield* electronWindow.currentMainOrFirst; - if (Option.isNone(existingWindow)) { - yield* electronWindow.setMain( - createWindow(runEffect, environment, electronWindow, backendConfig.httpBaseUrl), - ); - } - yield* checkForUpdatesFromMenu(); - }), - ); -} - function checkForUpdatesFromMenu(): Effect.Effect< void, never, @@ -867,7 +725,6 @@ function configureApplicationMenu(): Effect.Effect< > { return Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; - const environment = yield* DesktopEnvironment; const appName = yield* electronApp.name; const context = yield* Effect.context< ElectronApp.ElectronApp | DesktopWindowBoundaryServices @@ -882,14 +739,16 @@ function configureApplicationMenu(): Effect.Effect< { role: "about" }, { label: "Check for Updates...", - click: () => handleCheckForUpdatesMenuClick(runEffect, environment), + click: () => { + void runEffect(handleCheckForUpdatesMenuClick()); + }, }, { type: "separator" }, { label: "Settings...", accelerator: "CmdOrCtrl+,", click: () => { - void runEffect(dispatchMenuAction("open-settings", environment)); + void runEffect(dispatchMenuAction("open-settings")); }, }, { type: "separator" }, @@ -915,7 +774,7 @@ function configureApplicationMenu(): Effect.Effect< label: "Settings...", accelerator: "CmdOrCtrl+,", click: () => { - void runEffect(dispatchMenuAction("open-settings", environment)); + void runEffect(dispatchMenuAction("open-settings")); }, }, { type: "separator" as const }, @@ -945,7 +804,9 @@ function configureApplicationMenu(): Effect.Effect< submenu: [ { label: "Check for Updates...", - click: () => handleCheckForUpdatesMenuClick(runEffect, environment), + click: () => { + void runEffect(handleCheckForUpdatesMenuClick()); + }, }, ], }, @@ -957,57 +818,6 @@ function configureApplicationMenu(): Effect.Effect< }); } -function resolveResourcePath( - fileName: string, -): Effect.Effect, never, FileSystem.FileSystem | DesktopEnvironment> { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const environment = yield* DesktopEnvironment; - const candidates = environment.resolveResourcePathCandidates(fileName); - for (const candidate of candidates) { - const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)); - if (exists) { - return Option.some(candidate); - } - } - return Option.none(); - }); -} - -function resolveIconPath( - ext: "ico" | "icns" | "png", -): Effect.Effect, never, FileSystem.FileSystem | DesktopEnvironment> { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const environment = yield* DesktopEnvironment; - if (environment.isDevelopment && process.platform === "darwin" && ext === "png") { - const developmentDockIconPath = environment.developmentDockIconPath; - const developmentDockIconExists = yield* fileSystem - .exists(developmentDockIconPath) - .pipe(Effect.orElseSucceed(() => false)); - if (developmentDockIconExists) { - return Option.some(developmentDockIconPath); - } - } - - return yield* resolveResourcePath(`icon.${ext}`); - }); -} - -function resolveDesktopIconPaths(): Effect.Effect< - void, - never, - FileSystem.FileSystem | DesktopEnvironment -> { - return Effect.gen(function* () { - const [ico, icns, png] = yield* Effect.all( - [resolveIconPath("ico"), resolveIconPath("icns"), resolveIconPath("png")] as const, - { concurrency: "unbounded" }, - ); - desktopIconPaths = { ico, icns, png }; - }); -} - /** * Resolve the Electron userData directory path. * @@ -1049,11 +859,12 @@ function resolveUserDataPath(): Effect.Effect< function configureAppIdentity(): Effect.Effect< void, never, - FileSystem.FileSystem | ElectronApp.ElectronApp | DesktopEnvironment + FileSystem.FileSystem | ElectronApp.ElectronApp | DesktopEnvironment | DesktopAssets.DesktopAssets > { return Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; const environment = yield* DesktopEnvironment; + const assets = yield* DesktopAssets.DesktopAssets; const commitHash = yield* resolveAboutCommitHash(); yield* electronApp.setName(environment.displayName); yield* electronApp.setAboutPanelOptions({ @@ -1071,7 +882,8 @@ function configureAppIdentity(): Effect.Effect< } if (process.platform === "darwin") { - yield* Option.match(desktopIconPaths.png, { + const iconPaths = yield* assets.iconPaths; + yield* Option.match(iconPaths.png, { onNone: () => Effect.void, onSome: electronApp.setDockIcon, }); @@ -1116,203 +928,12 @@ function requestDesktopShutdownAndWait(): Effect.Effect { - if (process.platform === "darwin") return {}; // macOS uses .icns from app bundle - const ext = process.platform === "win32" ? "ico" : "png"; - const iconPath = Option.getOrUndefined(desktopIconPaths[ext]); - return iconPath ? { icon: iconPath } : {}; -} - -function getInitialWindowBackgroundColor(): string { - return nativeTheme.shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; -} - -function getWindowTitleBarOptions(): WindowTitleBarOptions { - if (process.platform === "darwin") { - return { - titleBarStyle: "hiddenInset", - trafficLightPosition: { x: 16, y: 18 }, - }; - } - - return { - titleBarStyle: "hidden", - titleBarOverlay: { - color: TITLEBAR_COLOR, - height: TITLEBAR_HEIGHT, - symbolColor: nativeTheme.shouldUseDarkColors - ? TITLEBAR_DARK_SYMBOL_COLOR - : TITLEBAR_LIGHT_SYMBOL_COLOR, - }, - }; -} - -function syncWindowAppearance(window: BrowserWindow): void { - if (window.isDestroyed()) { - return; - } - - window.setBackgroundColor(getInitialWindowBackgroundColor()); - const { titleBarOverlay } = getWindowTitleBarOptions(); - if (typeof titleBarOverlay === "object") { - window.setTitleBarOverlay(titleBarOverlay); - } -} - -function createWindow( - runEffect: DesktopEffectRunner, - environment: DesktopEnvironmentShape, - electronWindow: ElectronWindow.ElectronWindowShape, - backendHttpUrl: URL, -): BrowserWindow { - const window = new BrowserWindow({ - width: 1100, - height: 780, - minWidth: 840, - minHeight: 620, - show: false, - autoHideMenuBar: true, - backgroundColor: getInitialWindowBackgroundColor(), - ...getIconOption(), - title: environment.displayName, - ...getWindowTitleBarOptions(), - webPreferences: { - preload: environment.preloadPath, - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - }, - }); - - window.webContents.on("context-menu", (event, params) => { - event.preventDefault(); - - const menuTemplate: MenuItemConstructorOptions[] = []; - - if (params.misspelledWord) { - for (const suggestion of params.dictionarySuggestions.slice(0, 5)) { - menuTemplate.push({ - label: suggestion, - click: () => window.webContents.replaceMisspelling(suggestion), - }); - } - if (params.dictionarySuggestions.length === 0) { - menuTemplate.push({ label: "No suggestions", enabled: false }); - } - menuTemplate.push({ type: "separator" }); - } - - if (Option.isSome(ElectronShell.parseSafeExternalUrl(params.linkURL))) { - menuTemplate.push( - { - label: "Copy Link", - click: () => { - void runEffect( - Effect.gen(function* () { - const electronShell = yield* ElectronShell.ElectronShell; - yield* electronShell.copyText(params.linkURL); - }), - ); - }, - }, - { type: "separator" }, - ); - } - - if (params.mediaType === "image") { - menuTemplate.push({ - label: "Copy Image", - click: () => window.webContents.copyImageAt(params.x, params.y), - }); - menuTemplate.push({ type: "separator" }); - } - - menuTemplate.push( - { role: "cut", enabled: params.editFlags.canCut }, - { role: "copy", enabled: params.editFlags.canCopy }, - { role: "paste", enabled: params.editFlags.canPaste }, - { role: "selectAll", enabled: params.editFlags.canSelectAll }, - ); - - Menu.buildFromTemplate(menuTemplate).popup({ window }); - }); - - window.webContents.setWindowOpenHandler(({ url }) => { - if (Option.isSome(ElectronShell.parseSafeExternalUrl(url))) { - void runEffect( - Effect.gen(function* () { - const electronShell = yield* ElectronShell.ElectronShell; - yield* electronShell.openExternal(url); - }), - ); - } - return { action: "deny" }; - }); - - window.on("page-title-updated", (event) => { - event.preventDefault(); - window.setTitle(environment.displayName); - }); - window.webContents.on("did-finish-load", () => { - window.setTitle(environment.displayName); - void runEffect( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.emitState; - }), - ); - }); - - // On Linux/Wayland with `show: false`, Electron's `ready-to-show` only - // fires after `show()` is called, deadlocking the standard "wait for - // ready, then show" pattern. Add `did-finish-load` as a Linux-only - // fallback so the window still surfaces once the renderer has loaded - // the page. Other platforms keep the no-flash `ready-to-show` path, - // since `did-finish-load` typically fires before the first paint there. - const revealSubscribers: RevealSubscription[] = [(fire) => window.once("ready-to-show", fire)]; - if (process.platform === "linux") { - revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire)); - } - bindFirstRevealTrigger(revealSubscribers, () => { - void runEffect(electronWindow.reveal(window)); - }); - - if (environment.isDevelopment) { - void window.loadURL(resolveDesktopDevServerUrl(environment)); - window.webContents.openDevTools({ mode: "detach" }); - } else { - void window.loadURL(backendHttpUrl.href); - } - - window.on("closed", () => { - void runEffect(electronWindow.clearMain(window)); - }); - - return window; -} - function bootstrap() { return Effect.gen(function* () { const environment = yield* DesktopEnvironment; - const electronWindow = yield* ElectronWindow.ElectronWindow; + const desktopWindow = yield* DesktopWindow.DesktopWindow; const settingsState = yield* DesktopSettingsState.DesktopSettingsState; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - const context = yield* Effect.context(); - const runEffect = makeDesktopEffectRunner(context); yield* logDesktopInfo("bootstrap start"); const configuredBackendPort = resolveConfiguredDesktopBackendPort(process.env.T3CODE_PORT); if (environment.isDevelopment && configuredBackendPort === undefined) { @@ -1363,131 +984,7 @@ function bootstrap() { yield* logDesktopInfo("bootstrap backend start requested"); if (environment.isDevelopment) { - yield* electronWindow.setMain( - createWindow(runEffect, environment, electronWindow, backendConfig.httpBaseUrl), - ); - yield* logDesktopInfo("bootstrap main window created"); - } - }); -} - -function handleBeforeQuit( - event: Electron.Event, - runEffect: DesktopEffectRunner, - allowQuit: () => boolean, - markQuitAllowed: () => void, -): void { - if (allowQuit()) { - void runEffect( - Effect.gen(function* () { - const state = yield* DesktopState.DesktopState; - yield* Ref.set(state.quitting, true); - yield* logDesktopInfo("before-quit received"); - }), - ); - return; - } - - event.preventDefault(); - void runEffect( - Effect.gen(function* () { - const state = yield* DesktopState.DesktopState; - yield* Ref.set(state.quitting, true); - yield* logDesktopInfo("before-quit received"); - yield* requestDesktopShutdownAndWait(); - }), - ).finally(() => { - markQuitAllowed(); - void runEffect( - Effect.gen(function* () { - const electronApp = yield* ElectronApp.ElectronApp; - yield* electronApp.quit; - }), - ); - }); -} - -function handleActivate( - environment: DesktopEnvironmentShape, -): Effect.Effect { - return Effect.gen(function* () { - const context = yield* Effect.context(); - const runEffect = makeDesktopEffectRunner(context); - const electronWindow = yield* ElectronWindow.ElectronWindow; - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - const existingWindow = yield* electronWindow.currentMainOrFirst; - if (Option.isSome(existingWindow)) { - yield* electronWindow.reveal(existingWindow.value); - return; - } - if (environment.isDevelopment) { - const backendConfig = yield* serverExposure.backendConfig; - const window = createWindow( - runEffect, - environment, - electronWindow, - backendConfig.httpBaseUrl, - ); - yield* electronWindow.setMain(window); - return; - } - yield* createBackendWindowIfReady(runEffect, environment); - }); -} - -function handleWindowAllClosed(): Effect.Effect< - void, - never, - ElectronApp.ElectronApp | DesktopState.DesktopState -> { - return Effect.gen(function* () { - const electronApp = yield* ElectronApp.ElectronApp; - const state = yield* DesktopState.DesktopState; - if (process.platform !== "darwin" && !(yield* Ref.get(state.quitting))) { - yield* electronApp.quit; - } - }); -} - -function registerDesktopLifecycleHandlers(): Effect.Effect< - void, - never, - Scope.Scope | DesktopLifecycleBoundaryServices -> { - return Effect.gen(function* () { - const environment = yield* DesktopEnvironment; - const electronApp = yield* ElectronApp.ElectronApp; - const electronWindow = yield* ElectronWindow.ElectronWindow; - const context = yield* Effect.context(); - const runEffect = makeDesktopEffectRunner(context); - let quitAllowed = false; - yield* addScopedListener(nativeTheme, "updated", () => { - void runEffect(electronWindow.syncAllAppearance(syncWindowAppearance)); - }); - yield* electronApp.on("before-quit", (event: Electron.Event) => { - handleBeforeQuit( - event, - runEffect, - () => quitAllowed, - () => { - quitAllowed = true; - }, - ); - }); - yield* electronApp.on("activate", () => { - void runEffect(handleActivate(environment)); - }); - yield* electronApp.on("window-all-closed", () => { - void runEffect(handleWindowAllClosed()); - }); - - if (process.platform !== "win32") { - yield* addScopedListener(process, "SIGINT", () => { - quitFromSignal("SIGINT", runEffect); - }); - yield* addScopedListener(process, "SIGTERM", () => { - quitFromSignal("SIGTERM", runEffect); - }); + yield* desktopWindow.ensureMain; } }); } @@ -1515,6 +1012,7 @@ const program = Effect.scoped( const environment = yield* DesktopEnvironment; appRunId = (yield* Random.nextUUIDv4).replace(/-/g, "").slice(0, 12); const backendManager = yield* DesktopBackendManager; + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; const shellEnvironment = yield* DesktopShellEnvironment; const settingsState = yield* DesktopSettingsState.DesktopSettingsState; const updates = yield* DesktopUpdates.DesktopUpdates; @@ -1530,7 +1028,6 @@ const program = Effect.scoped( // Must happen before Electron's ready event so Chromium profile data // lands in the desktop-specific userData directory. yield* electronApp.setPath("userData", userDataPath); - yield* resolveDesktopIconPaths(); yield* logDesktopInfo("runtime logging configured", { logDir: environment.logDir }); yield* settingsState.load; @@ -1539,7 +1036,7 @@ const program = Effect.scoped( } yield* configureAppIdentity(); - yield* registerDesktopLifecycleHandlers(); + yield* lifecycle.register; yield* waitForElectronReady.pipe( Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)), diff --git a/apps/desktop/src/main/DesktopAssets.ts b/apps/desktop/src/main/DesktopAssets.ts new file mode 100644 index 00000000000..0d37a50b40b --- /dev/null +++ b/apps/desktop/src/main/DesktopAssets.ts @@ -0,0 +1,83 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as DesktopEnvironment from "../desktopEnvironment.ts"; + +export interface DesktopIconPaths { + readonly ico: Option.Option; + readonly icns: Option.Option; + readonly png: Option.Option; +} + +export interface DesktopAssetsShape { + readonly iconPaths: Effect.Effect; + readonly resolveResourcePath: (fileName: string) => Effect.Effect>; +} + +export class DesktopAssets extends Context.Service()( + "t3/desktop/Assets", +) {} + +const resolveResourcePath = ( + fileName: string, +): Effect.Effect< + Option.Option, + never, + FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment +> => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const candidates = environment.resolveResourcePathCandidates(fileName); + for (const candidate of candidates) { + const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)); + if (exists) { + return Option.some(candidate); + } + } + return Option.none(); + }); + +const resolveIconPath = ( + ext: keyof DesktopIconPaths, +): Effect.Effect< + Option.Option, + never, + FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment +> => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + if (environment.isDevelopment && process.platform === "darwin" && ext === "png") { + const developmentDockIconPath = environment.developmentDockIconPath; + const developmentDockIconExists = yield* fileSystem + .exists(developmentDockIconPath) + .pipe(Effect.orElseSucceed(() => false)); + if (developmentDockIconExists) { + return Option.some(developmentDockIconPath); + } + } + + return yield* resolveResourcePath(`icon.${ext}`); + }); + +const make = Effect.gen(function* () { + const context = yield* Effect.context< + FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment + >(); + const [ico, icns, png] = yield* Effect.all( + [resolveIconPath("ico"), resolveIconPath("icns"), resolveIconPath("png")] as const, + { concurrency: "unbounded" }, + ); + const iconPaths = { ico, icns, png } satisfies DesktopIconPaths; + + return DesktopAssets.of({ + iconPaths: Effect.succeed(iconPaths), + resolveResourcePath: (fileName) => resolveResourcePath(fileName).pipe(Effect.provide(context)), + }); +}); + +export const layer = Layer.effect(DesktopAssets, make); diff --git a/apps/desktop/src/main/DesktopLifecycle.ts b/apps/desktop/src/main/DesktopLifecycle.ts new file mode 100644 index 00000000000..625aee1bf03 --- /dev/null +++ b/apps/desktop/src/main/DesktopLifecycle.ts @@ -0,0 +1,174 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; + +import type * as Electron from "electron"; + +import { DesktopShutdown } from "../desktopShutdown.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronTheme from "../electron/ElectronTheme.ts"; +import * as DesktopState from "./DesktopState.ts"; +import * as DesktopWindow from "./DesktopWindow.ts"; + +export type DesktopLifecycleRuntimeServices = + | DesktopShutdown + | DesktopState.DesktopState + | DesktopWindow.DesktopWindow + | ElectronApp.ElectronApp + | ElectronTheme.ElectronTheme; + +export interface DesktopLifecycleShape { + readonly register: Effect.Effect; +} + +export class DesktopLifecycle extends Context.Service()( + "t3/desktop/Lifecycle", +) {} + +const logLifecycleInfo = (message: string, annotations?: Record) => + Effect.logInfo(message).pipe( + Effect.annotateLogs({ + scope: "desktop", + component: "desktop-lifecycle", + ...annotations, + }), + ); + +function makeDesktopEffectRunner(context: Context.Context) { + return (effect: Effect.Effect): Promise => + Effect.runPromiseWith(context as unknown as Context.Context)(effect); +} + +function addScopedListener>( + target: unknown, + eventName: string, + listener: (...args: Args) => void, +): Effect.Effect { + const eventTarget = target as { + on: (eventName: string, listener: (...args: Array) => void) => unknown; + removeListener: (eventName: string, listener: (...args: Array) => void) => unknown; + }; + const untypedListener = listener as unknown as (...args: Array) => void; + return Effect.acquireRelease( + Effect.sync(() => { + eventTarget.on(eventName, untypedListener); + }), + () => + Effect.sync(() => { + eventTarget.removeListener(eventName, untypedListener); + }), + ).pipe(Effect.asVoid); +} + +function requestDesktopShutdownAndWait(): Effect.Effect { + return Effect.gen(function* () { + const shutdown = yield* DesktopShutdown; + yield* shutdown.request; + yield* shutdown.awaitComplete; + }); +} + +function handleBeforeQuit( + event: Electron.Event, + runEffect: ReturnType, + allowQuit: () => boolean, + markQuitAllowed: () => void, +): void { + if (allowQuit()) { + void runEffect( + Effect.gen(function* () { + const state = yield* DesktopState.DesktopState; + yield* Ref.set(state.quitting, true); + yield* logLifecycleInfo("before-quit received"); + }), + ); + return; + } + + event.preventDefault(); + void runEffect( + Effect.gen(function* () { + const state = yield* DesktopState.DesktopState; + yield* Ref.set(state.quitting, true); + yield* logLifecycleInfo("before-quit received"); + yield* requestDesktopShutdownAndWait(); + }), + ).finally(() => { + markQuitAllowed(); + void runEffect( + Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + yield* electronApp.quit; + }), + ); + }); +} + +function quitFromSignal( + signal: "SIGINT" | "SIGTERM", + runEffect: ReturnType, +): void { + void runEffect( + Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + const state = yield* DesktopState.DesktopState; + const wasQuitting = yield* Ref.getAndSet(state.quitting, true); + if (wasQuitting) return; + yield* logLifecycleInfo("process signal received", { signal }); + yield* requestDesktopShutdownAndWait(); + yield* electronApp.quit; + }), + ); +} + +export const layer = Layer.succeed( + DesktopLifecycle, + DesktopLifecycle.of({ + register: Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const electronApp = yield* ElectronApp.ElectronApp; + const electronTheme = yield* ElectronTheme.ElectronTheme; + const context = yield* Effect.context(); + const runEffect = makeDesktopEffectRunner(context); + let quitAllowed = false; + yield* electronTheme.onUpdated(() => { + void runEffect(desktopWindow.syncAppearance); + }); + yield* electronApp.on("before-quit", (event: Electron.Event) => { + handleBeforeQuit( + event, + runEffect, + () => quitAllowed, + () => { + quitAllowed = true; + }, + ); + }); + yield* electronApp.on("activate", () => { + void runEffect(desktopWindow.activate); + }); + yield* electronApp.on("window-all-closed", () => { + void runEffect( + Effect.gen(function* () { + const app = yield* ElectronApp.ElectronApp; + const state = yield* DesktopState.DesktopState; + if (process.platform !== "darwin" && !(yield* Ref.get(state.quitting))) { + yield* app.quit; + } + }), + ); + }); + + if (process.platform !== "win32") { + yield* addScopedListener(process, "SIGINT", () => { + quitFromSignal("SIGINT", runEffect); + }); + yield* addScopedListener(process, "SIGTERM", () => { + quitFromSignal("SIGTERM", runEffect); + }); + } + }), + }), +); diff --git a/apps/desktop/src/main/DesktopSshPasswordPrompts.test.ts b/apps/desktop/src/main/DesktopSshPasswordPrompts.test.ts index 14ca8fdc48e..ff777aaa5e8 100644 --- a/apps/desktop/src/main/DesktopSshPasswordPrompts.test.ts +++ b/apps/desktop/src/main/DesktopSshPasswordPrompts.test.ts @@ -69,6 +69,7 @@ function makeElectronWindowLayer(window: ReturnType["wind return Layer.succeed( ElectronWindow.ElectronWindow, ElectronWindow.ElectronWindow.of({ + create: () => Effect.die("unexpected BrowserWindow creation"), main: Effect.succeed(Option.some(window as Electron.BrowserWindow)), currentMainOrFirst: Effect.succeed(Option.some(window as Electron.BrowserWindow)), focusedMainOrFirst: Effect.succeed(Option.some(window as Electron.BrowserWindow)), diff --git a/apps/desktop/src/main/DesktopUpdates.test.ts b/apps/desktop/src/main/DesktopUpdates.test.ts index c31191c3f8e..899b7c40a9f 100644 --- a/apps/desktop/src/main/DesktopUpdates.test.ts +++ b/apps/desktop/src/main/DesktopUpdates.test.ts @@ -1,9 +1,12 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import type { DesktopUpdateState } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as EffectPath from "effect/Path"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { TestClock } from "effect/testing"; @@ -32,11 +35,18 @@ interface UpdatesHarness { readonly emit: (eventName: string, payload?: unknown) => void; } +interface UpdatesHarnessOptions { + readonly checkForUpdates?: Effect.Effect< + void, + ElectronUpdater.ElectronUpdaterCheckForUpdatesError + >; +} + const flushCallbacks = Effect.callback((resume) => { setImmediate(() => resume(Effect.void)); }); -function makeHarness(): UpdatesHarness { +function makeHarness(options: UpdatesHarnessOptions = {}): UpdatesHarness { let checkCount = 0; let allowDowngrade = false; const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = []; @@ -77,7 +87,7 @@ function makeHarness(): UpdatesHarness { setDisableDifferentialDownload: () => Effect.void, checkForUpdates: Effect.sync(() => { checkCount += 1; - }), + }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)), downloadUpdate: Effect.void, quitAndInstall: () => Effect.void, on: (eventName, listener) => @@ -93,6 +103,7 @@ function makeHarness(): UpdatesHarness { } satisfies ElectronUpdater.ElectronUpdaterShape); const windowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { + create: () => Effect.die("unexpected BrowserWindow creation"), main: Effect.succeed(Option.none()), currentMainOrFirst: Effect.succeed(Option.none()), focusedMainOrFirst: Effect.succeed(Option.none()), @@ -256,4 +267,37 @@ describe("DesktopUpdates", () => { ); }).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + + it.effect("fails channel changes with a typed error while a check is in progress", () => + Effect.gen(function* () { + const checkStarted = yield* Deferred.make(); + const releaseCheck = yield* Deferred.make(); + const harness = makeHarness({ + checkForUpdates: Deferred.succeed(checkStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseCheck)), + ), + }); + + yield* Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + const checkFiber = yield* updates.check("manual").pipe(Effect.forkScoped); + yield* Deferred.await(checkStarted); + + const exit = yield* Effect.exit(updates.setChannel("nightly")); + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, DesktopUpdates.DesktopUpdateActionInProgressError); + assert.equal(error.action, "check"); + } + + yield* Deferred.succeed(releaseCheck, undefined); + yield* Fiber.join(checkFiber); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }), + ); }); diff --git a/apps/desktop/src/main/DesktopUpdates.ts b/apps/desktop/src/main/DesktopUpdates.ts index 977e7e75c47..5505cee47c7 100644 --- a/apps/desktop/src/main/DesktopUpdates.ts +++ b/apps/desktop/src/main/DesktopUpdates.ts @@ -6,6 +6,7 @@ import type { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; +import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -59,14 +60,40 @@ const DownloadProgressInfo = Schema.Struct({ const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso)); +export class DesktopUpdateActionInProgressError extends Data.TaggedError( + "DesktopUpdateActionInProgressError", +)<{ + readonly action: "check" | "download" | "install"; +}> { + override get message() { + return `Cannot change update tracks while an update ${this.action} action is in progress.`; + } +} + +export class DesktopUpdatePersistenceError extends Data.TaggedError( + "DesktopUpdatePersistenceError", +)<{ + readonly cause: DesktopSettingsState.DesktopSettingsPersistenceError; +}> { + override get message() { + return "Failed to persist desktop update settings."; + } +} + +export type DesktopUpdateConfigureError = never; + +export type DesktopUpdateSetChannelError = + | DesktopUpdateActionInProgressError + | DesktopUpdatePersistenceError; + export interface DesktopUpdatesShape { readonly getState: Effect.Effect; readonly emitState: Effect.Effect; readonly disabledReason: Effect.Effect>; - readonly configure: Effect.Effect; + readonly configure: Effect.Effect; readonly setChannel: ( channel: DesktopUpdateChannel, - ) => Effect.Effect; + ) => Effect.Effect; readonly check: (reason: string) => Effect.Effect; readonly download: Effect.Effect; readonly install: Effect.Effect; @@ -146,14 +173,13 @@ const make = Effect.gen(function* () { const settingsState = yield* DesktopSettingsState.DesktopSettingsState; const updatePersistedSettings = ( f: Parameters[0], - ): Effect.Effect => - settingsState - .updatePersisted(f) - .pipe( - Effect.provideService(DesktopEnvironment, environment), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ); + ): Effect.Effect => + settingsState.updatePersisted(f).pipe( + Effect.mapError((cause) => new DesktopUpdatePersistenceError({ cause })), + Effect.provideService(DesktopEnvironment, environment), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); const appUpdateYmlConfigRef = yield* Ref.make>(Option.none()); const updatePollerScopeRef = yield* Ref.make>(Option.none()); @@ -225,6 +251,13 @@ const make = Effect.gen(function* () { return (yield* Ref.get(updateStateRef)).errorContext; }); + const activeUpdateAction = Effect.gen(function* () { + if (yield* Ref.get(updateInstallInFlightRef)) return Option.some("install" as const); + if (yield* Ref.get(updateDownloadInFlightRef)) return Option.some("download" as const); + if (yield* Ref.get(updateCheckInFlightRef)) return Option.some("check" as const); + return Option.none<"check" | "download" | "install">(); + }); + const clearUpdatePollTimer = Effect.gen(function* () { const scope = yield* Ref.getAndSet(updatePollerScopeRef, Option.none()); yield* Option.match(scope, { @@ -270,7 +303,7 @@ const make = Effect.gen(function* () { return yield* electronUpdater.checkForUpdates.pipe( Effect.as(true), - Effect.catch((error: unknown) => + Effect.catch((error) => Effect.gen(function* () { const failedAt = yield* currentIsoTimestamp; const message = formatErrorMessage(error); @@ -296,15 +329,16 @@ const make = Effect.gen(function* () { } yield* Ref.set(updateDownloadInFlightRef, true); - yield* setState(reduceDesktopUpdateStateOnDownloadStart(state)); - yield* electronUpdater.setDisableDifferentialDownload( - isArm64HostRunningIntelBuild(environment.runtimeInfo), - ); - yield* logUpdaterInfo("downloading update"); - - return yield* electronUpdater.downloadUpdate.pipe( - Effect.as({ accepted: true, completed: true }), - Effect.catch((error: unknown) => + return yield* Effect.gen(function* () { + yield* setState(reduceDesktopUpdateStateOnDownloadStart(state)); + yield* electronUpdater.setDisableDifferentialDownload( + isArm64HostRunningIntelBuild(environment.runtimeInfo), + ); + yield* logUpdaterInfo("downloading update"); + yield* electronUpdater.downloadUpdate; + return { accepted: true, completed: true }; + }).pipe( + Effect.catch((error) => Effect.gen(function* () { const message = formatErrorMessage(error); yield* updateState((current) => @@ -341,7 +375,7 @@ const make = Effect.gen(function* () { }); return { accepted: true, completed: false }; }).pipe( - Effect.catch((error: unknown) => + Effect.catch((error) => Effect.gen(function* () { const message = formatErrorMessage(error); yield* Ref.set(updateInstallInFlightRef, false); @@ -570,13 +604,10 @@ const make = Effect.gen(function* () { }), setChannel: (nextChannel) => Effect.gen(function* () { - if ( - (yield* Ref.get(updateCheckInFlightRef)) || - (yield* Ref.get(updateDownloadInFlightRef)) || - (yield* Ref.get(updateInstallInFlightRef)) - ) { + const activeAction = yield* activeUpdateAction; + if (Option.isSome(activeAction)) { return yield* Effect.fail( - new Error("Cannot change update tracks while an update action is in progress."), + new DesktopUpdateActionInProgressError({ action: activeAction.value }), ); } @@ -600,7 +631,7 @@ const make = Effect.gen(function* () { const allowDowngrade = yield* electronUpdater.allowDowngrade; yield* electronUpdater.setAllowDowngrade(true); yield* checkForUpdates("channel-change").pipe( - Effect.ensuring(electronUpdater.setAllowDowngrade(allowDowngrade)), + Effect.ensuring(electronUpdater.setAllowDowngrade(allowDowngrade).pipe(Effect.ignore)), ); return yield* Ref.get(updateStateRef); }), diff --git a/apps/desktop/src/main/DesktopWindow.ts b/apps/desktop/src/main/DesktopWindow.ts new file mode 100644 index 00000000000..3293cf90937 --- /dev/null +++ b/apps/desktop/src/main/DesktopWindow.ts @@ -0,0 +1,351 @@ +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; + +import type * as Electron from "electron"; + +import type { DesktopEnvironmentShape } from "../desktopEnvironment.ts"; +import { DesktopEnvironment } from "../desktopEnvironment.ts"; +import * as ElectronMenu from "../electron/ElectronMenu.ts"; +import * as ElectronShell from "../electron/ElectronShell.ts"; +import * as ElectronTheme from "../electron/ElectronTheme.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts"; +import { bindFirstRevealTrigger, type RevealSubscription } from "../windowReveal.ts"; +import * as DesktopServerExposure from "./DesktopServerExposure.ts"; +import * as DesktopState from "./DesktopState.ts"; +import * as DesktopAssets from "./DesktopAssets.ts"; + +const TITLEBAR_HEIGHT = 40; +const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux +const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; +const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; + +type WindowTitleBarOptions = Pick< + Electron.BrowserWindowConstructorOptions, + "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition" +>; + +type DesktopWindowRuntimeServices = + | DesktopEnvironment + | DesktopAssets.DesktopAssets + | DesktopServerExposure.DesktopServerExposure + | DesktopState.DesktopState + | ElectronMenu.ElectronMenu + | ElectronShell.ElectronShell + | ElectronTheme.ElectronTheme + | ElectronWindow.ElectronWindow; + +export class DesktopWindowDevServerUrlMissingError extends Data.TaggedError( + "DesktopWindowDevServerUrlMissingError", +)<{}> { + override get message() { + return "VITE_DEV_SERVER_URL is required in desktop development."; + } +} + +export type DesktopWindowError = + | DesktopWindowDevServerUrlMissingError + | ElectronWindow.ElectronWindowCreateError; + +export interface DesktopWindowShape { + readonly createMain: Effect.Effect; + readonly ensureMain: Effect.Effect; + readonly revealOrCreateMain: Effect.Effect; + readonly activate: Effect.Effect; + readonly createMainIfBackendReady: Effect.Effect; + readonly handleBackendReady: Effect.Effect; + readonly dispatchMenuAction: (action: string) => Effect.Effect; + readonly syncAppearance: Effect.Effect; +} + +export class DesktopWindow extends Context.Service()( + "t3/desktop/Window", +) {} + +const logWindowInfo = (message: string, annotations?: Record) => + Effect.logInfo(message).pipe( + Effect.annotateLogs({ + scope: "desktop", + component: "desktop-window", + ...annotations, + }), + ); + +function resolveDesktopDevServerUrl( + environment: DesktopEnvironmentShape, +): Effect.Effect { + return Option.match(environment.devServerUrl, { + onNone: () => Effect.fail(new DesktopWindowDevServerUrlMissingError()), + onSome: Effect.succeed, + }); +} + +function getIconOption( + iconPaths: DesktopAssets.DesktopIconPaths, +): { icon: string } | Record { + if (process.platform === "darwin") return {}; // macOS uses .icns from app bundle + const ext = process.platform === "win32" ? "ico" : "png"; + return Option.match(iconPaths[ext], { + onNone: () => ({}), + onSome: (icon) => ({ icon }), + }); +} + +function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string { + return shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; +} + +function getWindowTitleBarOptions(shouldUseDarkColors: boolean): WindowTitleBarOptions { + if (process.platform === "darwin") { + return { + titleBarStyle: "hiddenInset", + trafficLightPosition: { x: 16, y: 18 }, + }; + } + + return { + titleBarStyle: "hidden", + titleBarOverlay: { + color: TITLEBAR_COLOR, + height: TITLEBAR_HEIGHT, + symbolColor: shouldUseDarkColors ? TITLEBAR_DARK_SYMBOL_COLOR : TITLEBAR_LIGHT_SYMBOL_COLOR, + }, + }; +} + +function syncWindowAppearance( + window: Electron.BrowserWindow, + shouldUseDarkColors: boolean, +): Effect.Effect { + return Effect.sync(() => { + if (window.isDestroyed()) { + return; + } + + window.setBackgroundColor(getInitialWindowBackgroundColor(shouldUseDarkColors)); + const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors); + if (typeof titleBarOverlay === "object") { + window.setTitleBarOverlay(titleBarOverlay); + } + }); +} + +const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + const assets = yield* DesktopAssets.DesktopAssets; + const electronMenu = yield* ElectronMenu.ElectronMenu; + const electronShell = yield* ElectronShell.ElectronShell; + const electronTheme = yield* ElectronTheme.ElectronTheme; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const state = yield* DesktopState.DesktopState; + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); + + const createWindow = ( + backendHttpUrl: URL, + ): Effect.Effect => + Effect.gen(function* () { + const iconPaths = yield* assets.iconPaths; + const iconOption = getIconOption(iconPaths); + const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; + const window = yield* electronWindow.create({ + width: 1100, + height: 780, + minWidth: 840, + minHeight: 620, + show: false, + autoHideMenuBar: true, + backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors), + ...iconOption, + title: environment.displayName, + ...getWindowTitleBarOptions(shouldUseDarkColors), + webPreferences: { + preload: environment.preloadPath, + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }); + + window.webContents.on("context-menu", (event, params) => { + event.preventDefault(); + + const menuTemplate: Electron.MenuItemConstructorOptions[] = []; + + if (params.misspelledWord) { + for (const suggestion of params.dictionarySuggestions.slice(0, 5)) { + menuTemplate.push({ + label: suggestion, + click: () => window.webContents.replaceMisspelling(suggestion), + }); + } + if (params.dictionarySuggestions.length === 0) { + menuTemplate.push({ label: "No suggestions", enabled: false }); + } + menuTemplate.push({ type: "separator" }); + } + + if (Option.isSome(ElectronShell.parseSafeExternalUrl(params.linkURL))) { + menuTemplate.push( + { + label: "Copy Link", + click: () => { + void runPromise(electronShell.copyText(params.linkURL)); + }, + }, + { type: "separator" }, + ); + } + + if (params.mediaType === "image") { + menuTemplate.push({ + label: "Copy Image", + click: () => window.webContents.copyImageAt(params.x, params.y), + }); + menuTemplate.push({ type: "separator" }); + } + + menuTemplate.push( + { role: "cut", enabled: params.editFlags.canCut }, + { role: "copy", enabled: params.editFlags.canCopy }, + { role: "paste", enabled: params.editFlags.canPaste }, + { role: "selectAll", enabled: params.editFlags.canSelectAll }, + ); + + void runPromise(electronMenu.popupTemplate({ window, template: menuTemplate })); + }); + + window.webContents.setWindowOpenHandler(({ url }) => { + if (Option.isSome(ElectronShell.parseSafeExternalUrl(url))) { + void runPromise(electronShell.openExternal(url)); + } + return { action: "deny" }; + }); + + window.on("page-title-updated", (event) => { + event.preventDefault(); + window.setTitle(environment.displayName); + }); + window.webContents.on("did-finish-load", () => { + window.setTitle(environment.displayName); + }); + + const revealSubscribers: RevealSubscription[] = [ + (fire) => window.once("ready-to-show", fire), + ]; + if (process.platform === "linux") { + revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire)); + } + bindFirstRevealTrigger(revealSubscribers, () => { + void runPromise(electronWindow.reveal(window)); + }); + + if (environment.isDevelopment) { + const devServerUrl = yield* resolveDesktopDevServerUrl(environment); + yield* Effect.sync(() => { + void window.loadURL(devServerUrl); + window.webContents.openDevTools({ mode: "detach" }); + }); + } else { + yield* Effect.sync(() => { + void window.loadURL(backendHttpUrl.href); + }); + } + + window.on("closed", () => { + void runPromise(electronWindow.clearMain(Option.some(window))); + }); + + return window; + }); + + const createMain = Effect.gen(function* () { + const backendConfig = yield* serverExposure.backendConfig; + const window = yield* createWindow(backendConfig.httpBaseUrl); + yield* electronWindow.setMain(window); + yield* logWindowInfo("main window created"); + return window; + }); + + const ensureMain = Effect.gen(function* () { + const existingWindow = yield* electronWindow.currentMainOrFirst; + if (Option.isSome(existingWindow)) { + return existingWindow.value; + } + return yield* createMain; + }); + + const revealOrCreateMain = Effect.gen(function* () { + const window = yield* ensureMain; + yield* electronWindow.reveal(window); + return window; + }); + + const createMainIfBackendReady = Effect.gen(function* () { + const backendReady = yield* Ref.get(state.backendReady); + if (!backendReady) return; + const existingWindow = yield* electronWindow.currentMainOrFirst; + if (Option.isSome(existingWindow)) return; + yield* createMain; + }); + + return DesktopWindow.of({ + createMain, + ensureMain, + revealOrCreateMain, + activate: Effect.gen(function* () { + const existingWindow = yield* electronWindow.currentMainOrFirst; + if (Option.isSome(existingWindow)) { + yield* electronWindow.reveal(existingWindow.value); + return; + } + if (environment.isDevelopment) { + yield* createMain; + return; + } + yield* createMainIfBackendReady; + }), + createMainIfBackendReady, + handleBackendReady: Effect.gen(function* () { + yield* Ref.set(state.backendReady, true); + yield* logWindowInfo("backend ready", { source: "http" }); + if (environment.isDevelopment) { + return; + } + yield* createMainIfBackendReady; + }), + dispatchMenuAction: (action) => + Effect.gen(function* () { + const existingWindow = yield* electronWindow.focusedMainOrFirst; + const targetWindow = Option.isSome(existingWindow) + ? existingWindow.value + : yield* createMain; + + const send = () => { + if (targetWindow.isDestroyed()) return; + targetWindow.webContents.send(MENU_ACTION_CHANNEL, action); + void runPromise(electronWindow.reveal(targetWindow)); + }; + + if (targetWindow.webContents.isLoadingMainFrame()) { + targetWindow.webContents.once("did-finish-load", send); + return; + } + + send(); + }), + syncAppearance: Effect.gen(function* () { + const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; + yield* electronWindow.syncAllAppearance((window) => + syncWindowAppearance(window, shouldUseDarkColors), + ); + }), + }); +}); + +export const layer = Layer.effect(DesktopWindow, make); From 4ecf54a98247d16358282992255c2d717be45450 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 20:11:52 -0700 Subject: [PATCH 16/43] Refactor desktop app into main-layer modules - Move backend, environment, menu, and shutdown logic under `apps/desktop/src/main` - Add coverage for the new desktop app wiring and IPC paths - Co-authored-by: codex --- apps/desktop/src/appBranding.test.ts | 47 - apps/desktop/src/appBranding.ts | 28 - apps/desktop/src/backendPort.test.ts | 150 --- apps/desktop/src/backendPort.ts | 98 -- apps/desktop/src/backendProcess.test.ts | 166 --- apps/desktop/src/backendProcess.ts | 122 --- apps/desktop/src/backendReadiness.test.ts | 113 -- apps/desktop/src/backendReadiness.ts | 47 - apps/desktop/src/confirmDialog.test.ts | 57 -- apps/desktop/src/confirmDialog.ts | 26 - .../desktop/src/desktopBackendManager.test.ts | 168 --- apps/desktop/src/desktopEnvironment.test.ts | 88 -- apps/desktop/src/desktopLogger.test.ts | 111 -- apps/desktop/src/desktopNetworkInterfaces.ts | 21 - apps/desktop/src/desktopShutdown.test.ts | 43 - apps/desktop/src/electron/ElectronApp.ts | 14 +- .../src/electron/ElectronDialog.test.ts | 93 ++ apps/desktop/src/electron/ElectronDialog.ts | 23 +- .../desktop/src/electron/ElectronMenu.test.ts | 26 +- apps/desktop/src/electron/ElectronMenu.ts | 7 + apps/desktop/src/electron/ElectronProtocol.ts | 2 +- .../desktop/src/ipc/methods/clientSettings.ts | 6 +- .../src/ipc/methods/savedEnvironments.ts | 2 +- .../desktop/src/ipc/methods/serverExposure.ts | 2 +- .../src/ipc/methods/windowLive.test.ts | 153 +++ apps/desktop/src/ipc/methods/windowLive.ts | 29 +- apps/desktop/src/main.ts | 961 ++---------------- apps/desktop/src/main/DesktopApp.ts | 234 +++++ .../src/main/DesktopAppIdentity.test.ts | 179 ++++ apps/desktop/src/main/DesktopAppIdentity.ts | 127 +++ .../src/main/DesktopApplicationMenu.test.ts | 148 +++ .../src/main/DesktopApplicationMenu.ts | 215 ++++ apps/desktop/src/main/DesktopAssets.ts | 2 +- .../main/DesktopBackendConfiguration.test.ts | 152 +++ .../src/main/DesktopBackendConfiguration.ts | 167 +++ apps/desktop/src/main/DesktopBackendEvents.ts | 96 ++ .../src/main/DesktopBackendManager.test.ts | 400 ++++++++ .../DesktopBackendManager.ts} | 336 +++--- apps/desktop/src/main/DesktopConfig.test.ts | 59 ++ apps/desktop/src/main/DesktopConfig.ts | 121 +++ .../src/main/DesktopEnvironment.test.ts | 113 ++ .../DesktopEnvironment.ts} | 132 ++- apps/desktop/src/main/DesktopLifecycle.ts | 45 +- .../src/main/DesktopLocalEnvironment.test.ts | 89 -- .../src/main/DesktopLocalEnvironment.ts | 48 - .../DesktopLogging.ts} | 6 +- apps/desktop/src/main/DesktopRun.ts | 65 ++ .../src/main/DesktopServerExposure.test.ts | 153 ++- .../desktop/src/main/DesktopServerExposure.ts | 246 ++++- apps/desktop/src/main/DesktopSettingsState.ts | 2 +- .../DesktopShellEnvironment.test.ts} | 216 +--- .../src/main/DesktopShellEnvironment.ts | 414 ++++++++ apps/desktop/src/main/DesktopShutdown.test.ts | 52 + .../DesktopShutdown.ts} | 6 +- .../DesktopSshEnvironment.test.ts} | 33 +- .../desktop/src/main/DesktopSshEnvironment.ts | 2 +- apps/desktop/src/main/DesktopUpdates.test.ts | 96 +- apps/desktop/src/main/DesktopUpdates.ts | 123 ++- apps/desktop/src/main/DesktopWindow.ts | 29 +- apps/desktop/src/rotatingFileSink.test.ts | 83 -- apps/desktop/src/runtimeArch.test.ts | 49 - apps/desktop/src/runtimeArch.ts | 39 - apps/desktop/src/serverExposure.test.ts | 258 ----- apps/desktop/src/serverExposure.ts | 201 ---- apps/desktop/src/syncShellEnvironment.ts | 764 -------------- apps/desktop/src/tailscaleEndpointProvider.ts | 29 +- apps/desktop/src/updateChannels.test.ts | 41 - apps/desktop/src/updateMachine.ts | 10 +- apps/desktop/src/updateState.test.ts | 175 ---- apps/desktop/src/updateState.ts | 52 - apps/desktop/src/windowReveal.test.ts | 74 -- apps/desktop/src/windowReveal.ts | 28 - 72 files changed, 3883 insertions(+), 4629 deletions(-) delete mode 100644 apps/desktop/src/appBranding.test.ts delete mode 100644 apps/desktop/src/appBranding.ts delete mode 100644 apps/desktop/src/backendPort.test.ts delete mode 100644 apps/desktop/src/backendPort.ts delete mode 100644 apps/desktop/src/backendProcess.test.ts delete mode 100644 apps/desktop/src/backendProcess.ts delete mode 100644 apps/desktop/src/backendReadiness.test.ts delete mode 100644 apps/desktop/src/backendReadiness.ts delete mode 100644 apps/desktop/src/confirmDialog.test.ts delete mode 100644 apps/desktop/src/confirmDialog.ts delete mode 100644 apps/desktop/src/desktopBackendManager.test.ts delete mode 100644 apps/desktop/src/desktopEnvironment.test.ts delete mode 100644 apps/desktop/src/desktopLogger.test.ts delete mode 100644 apps/desktop/src/desktopNetworkInterfaces.ts delete mode 100644 apps/desktop/src/desktopShutdown.test.ts create mode 100644 apps/desktop/src/electron/ElectronDialog.test.ts create mode 100644 apps/desktop/src/ipc/methods/windowLive.test.ts create mode 100644 apps/desktop/src/main/DesktopApp.ts create mode 100644 apps/desktop/src/main/DesktopAppIdentity.test.ts create mode 100644 apps/desktop/src/main/DesktopAppIdentity.ts create mode 100644 apps/desktop/src/main/DesktopApplicationMenu.test.ts create mode 100644 apps/desktop/src/main/DesktopApplicationMenu.ts create mode 100644 apps/desktop/src/main/DesktopBackendConfiguration.test.ts create mode 100644 apps/desktop/src/main/DesktopBackendConfiguration.ts create mode 100644 apps/desktop/src/main/DesktopBackendEvents.ts create mode 100644 apps/desktop/src/main/DesktopBackendManager.test.ts rename apps/desktop/src/{desktopBackendManager.ts => main/DesktopBackendManager.ts} (57%) create mode 100644 apps/desktop/src/main/DesktopConfig.test.ts create mode 100644 apps/desktop/src/main/DesktopConfig.ts create mode 100644 apps/desktop/src/main/DesktopEnvironment.test.ts rename apps/desktop/src/{desktopEnvironment.ts => main/DesktopEnvironment.ts} (63%) delete mode 100644 apps/desktop/src/main/DesktopLocalEnvironment.test.ts delete mode 100644 apps/desktop/src/main/DesktopLocalEnvironment.ts rename apps/desktop/src/{desktopLogger.ts => main/DesktopLogging.ts} (96%) create mode 100644 apps/desktop/src/main/DesktopRun.ts rename apps/desktop/src/{syncShellEnvironment.test.ts => main/DesktopShellEnvironment.test.ts} (56%) create mode 100644 apps/desktop/src/main/DesktopShellEnvironment.ts create mode 100644 apps/desktop/src/main/DesktopShutdown.test.ts rename apps/desktop/src/{desktopShutdown.ts => main/DesktopShutdown.ts} (85%) rename apps/desktop/src/{sshEnvironment.test.ts => main/DesktopSshEnvironment.test.ts} (82%) delete mode 100644 apps/desktop/src/rotatingFileSink.test.ts delete mode 100644 apps/desktop/src/runtimeArch.test.ts delete mode 100644 apps/desktop/src/runtimeArch.ts delete mode 100644 apps/desktop/src/serverExposure.test.ts delete mode 100644 apps/desktop/src/serverExposure.ts delete mode 100644 apps/desktop/src/syncShellEnvironment.ts delete mode 100644 apps/desktop/src/updateChannels.test.ts delete mode 100644 apps/desktop/src/updateState.test.ts delete mode 100644 apps/desktop/src/updateState.ts delete mode 100644 apps/desktop/src/windowReveal.test.ts delete mode 100644 apps/desktop/src/windowReveal.ts diff --git a/apps/desktop/src/appBranding.test.ts b/apps/desktop/src/appBranding.test.ts deleted file mode 100644 index 5e3e3a5a159..00000000000 --- a/apps/desktop/src/appBranding.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { resolveDesktopAppBranding, resolveDesktopAppStageLabel } from "./appBranding.ts"; - -describe("resolveDesktopAppStageLabel", () => { - it("uses Dev in desktop development", () => { - expect( - resolveDesktopAppStageLabel({ - isDevelopment: true, - appVersion: "0.0.17-nightly.20260414.1", - }), - ).toBe("Dev"); - }); - - it("uses Nightly for packaged nightly builds", () => { - expect( - resolveDesktopAppStageLabel({ - isDevelopment: false, - appVersion: "0.0.17-nightly.20260414.1", - }), - ).toBe("Nightly"); - }); - - it("uses Alpha for packaged stable builds", () => { - expect( - resolveDesktopAppStageLabel({ - isDevelopment: false, - appVersion: "0.0.17", - }), - ).toBe("Alpha"); - }); -}); - -describe("resolveDesktopAppBranding", () => { - it("returns a complete desktop branding payload", () => { - expect( - resolveDesktopAppBranding({ - isDevelopment: false, - appVersion: "0.0.17-nightly.20260414.1", - }), - ).toEqual({ - baseName: "T3 Code", - stageLabel: "Nightly", - displayName: "T3 Code (Nightly)", - }); - }); -}); diff --git a/apps/desktop/src/appBranding.ts b/apps/desktop/src/appBranding.ts deleted file mode 100644 index 3cb1539f761..00000000000 --- a/apps/desktop/src/appBranding.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { DesktopAppBranding, DesktopAppStageLabel } from "@t3tools/contracts"; - -import { isNightlyDesktopVersion } from "./updateChannels.ts"; - -const APP_BASE_NAME = "T3 Code"; - -export function resolveDesktopAppStageLabel(input: { - readonly isDevelopment: boolean; - readonly appVersion: string; -}): DesktopAppStageLabel { - if (input.isDevelopment) { - return "Dev"; - } - - return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : "Alpha"; -} - -export function resolveDesktopAppBranding(input: { - readonly isDevelopment: boolean; - readonly appVersion: string; -}): DesktopAppBranding { - const stageLabel = resolveDesktopAppStageLabel(input); - return { - baseName: APP_BASE_NAME, - stageLabel, - displayName: `${APP_BASE_NAME} (${stageLabel})`, - }; -} diff --git a/apps/desktop/src/backendPort.test.ts b/apps/desktop/src/backendPort.test.ts deleted file mode 100644 index c1608fd5a0e..00000000000 --- a/apps/desktop/src/backendPort.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import { Effect } from "effect"; -import { NetService } from "@t3tools/shared/Net"; - -import { resolveDesktopBackendPortEffect } from "./backendPort.ts"; - -type ProbeCall = readonly [port: number, host: string]; - -describe("resolveDesktopBackendPortEffect", () => { - it.effect("returns the starting port when it is available", () => - Effect.gen(function* () { - const calls: ProbeCall[] = []; - const port = yield* resolveDesktopBackendPortEffect({ - host: "127.0.0.1", - startPort: 3773, - canListenOnHost: (candidatePort, host) => - Effect.sync(() => { - calls.push([candidatePort, host]); - return candidatePort === 3773; - }), - }); - - assert.equal(port, 3773); - assert.deepEqual(calls, [[3773, "127.0.0.1"]]); - }), - ); - - it.effect("increments sequentially until it finds an available port", () => - Effect.gen(function* () { - const calls: ProbeCall[] = []; - const port = yield* resolveDesktopBackendPortEffect({ - host: "127.0.0.1", - startPort: 3773, - canListenOnHost: (candidatePort, host) => - Effect.sync(() => { - calls.push([candidatePort, host]); - return candidatePort === 3775; - }), - }); - - assert.equal(port, 3775); - assert.deepEqual(calls, [ - [3773, "127.0.0.1"], - [3774, "127.0.0.1"], - [3775, "127.0.0.1"], - ]); - }), - ); - - it.effect("treats wildcard-bound ports as unavailable even when loopback probing succeeds", () => - Effect.gen(function* () { - const calls: ProbeCall[] = []; - const port = yield* resolveDesktopBackendPortEffect({ - host: "127.0.0.1", - requiredHosts: ["0.0.0.0"], - startPort: 3773, - canListenOnHost: (candidatePort, host) => - Effect.sync(() => { - calls.push([candidatePort, host]); - if (candidatePort === 3773 && host === "127.0.0.1") return true; - if (candidatePort === 3773 && host === "0.0.0.0") return false; - return candidatePort === 3774; - }), - }); - - assert.equal(port, 3774); - assert.deepEqual(calls, [ - [3773, "127.0.0.1"], - [3773, "0.0.0.0"], - [3774, "127.0.0.1"], - [3774, "0.0.0.0"], - ]); - }), - ); - - it.effect("checks overlapping hosts sequentially to avoid self-interference", () => - Effect.gen(function* () { - let inFlightCount = 0; - const calls: ProbeCall[] = []; - const port = yield* resolveDesktopBackendPortEffect({ - host: "127.0.0.1", - requiredHosts: ["0.0.0.0", "::"], - startPort: 3773, - maxPort: 3773, - canListenOnHost: (candidatePort, host) => - Effect.gen(function* () { - calls.push([candidatePort, host]); - inFlightCount += 1; - const overlapped = inFlightCount > 1; - yield* Effect.yieldNow; - inFlightCount -= 1; - return !overlapped; - }), - }); - - assert.equal(port, 3773); - assert.deepEqual(calls, [ - [3773, "127.0.0.1"], - [3773, "0.0.0.0"], - [3773, "::"], - ]); - }), - ); - - it.effect("fails when the scan range is exhausted", () => - Effect.gen(function* () { - const calls: ProbeCall[] = []; - const result = yield* Effect.flip( - resolveDesktopBackendPortEffect({ - host: "127.0.0.1", - startPort: 65_534, - maxPort: 65_535, - canListenOnHost: (candidatePort, host) => - Effect.sync(() => { - calls.push([candidatePort, host]); - return false; - }), - }), - ); - - assert.equal( - result.message, - "No desktop backend port is available on hosts 127.0.0.1 between 65534 and 65535", - ); - assert.deepEqual(calls, [ - [65_534, "127.0.0.1"], - [65_535, "127.0.0.1"], - ]); - }), - ); - - it.effect("uses the injected NetService by default", () => - Effect.gen(function* () { - const port = yield* resolveDesktopBackendPortEffect({ - host: "127.0.0.1", - startPort: 3773, - maxPort: 3773, - }); - - assert.equal(port, 3773); - }).pipe( - Effect.provideService(NetService, { - canListenOnHost: (port) => Effect.succeed(port === 3773), - isPortAvailableOnLoopback: () => Effect.succeed(true), - reserveLoopbackPort: () => Effect.succeed(3773), - findAvailablePort: (preferred) => Effect.succeed(preferred), - }), - ), - ); -}); diff --git a/apps/desktop/src/backendPort.ts b/apps/desktop/src/backendPort.ts deleted file mode 100644 index d38a5105643..00000000000 --- a/apps/desktop/src/backendPort.ts +++ /dev/null @@ -1,98 +0,0 @@ -import * as Effect from "effect/Effect"; -import { NetService } from "@t3tools/shared/Net"; - -export const DEFAULT_DESKTOP_BACKEND_PORT = 3773; -const MAX_TCP_PORT = 65_535; - -export interface ResolveDesktopBackendPortEffectOptions { - readonly host: string; - readonly startPort?: number; - readonly maxPort?: number; - readonly requiredHosts?: ReadonlyArray; - readonly canListenOnHost?: (port: number, host: string) => Effect.Effect; -} - -const defaultCanListenOnHostEffect = ( - port: number, - host: string, -): Effect.Effect => - Effect.gen(function* () { - const net = yield* NetService; - return yield* net.canListenOnHost(port, host); - }).pipe(Effect.mapError(toError)); - -function toError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} - -const isValidPort = (port: number): boolean => - Number.isInteger(port) && port >= 1 && port <= MAX_TCP_PORT; - -const normalizeHosts = ( - host: string, - requiredHosts: ReadonlyArray, -): ReadonlyArray => - Array.from( - new Set( - [host, ...requiredHosts] - .map((candidate) => candidate.trim()) - .filter((candidate) => candidate.length > 0), - ), - ); - -function canListenOnAllHostsEffect( - port: number, - hosts: ReadonlyArray, - canListenOnHost: (port: number, host: string) => Effect.Effect, -): Effect.Effect { - return Effect.gen(function* () { - for (const candidateHost of hosts) { - if (!(yield* canListenOnHost(port, candidateHost))) { - return false; - } - } - - return true; - }); -} - -export function resolveDesktopBackendPortEffect({ - host, - startPort = DEFAULT_DESKTOP_BACKEND_PORT, - maxPort = MAX_TCP_PORT, - requiredHosts = [], - canListenOnHost = defaultCanListenOnHostEffect as ( - port: number, - host: string, - ) => Effect.Effect, -}: ResolveDesktopBackendPortEffectOptions): Effect.Effect { - return Effect.gen(function* () { - if (!isValidPort(startPort)) { - return yield* Effect.fail(new Error(`Invalid desktop backend start port: ${startPort}`)); - } - - if (!isValidPort(maxPort)) { - return yield* Effect.fail(new Error(`Invalid desktop backend max port: ${maxPort}`)); - } - - if (maxPort < startPort) { - return yield* Effect.fail( - new Error(`Desktop backend max port ${maxPort} is below start port ${startPort}`), - ); - } - - const hostsToCheck = normalizeHosts(host, requiredHosts); - - for (let port = startPort; port <= maxPort; port += 1) { - if (yield* canListenOnAllHostsEffect(port, hostsToCheck, canListenOnHost)) { - return port; - } - } - - return yield* Effect.fail( - new Error( - `No desktop backend port is available on hosts ${hostsToCheck.join(", ")} between ${startPort} and ${maxPort}`, - ), - ); - }); -} diff --git a/apps/desktop/src/backendProcess.test.ts b/apps/desktop/src/backendProcess.test.ts deleted file mode 100644 index 279068cdcec..00000000000 --- a/apps/desktop/src/backendProcess.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import { Duration, Effect, Layer, Schema, Sink, Stream } from "effect"; -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; - -import { - DesktopBackendBootstrap, - type DesktopBackendBootstrap as DesktopBackendBootstrapValue, -} from "@t3tools/contracts"; -import { runBackendProcess } from "./backendProcess.ts"; - -const bootstrap: DesktopBackendBootstrapValue = { - mode: "desktop", - noBrowser: true, - port: 3773, - t3Home: "/tmp/t3", - host: "127.0.0.1", - desktopBootstrapToken: "token", - tailscaleServeEnabled: true, - tailscaleServePort: 443, - otlpTracesUrl: "http://127.0.0.1:4318/v1/traces", -}; - -function makeProcess(options?: { - readonly stdout?: Stream.Stream; - readonly stderr?: Stream.Stream; - readonly exitCode?: Effect.Effect; - readonly kill?: ChildProcessSpawner.ChildProcessHandle["kill"]; -}): ChildProcessSpawner.ChildProcessHandle { - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(123), - stdout: options?.stdout ?? Stream.empty, - stderr: options?.stderr ?? Stream.empty, - all: Stream.merge(options?.stdout ?? Stream.empty, options?.stderr ?? Stream.empty), - exitCode: options?.exitCode ?? Effect.succeed(ChildProcessSpawner.ExitCode(0)), - isRunning: Effect.succeed(false), - kill: options?.kill ?? (() => Effect.void), - stdin: Sink.drain, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - unref: Effect.succeed(Effect.void), - }); -} - -function httpClientLayer(status: number) { - return Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request: HttpClientRequest.HttpClientRequest) => - Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status }))), - ), - ); -} - -function decodeBootstrap(raw: string) { - return Schema.decodeEffect(Schema.fromJsonString(DesktopBackendBootstrap))(raw); -} - -describe("runBackendProcess", () => { - it.effect("spawns the backend with fd3 bootstrap JSON and reports HTTP readiness", () => - Effect.gen(function* () { - let spawnedCommand: ChildProcess.Command | undefined; - let bootstrapJson = ""; - let finishExit: (() => void) | undefined; - let readyCount = 0; - - const spawnerLayer = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => - Effect.gen(function* () { - spawnedCommand = command; - if (command._tag === "StandardCommand") { - const fd3 = command.options.additionalFds?.fd3; - if (fd3?.type === "input" && fd3.stream) { - bootstrapJson = yield* fd3.stream.pipe(Stream.decodeText(), Stream.mkString); - } - } - - return makeProcess({ - exitCode: Effect.callback((resume) => { - finishExit = () => resume(Effect.succeed(ChildProcessSpawner.ExitCode(0))); - }), - }); - }), - ), - ); - - const exit = yield* runBackendProcess({ - executablePath: "/electron", - entryPath: "/server/bin.mjs", - cwd: "/server", - env: { ELECTRON_RUN_AS_NODE: "1" }, - bootstrap, - httpBaseUrl: new URL("http://127.0.0.1:3773"), - captureOutput: true, - onReady: () => - Effect.sync(() => { - readyCount += 1; - finishExit?.(); - }), - }).pipe(Effect.scoped, Effect.provide(Layer.merge(spawnerLayer, httpClientLayer(200)))); - - assert.equal(exit.code, 0); - assert.equal(readyCount, 1); - assert.isDefined(spawnedCommand); - if (spawnedCommand?._tag === "StandardCommand") { - assert.equal(spawnedCommand.command, "/electron"); - assert.deepEqual(spawnedCommand.args, ["/server/bin.mjs", "--bootstrap-fd", "3"]); - assert.equal(spawnedCommand.options.cwd, "/server"); - assert.equal(spawnedCommand.options.stdout, "pipe"); - assert.equal(spawnedCommand.options.stderr, "pipe"); - assert.equal(spawnedCommand.options.killSignal, "SIGTERM"); - assert.isDefined(spawnedCommand.options.forceKillAfter); - assert.equal( - Duration.toMillis(Duration.fromInputUnsafe(spawnedCommand.options.forceKillAfter)), - 2_000, - ); - } - assert.deepEqual(yield* decodeBootstrap(bootstrapJson), bootstrap); - }), - ); - - it.effect("inherits child output when captureOutput is false", () => - Effect.gen(function* () { - let spawnedCommand: ChildProcess.Command | undefined; - let finishExit: (() => void) | undefined; - let readyCount = 0; - - const spawnerLayer = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => - Effect.sync(() => { - spawnedCommand = command; - return makeProcess({ - exitCode: Effect.callback((resume) => { - finishExit = () => resume(Effect.succeed(ChildProcessSpawner.ExitCode(0))); - }), - }); - }), - ), - ); - - const exit = yield* runBackendProcess({ - executablePath: "/electron", - entryPath: "/server/bin.mjs", - cwd: "/server", - env: { ELECTRON_RUN_AS_NODE: "1" }, - bootstrap, - httpBaseUrl: new URL("http://127.0.0.1:3773"), - captureOutput: false, - onReady: () => - Effect.sync(() => { - readyCount += 1; - finishExit?.(); - }), - }).pipe(Effect.scoped, Effect.provide(Layer.merge(spawnerLayer, httpClientLayer(200)))); - - assert.equal(exit.code, 0); - assert.equal(readyCount, 1); - assert.isDefined(spawnedCommand); - if (spawnedCommand?._tag === "StandardCommand") { - assert.equal(spawnedCommand.options.stdout, "inherit"); - assert.equal(spawnedCommand.options.stderr, "inherit"); - } - }), - ); -}); diff --git a/apps/desktop/src/backendProcess.ts b/apps/desktop/src/backendProcess.ts deleted file mode 100644 index fe59a19b06f..00000000000 --- a/apps/desktop/src/backendProcess.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { - DesktopBackendBootstrap, - type DesktopBackendBootstrap as DesktopBackendBootstrapValue, -} from "@t3tools/contracts"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as PlatformError from "effect/PlatformError"; -import * as Result from "effect/Result"; -import * as Schema from "effect/Schema"; -import * as Scope from "effect/Scope"; -import * as Stream from "effect/Stream"; -import { HttpClient } from "effect/unstable/http"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; - -import { BackendTimeoutError, waitForHttpReadyEffect } from "./backendReadiness.ts"; - -const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1); -const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(2); - -export interface BackendProcessExit { - readonly code: number | null; - readonly reason: string; - readonly cause: unknown; -} - -export interface RunBackendProcessOptions { - readonly executablePath: string; - readonly entryPath: string; - readonly cwd: string; - readonly env: Record; - readonly bootstrap: DesktopBackendBootstrapValue; - readonly httpBaseUrl: URL; - readonly captureOutput: boolean; - readonly readinessTimeout?: Duration.Duration; - readonly onStarted?: (pid: number) => Effect.Effect; - readonly onReady?: () => Effect.Effect; - readonly onReadinessFailure?: (error: BackendTimeoutError) => Effect.Effect; - readonly onOutput?: (streamName: "stdout" | "stderr", chunk: Uint8Array) => Effect.Effect; -} - -const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); - -function describeProcessExit( - result: Result.Result, -): BackendProcessExit { - if (Result.isSuccess(result)) { - const code = Number(result.success); - return { - code, - reason: `code=${code}`, - cause: result.success, - }; - } - - return { - code: null, - reason: result.failure.message, - cause: result.failure, - }; -} - -function drainBackendOutput( - streamName: "stdout" | "stderr", - stream: Stream.Stream, - onOutput: (streamName: "stdout" | "stderr", chunk: Uint8Array) => Effect.Effect, -): Effect.Effect { - return stream.pipe( - Stream.runForEach((chunk) => onOutput(streamName, chunk)), - Effect.ignore, - ); -} - -export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( - options: RunBackendProcessOptions, -): Effect.fn.Return< - BackendProcessExit, - unknown, - ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient | Scope.Scope -> { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const bootstrapJson = yield* encodeBootstrapJson(options.bootstrap); - const onOutput = options.onOutput ?? (() => Effect.void); - const command = ChildProcess.make( - options.executablePath, - [options.entryPath, "--bootstrap-fd", "3"], - { - cwd: options.cwd, - env: options.env, - extendEnv: false, - // In Electron main, process.execPath points to the Electron binary. - // Run the child in Node mode so this backend process does not become a GUI app instance. - stdin: "ignore", - stdout: options.captureOutput ? "pipe" : "inherit", - stderr: options.captureOutput ? "pipe" : "inherit", - killSignal: "SIGTERM", - forceKillAfter: DEFAULT_BACKEND_TERMINATE_GRACE, - additionalFds: { - fd3: { - type: "input", - stream: Stream.encodeText(Stream.make(`${bootstrapJson}\n`)), - }, - }, - }, - ); - - const handle = yield* spawner.spawn(command); - - yield* options.onStarted?.(Number(handle.pid)) ?? Effect.void; - if (options.captureOutput) { - yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped); - yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped); - } - yield* waitForHttpReadyEffect(options.httpBaseUrl, { - timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, - }).pipe( - Effect.tap(() => options.onReady?.() ?? Effect.void), - Effect.catch((error) => options.onReadinessFailure?.(error) ?? Effect.void), - Effect.forkScoped, - ); - - return describeProcessExit(yield* Effect.result(handle.exitCode)); -}); diff --git a/apps/desktop/src/backendReadiness.test.ts b/apps/desktop/src/backendReadiness.test.ts deleted file mode 100644 index 34ad8a5f1f2..00000000000 --- a/apps/desktop/src/backendReadiness.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import { Duration, Effect, Fiber, Layer, Result } from "effect"; -import { TestClock } from "effect/testing"; -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; - -import { waitForHttpReadyEffect } from "./backendReadiness.ts"; - -function responseForRequest( - request: HttpClientRequest.HttpClientRequest, - status: number, -): HttpClientResponse.HttpClientResponse { - return HttpClientResponse.fromWeb(request, new Response(null, { status })); -} - -function httpClientLayer( - handler: ( - request: HttpClientRequest.HttpClientRequest, - ) => Effect.Effect, -) { - return Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => handler(request)), - ); -} - -describe("waitForHttpReadyEffect", () => { - it.effect("returns once the backend serves the requested readiness path", () => { - const requestUrls: Array = []; - const statuses = [503, 200]; - const layer = Layer.merge( - TestClock.layer(), - httpClientLayer((request) => - Effect.sync(() => { - const status = statuses.shift(); - assert.isDefined(status); - requestUrls.push(request.url); - return responseForRequest(request, status); - }), - ), - ); - - return Effect.gen(function* () { - const fiber = yield* waitForHttpReadyEffect(new URL("http://127.0.0.1:3773"), { - timeout: Duration.seconds(1), - interval: Duration.millis(100), - }).pipe(Effect.forkChild); - - yield* Effect.yieldNow; - assert.deepEqual(requestUrls, ["http://127.0.0.1:3773/"]); - - yield* TestClock.adjust(Duration.millis(100)); - yield* Fiber.join(fiber); - - assert.deepEqual(requestUrls, ["http://127.0.0.1:3773/", "http://127.0.0.1:3773/"]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("retries after a readiness request stalls past the per-request timeout", () => { - let calls = 0; - const layer = Layer.merge( - TestClock.layer(), - httpClientLayer((request) => { - calls += 1; - return calls === 1 ? Effect.never : Effect.succeed(responseForRequest(request, 200)); - }), - ); - - return Effect.gen(function* () { - const fiber = yield* waitForHttpReadyEffect(new URL("http://127.0.0.1:3773"), { - timeout: Duration.seconds(1), - interval: Duration.millis(100), - requestTimeout: Duration.millis(250), - }).pipe(Effect.forkChild); - - yield* Effect.yieldNow; - assert.equal(calls, 1); - - yield* TestClock.adjust(Duration.millis(350)); - yield* Fiber.join(fiber); - - assert.equal(calls, 2); - }).pipe(Effect.provide(layer)); - }); - - it.effect("times out using the Effect clock when readiness never succeeds", () => { - const layer = Layer.merge( - TestClock.layer(), - httpClientLayer(() => Effect.never), - ); - - return Effect.gen(function* () { - const fiber = yield* Effect.result( - waitForHttpReadyEffect(new URL("http://127.0.0.1:3773"), { - timeout: Duration.seconds(1), - interval: Duration.millis(100), - requestTimeout: Duration.millis(250), - }), - ).pipe(Effect.forkChild); - - yield* Effect.yieldNow; - yield* TestClock.adjust(Duration.millis(1_000)); - const result = yield* Fiber.join(fiber); - - assert.isTrue(Result.isFailure(result)); - if (Result.isFailure(result)) { - assert.include( - result.failure.message, - "Timed out waiting for backend readiness at http://127.0.0.1:3773/.", - ); - } - }).pipe(Effect.provide(layer)); - }); -}); diff --git a/apps/desktop/src/backendReadiness.ts b/apps/desktop/src/backendReadiness.ts deleted file mode 100644 index 813597304cf..00000000000 --- a/apps/desktop/src/backendReadiness.ts +++ /dev/null @@ -1,47 +0,0 @@ -import * as Data from "effect/Data"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Schedule from "effect/Schedule"; -import { HttpClient } from "effect/unstable/http"; - -export interface WaitForHttpReadyEffectOptions { - readonly timeout?: Duration.Duration; - readonly interval?: Duration.Duration; - readonly requestTimeout?: Duration.Duration; - readonly path?: string; -} - -const DEFAULT_TIMEOUT = Duration.seconds(30); -const DEFAULT_INTERVAL = Duration.millis(100); -const DEFAULT_REQUEST_TIMEOUT = Duration.seconds(1); - -export class BackendTimeoutError extends Data.TaggedError("BackendTimeoutError")<{ - readonly url: URL; -}> { - override get message() { - return `Timed out waiting for backend readiness at ${this.url.href}.`; - } -} - -export const waitForHttpReadyEffect = Effect.fn("waitForHttpReadyEffect")(function* ( - baseUrl: URL, - options?: WaitForHttpReadyEffectOptions, -): Effect.fn.Return { - const timeout = options?.timeout ?? DEFAULT_TIMEOUT; - const interval = options?.interval ?? DEFAULT_INTERVAL; - const requestTimeout = options?.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT; - const readinessPath = options?.path ?? "/"; - const requestUrl = new URL(readinessPath, baseUrl); - - const client = (yield* HttpClient.HttpClient).pipe( - HttpClient.filterStatusOk, - HttpClient.transformResponse(Effect.timeout(requestTimeout)), - HttpClient.retry(Schedule.spaced(interval)), - ); - - yield* client.get(requestUrl).pipe( - Effect.asVoid, - Effect.timeout(timeout), - Effect.mapError(() => new BackendTimeoutError({ url: baseUrl })), - ); -}); diff --git a/apps/desktop/src/confirmDialog.test.ts b/apps/desktop/src/confirmDialog.test.ts deleted file mode 100644 index de1d23eb178..00000000000 --- a/apps/desktop/src/confirmDialog.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { BrowserWindow } from "electron"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { showMessageBoxMock } = vi.hoisted(() => ({ - showMessageBoxMock: vi.fn(), -})); - -vi.mock("electron", () => ({ - dialog: { - showMessageBox: showMessageBoxMock, - }, -})); - -import { showDesktopConfirmDialog } from "./confirmDialog.ts"; - -describe("showDesktopConfirmDialog", () => { - beforeEach(() => { - showMessageBoxMock.mockReset(); - }); - - it("returns false and does not open a dialog for empty messages", async () => { - const result = await showDesktopConfirmDialog(" ", null); - - expect(result).toBe(false); - expect(showMessageBoxMock).not.toHaveBeenCalled(); - }); - - it("opens a dialog for the focused window and returns true on confirm", async () => { - const ownerWindow = { id: 1 } as BrowserWindow; - showMessageBoxMock.mockResolvedValue({ response: 1 }); - - const result = await showDesktopConfirmDialog("Delete worktree?", ownerWindow); - - expect(result).toBe(true); - expect(showMessageBoxMock).toHaveBeenCalledWith( - ownerWindow, - expect.objectContaining({ - buttons: ["No", "Yes"], - message: "Delete worktree?", - }), - ); - }); - - it("opens an app-level dialog when there is no focused window", async () => { - showMessageBoxMock.mockResolvedValue({ response: 0 }); - - const result = await showDesktopConfirmDialog("Delete worktree?", null); - - expect(result).toBe(false); - expect(showMessageBoxMock).toHaveBeenCalledWith( - expect.objectContaining({ - buttons: ["No", "Yes"], - message: "Delete worktree?", - }), - ); - }); -}); diff --git a/apps/desktop/src/confirmDialog.ts b/apps/desktop/src/confirmDialog.ts deleted file mode 100644 index c941d090652..00000000000 --- a/apps/desktop/src/confirmDialog.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { type BrowserWindow, dialog } from "electron"; - -const CONFIRM_BUTTON_INDEX = 1; - -export async function showDesktopConfirmDialog( - message: string, - ownerWindow: BrowserWindow | null, -): Promise { - const normalizedMessage = message.trim(); - if (normalizedMessage.length === 0) { - return false; - } - - const options = { - type: "question" as const, - buttons: ["No", "Yes"], - defaultId: 0, - cancelId: 0, - noLink: true, - message: normalizedMessage, - }; - const result = ownerWindow - ? await dialog.showMessageBox(ownerWindow, options) - : await dialog.showMessageBox(options); - return result.response === CONFIRM_BUTTON_INDEX; -} diff --git a/apps/desktop/src/desktopBackendManager.test.ts b/apps/desktop/src/desktopBackendManager.test.ts deleted file mode 100644 index 83a1dae57f7..00000000000 --- a/apps/desktop/src/desktopBackendManager.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import { Deferred, Duration, Effect, FileSystem, Layer, Option, Queue, Scope } from "effect"; -import { TestClock } from "effect/testing"; -import { HttpClient } from "effect/unstable/http"; -import { ChildProcessSpawner } from "effect/unstable/process"; - -import { - DesktopBackendConfiguration, - DesktopBackendEvents, - DesktopBackendManager, - DesktopBackendManagerLive, - DesktopBackendProcessRunner, - type DesktopBackendEventsShape, - type DesktopBackendProcessRunnerShape, - type DesktopBackendStartConfig, -} from "./desktopBackendManager.ts"; - -const baseConfig: DesktopBackendStartConfig = { - executablePath: "/electron", - entryPath: "/server/bin.mjs", - cwd: "/server", - env: { ELECTRON_RUN_AS_NODE: "1" }, - bootstrap: { - mode: "desktop", - noBrowser: true, - port: 3773, - t3Home: "/tmp/t3", - host: "127.0.0.1", - desktopBootstrapToken: "token", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }, - httpBaseUrl: new URL("http://127.0.0.1:3773"), - captureOutput: true, -}; - -function makeManagerLayer(input: { - readonly runner: DesktopBackendProcessRunnerShape; - readonly events?: Partial; - readonly config?: DesktopBackendStartConfig; -}) { - return DesktopBackendManagerLive.pipe( - Layer.provide( - Layer.mergeAll( - FileSystem.layerNoop({ - exists: () => Effect.succeed(true), - }), - Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.die("unexpected child process spawn")), - ), - Layer.succeed( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("unexpected HTTP request")), - ), - Layer.succeed(DesktopBackendConfiguration, { - resolve: Effect.succeed(input.config ?? baseConfig), - }), - Layer.succeed(DesktopBackendProcessRunner, input.runner), - Layer.succeed(DesktopBackendEvents, { - onStarting: Effect.void, - onStarted: () => Effect.void, - onReady: Effect.void, - onReadinessFailure: () => Effect.void, - onOutput: () => Effect.void, - onExit: () => Effect.void, - onRestartScheduled: () => Effect.void, - ...input.events, - } satisfies DesktopBackendEventsShape), - ), - ), - ); -} - -describe("DesktopBackendManager", () => { - it.effect("starts the configured backend and closes the scoped process on stop", () => { - return Effect.gen(function* () { - let startCount = 0; - let closedCount = 0; - const closed = yield* Deferred.make(); - const startedPids = yield* Queue.unbounded(); - - const layer = makeManagerLayer({ - events: { - onStarted: ({ pid }) => Queue.offer(startedPids, pid).pipe(Effect.asVoid), - }, - runner: { - run: (options) => - Effect.gen(function* () { - startCount += 1; - const scope = yield* Scope.Scope; - yield* Scope.addFinalizer( - scope, - Effect.sync(() => { - closedCount += 1; - }).pipe(Effect.andThen(Deferred.succeed(closed, void 0))), - ); - yield* options.onStarted?.(123) ?? Effect.void; - yield* options.onReady?.() ?? Effect.void; - yield* Deferred.await(closed); - return { code: 0, reason: "code=0", cause: 0 }; - }), - }, - }); - - yield* Effect.gen(function* () { - const manager = yield* DesktopBackendManager; - assert.isTrue(Option.isNone(yield* manager.currentConfig)); - - yield* manager.start; - assert.equal(yield* Queue.take(startedPids), 123); - assert.deepEqual(yield* manager.currentConfig, Option.some(baseConfig)); - - const runningSnapshot = yield* manager.snapshot; - assert.equal(runningSnapshot.ready, true); - assert.deepEqual(runningSnapshot.activePid, Option.some(123)); - - yield* manager.stop(); - assert.equal(startCount, 1); - assert.equal(closedCount, 1); - - const stoppedSnapshot = yield* manager.snapshot; - assert.equal(stoppedSnapshot.desiredRunning, false); - assert.equal(stoppedSnapshot.ready, false); - assert.equal(Option.isNone(stoppedSnapshot.activePid), true); - }).pipe(Effect.provide(layer)); - }); - }); - - it.effect("restarts an unexpectedly exited backend with the Effect clock", () => { - return Effect.gen(function* () { - const starts = yield* Queue.unbounded(); - const restartDelays = yield* Queue.unbounded(); - let startCount = 0; - - const layer = makeManagerLayer({ - events: { - onRestartScheduled: ({ delay }) => - Queue.offer(restartDelays, Duration.toMillis(delay)).pipe(Effect.asVoid), - }, - runner: { - run: (options) => - Effect.gen(function* () { - startCount += 1; - yield* Queue.offer(starts, startCount); - yield* options.onStarted?.(100 + startCount) ?? Effect.void; - return { - code: 1, - reason: `code=1 run=${startCount}`, - cause: ChildProcessSpawner.ExitCode(1), - }; - }), - }, - }); - - yield* Effect.gen(function* () { - const manager = yield* DesktopBackendManager; - yield* manager.start; - - assert.equal(yield* Queue.take(starts), 1); - assert.equal(yield* Queue.take(restartDelays), 500); - - yield* TestClock.adjust(Duration.millis(500)); - assert.equal(yield* Queue.take(starts), 2); - }).pipe(Effect.provide(Layer.merge(TestClock.layer(), layer))); - }); - }); -}); diff --git a/apps/desktop/src/desktopEnvironment.test.ts b/apps/desktop/src/desktopEnvironment.test.ts deleted file mode 100644 index 9e790649373..00000000000 --- a/apps/desktop/src/desktopEnvironment.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import { Effect, Option } from "effect"; -import * as EffectPath from "effect/Path"; - -import { makeDesktopEnvironment, resolveDesktopHomeDirectory } from "./desktopEnvironment.ts"; - -const makeEnvironment = (overrides: Partial[0]> = {}) => - makeDesktopEnvironment({ - dirname: "/repo/apps/desktop/dist-electron", - env: {}, - cwd: "/cwd", - platform: "darwin", - processArch: "arm64", - appVersion: "0.0.22", - appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar", - isPackaged: false, - resourcesPath: "/Applications/T3 Code.app/Contents/Resources", - runningUnderArm64Translation: false, - ...overrides, - }).pipe(Effect.provide(EffectPath.layer)); - -describe("DesktopEnvironment", () => { - it("resolves home directory from platform env with cwd fallback", () => { - assert.equal( - resolveDesktopHomeDirectory({ - env: { HOME: " /Users/alice " }, - cwd: "/cwd", - }), - "/Users/alice", - ); - assert.equal(resolveDesktopHomeDirectory({ env: {}, cwd: "/cwd" }), "/cwd"); - }); - - it.effect("derives state paths and development identity inside Effect", () => - Effect.gen(function* () { - const environment = yield* makeEnvironment({ - env: { - HOME: "/Users/alice", - T3CODE_HOME: " /tmp/t3 ", - VITE_DEV_SERVER_URL: " http://localhost:5173 ", - T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH: " /remote/server.mjs ", - }, - }); - - assert.equal(environment.isDevelopment, true); - assert.equal(environment.baseDir, "/tmp/t3"); - assert.equal(environment.stateDir, "/tmp/t3/userdata"); - assert.equal(environment.desktopSettingsPath, "/tmp/t3/userdata/desktop-settings.json"); - assert.equal(environment.clientSettingsPath, "/tmp/t3/userdata/client-settings.json"); - assert.equal( - environment.savedEnvironmentRegistryPath, - "/tmp/t3/userdata/saved-environments.json", - ); - assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json"); - assert.equal(environment.logDir, "/tmp/t3/userdata/logs"); - assert.equal(environment.rootDir, "/repo"); - assert.equal(environment.appRoot, "/repo"); - assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs"); - assert.equal(environment.backendCwd, "/repo"); - assert.equal(environment.appUserModelId, "com.t3tools.t3code.dev"); - assert.equal(environment.linuxWmClass, "t3code-dev"); - assert.deepEqual(environment.devServerUrl, Option.some("http://localhost:5173")); - assert.deepEqual(environment.devRemoteT3ServerEntryPath, Option.some("/remote/server.mjs")); - }), - ); - - it.effect("resolves picker defaults without nullish sentinels", () => - Effect.gen(function* () { - const environment = yield* makeEnvironment({ - env: { HOME: "/Users/alice" }, - }); - - assert.deepEqual(environment.resolvePickFolderDefaultPath(null), Option.none()); - assert.deepEqual( - environment.resolvePickFolderDefaultPath({ initialPath: " " }), - Option.none(), - ); - assert.deepEqual( - environment.resolvePickFolderDefaultPath({ initialPath: "~" }), - Option.some("/Users/alice"), - ); - assert.deepEqual( - environment.resolvePickFolderDefaultPath({ initialPath: "~/project" }), - Option.some("/Users/alice/project"), - ); - }), - ); -}); diff --git a/apps/desktop/src/desktopLogger.test.ts b/apps/desktop/src/desktopLogger.test.ts deleted file mode 100644 index 4a82b2048a8..00000000000 --- a/apps/desktop/src/desktopLogger.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, describe, it } from "@effect/vitest"; -import { Effect, FileSystem, Path } from "effect"; -import * as EffectPath from "effect/Path"; - -import { DesktopEnvironment, makeDesktopEnvironment } from "./desktopEnvironment.ts"; -import { - DesktopBackendOutputLog, - DesktopBackendOutputLogLive, - DesktopLoggerLive, - makeRotatingLogFileWriter, -} from "./desktopLogger.ts"; - -const textEncoder = new TextEncoder(); - -const makePackagedEnvironment = (baseDir: string) => - makeDesktopEnvironment({ - dirname: "/repo/apps/desktop/dist-electron", - env: { - HOME: baseDir, - T3CODE_HOME: baseDir, - }, - cwd: "/cwd", - platform: "darwin", - processArch: "arm64", - appVersion: "0.0.22", - appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar", - isPackaged: true, - resourcesPath: "/Applications/T3 Code.app/Contents/Resources", - runningUnderArm64Translation: false, - }).pipe(Effect.provide(EffectPath.layer)); - -describe("DesktopLogger", () => { - it.effect("rotates log files through the Effect FileSystem service", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-logger-", - }); - const logPath = path.join(dir, "desktop-main.log"); - const writer = yield* makeRotatingLogFileWriter({ - filePath: logPath, - maxBytes: 8, - maxFiles: 2, - }); - - yield* writer.writeText("12345678"); - yield* writer.writeText("abc"); - - assert.equal(yield* fileSystem.readFileString(logPath), "abc"); - assert.equal( - yield* fileSystem.readFileString(path.join(dir, "desktop-main.log.1")), - "12345678", - ); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - - it.effect("writes packaged desktop Effect logs through the logger layer", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-logger-layer-", - }); - const environment = yield* makePackagedEnvironment(baseDir); - - yield* Effect.logInfo("desktop logger layer test").pipe( - Effect.annotateLogs({ testRun: "desktop-logger-layer" }), - Effect.provide(DesktopLoggerLive), - Effect.provideService(DesktopEnvironment, environment), - Effect.scoped, - ); - - const contents = yield* fileSystem.readFileString( - path.join(environment.logDir, "desktop-main.log"), - ); - assert.match(contents, /desktop logger layer test/); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - - it.effect("writes packaged backend child output through an Effect service", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-output-", - }); - const environment = yield* makePackagedEnvironment(baseDir); - - yield* Effect.gen(function* () { - const outputLog = yield* DesktopBackendOutputLog; - yield* outputLog.writeSessionBoundary({ - phase: "START", - runId: "run-1", - details: "pid=123 cwd=/tmp/project", - }); - yield* outputLog.writeOutputChunk("stdout", textEncoder.encode("server ready\n")); - }).pipe( - Effect.provide(DesktopBackendOutputLogLive), - Effect.provideService(DesktopEnvironment, environment), - ); - - const contents = yield* fileSystem.readFileString( - path.join(environment.logDir, "server-child.log"), - ); - assert.match(contents, /APP SESSION START run=run-1 pid=123 cwd=\/tmp\/project/); - assert.match(contents, /server ready/); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); -}); diff --git a/apps/desktop/src/desktopNetworkInterfaces.ts b/apps/desktop/src/desktopNetworkInterfaces.ts deleted file mode 100644 index 870b5eff10e..00000000000 --- a/apps/desktop/src/desktopNetworkInterfaces.ts +++ /dev/null @@ -1,21 +0,0 @@ -import * as NodeOS from "node:os"; - -import { Context, Effect, Layer } from "effect"; - -import type { DesktopNetworkInterfaces } from "./serverExposure.ts"; - -export interface DesktopNetworkInterfacesServiceShape { - readonly read: Effect.Effect; -} - -export class DesktopNetworkInterfacesService extends Context.Service< - DesktopNetworkInterfacesService, - DesktopNetworkInterfacesServiceShape ->()("t3/desktop/NetworkInterfaces") {} - -export const layer = Layer.succeed( - DesktopNetworkInterfacesService, - DesktopNetworkInterfacesService.of({ - read: Effect.sync(() => NodeOS.networkInterfaces()), - }), -); diff --git a/apps/desktop/src/desktopShutdown.test.ts b/apps/desktop/src/desktopShutdown.test.ts deleted file mode 100644 index 47f286fda5a..00000000000 --- a/apps/desktop/src/desktopShutdown.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import { Effect, Fiber } from "effect"; - -import { makeDesktopShutdown } from "./desktopShutdown.ts"; - -describe("DesktopShutdown", () => { - it.effect("unblocks request waiters when shutdown is requested", () => - Effect.gen(function* () { - const shutdown = yield* makeDesktopShutdown; - const waiter = yield* shutdown.awaitRequest.pipe(Effect.as("requested"), Effect.forkChild); - - yield* shutdown.request; - - assert.equal(yield* Fiber.join(waiter), "requested"); - }), - ); - - it.effect("tracks completion after resources finish closing", () => - Effect.gen(function* () { - const shutdown = yield* makeDesktopShutdown; - const waiter = yield* shutdown.awaitComplete.pipe(Effect.as("complete"), Effect.forkChild); - - assert.equal(yield* shutdown.isComplete, false); - yield* shutdown.markComplete; - - assert.equal(yield* shutdown.isComplete, true); - assert.equal(yield* Fiber.join(waiter), "complete"); - }), - ); - - it.effect("allows repeated requests and completion marks", () => - Effect.gen(function* () { - const shutdown = yield* makeDesktopShutdown; - - yield* shutdown.request; - yield* shutdown.request; - yield* shutdown.markComplete; - yield* shutdown.markComplete; - - assert.equal(yield* shutdown.isComplete, true); - }), - ); -}); diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 1dfec7e573a..2e330c2d275 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -45,22 +45,16 @@ export class ElectronApp extends Context.Service( const addScopedAppListener = >( eventName: string, listener: (...args: Args) => void, -): Effect.Effect => { - const app = Electron.app as { - on: (eventName: string, listener: (...args: Array) => void) => unknown; - removeListener: (eventName: string, listener: (...args: Array) => void) => unknown; - }; - const untypedListener = listener as unknown as (...args: Array) => void; - return Effect.acquireRelease( +): Effect.Effect => + Effect.acquireRelease( Effect.sync(() => { - app.on(eventName, untypedListener); + Electron.app.on(eventName as any, listener as any); }), () => Effect.sync(() => { - app.removeListener(eventName, untypedListener); + Electron.app.removeListener(eventName as any, listener as any); }), ).pipe(Effect.asVoid); -}; const make = ElectronApp.of({ metadata: Effect.sync(() => ({ diff --git a/apps/desktop/src/electron/ElectronDialog.test.ts b/apps/desktop/src/electron/ElectronDialog.test.ts new file mode 100644 index 00000000000..61b40bcfc4c --- /dev/null +++ b/apps/desktop/src/electron/ElectronDialog.test.ts @@ -0,0 +1,93 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import type { BrowserWindow } from "electron"; +import { beforeEach, vi } from "vitest"; + +import * as ElectronDialog from "./ElectronDialog.ts"; + +const { showMessageBoxMock, showOpenDialogMock, showErrorBoxMock } = vi.hoisted(() => ({ + showMessageBoxMock: vi.fn(), + showOpenDialogMock: vi.fn(), + showErrorBoxMock: vi.fn(), +})); + +vi.mock("electron", () => ({ + dialog: { + showMessageBox: showMessageBoxMock, + showOpenDialog: showOpenDialogMock, + showErrorBox: showErrorBoxMock, + }, +})); + +describe("ElectronDialog", () => { + beforeEach(() => { + showMessageBoxMock.mockReset(); + showOpenDialogMock.mockReset(); + showErrorBoxMock.mockReset(); + }); + + it.effect("returns false without opening a confirm dialog for empty messages", () => + Effect.gen(function* () { + const dialog = yield* ElectronDialog.ElectronDialog; + + const result = yield* dialog.confirm({ + message: " ", + owner: Option.none(), + }); + + assert.isFalse(result); + assert.equal(showMessageBoxMock.mock.calls.length, 0); + }).pipe(Effect.provide(ElectronDialog.layer)), + ); + + it.effect("opens a confirm dialog for the owner window", () => + Effect.gen(function* () { + const owner = { id: 1 } as BrowserWindow; + showMessageBoxMock.mockResolvedValue({ response: 1 }); + const dialog = yield* ElectronDialog.ElectronDialog; + + const result = yield* dialog.confirm({ + message: "Delete worktree?", + owner: Option.some(owner), + }); + + assert.isTrue(result); + assert.deepEqual(showMessageBoxMock.mock.calls[0], [ + owner, + { + type: "question", + buttons: ["No", "Yes"], + defaultId: 0, + cancelId: 0, + noLink: true, + message: "Delete worktree?", + }, + ]); + }).pipe(Effect.provide(ElectronDialog.layer)), + ); + + it.effect("opens an app-level confirm dialog when there is no owner window", () => + Effect.gen(function* () { + showMessageBoxMock.mockResolvedValue({ response: 0 }); + const dialog = yield* ElectronDialog.ElectronDialog; + + const result = yield* dialog.confirm({ + message: "Delete worktree?", + owner: Option.none(), + }); + + assert.isFalse(result); + assert.deepEqual(showMessageBoxMock.mock.calls[0], [ + { + type: "question", + buttons: ["No", "Yes"], + defaultId: 0, + cancelId: 0, + noLink: true, + message: "Delete worktree?", + }, + ]); + }).pipe(Effect.provide(ElectronDialog.layer)), + ); +}); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index b0fee9cabf3..2dd4f5d17a0 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -5,7 +5,7 @@ import * as Option from "effect/Option"; import * as Electron from "electron"; -import { showDesktopConfirmDialog } from "../confirmDialog.ts"; +const CONFIRM_BUTTON_INDEX = 1; export interface ElectronDialogPickFolderInput { readonly owner: Option.Option; @@ -56,7 +56,26 @@ const make = ElectronDialog.of({ return Option.fromNullishOr(result.filePaths[0]); }), confirm: (input) => - Effect.promise(() => showDesktopConfirmDialog(input.message, Option.getOrNull(input.owner))), + Effect.gen(function* () { + const normalizedMessage = input.message.trim(); + if (normalizedMessage.length === 0) { + return false; + } + + const options = { + type: "question" as const, + buttons: ["No", "Yes"], + defaultId: 0, + cancelId: 0, + noLink: true, + message: normalizedMessage, + }; + const result = yield* Option.match(input.owner, { + onNone: () => Effect.promise(() => Electron.dialog.showMessageBox(options)), + onSome: (owner) => Effect.promise(() => Electron.dialog.showMessageBox(owner, options)), + }); + return result.response === CONFIRM_BUTTON_INDEX; + }), showMessageBox: (options) => Effect.promise(() => Electron.dialog.showMessageBox(options)), showErrorBox: (title, content) => Effect.sync(() => { diff --git a/apps/desktop/src/electron/ElectronMenu.test.ts b/apps/desktop/src/electron/ElectronMenu.test.ts index e3767d8a293..de2f014e2e5 100644 --- a/apps/desktop/src/electron/ElectronMenu.test.ts +++ b/apps/desktop/src/electron/ElectronMenu.test.ts @@ -4,14 +4,18 @@ import * as Option from "effect/Option"; import type * as Electron from "electron"; import { beforeEach, vi } from "vitest"; -const { buildFromTemplateMock, createFromNamedImageMock } = vi.hoisted(() => ({ - buildFromTemplateMock: vi.fn(), - createFromNamedImageMock: vi.fn(), -})); +const { buildFromTemplateMock, createFromNamedImageMock, setApplicationMenuMock } = vi.hoisted( + () => ({ + buildFromTemplateMock: vi.fn(), + createFromNamedImageMock: vi.fn(), + setApplicationMenuMock: vi.fn(), + }), +); vi.mock("electron", () => ({ Menu: { buildFromTemplate: buildFromTemplateMock, + setApplicationMenu: setApplicationMenuMock, }, nativeImage: { createFromNamedImage: createFromNamedImageMock, @@ -24,8 +28,22 @@ describe("ElectronMenu", () => { beforeEach(() => { buildFromTemplateMock.mockReset(); createFromNamedImageMock.mockReset(); + setApplicationMenuMock.mockReset(); }); + it.effect("sets the native application menu from a template", () => + Effect.gen(function* () { + const menu = { id: "application-menu" }; + buildFromTemplateMock.mockReturnValue(menu); + + const electronMenu = yield* ElectronMenu.ElectronMenu; + yield* electronMenu.setApplicationMenu([{ role: "about" }]); + + assert.deepEqual(buildFromTemplateMock.mock.calls, [[[{ role: "about" }]]]); + assert.deepEqual(setApplicationMenuMock.mock.calls, [[menu]]); + }).pipe(Effect.provide(ElectronMenu.layer)), + ); + it.effect("returns none without building a menu when there are no valid items", () => Effect.gen(function* () { const electronMenu = yield* ElectronMenu.ElectronMenu; diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 8ea21017787..54ecd63cc40 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -23,6 +23,9 @@ export interface ElectronMenuTemplateInput { } export interface ElectronMenuShape { + readonly setApplicationMenu: ( + template: readonly Electron.MenuItemConstructorOptions[], + ) => Effect.Effect; readonly showContextMenu: ( input: ElectronMenuContextInput, ) => Effect.Effect>; @@ -130,6 +133,10 @@ export const layer = Layer.sync(ElectronMenu, () => { }; return ElectronMenu.of({ + setApplicationMenu: (template) => + Effect.sync(() => { + Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); + }), popupTemplate: (input) => { if (input.template.length === 0) { return Effect.void; diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 063e63e90df..d62dbb36ec1 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -9,7 +9,7 @@ import * as Scope from "effect/Scope"; import * as Electron from "electron"; -import { DesktopEnvironment, type DesktopEnvironmentShape } from "../desktopEnvironment.ts"; +import { DesktopEnvironment, type DesktopEnvironmentShape } from "../main/DesktopEnvironment.ts"; export const DESKTOP_SCHEME = "t3"; diff --git a/apps/desktop/src/ipc/methods/clientSettings.ts b/apps/desktop/src/ipc/methods/clientSettings.ts index 84be90d1615..e1162521e93 100644 --- a/apps/desktop/src/ipc/methods/clientSettings.ts +++ b/apps/desktop/src/ipc/methods/clientSettings.ts @@ -2,8 +2,8 @@ import { ClientSettingsSchema } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import * as DesktopEnvironment from "../../main/DesktopEnvironment.ts"; import { readClientSettingsEffect, writeClientSettingsEffect } from "../../clientPersistence.ts"; -import { DesktopEnvironment } from "../../desktopEnvironment.ts"; import { GET_CLIENT_SETTINGS_CHANNEL, SET_CLIENT_SETTINGS_CHANNEL } from "../channels.ts"; import { makeIpcMethod } from "../DesktopIpc.ts"; @@ -13,7 +13,7 @@ export const getClientSettings = makeIpcMethod({ result: Schema.NullOr(ClientSettingsSchema), handler: () => Effect.gen(function* () { - const environment = yield* DesktopEnvironment; + const environment = yield* DesktopEnvironment.DesktopEnvironment; return yield* readClientSettingsEffect(environment.clientSettingsPath); }), }); @@ -24,7 +24,7 @@ export const setClientSettings = makeIpcMethod({ result: Schema.Void, handler: (settings) => Effect.gen(function* () { - const environment = yield* DesktopEnvironment; + const environment = yield* DesktopEnvironment.DesktopEnvironment; yield* writeClientSettingsEffect(environment.clientSettingsPath, settings); }), }); diff --git a/apps/desktop/src/ipc/methods/savedEnvironments.ts b/apps/desktop/src/ipc/methods/savedEnvironments.ts index 24ec8dae507..fc76bf85b37 100644 --- a/apps/desktop/src/ipc/methods/savedEnvironments.ts +++ b/apps/desktop/src/ipc/methods/savedEnvironments.ts @@ -9,7 +9,7 @@ import { writeSavedEnvironmentRegistryEffect, writeSavedEnvironmentSecretEffect, } from "../../clientPersistence.ts"; -import * as DesktopEnvironment from "../../desktopEnvironment.ts"; +import * as DesktopEnvironment from "../../main/DesktopEnvironment.ts"; import * as ElectronSafeStorage from "../../electron/ElectronSafeStorage.ts"; import { GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, diff --git a/apps/desktop/src/ipc/methods/serverExposure.ts b/apps/desktop/src/ipc/methods/serverExposure.ts index 4bf9fcac518..82ba7456cb4 100644 --- a/apps/desktop/src/ipc/methods/serverExposure.ts +++ b/apps/desktop/src/ipc/methods/serverExposure.ts @@ -9,7 +9,7 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { DesktopShutdown } from "../../desktopShutdown.ts"; +import { DesktopShutdown } from "../../main/DesktopShutdown.ts"; import { GET_ADVERTISED_ENDPOINTS_CHANNEL, GET_SERVER_EXPOSURE_STATE_CHANNEL, diff --git a/apps/desktop/src/ipc/methods/windowLive.test.ts b/apps/desktop/src/ipc/methods/windowLive.test.ts new file mode 100644 index 00000000000..7d73d965a08 --- /dev/null +++ b/apps/desktop/src/ipc/methods/windowLive.test.ts @@ -0,0 +1,153 @@ +import { assert, describe, it } from "@effect/vitest"; +import { Effect, Layer, Option } from "effect"; +import * as EffectPath from "effect/Path"; +import type * as Electron from "electron"; + +import * as DesktopBackendManager from "../../main/DesktopBackendManager.ts"; +import * as DesktopConfig from "../../main/DesktopConfig.ts"; +import { layer as makeDesktopEnvironmentLayer } from "../../main/DesktopEnvironment.ts"; +import * as ElectronDialog from "../../electron/ElectronDialog.ts"; +import * as ElectronMenu from "../../electron/ElectronMenu.ts"; +import * as ElectronShell from "../../electron/ElectronShell.ts"; +import * as ElectronTheme from "../../electron/ElectronTheme.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import * as DesktopWindowIpc from "./window.ts"; +import * as DesktopWindowIpcActionsLive from "./windowLive.ts"; + +const backendConfig: DesktopBackendManager.DesktopBackendStartConfig = { + executablePath: "/electron", + entryPath: "/server/bin.mjs", + cwd: "/server", + env: { ELECTRON_RUN_AS_NODE: "1" }, + bootstrap: { + mode: "desktop", + noBrowser: true, + port: 3773, + t3Home: "/tmp/t3", + host: "127.0.0.1", + desktopBootstrapToken: "token", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }, + httpBaseUrl: new URL("http://127.0.0.1:3773"), + captureOutput: true, +}; + +const noWindow = Effect.succeed(Option.none()); + +function makeLayer(currentConfig: Option.Option) { + return DesktopWindowIpcActionsLive.layer.pipe( + Layer.provide( + Layer.mergeAll( + makeDesktopEnvironmentLayer({ + dirname: "/repo/apps/desktop/src", + cwd: "/repo", + platform: "darwin", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll(EffectPath.layer, DesktopConfig.layerTest({ T3CODE_HOME: "/tmp/t3" })), + ), + ), + Layer.succeed( + DesktopBackendManager.DesktopBackendManager, + DesktopBackendManager.DesktopBackendManager.of({ + start: Effect.void, + stop: () => Effect.void, + shutdown: Effect.void, + currentConfig: Effect.succeed(currentConfig), + snapshot: Effect.succeed({ + desiredRunning: false, + ready: false, + activePid: Option.none(), + restartAttempt: 0, + restartScheduled: false, + shuttingDown: false, + }), + }), + ), + Layer.succeed(ElectronDialog.ElectronDialog, { + pickFolder: () => Effect.succeed(Option.none()), + confirm: () => Effect.succeed(false), + showMessageBox: () => + Effect.succeed({ + response: 0, + checkboxChecked: false, + } satisfies Electron.MessageBoxReturnValue), + showErrorBox: () => Effect.void, + }), + Layer.succeed(ElectronMenu.ElectronMenu, { + setApplicationMenu: () => Effect.void, + showContextMenu: () => Effect.succeed(Option.none()), + popupTemplate: () => Effect.void, + }), + Layer.succeed(ElectronShell.ElectronShell, { + openExternal: () => Effect.succeed(false), + copyText: () => Effect.void, + }), + Layer.succeed(ElectronTheme.ElectronTheme, { + shouldUseDarkColors: Effect.succeed(false), + setSource: () => Effect.void, + onUpdated: () => Effect.void, + }), + Layer.succeed(ElectronWindow.ElectronWindow, { + create: () => Effect.die(new Error("unexpected BrowserWindow creation")), + main: noWindow, + currentMainOrFirst: noWindow, + focusedMainOrFirst: noWindow, + setMain: () => Effect.void, + clearMain: () => Effect.void, + reveal: () => Effect.void, + sendAll: () => Effect.void, + destroyAll: Effect.void, + syncAllAppearance: () => Effect.void, + }), + ), + ), + ); +} + +describe("DesktopWindowIpcActionsLive", () => { + it.effect("returns null before the backend config has been resolved", () => + Effect.gen(function* () { + const window = yield* DesktopWindowIpc.DesktopWindowIpcActions; + + assert.equal(yield* window.getLocalEnvironmentBootstrap, null); + }).pipe(Effect.provide(makeLayer(Option.none()))), + ); + + it.effect("derives the local bootstrap from the current backend config", () => + Effect.gen(function* () { + const window = yield* DesktopWindowIpc.DesktopWindowIpcActions; + + assert.deepEqual(yield* window.getLocalEnvironmentBootstrap, { + label: "Local environment", + httpBaseUrl: "http://127.0.0.1:3773/", + wsBaseUrl: "ws://127.0.0.1:3773/", + bootstrapToken: "token", + }); + }).pipe(Effect.provide(makeLayer(Option.some(backendConfig)))), + ); + + it.effect("uses wss when the backend base URL is https", () => + Effect.gen(function* () { + const window = yield* DesktopWindowIpc.DesktopWindowIpcActions; + + assert.equal((yield* window.getLocalEnvironmentBootstrap)?.wsBaseUrl, "wss://example.test/"); + }).pipe( + Effect.provide( + makeLayer( + Option.some({ + ...backendConfig, + httpBaseUrl: new URL("https://example.test"), + }), + ), + ), + ), + ); +}); diff --git a/apps/desktop/src/ipc/methods/windowLive.ts b/apps/desktop/src/ipc/methods/windowLive.ts index 1684e2dcb6e..5a4a1528c53 100644 --- a/apps/desktop/src/ipc/methods/windowLive.ts +++ b/apps/desktop/src/ipc/methods/windowLive.ts @@ -2,20 +2,26 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as DesktopEnvironment from "../../desktopEnvironment.ts"; +import * as DesktopEnvironment from "../../main/DesktopEnvironment.ts"; +import * as DesktopBackendManager from "../../main/DesktopBackendManager.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronMenu from "../../electron/ElectronMenu.ts"; import * as ElectronShell from "../../electron/ElectronShell.ts"; import * as ElectronTheme from "../../electron/ElectronTheme.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; -import * as DesktopLocalEnvironment from "../../main/DesktopLocalEnvironment.ts"; import * as DesktopWindowIpc from "./window.ts"; +function toWebSocketBaseUrl(httpBaseUrl: URL): string { + const url = new URL(httpBaseUrl.href); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.href; +} + export const layer = Layer.effect( DesktopWindowIpc.DesktopWindowIpcActions, Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; - const localEnvironment = yield* DesktopLocalEnvironment.DesktopLocalEnvironment; + const backendManager = yield* DesktopBackendManager.DesktopBackendManager; const electronDialog = yield* ElectronDialog.ElectronDialog; const electronMenu = yield* ElectronMenu.ElectronMenu; const electronShell = yield* ElectronShell.ElectronShell; @@ -24,7 +30,22 @@ export const layer = Layer.effect( return DesktopWindowIpc.DesktopWindowIpcActions.of({ getAppBranding: Effect.succeed(environment.branding), - getLocalEnvironmentBootstrap: localEnvironment.bootstrap.pipe(Effect.map(Option.getOrNull)), + getLocalEnvironmentBootstrap: backendManager.currentConfig.pipe( + Effect.map( + Option.map((config) => { + const bootstrap = config.bootstrap; + return { + label: "Local environment", + httpBaseUrl: config.httpBaseUrl.href, + wsBaseUrl: toWebSocketBaseUrl(config.httpBaseUrl), + ...(bootstrap.desktopBootstrapToken + ? { bootstrapToken: bootstrap.desktopBootstrapToken } + : {}), + }; + }), + ), + Effect.map(Option.getOrNull), + ), pickFolder: (options) => Effect.gen(function* () { const selectedPath = yield* electronDialog.pickFolder({ diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 23b7531eada..3b174328814 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,75 +1,47 @@ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; -import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; -import * as Cause from "effect/Cause"; -import * as Context from "effect/Context"; -import * as Duration from "effect/Duration"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; +import * as EffectPath from "effect/Path"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as EffectPath from "effect/Path"; -import * as Random from "effect/Random"; -import * as Ref from "effect/Ref"; -import * as Schema from "effect/Schema"; -import * as Scope from "effect/Scope"; -import { ipcMain, type MenuItemConstructorOptions, Menu } from "electron"; +import * as Electron from "electron"; import * as NetService from "@t3tools/shared/Net"; -import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; -import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; +import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; -import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPortEffect } from "./backendPort.ts"; -import { type DesktopSettings } from "./desktopSettings.ts"; -import { - DesktopBackendConfiguration, - DesktopBackendEvents, - DesktopBackendManager, - DesktopBackendManagerLive, - DesktopBackendProcessRunnerLive, - type DesktopBackendManagerShape, - type DesktopBackendStartConfig, -} from "./desktopBackendManager.ts"; -import * as DesktopNetworkInterfaces from "./desktopNetworkInterfaces.ts"; -import { - DesktopBackendOutputLog, - DesktopBackendOutputLogLive, - DesktopLoggerLive, -} from "./desktopLogger.ts"; -import { - DesktopEnvironment, - makeDesktopEnvironment, - type DesktopEnvironmentShape, -} from "./desktopEnvironment.ts"; -import * as DesktopSecretStorage from "./electron/ElectronSafeStorage.ts"; +import type { DesktopSettings } from "./desktopSettings.ts"; +import * as DesktopIpc from "./ipc/DesktopIpc.ts"; +import { DesktopServerExposureIpcActions } from "./ipc/methods/serverExposure.ts"; +import { DesktopUpdateIpcActions } from "./ipc/methods/updates.ts"; +import * as DesktopWindowIpcActionsLive from "./ipc/methods/windowLive.ts"; import * as ElectronApp from "./electron/ElectronApp.ts"; import * as ElectronDialog from "./electron/ElectronDialog.ts"; import * as ElectronMenu from "./electron/ElectronMenu.ts"; import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; +import * as DesktopSecretStorage from "./electron/ElectronSafeStorage.ts"; import * as ElectronShell from "./electron/ElectronShell.ts"; import * as ElectronTheme from "./electron/ElectronTheme.ts"; import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; -import * as DesktopIpc from "./ipc/DesktopIpc.ts"; import * as ElectronWindow from "./electron/ElectronWindow.ts"; -import { DesktopShutdown, makeDesktopShutdown } from "./desktopShutdown.ts"; -import { installDesktopIpcHandlers } from "./ipc/DesktopIpcHandlers.ts"; -import { DesktopServerExposureIpcActions } from "./ipc/methods/serverExposure.ts"; -import { DesktopUpdateIpcActions } from "./ipc/methods/updates.ts"; -import * as DesktopWindowIpcActionsLive from "./ipc/methods/windowLive.ts"; -import { - DesktopShellEnvironment, - DesktopShellEnvironmentConfigLive, - DesktopShellEnvironmentLive, - DesktopShellEnvironmentProbeLive, -} from "./syncShellEnvironment.ts"; +import * as DesktopApp from "./main/DesktopApp.ts"; +import * as DesktopAppIdentity from "./main/DesktopAppIdentity.ts"; +import * as DesktopApplicationMenu from "./main/DesktopApplicationMenu.ts"; import * as DesktopAssets from "./main/DesktopAssets.ts"; -import { formatErrorMessage } from "./main/DesktopErrors.ts"; +import * as DesktopBackendConfiguration from "./main/DesktopBackendConfiguration.ts"; +import * as DesktopBackendEvents from "./main/DesktopBackendEvents.ts"; +import * as DesktopBackendManager from "./main/DesktopBackendManager.ts"; +import * as DesktopConfig from "./main/DesktopConfig.ts"; +import * as DesktopEnvironment from "./main/DesktopEnvironment.ts"; import * as DesktopLifecycle from "./main/DesktopLifecycle.ts"; -import * as DesktopLocalEnvironment from "./main/DesktopLocalEnvironment.ts"; +import { DesktopBackendOutputLogLive, DesktopLoggerLive } from "./main/DesktopLogging.ts"; +import * as DesktopRun from "./main/DesktopRun.ts"; import * as DesktopServerExposure from "./main/DesktopServerExposure.ts"; import * as DesktopSettingsState from "./main/DesktopSettingsState.ts"; +import * as DesktopShellEnvironment from "./main/DesktopShellEnvironment.ts"; +import * as DesktopShutdown from "./main/DesktopShutdown.ts"; import * as DesktopSshEnvironment from "./main/DesktopSshEnvironment.ts"; import * as DesktopSshPasswordPrompts from "./main/DesktopSshPasswordPrompts.ts"; import * as DesktopSshRemoteApi from "./main/DesktopSshRemoteApi.ts"; @@ -77,240 +49,21 @@ import * as DesktopState from "./main/DesktopState.ts"; import * as DesktopUpdates from "./main/DesktopUpdates.ts"; import * as DesktopWindow from "./main/DesktopWindow.ts"; -const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i; -const COMMIT_HASH_DISPLAY_LENGTH = 12; -const AppPackageMetadata = Schema.Struct({ - t3codeCommitHash: Schema.optional(Schema.String), -}); -interface BackendObservabilitySettings { - readonly otlpTracesUrl: string | undefined; - readonly otlpMetricsUrl: string | undefined; -} -let backendBootstrapToken = ""; -let aboutCommitHashCache: Option.Option | undefined; -let appRunId = "startup"; -let backendObservabilitySettings: BackendObservabilitySettings = { - otlpTracesUrl: undefined, - otlpMetricsUrl: undefined, -}; - -interface DesktopEffectRunner { - (effect: Effect.Effect): Promise; -} - -type DesktopWindowBoundaryServices = - | ElectronDialog.ElectronDialog - | DesktopUpdates.DesktopUpdates - | DesktopWindow.DesktopWindow; - -function makeDesktopEffectRunner(context: Context.Context): DesktopEffectRunner { - return (effect: Effect.Effect) => - Effect.runPromiseWith(context as unknown as Context.Context)(effect); -} - -const withDesktopLogAnnotations = ( - effect: Effect.Effect, - annotations?: Record, -): Effect.Effect => - effect.pipe( - Effect.annotateLogs({ - scope: "desktop", - runId: appRunId, - ...annotations, - }), - ); - -const logDesktopInfo = ( - message: string, - annotations?: Record, -): Effect.Effect => withDesktopLogAnnotations(Effect.logInfo(message), annotations); - -const logDesktopWarning = ( - message: string, - annotations?: Record, -): Effect.Effect => withDesktopLogAnnotations(Effect.logWarning(message), annotations); - -const logDesktopError = ( - message: string, - annotations?: Record, -): Effect.Effect => withDesktopLogAnnotations(Effect.logError(message), annotations); - -const logUpdaterInfo = ( - message: string, - annotations?: Record, -): Effect.Effect => - withDesktopLogAnnotations(Effect.logInfo(message), { - component: "desktop-updater", - ...annotations, - }); - -function readPersistedBackendObservabilitySettings(): Effect.Effect< - BackendObservabilitySettings, - never, - FileSystem.FileSystem | DesktopEnvironment -> { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const environment = yield* DesktopEnvironment; - const exists = yield* fileSystem - .exists(environment.serverSettingsPath) - .pipe(Effect.orElseSucceed(() => false)); - if (!exists) { - return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; - } - - const raw = yield* fileSystem - .readFileString(environment.serverSettingsPath) - .pipe(Effect.option); - if (Option.isNone(raw)) { - yield* logDesktopWarning("failed to read persisted backend observability settings"); - return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; - } - - return yield* Effect.try({ - try: () => parsePersistedServerObservabilitySettings(raw.value), - catch: (error) => error, - }).pipe( - Effect.catch((error) => - logDesktopWarning("failed to parse persisted backend observability settings", { - error, - }).pipe(Effect.as({ otlpTracesUrl: undefined, otlpMetricsUrl: undefined })), - ), - ); - }); -} - -function resolveConfiguredDesktopBackendPort(rawPort: string | undefined): number | undefined { - if (!rawPort) { - return undefined; - } - - const parsedPort = Number.parseInt(rawPort, 10); - if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65_535) { - return undefined; - } - - return parsedPort; -} - -function backendChildEnv(): NodeJS.ProcessEnv { - const env = { ...process.env }; - delete env.T3CODE_PORT; - delete env.T3CODE_MODE; - delete env.T3CODE_NO_BROWSER; - delete env.T3CODE_HOST; - delete env.T3CODE_DESKTOP_WS_URL; - delete env.T3CODE_DESKTOP_LAN_ACCESS; - delete env.T3CODE_DESKTOP_LAN_HOST; - delete env.T3CODE_DESKTOP_HTTPS_ENDPOINTS; - delete env.T3CODE_TAILSCALE_SERVE; - delete env.T3CODE_TAILSCALE_SERVE_PORT; - return env; -} +const desktopConfigLayer = DesktopConfig.layer; -function relaunchDesktopAppEffect( - reason: string, -): Effect.Effect< - void, - never, - ElectronApp.ElectronApp | DesktopEnvironment | DesktopShutdown | DesktopState.DesktopState -> { - return Effect.gen(function* () { - const electronApp = yield* ElectronApp.ElectronApp; - const environment = yield* DesktopEnvironment; - const state = yield* DesktopState.DesktopState; - const context = yield* Effect.context< - ElectronApp.ElectronApp | DesktopEnvironment | DesktopShutdown | DesktopState.DesktopState - >(); - const runEffect = makeDesktopEffectRunner(context); - yield* logDesktopInfo("desktop relaunch requested", { reason }); - yield* Effect.sync(() => { - setImmediate(() => { - void runEffect( - Ref.set(state.quitting, true).pipe(Effect.andThen(requestDesktopShutdownAndWait())), - ).finally(() => { - if (environment.isDevelopment) { - void runEffect(electronApp.exit(75)); - return; - } - void runEffect( - electronApp - .relaunch({ - execPath: process.execPath, - args: process.argv.slice(1), - }) - .pipe(Effect.andThen(electronApp.exit(0))), - ); - }); - }); - }); - }); -} - -const resolveBackendStartConfig: Effect.Effect< - DesktopBackendStartConfig, - never, - FileSystem.FileSystem | DesktopEnvironment | DesktopServerExposure.DesktopServerExposure -> = Effect.gen(function* () { - const environment = yield* DesktopEnvironment; - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - const backendExposure = yield* serverExposure.backendConfig; - backendObservabilitySettings = yield* readPersistedBackendObservabilitySettings(); - const captureBackendLogs = !environment.isDevelopment; - - return { - executablePath: process.execPath, - entryPath: environment.backendEntryPath, - cwd: environment.backendCwd, - env: { - ...backendChildEnv(), - ELECTRON_RUN_AS_NODE: "1", - }, - bootstrap: { - mode: "desktop", - noBrowser: true, - port: backendExposure.port, - t3Home: environment.baseDir, - host: backendExposure.bindHost, - desktopBootstrapToken: backendBootstrapToken, - tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, - tailscaleServePort: backendExposure.tailscaleServePort, - ...(backendObservabilitySettings.otlpTracesUrl - ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl } - : {}), - ...(backendObservabilitySettings.otlpMetricsUrl - ? { otlpMetricsUrl: backendObservabilitySettings.otlpMetricsUrl } - : {}), - }, - httpBaseUrl: backendExposure.httpBaseUrl, - captureOutput: captureBackendLogs, - }; -}); - -const randomHexString = (length: number): Effect.Effect => - Effect.gen(function* () { - let value = ""; - while (value.length < length) { - value += (yield* Random.nextUUIDv4).replace(/-/g, ""); - } - return value.slice(0, length); - }); - -const desktopEnvironmentLayer = Layer.effect( - DesktopEnvironment, +const desktopEnvironmentLayer = Layer.unwrap( Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; const metadata = yield* electronApp.metadata; - return yield* makeDesktopEnvironment({ + return DesktopEnvironment.layer({ dirname: __dirname, - env: process.env, cwd: process.cwd(), platform: process.platform, processArch: process.arch, ...metadata, }); }), -).pipe(Layer.provide(Layer.mergeAll(EffectPath.layer, ElectronApp.layer))); +).pipe(Layer.provide(Layer.mergeAll(EffectPath.layer, ElectronApp.layer, desktopConfigLayer))); const desktopLoggerLayer = DesktopLoggerLive.pipe(Layer.provide(NodeServices.layer)); @@ -318,72 +71,10 @@ const desktopBackendOutputLogLayer = DesktopBackendOutputLogLive.pipe( Layer.provide(NodeServices.layer), ); -const desktopBackendConfigurationLayer = Layer.effect( - DesktopBackendConfiguration, - Effect.gen(function* () { - const environment = yield* DesktopEnvironment; - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - return { - resolve: resolveBackendStartConfig.pipe( - Effect.provideService(DesktopEnvironment, environment), - Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), - ), - }; - }), -); -const desktopBackendEventsLayer = Layer.effect( - DesktopBackendEvents, - Effect.gen(function* () { - const backendOutputLog = yield* DesktopBackendOutputLog; - const desktopWindow = yield* DesktopWindow.DesktopWindow; - const state = yield* DesktopState.DesktopState; - - return { - onStarting: Ref.set(state.backendReady, false), - onStarted: ({ pid, config }) => - backendOutputLog.writeSessionBoundary({ - phase: "START", - runId: appRunId, - details: `pid=${pid} port=${config.bootstrap.port} cwd=${config.cwd}`, - }), - onReady: desktopWindow.handleBackendReady.pipe( - Effect.catch((error) => - logDesktopError("failed to open main window after backend readiness", { - message: error.message, - }), - ), - ), - onReadinessFailure: (error) => - logDesktopWarning("backend readiness check failed during bootstrap", { - error: formatErrorMessage(error), - }), - onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), - onExit: ({ pid, reason }) => - Effect.gen(function* () { - yield* Option.match(pid, { - onNone: () => Effect.void, - onSome: (value) => - backendOutputLog.writeSessionBoundary({ - phase: "END", - runId: appRunId, - details: `pid=${value} ${reason}`, - }), - }); - yield* Ref.set(state.backendReady, false); - }), - onRestartScheduled: ({ reason, delay }) => - logDesktopError("backend exited unexpectedly; restart scheduled", { - reason, - delayMs: Duration.toMillis(delay), - }), - }; - }), -); - -function resolveDesktopSshCliRunner( - environment: DesktopEnvironmentShape, +const resolveDesktopSshCliRunner = ( + environment: DesktopEnvironment.DesktopEnvironmentShape, settings: DesktopSettings, -): RemoteT3RunnerOptions { +): RemoteT3RunnerOptions => { const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath); if (environment.isDevelopment && devRemoteEntryPath !== undefined) { return { nodeScriptPath: devRemoteEntryPath }; @@ -395,11 +86,11 @@ function resolveDesktopSshCliRunner( isDevelopment: environment.isDevelopment, }), }; -} +}; const desktopSshEnvironmentLayer = Layer.unwrap( Effect.gen(function* () { - const environment = yield* DesktopEnvironment; + const environment = yield* DesktopEnvironment.DesktopEnvironment; const settingsState = yield* DesktopSettingsState.DesktopSettingsState; return DesktopSshEnvironment.layer({ resolveCliRunner: settingsState.get.pipe( @@ -414,33 +105,28 @@ const desktopSshRuntimeLayer = Layer.mergeAll( DesktopSshRemoteApi.layer, ).pipe(Layer.provideMerge(DesktopSshPasswordPrompts.layer()), Layer.provideMerge(NetService.layer)); -const desktopShellEnvironmentProbeLayer = DesktopShellEnvironmentProbeLive.pipe( - Layer.provide(NodeServices.layer), -); +const desktopShellEnvironmentLayer = DesktopShellEnvironment.layer; -const desktopShellEnvironmentLayer = DesktopShellEnvironmentLive.pipe( - Layer.provide( - Layer.mergeAll(DesktopShellEnvironmentConfigLive, desktopShellEnvironmentProbeLayer), - ), +const desktopWindowLayer = DesktopWindow.layer.pipe(Layer.provideMerge(DesktopAssets.layer)); + +const desktopAppIdentityLayer = DesktopAppIdentity.layer.pipe( + Layer.provideMerge(DesktopAssets.layer), ); const desktopServerExposureLayer = DesktopServerExposure.layer.pipe( Layer.provideMerge(NodeServices.layer), Layer.provideMerge(NodeHttpClient.layerUndici), - Layer.provideMerge(DesktopNetworkInterfaces.layer), + Layer.provideMerge(DesktopServerExposure.networkInterfacesLayer), + Layer.provideMerge(desktopConfigLayer), Layer.provideMerge(DesktopSettingsState.layer), Layer.provideMerge(desktopEnvironmentLayer), ); -type DesktopServerExposureIpcActionServices = - | ElectronApp.ElectronApp - | DesktopEnvironment - | DesktopState.DesktopState; - const desktopServerExposureIpcActionsLayer = Layer.effect( DesktopServerExposureIpcActions, Effect.gen(function* () { - const context = yield* Effect.context(); + const context = yield* Effect.context(); + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; return DesktopServerExposureIpcActions.of({ getState: serverExposure.getState, @@ -448,7 +134,7 @@ const desktopServerExposureIpcActionsLayer = Layer.effect( Effect.gen(function* () { const change = yield* serverExposure.setMode(nextMode); if (change.requiresRelaunch) { - yield* relaunchDesktopAppEffect(`serverExposureMode=${nextMode}`); + yield* lifecycle.relaunch(`serverExposureMode=${nextMode}`); } return change.state; }).pipe(Effect.provide(context)), @@ -456,7 +142,7 @@ const desktopServerExposureIpcActionsLayer = Layer.effect( Effect.gen(function* () { const change = yield* serverExposure.setTailscaleServeEnabled(input); if (change.requiresRelaunch) { - yield* relaunchDesktopAppEffect( + yield* lifecycle.relaunch( change.state.tailscaleServeEnabled ? "tailscale-serve-enabled" : "tailscale-serve-disabled", @@ -467,13 +153,12 @@ const desktopServerExposureIpcActionsLayer = Layer.effect( getAdvertisedEndpoints: serverExposure.getAdvertisedEndpoints, }); }), -); - -const desktopUpdatesLayer = DesktopUpdates.layer.pipe(Layer.provideMerge(ElectronUpdater.layer)); +).pipe(Layer.provideMerge(DesktopLifecycle.layer), Layer.provideMerge(desktopWindowLayer)); -const desktopAssetsLayer = DesktopAssets.layer; - -const desktopWindowLayer = DesktopWindow.layer.pipe(Layer.provideMerge(desktopAssetsLayer)); +const desktopUpdatesLayer = DesktopUpdates.layer.pipe( + Layer.provideMerge(ElectronUpdater.layer), + Layer.provideMerge(desktopConfigLayer), +); const desktopUpdateIpcActionsLayer = Layer.effect( DesktopUpdateIpcActions, @@ -489,34 +174,39 @@ const desktopUpdateIpcActionsLayer = Layer.effect( }), ).pipe(Layer.provideMerge(desktopUpdatesLayer)); +const desktopApplicationMenuLayer = DesktopApplicationMenu.layer.pipe( + Layer.provideMerge(desktopUpdatesLayer), + Layer.provideMerge(desktopWindowLayer), +); + const desktopBackendDependenciesLayer = Layer.mergeAll( NodeServices.layer, NodeHttpClient.layerUndici, NetService.layer, - DesktopBackendProcessRunnerLive, - desktopBackendConfigurationLayer, - desktopBackendEventsLayer.pipe( + DesktopBackendConfiguration.layer, + DesktopBackendEvents.layer.pipe( Layer.provide(desktopBackendOutputLogLayer), Layer.provide(desktopWindowLayer), ), ); -const desktopBackendManagerLayer = DesktopBackendManagerLive.pipe( +const desktopBackendManagerLayer = DesktopBackendManager.layer.pipe( Layer.provide(desktopBackendDependenciesLayer), ); -const desktopBackendRuntimeLayer = DesktopLocalEnvironment.layer.pipe( - Layer.provideMerge(desktopBackendManagerLayer), +const desktopBackendRuntimeLayer = desktopBackendManagerLayer.pipe( Layer.provideMerge(desktopServerExposureLayer), ); const desktopRuntimeLayer = Layer.mergeAll( desktopLoggerLayer, + desktopAppIdentityLayer, + desktopApplicationMenuLayer, desktopShellEnvironmentLayer, desktopSshRuntimeLayer, DesktopLifecycle.layer, desktopWindowLayer, - Layer.succeed(DesktopIpc.DesktopIpc, DesktopIpc.make(ipcMain)), + Layer.succeed(DesktopIpc.DesktopIpc, DesktopIpc.make(Electron.ipcMain)), desktopServerExposureIpcActionsLayer, desktopUpdateIpcActionsLayer, DesktopWindowIpcActionsLive.layer, @@ -524,7 +214,6 @@ const desktopRuntimeLayer = Layer.mergeAll( ).pipe( Layer.provideMerge(NodeServices.layer), Layer.provideMerge(NodeHttpClient.layerUndici), - Layer.provideMerge(DesktopNetworkInterfaces.layer), Layer.provideMerge(desktopBackendRuntimeLayer), Layer.provideMerge(ElectronWindow.layer), Layer.provideMerge(ElectronApp.layer), @@ -533,533 +222,11 @@ const desktopRuntimeLayer = Layer.mergeAll( Layer.provideMerge(ElectronProtocol.layer), Layer.provideMerge(ElectronShell.layer), Layer.provideMerge(ElectronTheme.layer), + Layer.provideMerge(NetService.layer), Layer.provideMerge(desktopEnvironmentLayer), + Layer.provideMerge(DesktopShutdown.layer), + Layer.provideMerge(DesktopRun.layer), + Layer.provideMerge(DesktopState.layer), ); -function normalizeCommitHash(value: unknown): string | null { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - if (!COMMIT_HASH_PATTERN.test(trimmed)) { - return null; - } - return trimmed.slice(0, COMMIT_HASH_DISPLAY_LENGTH).toLowerCase(); -} - -function resolveEmbeddedCommitHashEffect(): Effect.Effect< - Option.Option, - never, - FileSystem.FileSystem | DesktopEnvironment -> { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const environment = yield* DesktopEnvironment; - const packageJsonPath = environment.path.join(environment.appRoot, "package.json"); - const raw = yield* fileSystem.readFileString(packageJsonPath).pipe(Effect.option); - return yield* Option.match(raw, { - onNone: () => Effect.succeed(Option.none()), - onSome: (value) => - Schema.decodeEffect(Schema.fromJsonString(AppPackageMetadata))(value).pipe( - Effect.map((parsed) => - Option.fromNullishOr(normalizeCommitHash(parsed.t3codeCommitHash)), - ), - Effect.catch(() => Effect.succeed(Option.none())), - ), - }); - }); -} - -function resolveAboutCommitHash(): Effect.Effect< - Option.Option, - never, - FileSystem.FileSystem | DesktopEnvironment -> { - if (aboutCommitHashCache !== undefined) { - return Effect.succeed(aboutCommitHashCache); - } - - const envCommitHash = normalizeCommitHash(process.env.T3CODE_COMMIT_HASH); - if (envCommitHash) { - aboutCommitHashCache = Option.some(envCommitHash); - return Effect.succeed(aboutCommitHashCache); - } - - // Only packaged builds are required to expose commit metadata. - return Effect.gen(function* () { - const environment = yield* DesktopEnvironment; - if (!environment.isPackaged) { - aboutCommitHashCache = Option.none(); - return aboutCommitHashCache; - } - - return yield* resolveEmbeddedCommitHashEffect().pipe( - Effect.tap((commitHash) => - Effect.sync(() => { - aboutCommitHashCache = commitHash; - }), - ), - ); - }); -} - -function handleFatalStartupError( - stage: string, - error: unknown, -): Effect.Effect< - void, - never, - | DesktopShutdown - | DesktopState.DesktopState - | ElectronApp.ElectronApp - | ElectronDialog.ElectronDialog -> { - return Effect.gen(function* () { - const shutdown = yield* DesktopShutdown; - const state = yield* DesktopState.DesktopState; - const electronApp = yield* ElectronApp.ElectronApp; - const electronDialog = yield* ElectronDialog.ElectronDialog; - const message = formatErrorMessage(error); - const detail = - error instanceof Error && typeof error.stack === "string" ? `\n${error.stack}` : ""; - yield* logDesktopError("fatal startup error", { - stage, - message, - ...(detail.length > 0 ? { detail } : {}), - }); - const wasQuitting = yield* Ref.getAndSet(state.quitting, true); - if (!wasQuitting) { - yield* electronDialog.showErrorBox( - "T3 Code failed to start", - `Stage: ${stage}\n${message}${detail}`, - ); - } - yield* shutdown.request; - yield* electronApp.quit; - }); -} - -function registerDesktopProtocol(): Effect.Effect< - void, - unknown, - FileSystem.FileSystem | DesktopEnvironment | ElectronProtocol.ElectronProtocol | Scope.Scope -> { - return Effect.gen(function* () { - const electronProtocol = yield* ElectronProtocol.ElectronProtocol; - yield* electronProtocol.registerDesktopFileProtocol; - }); -} - -function dispatchMenuAction( - action: string, -): Effect.Effect { - return Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.dispatchMenuAction(action); - }); -} - -function handleCheckForUpdatesMenuClick(): Effect.Effect< - void, - DesktopWindow.DesktopWindowError, - DesktopUpdates.DesktopUpdates | ElectronDialog.ElectronDialog | DesktopWindow.DesktopWindow -> { - return Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - const electronDialog = yield* ElectronDialog.ElectronDialog; - const disabledReason = yield* updates.disabledReason; - if (Option.isSome(disabledReason)) { - yield* logUpdaterInfo("manual update check requested, but updates are disabled", { - disabledReason: disabledReason.value, - }); - yield* electronDialog.showMessageBox({ - type: "info", - title: "Updates unavailable", - message: "Automatic updates are not available right now.", - detail: disabledReason.value, - buttons: ["OK"], - }); - return; - } - - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.ensureMain; - yield* checkForUpdatesFromMenu(); - }); -} - -function checkForUpdatesFromMenu(): Effect.Effect< - void, - never, - DesktopUpdates.DesktopUpdates | ElectronDialog.ElectronDialog -> { - return Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - const electronDialog = yield* ElectronDialog.ElectronDialog; - const result = yield* updates.check("menu"); - const updateState = result.state; - - if (updateState.status === "up-to-date") { - yield* electronDialog.showMessageBox({ - type: "info", - title: "You're up to date!", - message: `T3 Code ${updateState.currentVersion} is currently the newest version available.`, - buttons: ["OK"], - }); - } else if (updateState.status === "error") { - yield* electronDialog.showMessageBox({ - type: "warning", - title: "Update check failed", - message: "Could not check for updates.", - detail: updateState.message ?? "An unknown error occurred. Please try again later.", - buttons: ["OK"], - }); - } - }); -} - -function configureApplicationMenu(): Effect.Effect< - void, - never, - ElectronApp.ElectronApp | DesktopWindowBoundaryServices -> { - return Effect.gen(function* () { - const electronApp = yield* ElectronApp.ElectronApp; - const appName = yield* electronApp.name; - const context = yield* Effect.context< - ElectronApp.ElectronApp | DesktopWindowBoundaryServices - >(); - const runEffect = makeDesktopEffectRunner(context); - const template: MenuItemConstructorOptions[] = []; - - if (process.platform === "darwin") { - template.push({ - label: appName, - submenu: [ - { role: "about" }, - { - label: "Check for Updates...", - click: () => { - void runEffect(handleCheckForUpdatesMenuClick()); - }, - }, - { type: "separator" }, - { - label: "Settings...", - accelerator: "CmdOrCtrl+,", - click: () => { - void runEffect(dispatchMenuAction("open-settings")); - }, - }, - { type: "separator" }, - { role: "services" }, - { type: "separator" }, - { role: "hide" }, - { role: "hideOthers" }, - { role: "unhide" }, - { type: "separator" }, - { role: "quit" }, - ], - }); - } - - template.push( - { - label: "File", - submenu: [ - ...(process.platform === "darwin" - ? [] - : [ - { - label: "Settings...", - accelerator: "CmdOrCtrl+,", - click: () => { - void runEffect(dispatchMenuAction("open-settings")); - }, - }, - { type: "separator" as const }, - ]), - { role: process.platform === "darwin" ? "close" : "quit" }, - ], - }, - { role: "editMenu" }, - { - label: "View", - submenu: [ - { role: "reload" }, - { role: "forceReload" }, - { role: "toggleDevTools" }, - { type: "separator" }, - { role: "resetZoom" }, - { role: "zoomIn", accelerator: "CmdOrCtrl+=" }, - { role: "zoomIn", accelerator: "CmdOrCtrl+Plus", visible: false }, - { role: "zoomOut" }, - { type: "separator" }, - { role: "togglefullscreen" }, - ], - }, - { role: "windowMenu" }, - { - role: "help", - submenu: [ - { - label: "Check for Updates...", - click: () => { - void runEffect(handleCheckForUpdatesMenuClick()); - }, - }, - ], - }, - ); - - yield* Effect.sync(() => { - Menu.setApplicationMenu(Menu.buildFromTemplate(template)); - }); - }); -} - -/** - * Resolve the Electron userData directory path. - * - * Electron derives the default userData path from `productName` in - * package.json, which currently produces directories with spaces and - * parentheses (e.g. `~/.config/T3 Code (Alpha)` on Linux). This is - * unfriendly for shell usage and violates Linux naming conventions. - * - * We override it to a clean lowercase name (`t3code`). If the legacy - * directory already exists we keep using it so existing users don't - * lose their Chromium profile data (localStorage, cookies, sessions). - */ -function resolveUserDataPath(): Effect.Effect< - string, - never, - FileSystem.FileSystem | DesktopEnvironment -> { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const environment = yield* DesktopEnvironment; - const appDataBase = - process.platform === "win32" - ? process.env.APPDATA || - environment.path.join(environment.homeDirectory, "AppData", "Roaming") - : process.platform === "darwin" - ? environment.path.join(environment.homeDirectory, "Library", "Application Support") - : process.env.XDG_CONFIG_HOME || - environment.path.join(environment.homeDirectory, ".config"); - const legacyPath = environment.path.join(appDataBase, environment.legacyUserDataDirName); - const legacyPathExists = yield* fileSystem - .exists(legacyPath) - .pipe(Effect.orElseSucceed(() => false)); - return legacyPathExists - ? legacyPath - : environment.path.join(appDataBase, environment.userDataDirName); - }); -} - -function configureAppIdentity(): Effect.Effect< - void, - never, - FileSystem.FileSystem | ElectronApp.ElectronApp | DesktopEnvironment | DesktopAssets.DesktopAssets -> { - return Effect.gen(function* () { - const electronApp = yield* ElectronApp.ElectronApp; - const environment = yield* DesktopEnvironment; - const assets = yield* DesktopAssets.DesktopAssets; - const commitHash = yield* resolveAboutCommitHash(); - yield* electronApp.setName(environment.displayName); - yield* electronApp.setAboutPanelOptions({ - applicationName: environment.displayName, - applicationVersion: environment.appVersion, - version: Option.getOrElse(commitHash, () => "unknown"), - }); - - if (process.platform === "win32") { - yield* electronApp.setAppUserModelId(environment.appUserModelId); - } - - if (process.platform === "linux") { - yield* electronApp.setDesktopName(environment.linuxDesktopEntryName); - } - - if (process.platform === "darwin") { - const iconPaths = yield* assets.iconPaths; - yield* Option.match(iconPaths.png, { - onNone: () => Effect.void, - onSome: electronApp.setDockIcon, - }); - } - }); -} - -function startBackend(): Effect.Effect< - void, - never, - DesktopBackendManager | DesktopState.DesktopState -> { - return Effect.gen(function* () { - const state = yield* DesktopState.DesktopState; - if (yield* Ref.get(state.quitting)) return; - const backendManager = yield* DesktopBackendManager; - yield* backendManager.start; - }).pipe( - Effect.catchCause((cause) => - logDesktopError("failed to start backend", { - cause: Cause.pretty(cause), - }), - ), - ); -} - -function closeDesktopResourcesWithManager( - backendManager: DesktopBackendManagerShape, - updates: DesktopUpdates.DesktopUpdatesShape, -): Effect.Effect { - return Effect.gen(function* () { - yield* backendManager.shutdown; - yield* updates.shutdown; - }); -} - -function requestDesktopShutdownAndWait(): Effect.Effect { - return Effect.gen(function* () { - const shutdown = yield* DesktopShutdown; - yield* shutdown.request; - yield* shutdown.awaitComplete; - }); -} - -function bootstrap() { - return Effect.gen(function* () { - const environment = yield* DesktopEnvironment; - const desktopWindow = yield* DesktopWindow.DesktopWindow; - const settingsState = yield* DesktopSettingsState.DesktopSettingsState; - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - yield* logDesktopInfo("bootstrap start"); - const configuredBackendPort = resolveConfiguredDesktopBackendPort(process.env.T3CODE_PORT); - if (environment.isDevelopment && configuredBackendPort === undefined) { - return yield* Effect.fail(new Error("T3CODE_PORT is required in desktop development.")); - } - - const backendPort = - configuredBackendPort ?? - (yield* resolveDesktopBackendPortEffect({ - host: DesktopServerExposure.DESKTOP_LOOPBACK_HOST, - startPort: DEFAULT_DESKTOP_BACKEND_PORT, - requiredHosts: DesktopServerExposure.DESKTOP_REQUIRED_PORT_PROBE_HOSTS, - })); - yield* logDesktopInfo( - configuredBackendPort === undefined - ? "selected backend port via sequential scan" - : "using configured backend port", - { - port: backendPort, - ...(configuredBackendPort === undefined ? { startPort: DEFAULT_DESKTOP_BACKEND_PORT } : {}), - }, - ); - backendBootstrapToken = yield* randomHexString(48); - const settings = yield* settingsState.get; - if (settings.serverExposureMode !== environment.defaultDesktopSettings.serverExposureMode) { - yield* logDesktopInfo("bootstrap restoring persisted server exposure mode", { - mode: settings.serverExposureMode, - }); - } - const serverExposureState = yield* serverExposure.configureFromSettings({ port: backendPort }); - const backendConfig = yield* serverExposure.backendConfig; - yield* logDesktopInfo("bootstrap resolved backend endpoint", { - baseUrl: backendConfig.httpBaseUrl.href, - }); - if (serverExposureState.endpointUrl) { - yield* logDesktopInfo("bootstrap enabled network access", { - endpointUrl: serverExposureState.endpointUrl, - }); - } else if (settings.serverExposureMode === "network-accessible") { - yield* logDesktopWarning( - "bootstrap fell back to local-only because no advertised network host was available", - ); - } - - yield* installDesktopIpcHandlers; - yield* logDesktopInfo("bootstrap ipc handlers registered"); - yield* startBackend(); - yield* logDesktopInfo("bootstrap backend start requested"); - - if (environment.isDevelopment) { - yield* desktopWindow.ensureMain; - } - }); -} - -function fatalStartupCause(stage: string, cause: Cause.Cause) { - return handleFatalStartupError(stage, new Error(Cause.pretty(cause))).pipe( - Effect.andThen(Effect.failCause(cause)), - ); -} - -const waitForElectronReady = Effect.gen(function* () { - const electronApp = yield* ElectronApp.ElectronApp; - yield* electronApp.whenReady; -}); - -const program = Effect.scoped( - Effect.gen(function* () { - const shutdown = yield* makeDesktopShutdown; - - yield* Effect.gen(function* () { - const electronApp = yield* ElectronApp.ElectronApp; - const electronProtocol = yield* ElectronProtocol.ElectronProtocol; - yield* electronProtocol.registerDesktopSchemePrivileges; - - const environment = yield* DesktopEnvironment; - appRunId = (yield* Random.nextUUIDv4).replace(/-/g, "").slice(0, 12); - const backendManager = yield* DesktopBackendManager; - const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; - const shellEnvironment = yield* DesktopShellEnvironment; - const settingsState = yield* DesktopSettingsState.DesktopSettingsState; - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* Scope.addFinalizer( - yield* Scope.Scope, - closeDesktopResourcesWithManager(backendManager, updates).pipe( - Effect.ensuring(shutdown.markComplete), - ), - ); - - yield* shellEnvironment.sync; - const userDataPath = yield* resolveUserDataPath(); - // Must happen before Electron's ready event so Chromium profile data - // lands in the desktop-specific userData directory. - yield* electronApp.setPath("userData", userDataPath); - yield* logDesktopInfo("runtime logging configured", { logDir: environment.logDir }); - yield* settingsState.load; - - if (process.platform === "linux") { - yield* electronApp.appendCommandLineSwitch("class", environment.linuxWmClass); - } - - yield* configureAppIdentity(); - yield* lifecycle.register; - - yield* waitForElectronReady.pipe( - Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)), - ); - yield* logDesktopInfo("app ready"); - yield* configureAppIdentity(); - yield* configureApplicationMenu(); - yield* registerDesktopProtocol(); - yield* updates.configure; - yield* bootstrap().pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); - yield* shutdown.awaitRequest; - }).pipe(Effect.provideService(DesktopShutdown, shutdown)); - }), -).pipe( - Effect.catchCause((cause) => - logDesktopError("desktop main fiber failed", { - cause: Cause.pretty(cause), - }), - ), -); - -program.pipe( - Effect.provide(desktopRuntimeLayer), - Effect.provide(DesktopState.layer), - NodeRuntime.runMain, -); +DesktopApp.program.pipe(Effect.provide(desktopRuntimeLayer), NodeRuntime.runMain); diff --git a/apps/desktop/src/main/DesktopApp.ts b/apps/desktop/src/main/DesktopApp.ts new file mode 100644 index 00000000000..c080b2c1daa --- /dev/null +++ b/apps/desktop/src/main/DesktopApp.ts @@ -0,0 +1,234 @@ +import * as Cause from "effect/Cause"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; + +import * as NetService from "@t3tools/shared/Net"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronDialog from "../electron/ElectronDialog.ts"; +import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; +import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; +import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; +import * as DesktopApplicationMenu from "./DesktopApplicationMenu.ts"; +import * as DesktopBackendManager from "./DesktopBackendManager.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopLifecycle from "./DesktopLifecycle.ts"; +import * as DesktopRun from "./DesktopRun.ts"; +import * as DesktopServerExposure from "./DesktopServerExposure.ts"; +import * as DesktopSettingsState from "./DesktopSettingsState.ts"; +import * as DesktopShellEnvironment from "./DesktopShellEnvironment.ts"; +import * as DesktopShutdown from "./DesktopShutdown.ts"; +import * as DesktopState from "./DesktopState.ts"; +import * as DesktopUpdates from "./DesktopUpdates.ts"; +import * as DesktopWindow from "./DesktopWindow.ts"; + +const DEFAULT_DESKTOP_BACKEND_PORT = 3773; +const MAX_TCP_PORT = 65_535; +const DESKTOP_BACKEND_PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::"] as const; + +class DesktopBackendPortUnavailableError extends Data.TaggedError( + "DesktopBackendPortUnavailableError", +)<{ + readonly startPort: number; + readonly maxPort: number; + readonly hosts: readonly string[]; +}> { + override get message() { + return `No desktop backend port is available on hosts ${this.hosts.join(", ")} between ${this.startPort} and ${this.maxPort}.`; + } +} + +const resolveDesktopBackendPort = Effect.fn("resolveDesktopBackendPort")(function* ( + configuredPort: Option.Option, +) { + if (Option.isSome(configuredPort)) { + return { + port: configuredPort.value, + selectedByScan: false, + } as const; + } + + const net = yield* NetService.NetService; + for (let port = DEFAULT_DESKTOP_BACKEND_PORT; port <= MAX_TCP_PORT; port += 1) { + let availableOnEveryHost = true; + + for (const host of DESKTOP_BACKEND_PORT_PROBE_HOSTS) { + if (!(yield* net.canListenOnHost(port, host))) { + availableOnEveryHost = false; + break; + } + } + + if (availableOnEveryHost) { + return { + port, + selectedByScan: true, + } as const; + } + } + + return yield* Effect.fail( + new DesktopBackendPortUnavailableError({ + startPort: DEFAULT_DESKTOP_BACKEND_PORT, + maxPort: MAX_TCP_PORT, + hosts: DESKTOP_BACKEND_PORT_PROBE_HOSTS, + }), + ); +}); + +const handleFatalStartupError = ( + stage: string, + error: unknown, +): Effect.Effect< + void, + never, + | DesktopShutdown.DesktopShutdown + | DesktopRun.DesktopRun + | DesktopState.DesktopState + | ElectronApp.ElectronApp + | ElectronDialog.ElectronDialog +> => + Effect.gen(function* () { + const shutdown = yield* DesktopShutdown.DesktopShutdown; + const state = yield* DesktopState.DesktopState; + const electronApp = yield* ElectronApp.ElectronApp; + const electronDialog = yield* ElectronDialog.ElectronDialog; + const run = yield* DesktopRun.DesktopRun; + const message = error instanceof Error ? error.message : String(error); + const detail = + error instanceof Error && typeof error.stack === "string" ? `\n${error.stack}` : ""; + yield* run.logError("fatal startup error", { + stage, + message, + ...(detail.length > 0 ? { detail } : {}), + }); + const wasQuitting = yield* Ref.getAndSet(state.quitting, true); + if (!wasQuitting) { + yield* electronDialog.showErrorBox( + "T3 Code failed to start", + `Stage: ${stage}\n${message}${detail}`, + ); + } + yield* shutdown.request; + yield* electronApp.quit; + }); + +const fatalStartupCause = (stage: string, cause: Cause.Cause) => + handleFatalStartupError(stage, new Error(Cause.pretty(cause))).pipe( + Effect.andThen(Effect.failCause(cause)), + ); + +const bootstrap = Effect.gen(function* () { + const backendManager = yield* DesktopBackendManager.DesktopBackendManager; + const state = yield* DesktopState.DesktopState; + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const settingsState = yield* DesktopSettingsState.DesktopSettingsState; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const run = yield* DesktopRun.DesktopRun; + yield* run.logInfo("bootstrap start"); + + if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) { + return yield* Effect.fail(new Error("T3CODE_PORT is required in desktop development.")); + } + + const backendPortSelection = yield* resolveDesktopBackendPort(environment.configuredBackendPort); + const backendPort = backendPortSelection.port; + yield* run.logInfo( + backendPortSelection.selectedByScan + ? "selected backend port via sequential scan" + : "using configured backend port", + { + port: backendPort, + ...(backendPortSelection.selectedByScan ? { startPort: DEFAULT_DESKTOP_BACKEND_PORT } : {}), + }, + ); + + const settings = yield* settingsState.get; + if (settings.serverExposureMode !== environment.defaultDesktopSettings.serverExposureMode) { + yield* run.logInfo("bootstrap restoring persisted server exposure mode", { + mode: settings.serverExposureMode, + }); + } + const serverExposureState = yield* serverExposure.configureFromSettings({ port: backendPort }); + const backendConfig = yield* serverExposure.backendConfig; + yield* run.logInfo("bootstrap resolved backend endpoint", { + baseUrl: backendConfig.httpBaseUrl.href, + }); + if (serverExposureState.endpointUrl) { + yield* run.logInfo("bootstrap enabled network access", { + endpointUrl: serverExposureState.endpointUrl, + }); + } else if (settings.serverExposureMode === "network-accessible") { + yield* run.logWarning( + "bootstrap fell back to local-only because no advertised network host was available", + ); + } + + yield* installDesktopIpcHandlers; + yield* run.logInfo("bootstrap ipc handlers registered"); + + if (!(yield* Ref.get(state.quitting))) { + yield* backendManager.start; + } + yield* run.logInfo("bootstrap backend start requested"); + + if (environment.isDevelopment) { + yield* desktopWindow.ensureMain; + } +}); + +export const program = Effect.scoped( + Effect.gen(function* () { + const shutdown = yield* DesktopShutdown.DesktopShutdown; + + yield* Effect.gen(function* () { + const appIdentity = yield* DesktopAppIdentity.DesktopAppIdentity; + const applicationMenu = yield* DesktopApplicationMenu.DesktopApplicationMenu; + const backendManager = yield* DesktopBackendManager.DesktopBackendManager; + const electronApp = yield* ElectronApp.ElectronApp; + const electronProtocol = yield* ElectronProtocol.ElectronProtocol; + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; + const settingsState = yield* DesktopSettingsState.DesktopSettingsState; + const updates = yield* DesktopUpdates.DesktopUpdates; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const run = yield* DesktopRun.DesktopRun; + + yield* electronProtocol.registerDesktopSchemePrivileges; + yield* run.refreshId; + yield* Scope.addFinalizer( + yield* Scope.Scope, + Effect.zip(backendManager.shutdown, updates.shutdown).pipe( + Effect.ensuring(shutdown.markComplete), + ), + ); + + yield* shellEnvironment.installIntoProcess; + const userDataPath = yield* appIdentity.resolveUserDataPath; + yield* electronApp.setPath("userData", userDataPath); + yield* run.logInfo("runtime logging configured", { logDir: environment.logDir }); + yield* settingsState.load; + + if (environment.platform === "linux") { + yield* electronApp.appendCommandLineSwitch("class", environment.linuxWmClass); + } + + yield* appIdentity.configure; + yield* lifecycle.register; + + yield* electronApp.whenReady.pipe( + Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)), + ); + yield* run.logInfo("app ready"); + yield* appIdentity.configure; + yield* applicationMenu.configure; + yield* electronProtocol.registerDesktopFileProtocol; + yield* updates.configure; + yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); + yield* shutdown.awaitRequest; + }); + }), +); diff --git a/apps/desktop/src/main/DesktopAppIdentity.test.ts b/apps/desktop/src/main/DesktopAppIdentity.test.ts new file mode 100644 index 00000000000..a79f2565c4c --- /dev/null +++ b/apps/desktop/src/main/DesktopAppIdentity.test.ts @@ -0,0 +1,179 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as EffectPath from "effect/Path"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import type * as Electron from "electron"; + +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; +import * as DesktopAssets from "./DesktopAssets.ts"; +import * as DesktopConfig from "./DesktopConfig.ts"; +import { + DesktopEnvironment, + layer as makeDesktopEnvironmentLayer, + type MakeDesktopEnvironmentInput, +} from "./DesktopEnvironment.ts"; + +const defaultEnvironmentInput = { + dirname: "/repo/apps/desktop/dist-electron", + cwd: "/repo", + platform: "darwin", + processArch: "arm64", + appVersion: "1.2.3", + appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar", + isPackaged: true, + resourcesPath: "/Applications/T3 Code.app/Contents/Resources", + runningUnderArm64Translation: false, +} satisfies MakeDesktopEnvironmentInput; + +type TestEnvironmentInput = Partial & { + readonly env?: Record; +}; + +interface ElectronAppCalls { + readonly setAboutPanelOptions: Array; + readonly setDockIcon: string[]; + readonly setName: string[]; +} + +const makeElectronAppLayer = (calls: ElectronAppCalls) => + Layer.succeed(ElectronApp.ElectronApp, { + metadata: Effect.die("unexpected metadata read"), + name: Effect.succeed("T3 Code"), + whenReady: Effect.void, + quit: Effect.void, + exit: () => Effect.void, + relaunch: () => Effect.void, + setPath: () => Effect.void, + setName: (name) => + Effect.sync(() => { + calls.setName.push(name); + }), + setAboutPanelOptions: (options) => + Effect.sync(() => { + calls.setAboutPanelOptions.push(options); + }), + setAppUserModelId: () => Effect.void, + setDesktopName: () => Effect.void, + setDockIcon: (iconPath) => + Effect.sync(() => { + calls.setDockIcon.push(iconPath); + }), + appendCommandLineSwitch: () => Effect.void, + on: () => Effect.void, + } satisfies ElectronApp.ElectronAppShape); + +const makeAssetsLayer = (png: Option.Option) => + Layer.succeed(DesktopAssets.DesktopAssets, { + iconPaths: Effect.succeed({ + ico: Option.none(), + icns: Option.none(), + png, + }), + resolveResourcePath: () => Effect.succeed(Option.none()), + } satisfies DesktopAssets.DesktopAssetsShape); + +const makeEnvironmentLayer = (overrides: TestEnvironmentInput = {}) => { + const { env, ...environmentOverrides } = overrides; + return makeDesktopEnvironmentLayer({ + ...defaultEnvironmentInput, + ...environmentOverrides, + }).pipe( + Layer.provide( + Layer.mergeAll( + EffectPath.layer, + DesktopConfig.layerTest({ + HOME: "/Users/alice", + ...env, + }), + ), + ), + ); +}; + +const withIdentity = ( + effect: Effect.Effect< + A, + E, + R | DesktopAppIdentity.DesktopAppIdentity | DesktopEnvironment | FileSystem.FileSystem + >, + input: { + readonly calls?: ElectronAppCalls; + readonly environment?: TestEnvironmentInput; + readonly legacyPathExists?: boolean; + readonly packageJson?: string; + readonly pngIconPath?: Option.Option; + } = {}, +) => { + const calls: ElectronAppCalls = input.calls ?? { + setAboutPanelOptions: [], + setDockIcon: [], + setName: [], + }; + + return effect.pipe( + Effect.provide( + DesktopAppIdentity.layer.pipe( + Layer.provideMerge( + FileSystem.layerNoop({ + exists: (path) => + Effect.succeed(input.legacyPathExists === true && path.includes("T3 Code (Alpha)")), + readFileString: () => + Effect.succeed(input.packageJson ?? '{"t3codeCommitHash":"abcdef1234567890"}'), + }), + ), + Layer.provideMerge(makeAssetsLayer(input.pngIconPath ?? Option.none())), + Layer.provideMerge(makeElectronAppLayer(calls)), + Layer.provideMerge(makeEnvironmentLayer(input.environment)), + ), + ), + ); +}; + +describe("DesktopAppIdentity", () => { + it.effect("keeps using the legacy userData path when it already exists", () => + withIdentity( + Effect.gen(function* () { + const identity = yield* DesktopAppIdentity.DesktopAppIdentity; + const userDataPath = yield* identity.resolveUserDataPath; + + assert.equal(userDataPath, "/Users/alice/Library/Application Support/T3 Code (Alpha)"); + }), + { legacyPathExists: true }, + ), + ); + + it.effect("configures app identity from the environment commit override", () => { + const calls: ElectronAppCalls = { + setAboutPanelOptions: [], + setDockIcon: [], + setName: [], + }; + + return withIdentity( + Effect.gen(function* () { + const identity = yield* DesktopAppIdentity.DesktopAppIdentity; + yield* identity.configure; + + assert.deepEqual(calls.setName, ["T3 Code (Alpha)"]); + assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "T3 Code (Alpha)"); + assert.equal(calls.setAboutPanelOptions[0]?.applicationVersion, "1.2.3"); + assert.equal(calls.setAboutPanelOptions[0]?.version, "0123456789ab"); + assert.deepEqual(calls.setDockIcon, ["/icon.png"]); + }), + { + calls, + environment: { + env: { + HOME: "/Users/alice", + T3CODE_COMMIT_HASH: "0123456789abcdef", + }, + }, + pngIconPath: Option.some("/icon.png"), + }, + ); + }); +}); diff --git a/apps/desktop/src/main/DesktopAppIdentity.ts b/apps/desktop/src/main/DesktopAppIdentity.ts new file mode 100644 index 00000000000..cb488946450 --- /dev/null +++ b/apps/desktop/src/main/DesktopAppIdentity.ts @@ -0,0 +1,127 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; + +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as DesktopAssets from "./DesktopAssets.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/i; +const COMMIT_HASH_DISPLAY_LENGTH = 12; + +const AppPackageMetadata = Schema.Struct({ + t3codeCommitHash: Schema.optional(Schema.String), +}); + +export interface DesktopAppIdentityShape { + readonly resolveUserDataPath: Effect.Effect; + readonly configure: Effect.Effect; +} + +export class DesktopAppIdentity extends Context.Service< + DesktopAppIdentity, + DesktopAppIdentityShape +>()("t3/desktop/AppIdentity") {} + +const normalizeCommitHash = (value: string): Option.Option => { + const trimmed = value.trim(); + return COMMIT_HASH_PATTERN.test(trimmed) + ? Option.some(trimmed.slice(0, COMMIT_HASH_DISPLAY_LENGTH).toLowerCase()) + : Option.none(); +}; + +const make = Effect.gen(function* () { + const assets = yield* DesktopAssets.DesktopAssets; + const electronApp = yield* ElectronApp.ElectronApp; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const commitHashCache = yield* Ref.make>>(Option.none()); + + const resolveEmbeddedCommitHash = Effect.gen(function* () { + const packageJsonPath = environment.path.join(environment.appRoot, "package.json"); + const raw = yield* fileSystem.readFileString(packageJsonPath).pipe(Effect.option); + return yield* Option.match(raw, { + onNone: () => Effect.succeed(Option.none()), + onSome: (value) => + Schema.decodeEffect(Schema.fromJsonString(AppPackageMetadata))(value).pipe( + Effect.map((parsed) => + Option.fromNullishOr(parsed.t3codeCommitHash).pipe(Option.flatMap(normalizeCommitHash)), + ), + Effect.catch(() => Effect.succeed(Option.none())), + ), + }); + }); + + const resolveAboutCommitHash = Effect.gen(function* () { + const cached = yield* Ref.get(commitHashCache); + if (Option.isSome(cached)) { + return cached.value; + } + + const override = Option.flatMap(environment.commitHashOverride, normalizeCommitHash); + if (Option.isSome(override)) { + yield* Ref.set(commitHashCache, Option.some(override)); + return override; + } + + if (!environment.isPackaged) { + const empty = Option.none(); + yield* Ref.set(commitHashCache, Option.some(empty)); + return empty; + } + + const commitHash = yield* resolveEmbeddedCommitHash; + yield* Ref.set(commitHashCache, Option.some(commitHash)); + return commitHash; + }); + + const resolveUserDataPath = Effect.gen(function* () { + const legacyPath = environment.path.join( + environment.appDataDirectory, + environment.legacyUserDataDirName, + ); + const legacyPathExists = yield* fileSystem + .exists(legacyPath) + .pipe(Effect.orElseSucceed(() => false)); + return legacyPathExists + ? legacyPath + : environment.path.join(environment.appDataDirectory, environment.userDataDirName); + }); + + const configure = Effect.gen(function* () { + const commitHash = yield* resolveAboutCommitHash; + yield* electronApp.setName(environment.displayName); + yield* electronApp.setAboutPanelOptions({ + applicationName: environment.displayName, + applicationVersion: environment.appVersion, + version: Option.getOrElse(commitHash, () => "unknown"), + }); + + if (environment.platform === "win32") { + yield* electronApp.setAppUserModelId(environment.appUserModelId); + } + + if (environment.platform === "linux") { + yield* electronApp.setDesktopName(environment.linuxDesktopEntryName); + } + + if (environment.platform === "darwin") { + const iconPaths = yield* assets.iconPaths; + yield* Option.match(iconPaths.png, { + onNone: () => Effect.void, + onSome: electronApp.setDockIcon, + }); + } + }); + + return DesktopAppIdentity.of({ + resolveUserDataPath, + configure, + }); +}); + +export const layer = Layer.effect(DesktopAppIdentity, make); diff --git a/apps/desktop/src/main/DesktopApplicationMenu.test.ts b/apps/desktop/src/main/DesktopApplicationMenu.test.ts new file mode 100644 index 00000000000..3791d03d4c8 --- /dev/null +++ b/apps/desktop/src/main/DesktopApplicationMenu.test.ts @@ -0,0 +1,148 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as EffectPath from "effect/Path"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import type * as Electron from "electron"; + +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronDialog from "../electron/ElectronDialog.ts"; +import * as ElectronMenu from "../electron/ElectronMenu.ts"; +import * as DesktopApplicationMenu from "./DesktopApplicationMenu.ts"; +import * as DesktopConfig from "./DesktopConfig.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopRun from "./DesktopRun.ts"; +import * as DesktopUpdates from "./DesktopUpdates.ts"; +import * as DesktopWindow from "./DesktopWindow.ts"; + +const environmentInput = { + dirname: "/repo/apps/desktop/dist-electron", + cwd: "/repo", + platform: "linux", + processArch: "arm64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: false, + resourcesPath: "/repo/resources", + runningUnderArm64Translation: false, +} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; + +const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { + metadata: Effect.die("unexpected metadata read"), + name: Effect.succeed("T3 Code"), + whenReady: Effect.void, + quit: Effect.void, + exit: () => Effect.void, + relaunch: () => Effect.void, + setPath: () => Effect.void, + setName: () => Effect.void, + setAboutPanelOptions: () => Effect.void, + setAppUserModelId: () => Effect.void, + setDesktopName: () => Effect.void, + setDockIcon: () => Effect.void, + appendCommandLineSwitch: () => Effect.void, + on: () => Effect.void, +} satisfies ElectronApp.ElectronAppShape); + +const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, { + pickFolder: () => Effect.succeed(Option.none()), + confirm: () => Effect.succeed(false), + showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }), + showErrorBox: () => Effect.void, +} satisfies ElectronDialog.ElectronDialogShape); + +const desktopRunLayer = Layer.succeed(DesktopRun.DesktopRun, { + id: Effect.succeed("test-run"), + refreshId: Effect.succeed("test-run"), + logInfo: () => Effect.void, + logWarning: () => Effect.void, + logError: () => Effect.void, +} satisfies DesktopRun.DesktopRunShape); + +const desktopUpdatesLayer = Layer.succeed(DesktopUpdates.DesktopUpdates, { + getState: Effect.die("unexpected getState"), + emitState: Effect.void, + disabledReason: Effect.succeed(Option.none()), + configure: Effect.void, + setChannel: () => Effect.die("unexpected setChannel"), + check: () => Effect.die("unexpected check"), + download: Effect.die("unexpected download"), + install: Effect.die("unexpected install"), + shutdown: Effect.void, +} satisfies DesktopUpdates.DesktopUpdatesShape); + +const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => + Layer.succeed(DesktopWindow.DesktopWindow, { + createMain: Effect.die("unexpected createMain"), + ensureMain: Effect.die("unexpected ensureMain"), + revealOrCreateMain: Effect.die("unexpected revealOrCreateMain"), + activate: Effect.void, + createMainIfBackendReady: Effect.void, + handleBackendReady: Effect.void, + dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), + syncAppearance: Effect.void, + } satisfies DesktopWindow.DesktopWindowShape); + +const makeElectronMenuLayer = ( + applicationMenuTemplate: Deferred.Deferred, +) => + Layer.succeed(ElectronMenu.ElectronMenu, { + setApplicationMenu: (template) => + Deferred.succeed(applicationMenuTemplate, template).pipe(Effect.asVoid), + popupTemplate: () => Effect.void, + showContextMenu: () => Effect.succeed(Option.none()), + } satisfies ElectronMenu.ElectronMenuShape); + +describe("DesktopApplicationMenu", () => { + it.effect("installs the native menu and routes Settings through DesktopWindow", () => + Effect.gen(function* () { + const selectedAction = yield* Deferred.make(); + const applicationMenuTemplate = + yield* Deferred.make(); + + yield* Effect.gen(function* () { + const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu; + yield* menu.configure; + }).pipe( + Effect.provide( + DesktopApplicationMenu.layer.pipe( + Layer.provideMerge(makeElectronMenuLayer(applicationMenuTemplate)), + Layer.provideMerge(makeDesktopWindowLayer(selectedAction)), + Layer.provideMerge(desktopUpdatesLayer), + Layer.provideMerge(desktopRunLayer), + Layer.provideMerge(electronDialogLayer), + Layer.provideMerge(electronAppLayer), + Layer.provideMerge( + DesktopEnvironment.layer(environmentInput).pipe( + Layer.provide( + Layer.mergeAll( + EffectPath.layer, + DesktopConfig.layerTest({ HOME: "/Users/alice" }), + ), + ), + ), + ), + ), + ), + ); + + const template = yield* Deferred.await(applicationMenuTemplate); + const fileMenu = template.find((item) => item.label === "File"); + assert.isDefined(fileMenu); + if (!Array.isArray(fileMenu.submenu)) { + throw new Error("Expected File menu submenu to be an array."); + } + const settingsItem = fileMenu.submenu.find((item) => item.label === "Settings..."); + assert.isDefined(settingsItem); + const settingsClick = settingsItem.click; + if (typeof settingsClick !== "function") { + throw new Error("Expected Settings menu item to have a click handler."); + } + + settingsClick({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent); + assert.equal(yield* Deferred.await(selectedAction), "open-settings"); + }), + ); +}); diff --git a/apps/desktop/src/main/DesktopApplicationMenu.ts b/apps/desktop/src/main/DesktopApplicationMenu.ts new file mode 100644 index 00000000000..6b7afd6f935 --- /dev/null +++ b/apps/desktop/src/main/DesktopApplicationMenu.ts @@ -0,0 +1,215 @@ +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import type * as Electron from "electron"; + +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronDialog from "../electron/ElectronDialog.ts"; +import * as ElectronMenu from "../electron/ElectronMenu.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopRun from "./DesktopRun.ts"; +import * as DesktopUpdates from "./DesktopUpdates.ts"; +import * as DesktopWindow from "./DesktopWindow.ts"; + +type DesktopApplicationMenuRuntimeServices = + | DesktopEnvironment.DesktopEnvironment + | DesktopRun.DesktopRun + | DesktopUpdates.DesktopUpdates + | DesktopWindow.DesktopWindow + | ElectronApp.ElectronApp + | ElectronDialog.ElectronDialog + | ElectronMenu.ElectronMenu; + +export interface DesktopApplicationMenuShape { + readonly configure: Effect.Effect; +} + +export class DesktopApplicationMenu extends Context.Service< + DesktopApplicationMenu, + DesktopApplicationMenuShape +>()("t3/desktop/ApplicationMenu") {} + +const dispatchMenuAction = ( + action: string, +): Effect.Effect => + Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.dispatchMenuAction(action); + }); + +const checkForUpdatesFromMenu: Effect.Effect< + void, + never, + DesktopUpdates.DesktopUpdates | ElectronDialog.ElectronDialog +> = Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + const electronDialog = yield* ElectronDialog.ElectronDialog; + const result = yield* updates.check("menu"); + const updateState = result.state; + + if (updateState.status === "up-to-date") { + yield* electronDialog.showMessageBox({ + type: "info", + title: "You're up to date!", + message: `T3 Code ${updateState.currentVersion} is currently the newest version available.`, + buttons: ["OK"], + }); + } else if (updateState.status === "error") { + yield* electronDialog.showMessageBox({ + type: "warning", + title: "Update check failed", + message: "Could not check for updates.", + detail: updateState.message ?? "An unknown error occurred. Please try again later.", + buttons: ["OK"], + }); + } +}); + +const handleCheckForUpdatesMenuClick: Effect.Effect< + void, + DesktopWindow.DesktopWindowError, + | DesktopRun.DesktopRun + | DesktopUpdates.DesktopUpdates + | ElectronDialog.ElectronDialog + | DesktopWindow.DesktopWindow +> = Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + const electronDialog = yield* ElectronDialog.ElectronDialog; + const run = yield* DesktopRun.DesktopRun; + const disabledReason = yield* updates.disabledReason; + if (Option.isSome(disabledReason)) { + yield* run.logInfo("manual update check requested, but updates are disabled", { + component: "desktop-updater", + disabledReason: disabledReason.value, + }); + yield* electronDialog.showMessageBox({ + type: "info", + title: "Updates unavailable", + message: "Automatic updates are not available right now.", + detail: disabledReason.value, + buttons: ["OK"], + }); + return; + } + + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.ensureMain; + yield* checkForUpdatesFromMenu; +}); + +const make = Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + const electronMenu = yield* ElectronMenu.ElectronMenu; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const run = yield* DesktopRun.DesktopRun; + const appName = yield* electronApp.name; + const context = yield* Effect.context(); + + const runMenuEffect = (action: string, effect: Effect.Effect) => { + void Effect.runPromiseWith(context as unknown as Context.Context)( + effect.pipe( + Effect.catchCause((cause) => + run.logError("desktop menu action failed", { + action, + cause: Cause.pretty(cause), + }), + ), + ), + ); + }; + + const checkForUpdatesClick = () => { + runMenuEffect("check-for-updates", handleCheckForUpdatesMenuClick); + }; + + const settingsClick = () => { + runMenuEffect("open-settings", dispatchMenuAction("open-settings")); + }; + + const configure = Effect.gen(function* () { + const template: Electron.MenuItemConstructorOptions[] = []; + + if (environment.platform === "darwin") { + template.push({ + label: appName, + submenu: [ + { role: "about" }, + { + label: "Check for Updates...", + click: checkForUpdatesClick, + }, + { type: "separator" }, + { + label: "Settings...", + accelerator: "CmdOrCtrl+,", + click: settingsClick, + }, + { type: "separator" }, + { role: "services" }, + { type: "separator" }, + { role: "hide" }, + { role: "hideOthers" }, + { role: "unhide" }, + { type: "separator" }, + { role: "quit" }, + ], + }); + } + + template.push( + { + label: "File", + submenu: [ + ...(environment.platform === "darwin" + ? [] + : [ + { + label: "Settings...", + accelerator: "CmdOrCtrl+,", + click: settingsClick, + }, + { type: "separator" as const }, + ]), + { role: environment.platform === "darwin" ? "close" : "quit" }, + ], + }, + { role: "editMenu" }, + { + label: "View", + submenu: [ + { role: "reload" }, + { role: "forceReload" }, + { role: "toggleDevTools" }, + { type: "separator" }, + { role: "resetZoom" }, + { role: "zoomIn", accelerator: "CmdOrCtrl+=" }, + { role: "zoomIn", accelerator: "CmdOrCtrl+Plus", visible: false }, + { role: "zoomOut" }, + { type: "separator" }, + { role: "togglefullscreen" }, + ], + }, + { role: "windowMenu" }, + { + role: "help", + submenu: [ + { + label: "Check for Updates...", + click: checkForUpdatesClick, + }, + ], + }, + ); + + yield* electronMenu.setApplicationMenu(template); + }); + + return DesktopApplicationMenu.of({ + configure, + }); +}); + +export const layer = Layer.effect(DesktopApplicationMenu, make); diff --git a/apps/desktop/src/main/DesktopAssets.ts b/apps/desktop/src/main/DesktopAssets.ts index 0d37a50b40b..d13bc4d7fe6 100644 --- a/apps/desktop/src/main/DesktopAssets.ts +++ b/apps/desktop/src/main/DesktopAssets.ts @@ -4,7 +4,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as DesktopEnvironment from "../desktopEnvironment.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; export interface DesktopIconPaths { readonly ico: Option.Option; diff --git a/apps/desktop/src/main/DesktopBackendConfiguration.test.ts b/apps/desktop/src/main/DesktopBackendConfiguration.test.ts new file mode 100644 index 00000000000..ae10d32f579 --- /dev/null +++ b/apps/desktop/src/main/DesktopBackendConfiguration.test.ts @@ -0,0 +1,152 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as EffectPath from "effect/Path"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; + +import { DesktopEnvironment, layer as makeDesktopEnvironmentLayer } from "./DesktopEnvironment.ts"; +import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; +import * as DesktopConfig from "./DesktopConfig.ts"; +import * as DesktopRun from "./DesktopRun.ts"; +import * as DesktopServerExposure from "./DesktopServerExposure.ts"; + +const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExposure, { + getState: Effect.die("unexpected getState"), + backendConfig: Effect.succeed({ + port: 4888, + bindHost: "0.0.0.0", + httpBaseUrl: new URL("http://127.0.0.1:4888"), + tailscaleServeEnabled: true, + tailscaleServePort: 8443, + }), + configureFromSettings: () => Effect.die("unexpected configureFromSettings"), + setMode: () => Effect.die("unexpected setMode"), + setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"), + getAdvertisedEndpoints: Effect.succeed([]), +} satisfies DesktopServerExposure.DesktopServerExposureShape); + +function makeEnvironmentLayer(baseDir: string) { + return makeDesktopEnvironmentLayer({ + dirname: "/repo/apps/desktop/src", + cwd: "/repo", + platform: "darwin", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll( + EffectPath.layer, + DesktopConfig.layerTest({ + T3CODE_HOME: baseDir, + T3CODE_PORT: "9999", + T3CODE_MODE: "desktop", + T3CODE_DESKTOP_LAN_HOST: "192.168.1.50", + }), + ), + ), + ); +} + +const withHarness = ( + effect: Effect.Effect< + A, + E, + | R + | DesktopEnvironment + | FileSystem.FileSystem + | DesktopBackendConfiguration.DesktopBackendConfiguration + >, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + return yield* effect.pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopRun.layer), + Layer.provideMerge(makeEnvironmentLayer(baseDir)), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); + +describe("DesktopBackendConfiguration", () => { + it.effect("resolves backend start config with a stable scoped bootstrap token", () => + withHarness( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment; + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + + const first = yield* configuration.resolve; + const second = yield* configuration.resolve; + + assert.equal(first.executablePath, process.execPath); + assert.equal(first.entryPath, environment.backendEntryPath); + assert.equal(first.cwd, environment.backendCwd); + assert.equal(first.captureOutput, true); + assert.equal(first.env.ELECTRON_RUN_AS_NODE, "1"); + assert.isUndefined(first.env.T3CODE_PORT); + assert.isUndefined(first.env.T3CODE_MODE); + assert.isUndefined(first.env.T3CODE_DESKTOP_LAN_HOST); + + assert.equal(first.bootstrap.mode, "desktop"); + assert.equal(first.bootstrap.noBrowser, true); + assert.equal(first.bootstrap.port, 4888); + assert.equal(first.bootstrap.host, "0.0.0.0"); + assert.equal(first.bootstrap.t3Home, environment.baseDir); + assert.equal(first.bootstrap.tailscaleServeEnabled, true); + assert.equal(first.bootstrap.tailscaleServePort, 8443); + assert.match(first.bootstrap.desktopBootstrapToken, /^[0-9a-f]{48}$/i); + assert.equal(second.bootstrap.desktopBootstrapToken, first.bootstrap.desktopBootstrapToken); + }), + ), + ); + + it.effect("includes persisted backend observability endpoints when present", () => + withHarness( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment; + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + + yield* fileSystem.makeDirectory(environment.path.dirname(environment.serverSettingsPath), { + recursive: true, + }); + yield* fileSystem.writeFileString( + environment.serverSettingsPath, + JSON.stringify({ + observability: { + otlpTracesUrl: " http://127.0.0.1:4318/v1/traces ", + otlpMetricsUrl: " http://127.0.0.1:4318/v1/metrics ", + }, + }), + ); + + const config = yield* configuration.resolve; + assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces"); + assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics"); + }), + ), + ); + + it.effect("omits backend observability endpoints when settings are missing", () => + withHarness( + Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolve; + + assert.isUndefined(config.bootstrap.otlpTracesUrl); + assert.isUndefined(config.bootstrap.otlpMetricsUrl); + }), + ), + ); +}); diff --git a/apps/desktop/src/main/DesktopBackendConfiguration.ts b/apps/desktop/src/main/DesktopBackendConfiguration.ts new file mode 100644 index 00000000000..dd1a417ac0f --- /dev/null +++ b/apps/desktop/src/main/DesktopBackendConfiguration.ts @@ -0,0 +1,167 @@ +import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Random from "effect/Random"; +import * as Ref from "effect/Ref"; + +import * as DesktopBackendManager from "./DesktopBackendManager.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopServerExposure from "./DesktopServerExposure.ts"; +import * as DesktopRun from "./DesktopRun.ts"; + +export interface DesktopBackendConfigurationShape { + readonly resolve: Effect.Effect; +} + +export class DesktopBackendConfiguration extends Context.Service< + DesktopBackendConfiguration, + DesktopBackendConfigurationShape +>()("t3/desktop/BackendConfiguration") {} + +interface BackendObservabilitySettings { + readonly otlpTracesUrl: Option.Option; + readonly otlpMetricsUrl: Option.Option; +} + +const emptyBackendObservabilitySettings: BackendObservabilitySettings = { + otlpTracesUrl: Option.none(), + otlpMetricsUrl: Option.none(), +}; + +const DESKTOP_BACKEND_ENV_NAMES = [ + "T3CODE_PORT", + "T3CODE_MODE", + "T3CODE_NO_BROWSER", + "T3CODE_HOST", + "T3CODE_DESKTOP_WS_URL", + "T3CODE_DESKTOP_LAN_ACCESS", + "T3CODE_DESKTOP_LAN_HOST", + "T3CODE_DESKTOP_HTTPS_ENDPOINTS", + "T3CODE_TAILSCALE_SERVE", + "T3CODE_TAILSCALE_SERVE_PORT", +] as const; + +const backendChildEnvPatch = (): Record => + Object.fromEntries(DESKTOP_BACKEND_ENV_NAMES.map((name) => [name, undefined])); + +const readPersistedBackendObservabilitySettings: Effect.Effect< + BackendObservabilitySettings, + never, + FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment | DesktopRun.DesktopRun +> = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const run = yield* DesktopRun.DesktopRun; + const exists = yield* fileSystem + .exists(environment.serverSettingsPath) + .pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return emptyBackendObservabilitySettings; + } + + const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(Effect.option); + if (Option.isNone(raw)) { + yield* run.logWarning("failed to read persisted backend observability settings"); + return emptyBackendObservabilitySettings; + } + + const parsed = parsePersistedServerObservabilitySettings(raw.value); + return { + otlpTracesUrl: Option.fromNullishOr(parsed.otlpTracesUrl), + otlpMetricsUrl: Option.fromNullishOr(parsed.otlpMetricsUrl), + }; +}); + +const getOrCreateBootstrapToken = ( + tokenRef: Ref.Ref>, +): Effect.Effect => + Effect.gen(function* () { + const existing = yield* Ref.get(tokenRef); + if (Option.isSome(existing)) { + return existing.value; + } + + let token = ""; + while (token.length < 48) { + token += (yield* Random.nextUUIDv4).replace(/-/g, ""); + } + token = token.slice(0, 48); + yield* Ref.set(tokenRef, Option.some(token)); + return token; + }); + +const resolveBackendStartConfig = (input: { + readonly bootstrapToken: string; + readonly observabilitySettings: BackendObservabilitySettings; +}): Effect.Effect< + DesktopBackendManager.DesktopBackendStartConfig, + never, + DesktopEnvironment.DesktopEnvironment | DesktopServerExposure.DesktopServerExposure +> => + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const backendExposure = yield* serverExposure.backendConfig; + + return { + executablePath: process.execPath, + entryPath: environment.backendEntryPath, + cwd: environment.backendCwd, + env: { + ...backendChildEnvPatch(), + ELECTRON_RUN_AS_NODE: "1", + }, + bootstrap: { + mode: "desktop", + noBrowser: true, + port: backendExposure.port, + t3Home: environment.baseDir, + host: backendExposure.bindHost, + desktopBootstrapToken: input.bootstrapToken, + tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, + tailscaleServePort: backendExposure.tailscaleServePort, + ...Option.match(input.observabilitySettings.otlpTracesUrl, { + onNone: () => ({}), + onSome: (otlpTracesUrl) => ({ otlpTracesUrl }), + }), + ...Option.match(input.observabilitySettings.otlpMetricsUrl, { + onNone: () => ({}), + onSome: (otlpMetricsUrl) => ({ otlpMetricsUrl }), + }), + }, + httpBaseUrl: backendExposure.httpBaseUrl, + captureOutput: !environment.isDevelopment, + }; + }); + +export const layer = Layer.effect( + DesktopBackendConfiguration, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const run = yield* DesktopRun.DesktopRun; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const tokenRef = yield* Ref.make(Option.none()); + + return DesktopBackendConfiguration.of({ + resolve: Effect.gen(function* () { + const bootstrapToken = yield* getOrCreateBootstrapToken(tokenRef); + const observabilitySettings = yield* readPersistedBackendObservabilitySettings.pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), + Effect.provideService(DesktopRun.DesktopRun, run), + ); + return yield* resolveBackendStartConfig({ + bootstrapToken, + observabilitySettings, + }).pipe( + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), + Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), + ); + }), + }); + }), +); diff --git a/apps/desktop/src/main/DesktopBackendEvents.ts b/apps/desktop/src/main/DesktopBackendEvents.ts new file mode 100644 index 00000000000..d4d6539ee17 --- /dev/null +++ b/apps/desktop/src/main/DesktopBackendEvents.ts @@ -0,0 +1,96 @@ +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; + +import type { + BackendTimeoutError, + BackendProcessOutputStream, + DesktopBackendStartConfig, +} from "./DesktopBackendManager.ts"; +import { DesktopBackendOutputLog } from "./DesktopLogging.ts"; +import * as DesktopRun from "./DesktopRun.ts"; +import * as DesktopState from "./DesktopState.ts"; +import * as DesktopWindow from "./DesktopWindow.ts"; + +export interface DesktopBackendEventsShape { + readonly onStarting: Effect.Effect; + readonly onStarted: (input: { + readonly pid: number; + readonly config: DesktopBackendStartConfig; + }) => Effect.Effect; + readonly onReady: Effect.Effect; + readonly onReadinessFailure: (error: BackendTimeoutError) => Effect.Effect; + readonly onOutput: ( + streamName: BackendProcessOutputStream, + chunk: Uint8Array, + ) => Effect.Effect; + readonly onExit: (input: { + readonly pid: Option.Option; + readonly reason: string; + }) => Effect.Effect; + readonly onRestartScheduled: (input: { + readonly reason: string; + readonly delay: Duration.Duration; + }) => Effect.Effect; +} + +export class DesktopBackendEvents extends Context.Service< + DesktopBackendEvents, + DesktopBackendEventsShape +>()("t3/desktop/BackendEvents") {} + +const make = Effect.gen(function* () { + const backendOutputLog = yield* DesktopBackendOutputLog; + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const run = yield* DesktopRun.DesktopRun; + const state = yield* DesktopState.DesktopState; + + return DesktopBackendEvents.of({ + onStarting: Ref.set(state.backendReady, false), + onStarted: ({ pid, config }) => + Effect.gen(function* () { + const runId = yield* run.id; + yield* backendOutputLog.writeSessionBoundary({ + phase: "START", + runId, + details: `pid=${pid} port=${config.bootstrap.port} cwd=${config.cwd}`, + }); + }), + onReady: desktopWindow.handleBackendReady.pipe( + Effect.catch((error) => + run.logError("failed to open main window after backend readiness", { + message: error.message, + }), + ), + ), + onReadinessFailure: (error) => + run.logWarning("backend readiness check failed during bootstrap", { error: error.message }), + onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), + onExit: ({ pid, reason }) => + Effect.gen(function* () { + yield* Option.match(pid, { + onNone: () => Effect.void, + onSome: (value) => + Effect.gen(function* () { + const runId = yield* run.id; + yield* backendOutputLog.writeSessionBoundary({ + phase: "END", + runId, + details: `pid=${value} ${reason}`, + }); + }), + }); + yield* Ref.set(state.backendReady, false); + }), + onRestartScheduled: ({ reason, delay }) => + run.logError("backend exited unexpectedly; restart scheduled", { + reason, + delayMs: Duration.toMillis(delay), + }), + }); +}); + +export const layer = Layer.effect(DesktopBackendEvents, make); diff --git a/apps/desktop/src/main/DesktopBackendManager.test.ts b/apps/desktop/src/main/DesktopBackendManager.test.ts new file mode 100644 index 00000000000..749a8a075e1 --- /dev/null +++ b/apps/desktop/src/main/DesktopBackendManager.test.ts @@ -0,0 +1,400 @@ +import { + DesktopBackendBootstrap, + type DesktopBackendBootstrap as DesktopBackendBootstrapValue, +} from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import { + Deferred, + Duration, + Effect, + FileSystem, + Layer, + Option, + Queue, + Schema, + Sink, + Scope, + Stream, +} from "effect"; +import { TestClock } from "effect/testing"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import * as DesktopBackendManager from "./DesktopBackendManager.ts"; +import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; +import * as DesktopBackendEvents from "./DesktopBackendEvents.ts"; + +const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { + executablePath: "/electron", + entryPath: "/server/bin.mjs", + cwd: "/server", + env: { ELECTRON_RUN_AS_NODE: "1" }, + bootstrap: { + mode: "desktop", + noBrowser: true, + port: 3773, + t3Home: "/tmp/t3", + host: "127.0.0.1", + desktopBootstrapToken: "token", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }, + httpBaseUrl: new URL("http://127.0.0.1:3773"), + captureOutput: true, +}; + +const configWithObservability: DesktopBackendBootstrapValue = { + ...baseConfig.bootstrap, + tailscaleServeEnabled: true, + otlpTracesUrl: "http://127.0.0.1:4318/v1/traces", +}; + +function makeProcess(options?: { + readonly stdout?: Stream.Stream; + readonly stderr?: Stream.Stream; + readonly exitCode?: Effect.Effect; + readonly kill?: ChildProcessSpawner.ChildProcessHandle["kill"]; +}): ChildProcessSpawner.ChildProcessHandle { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + stdout: options?.stdout ?? Stream.empty, + stderr: options?.stderr ?? Stream.empty, + all: Stream.merge(options?.stdout ?? Stream.empty, options?.stderr ?? Stream.empty), + exitCode: options?.exitCode ?? Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: options?.kill ?? (() => Effect.void), + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }); +} + +function responseForRequest( + request: HttpClientRequest.HttpClientRequest, + status: number, +): HttpClientResponse.HttpClientResponse { + return HttpClientResponse.fromWeb(request, new Response(null, { status })); +} + +function httpClientLayer( + handler: ( + request: HttpClientRequest.HttpClientRequest, + ) => Effect.Effect, +) { + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => handler(request)), + ); +} + +const healthyHttpClientLayer = httpClientLayer((request) => + Effect.succeed(responseForRequest(request, 200)), +); + +function decodeBootstrap(raw: string) { + return Schema.decodeEffect(Schema.fromJsonString(DesktopBackendBootstrap))(raw); +} + +function makeManagerLayer(input: { + readonly spawnerLayer: Layer.Layer; + readonly httpClientLayer?: Layer.Layer; + readonly events?: Partial; + readonly config?: DesktopBackendManager.DesktopBackendStartConfig; +}) { + return DesktopBackendManager.layer.pipe( + Layer.provide( + Layer.mergeAll( + FileSystem.layerNoop({ + exists: () => Effect.succeed(true), + }), + Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, { + resolve: Effect.succeed(input.config ?? baseConfig), + }), + input.spawnerLayer, + input.httpClientLayer ?? healthyHttpClientLayer, + Layer.succeed(DesktopBackendEvents.DesktopBackendEvents, { + onStarting: Effect.void, + onStarted: () => Effect.void, + onReady: Effect.void, + onReadinessFailure: () => Effect.void, + onOutput: () => Effect.void, + onExit: () => Effect.void, + onRestartScheduled: () => Effect.void, + ...input.events, + } satisfies DesktopBackendEvents.DesktopBackendEventsShape), + ), + ), + ); +} + +describe("DesktopBackendManager", () => { + it.effect("spawns the backend with fd3 bootstrap JSON and reports HTTP readiness", () => + Effect.gen(function* () { + let spawnedCommand: ChildProcess.Command | undefined; + let bootstrapJson = ""; + let readyCount = 0; + const ready = yield* Deferred.make(); + const exited = yield* Queue.unbounded(); + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + spawnedCommand = command; + if (command._tag === "StandardCommand") { + const fd3 = command.options.additionalFds?.fd3; + if (fd3?.type === "input" && fd3.stream) { + bootstrapJson = yield* fd3.stream.pipe(Stream.decodeText(), Stream.mkString); + } + } + + return makeProcess({ + exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }); + }), + ), + ); + + const managerLayer = makeManagerLayer({ + config: { + ...baseConfig, + bootstrap: configWithObservability, + }, + spawnerLayer, + events: { + onReady: Effect.sync(() => { + readyCount += 1; + }).pipe(Effect.andThen(Deferred.succeed(ready, void 0))), + onExit: () => Queue.offer(exited, void 0).pipe(Effect.asVoid), + }, + }); + + yield* Effect.gen(function* () { + const manager = yield* DesktopBackendManager.DesktopBackendManager; + yield* manager.start; + yield* Queue.take(exited); + + assert.equal(readyCount, 1); + assert.isDefined(spawnedCommand); + if (spawnedCommand?._tag === "StandardCommand") { + assert.equal(spawnedCommand.command, "/electron"); + assert.deepEqual(spawnedCommand.args, ["/server/bin.mjs", "--bootstrap-fd", "3"]); + assert.equal(spawnedCommand.options.cwd, "/server"); + assert.equal(spawnedCommand.options.extendEnv, true); + assert.equal(spawnedCommand.options.stdout, "pipe"); + assert.equal(spawnedCommand.options.stderr, "pipe"); + assert.equal(spawnedCommand.options.killSignal, "SIGTERM"); + assert.isDefined(spawnedCommand.options.forceKillAfter); + assert.equal( + Duration.toMillis(Duration.fromInputUnsafe(spawnedCommand.options.forceKillAfter)), + 2_000, + ); + } + assert.deepEqual(yield* decodeBootstrap(bootstrapJson), configWithObservability); + }).pipe(Effect.provide(managerLayer)); + }), + ); + + it.effect("retries HTTP readiness before reporting the backend ready", () => + Effect.gen(function* () { + const requestUrls: Array = []; + const statuses = [503, 200]; + let readyCount = 0; + const firstRequest = yield* Deferred.make(); + const ready = yield* Deferred.make(); + const exited = yield* Queue.unbounded(); + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }), + ), + ), + ); + + const managerLayer = makeManagerLayer({ + spawnerLayer, + httpClientLayer: httpClientLayer((request) => + Effect.gen(function* () { + const status = statuses.shift(); + assert.isDefined(status); + requestUrls.push(request.url); + yield* Deferred.succeed(firstRequest, void 0); + return responseForRequest(request, status); + }), + ), + events: { + onReady: Effect.sync(() => { + readyCount += 1; + }).pipe(Effect.andThen(Deferred.succeed(ready, void 0))), + onExit: () => Queue.offer(exited, void 0).pipe(Effect.asVoid), + }, + }); + + yield* Effect.gen(function* () { + const manager = yield* DesktopBackendManager.DesktopBackendManager; + yield* manager.start; + yield* Deferred.await(firstRequest); + + assert.equal(readyCount, 0); + assert.deepEqual(requestUrls, ["http://127.0.0.1:3773/"]); + + yield* TestClock.adjust(Duration.millis(100)); + yield* Queue.take(exited); + + assert.equal(readyCount, 1); + assert.deepEqual(requestUrls, ["http://127.0.0.1:3773/", "http://127.0.0.1:3773/"]); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); + }), + ); + + it.effect("inherits child output when captureOutput is false", () => + Effect.gen(function* () { + let spawnedCommand: ChildProcess.Command | undefined; + const ready = yield* Deferred.make(); + const exited = yield* Queue.unbounded(); + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.sync(() => { + spawnedCommand = command; + return makeProcess({ + exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }); + }), + ), + ); + + const managerLayer = makeManagerLayer({ + config: { + ...baseConfig, + captureOutput: false, + }, + spawnerLayer, + events: { + onReady: Deferred.succeed(ready, void 0).pipe(Effect.asVoid), + onExit: () => Queue.offer(exited, void 0).pipe(Effect.asVoid), + }, + }); + + yield* Effect.gen(function* () { + const manager = yield* DesktopBackendManager.DesktopBackendManager; + yield* manager.start; + yield* Queue.take(exited); + + assert.isDefined(spawnedCommand); + if (spawnedCommand?._tag === "StandardCommand") { + assert.equal(spawnedCommand.options.stdout, "inherit"); + assert.equal(spawnedCommand.options.stderr, "inherit"); + } + }).pipe(Effect.provide(managerLayer)); + }), + ); + + it.effect("starts the configured backend and closes the scoped process on stop", () => + Effect.gen(function* () { + let startCount = 0; + let closedCount = 0; + const closed = yield* Deferred.make(); + const startedPids = yield* Queue.unbounded(); + const ready = yield* Deferred.make(); + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + const scope = yield* Scope.Scope; + startCount += 1; + const close = Effect.sync(() => { + closedCount += 1; + }).pipe(Effect.andThen(Deferred.succeed(closed, void 0)), Effect.asVoid); + + yield* Scope.addFinalizer(scope, close); + + return makeProcess({ + exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + kill: () => close, + }); + }), + ), + ); + + const managerLayer = makeManagerLayer({ + spawnerLayer, + events: { + onStarted: ({ pid }) => Queue.offer(startedPids, pid).pipe(Effect.asVoid), + onReady: Deferred.succeed(ready, void 0).pipe(Effect.asVoid), + }, + }); + + yield* Effect.gen(function* () { + const manager = yield* DesktopBackendManager.DesktopBackendManager; + assert.isTrue(Option.isNone(yield* manager.currentConfig)); + + yield* manager.start; + assert.equal(yield* Queue.take(startedPids), 123); + yield* Deferred.await(ready); + assert.deepEqual(yield* manager.currentConfig, Option.some(baseConfig)); + + const runningSnapshot = yield* manager.snapshot; + assert.equal(runningSnapshot.ready, true); + assert.deepEqual(runningSnapshot.activePid, Option.some(123)); + + yield* manager.stop(); + assert.equal(startCount, 1); + assert.equal(closedCount, 1); + + const stoppedSnapshot = yield* manager.snapshot; + assert.equal(stoppedSnapshot.desiredRunning, false); + assert.equal(stoppedSnapshot.ready, false); + assert.equal(Option.isNone(stoppedSnapshot.activePid), true); + }).pipe(Effect.provide(managerLayer)); + }), + ); + + it.effect("restarts an unexpectedly exited backend with the Effect clock", () => + Effect.gen(function* () { + const starts = yield* Queue.unbounded(); + const restartDelays = yield* Queue.unbounded(); + let startCount = 0; + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.sync(() => { + startCount += 1; + return makeProcess({ + exitCode: Queue.offer(starts, startCount).pipe( + Effect.as(ChildProcessSpawner.ExitCode(1)), + ), + }); + }), + ), + ); + + const managerLayer = makeManagerLayer({ + spawnerLayer, + events: { + onRestartScheduled: ({ delay }) => + Queue.offer(restartDelays, Duration.toMillis(delay)).pipe(Effect.asVoid), + }, + }); + + yield* Effect.gen(function* () { + const manager = yield* DesktopBackendManager.DesktopBackendManager; + yield* manager.start; + + assert.equal(yield* Queue.take(starts), 1); + assert.equal(yield* Queue.take(restartDelays), 500); + + yield* TestClock.adjust(Duration.millis(500)); + assert.equal(yield* Queue.take(starts), 2); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); + }), + ); +}); diff --git a/apps/desktop/src/desktopBackendManager.ts b/apps/desktop/src/main/DesktopBackendManager.ts similarity index 57% rename from apps/desktop/src/desktopBackendManager.ts rename to apps/desktop/src/main/DesktopBackendManager.ts index 168d65c3459..9701d06ae5e 100644 --- a/apps/desktop/src/desktopBackendManager.ts +++ b/apps/desktop/src/main/DesktopBackendManager.ts @@ -1,111 +1,109 @@ -import type { DesktopBackendBootstrap } from "@t3tools/contracts"; import { Context, + Data, Duration, Effect, Exit, Fiber, FileSystem, + PlatformError, Layer, Option, Ref, + Result, + Schema, Scope, + Schedule, Semaphore, + Stream, } from "effect"; import { HttpClient } from "effect/unstable/http"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { - runBackendProcess, - type BackendProcessExit, - type RunBackendProcessOptions, -} from "./backendProcess.ts"; -import type { BackendTimeoutError } from "./backendReadiness.ts"; + DesktopBackendBootstrap, + type DesktopBackendBootstrap as DesktopBackendBootstrapValue, +} from "@t3tools/contracts"; + +import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; +import * as DesktopBackendEvents from "./DesktopBackendEvents.ts"; const INITIAL_RESTART_DELAY = Duration.millis(500); const MAX_RESTART_DELAY = Duration.seconds(10); +const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1); +const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100); +const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1); +const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(2); + +type BackendProcessLayerServices = ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient; -type BackendRunnerRequirements = - | ChildProcessSpawner.ChildProcessSpawner - | HttpClient.HttpClient - | Scope.Scope; +type BackendProcessRunRequirements = BackendProcessLayerServices | Scope.Scope; + +export type BackendProcessOutputStream = "stdout" | "stderr"; export interface DesktopBackendStartConfig { readonly executablePath: string; readonly entryPath: string; readonly cwd: string; readonly env: Record; - readonly bootstrap: DesktopBackendBootstrap; + readonly bootstrap: DesktopBackendBootstrapValue; readonly httpBaseUrl: URL; readonly captureOutput: boolean; } -export interface DesktopBackendSnapshot { - readonly desiredRunning: boolean; - readonly ready: boolean; - readonly activePid: Option.Option; - readonly restartAttempt: number; - readonly restartScheduled: boolean; - readonly shuttingDown: boolean; +interface BackendProcessExit { + readonly code: Option.Option; + readonly reason: string; + readonly result: Result.Result; } -export interface DesktopBackendProcessRunnerShape { - readonly run: ( - options: RunBackendProcessOptions, - ) => Effect.Effect; +export class BackendTimeoutError extends Data.TaggedError("BackendTimeoutError")<{ + readonly url: URL; +}> { + override get message() { + return `Timed out waiting for backend readiness at ${this.url.href}.`; + } } -export class DesktopBackendProcessRunner extends Context.Service< - DesktopBackendProcessRunner, - DesktopBackendProcessRunnerShape ->()("t3/desktop/BackendProcessRunner") {} - -export const DesktopBackendProcessRunnerLive = Layer.succeed(DesktopBackendProcessRunner, { - run: runBackendProcess, -} satisfies DesktopBackendProcessRunnerShape); +class BackendProcessBootstrapEncodeError extends Data.TaggedError( + "BackendProcessBootstrapEncodeError", +)<{ + readonly cause: Schema.SchemaError; +}> { + override get message() { + return `Failed to encode desktop backend bootstrap payload: ${this.cause.message}`; + } +} -export interface DesktopBackendConfigurationShape { - readonly resolve: Effect.Effect; +class BackendProcessSpawnError extends Data.TaggedError("BackendProcessSpawnError")<{ + readonly cause: PlatformError.PlatformError; +}> { + override get message() { + return `Failed to spawn desktop backend process: ${this.cause.message}`; + } } -export class DesktopBackendConfiguration extends Context.Service< - DesktopBackendConfiguration, - DesktopBackendConfigurationShape ->()("t3/desktop/BackendConfiguration") {} - -export interface DesktopBackendEventsShape { - readonly onStarting: Effect.Effect; - readonly onStarted: (input: { - readonly pid: number; - readonly config: DesktopBackendStartConfig; - }) => Effect.Effect; - readonly onReady: Effect.Effect; - readonly onReadinessFailure: (error: BackendTimeoutError) => Effect.Effect; - readonly onOutput: (streamName: "stdout" | "stderr", chunk: Uint8Array) => Effect.Effect; - readonly onExit: (input: { - readonly pid: Option.Option; - readonly reason: string; - }) => Effect.Effect; - readonly onRestartScheduled: (input: { - readonly reason: string; - readonly delay: Duration.Duration; - }) => Effect.Effect; +type BackendProcessError = BackendProcessBootstrapEncodeError | BackendProcessSpawnError; + +interface RunBackendProcessOptions extends DesktopBackendStartConfig { + readonly readinessTimeout?: Duration.Duration; + readonly onStarted?: (pid: number) => Effect.Effect; + readonly onReady?: () => Effect.Effect; + readonly onReadinessFailure?: (error: BackendTimeoutError) => Effect.Effect; + readonly onOutput?: ( + streamName: BackendProcessOutputStream, + chunk: Uint8Array, + ) => Effect.Effect; } -export class DesktopBackendEvents extends Context.Service< - DesktopBackendEvents, - DesktopBackendEventsShape ->()("t3/desktop/BackendEvents") {} - -export const DesktopBackendEventsSilent = Layer.succeed(DesktopBackendEvents, { - onStarting: Effect.void, - onStarted: () => Effect.void, - onReady: Effect.void, - onReadinessFailure: () => Effect.void, - onOutput: () => Effect.void, - onExit: () => Effect.void, - onRestartScheduled: () => Effect.void, -} satisfies DesktopBackendEventsShape); +export interface DesktopBackendSnapshot { + readonly desiredRunning: boolean; + readonly ready: boolean; + readonly activePid: Option.Option; + readonly restartAttempt: number; + readonly restartScheduled: boolean; + readonly shuttingDown: boolean; +} export interface DesktopBackendManagerShape { readonly start: Effect.Effect; @@ -177,14 +175,114 @@ const closeRun = ( ).pipe(Effect.ignore); }; -export const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(function* () { +const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); + +const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(function* ( + baseUrl: URL, + timeout: Duration.Duration, +): Effect.fn.Return { + const client = (yield* HttpClient.HttpClient).pipe( + HttpClient.filterStatusOk, + HttpClient.transformResponse(Effect.timeout(DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT)), + HttpClient.retry(Schedule.spaced(DEFAULT_BACKEND_READINESS_INTERVAL)), + ); + + yield* client.get(new URL("/", baseUrl)).pipe( + Effect.asVoid, + Effect.timeout(timeout), + Effect.mapError(() => new BackendTimeoutError({ url: baseUrl })), + ); +}); + +function describeProcessExit( + result: Result.Result, +): BackendProcessExit { + if (Result.isSuccess(result)) { + const code = Number(result.success); + return { + code: Option.some(code), + reason: `code=${code}`, + result, + }; + } + + return { + code: Option.none(), + reason: result.failure.message, + result, + }; +} + +function drainBackendOutput( + streamName: BackendProcessOutputStream, + stream: Stream.Stream, + onOutput: (streamName: BackendProcessOutputStream, chunk: Uint8Array) => Effect.Effect, +): Effect.Effect { + return stream.pipe( + Stream.runForEach((chunk) => onOutput(streamName, chunk)), + Effect.ignore, + ); +} + +const runBackendProcess = Effect.fn("runBackendProcess")(function* ( + options: RunBackendProcessOptions, +): Effect.fn.Return { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const bootstrapJson = yield* encodeBootstrapJson(options.bootstrap).pipe( + Effect.mapError((cause) => new BackendProcessBootstrapEncodeError({ cause })), + ); + const onOutput = options.onOutput ?? (() => Effect.void); + const command = ChildProcess.make( + options.executablePath, + [options.entryPath, "--bootstrap-fd", "3"], + { + cwd: options.cwd, + env: options.env, + extendEnv: true, + // In Electron main, process.execPath points to the Electron binary. + // Run the child in Node mode so this backend process does not become a GUI app instance. + stdin: "ignore", + stdout: options.captureOutput ? "pipe" : "inherit", + stderr: options.captureOutput ? "pipe" : "inherit", + killSignal: "SIGTERM", + forceKillAfter: DEFAULT_BACKEND_TERMINATE_GRACE, + additionalFds: { + fd3: { + type: "input", + stream: Stream.encodeText(Stream.make(`${bootstrapJson}\n`)), + }, + }, + }, + ); + + const handle = yield* spawner + .spawn(command) + .pipe(Effect.mapError((cause) => new BackendProcessSpawnError({ cause }))); + + yield* options.onStarted?.(Number(handle.pid)) ?? Effect.void; + if (options.captureOutput) { + yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped); + yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped); + } + yield* waitForHttpReady( + options.httpBaseUrl, + options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, + ).pipe( + Effect.tap(() => options.onReady?.() ?? Effect.void), + Effect.catch((error) => options.onReadinessFailure?.(error) ?? Effect.void), + Effect.forkScoped, + ); + + return describeProcessExit(yield* Effect.result(handle.exitCode)); +}); + +const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(function* () { const parentScope = yield* Scope.Scope; const fileSystem = yield* FileSystem.FileSystem; - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const events = yield* DesktopBackendEvents.DesktopBackendEvents; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; - const configuration = yield* DesktopBackendConfiguration; - const events = yield* DesktopBackendEvents; - const runner = yield* DesktopBackendProcessRunner; const state = yield* Ref.make(initialState); const mutex = yield* Semaphore.make(1); @@ -229,9 +327,7 @@ export const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")( } yield* events.onStarting; - const config = yield* configuration.resolve.pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - ); + const config = yield* configuration.resolve; const entryExists = yield* fileSystem .exists(config.entryPath) .pipe(Effect.orElseSucceed(() => false)); @@ -320,45 +416,43 @@ export const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")( }), ); - const program = runner - .run({ - ...config, - onStarted: (pid) => - Effect.gen(function* () { - yield* updateActiveRun(runId, (run) => ({ - ...run, - pid: Option.some(pid), - })); - yield* Ref.update(state, (latest) => ({ - ...latest, - restartAttempt: 0, - })); - yield* events.onStarted({ pid, config }); - }), - onReady: () => - Effect.gen(function* () { - yield* Ref.update(state, (latest) => ({ - ...latest, - ready: Option.match(latest.active, { - onNone: () => latest.ready, - onSome: (run) => (run.id === runId ? true : latest.ready), - }), - })); - yield* events.onReady; - }), - onReadinessFailure: events.onReadinessFailure, - onOutput: events.onOutput, - }) - .pipe( - Scope.provide(runScope), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.matchEffect({ - onFailure: (error) => finalizeRun(formatUnknownError(error)), - onSuccess: (exit) => finalizeRun(exit.reason), + const program = runBackendProcess({ + ...config, + onStarted: (pid) => + Effect.gen(function* () { + yield* updateActiveRun(runId, (run) => ({ + ...run, + pid: Option.some(pid), + })); + yield* Ref.update(state, (latest) => ({ + ...latest, + restartAttempt: 0, + })); + yield* events.onStarted({ pid, config }); }), - Effect.ensuring(Scope.close(runScope, Exit.void).pipe(Effect.ignore)), - ); + onReady: () => + Effect.gen(function* () { + yield* Ref.update(state, (latest) => ({ + ...latest, + ready: Option.match(latest.active, { + onNone: () => latest.ready, + onSome: (run) => (run.id === runId ? true : latest.ready), + }), + })); + yield* events.onReady; + }), + onReadinessFailure: events.onReadinessFailure, + onOutput: events.onOutput, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(HttpClient.HttpClient, httpClient), + Scope.provide(runScope), + Effect.matchEffect({ + onFailure: (error) => finalizeRun(error.message), + onSuccess: (exit) => finalizeRun(exit.reason), + }), + Effect.ensuring(Scope.close(runScope, Exit.void).pipe(Effect.ignore)), + ); const fiber = yield* Effect.forkIn(program, parentScope); yield* updateActiveRun(runId, (run) => ({ @@ -467,14 +561,4 @@ export const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")( }); }); -export const DesktopBackendManagerLive = Layer.effect( - DesktopBackendManager, - makeDesktopBackendManager(), -); - -function formatUnknownError(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - return String(error); -} +export const layer = Layer.effect(DesktopBackendManager, makeDesktopBackendManager()); diff --git a/apps/desktop/src/main/DesktopConfig.test.ts b/apps/desktop/src/main/DesktopConfig.test.ts new file mode 100644 index 00000000000..f45473ebb94 --- /dev/null +++ b/apps/desktop/src/main/DesktopConfig.test.ts @@ -0,0 +1,59 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as DesktopConfig from "./DesktopConfig.ts"; + +describe("DesktopConfig", () => { + it.effect("loads typed desktop config from the effect ConfigProvider", () => + Effect.gen(function* () { + const config = yield* DesktopConfig.DesktopConfig; + + assert.deepEqual(config.home, Option.some("/Users/alice")); + assert.deepEqual(config.t3Home, Option.some("/tmp/t3")); + assert.deepEqual( + Option.map(config.devServerUrl, (url) => url.href), + Option.some("http://localhost:5173/"), + ); + assert.deepEqual(config.configuredBackendPort, Option.some(4949)); + assert.deepEqual(config.commitHashOverride, Option.some("0123456789abcdef")); + assert.deepEqual(config.desktopLanHostOverride, Option.some("192.168.1.50")); + assert.deepEqual(config.desktopHttpsEndpointUrls, [ + "https://t3.example.test", + "https://tailnet.example.test", + ]); + assert.equal(config.disableAutoUpdate, true); + assert.deepEqual(config.desktopUpdateGithubToken, Option.some("desktop-token")); + assert.equal(config.mockUpdates, true); + assert.equal(config.mockUpdateServerPort, 4141); + }).pipe( + Effect.provide( + DesktopConfig.layer.pipe( + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + HOME: " /Users/alice ", + T3CODE_HOME: " /tmp/t3 ", + VITE_DEV_SERVER_URL: "http://localhost:5173", + T3CODE_PORT: "4949", + T3CODE_COMMIT_HASH: " 0123456789abcdef ", + T3CODE_DESKTOP_LAN_HOST: " 192.168.1.50 ", + T3CODE_DESKTOP_HTTPS_ENDPOINTS: + " https://t3.example.test, https://tailnet.example.test ", + T3CODE_DISABLE_AUTO_UPDATE: "1", + T3CODE_DESKTOP_UPDATE_GITHUB_TOKEN: " desktop-token ", + GH_TOKEN: "ignored-token", + T3CODE_DESKTOP_MOCK_UPDATES: "true", + T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141", + }, + }), + ), + ), + ), + ), + ), + ); +}); diff --git a/apps/desktop/src/main/DesktopConfig.ts b/apps/desktop/src/main/DesktopConfig.ts new file mode 100644 index 00000000000..64f8bd537b3 --- /dev/null +++ b/apps/desktop/src/main/DesktopConfig.ts @@ -0,0 +1,121 @@ +import * as Config from "effect/Config"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +export interface DesktopConfigShape { + readonly home: Option.Option; + readonly userProfile: Option.Option; + readonly homeDrive: Option.Option; + readonly homePath: Option.Option; + readonly appDataDirectory: Option.Option; + readonly xdgConfigHome: Option.Option; + readonly t3Home: Option.Option; + readonly devServerUrl: Option.Option; + readonly devRemoteT3ServerEntryPath: Option.Option; + readonly configuredBackendPort: Option.Option; + readonly commitHashOverride: Option.Option; + readonly desktopLanHostOverride: Option.Option; + readonly desktopHttpsEndpointUrls: readonly string[]; + readonly appImagePath: Option.Option; + readonly disableAutoUpdate: boolean; + readonly desktopUpdateGithubToken: Option.Option; + readonly mockUpdates: boolean; + readonly mockUpdateServerPort: number; +} + +export class DesktopConfig extends Context.Service()( + "t3/desktop/Config", +) {} + +const trimNonEmptyOption = (value: string): Option.Option => { + const trimmed = value.trim(); + return trimmed.length > 0 ? Option.some(trimmed) : Option.none(); +}; + +const trimmedString = (name: string) => + Config.string(name).pipe(Config.option, Config.map(Option.flatMap(trimNonEmptyOption))); + +const optionalBoolean = (name: string) => + Config.boolean(name).pipe(Config.option, Config.map(Option.getOrElse(() => false))); + +const commaSeparatedStrings = (name: string) => + trimmedString(name).pipe( + Config.map( + Option.match({ + onNone: () => [], + onSome: (value) => + value + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0), + }), + ), + ); + +const firstSomeOf = (values: ReadonlyArray>): Option.Option => + Option.firstSomeOf(values); + +const compactEnv = (env: Readonly>): Record => + Object.fromEntries( + Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ); + +const EnvDesktopConfig = Config.all({ + home: trimmedString("HOME"), + userProfile: trimmedString("USERPROFILE"), + homeDrive: trimmedString("HOMEDRIVE"), + homePath: trimmedString("HOMEPATH"), + appDataDirectory: trimmedString("APPDATA"), + xdgConfigHome: trimmedString("XDG_CONFIG_HOME"), + t3Home: trimmedString("T3CODE_HOME"), + devServerUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option), + devRemoteT3ServerEntryPath: trimmedString("T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH"), + configuredBackendPort: Config.port("T3CODE_PORT").pipe(Config.option), + commitHashOverride: trimmedString("T3CODE_COMMIT_HASH"), + desktopLanHostOverride: trimmedString("T3CODE_DESKTOP_LAN_HOST"), + desktopHttpsEndpointUrls: commaSeparatedStrings("T3CODE_DESKTOP_HTTPS_ENDPOINTS"), + appImagePath: trimmedString("APPIMAGE"), + disableAutoUpdate: optionalBoolean("T3CODE_DISABLE_AUTO_UPDATE"), + desktopUpdateGithubToken: trimmedString("T3CODE_DESKTOP_UPDATE_GITHUB_TOKEN"), + ghToken: trimmedString("GH_TOKEN"), + mockUpdates: optionalBoolean("T3CODE_DESKTOP_MOCK_UPDATES"), + mockUpdateServerPort: Config.port("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe( + Config.withDefault(3000), + ), +}).pipe( + Config.map( + (config): DesktopConfigShape => ({ + home: config.home, + userProfile: config.userProfile, + homeDrive: config.homeDrive, + homePath: config.homePath, + appDataDirectory: config.appDataDirectory, + xdgConfigHome: config.xdgConfigHome, + t3Home: config.t3Home, + devServerUrl: config.devServerUrl, + devRemoteT3ServerEntryPath: config.devRemoteT3ServerEntryPath, + configuredBackendPort: config.configuredBackendPort, + commitHashOverride: config.commitHashOverride, + desktopLanHostOverride: config.desktopLanHostOverride, + desktopHttpsEndpointUrls: config.desktopHttpsEndpointUrls, + appImagePath: config.appImagePath, + disableAutoUpdate: config.disableAutoUpdate, + desktopUpdateGithubToken: firstSomeOf([config.desktopUpdateGithubToken, config.ghToken]), + mockUpdates: config.mockUpdates, + mockUpdateServerPort: config.mockUpdateServerPort, + }), + ), +); + +export const layer = Layer.effect( + DesktopConfig, + Effect.gen(function* () { + return yield* EnvDesktopConfig; + }), +); + +export const layerTest = (env: Readonly>) => + layer.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: compactEnv(env) })))); diff --git a/apps/desktop/src/main/DesktopEnvironment.test.ts b/apps/desktop/src/main/DesktopEnvironment.test.ts new file mode 100644 index 00000000000..c0c4434c019 --- /dev/null +++ b/apps/desktop/src/main/DesktopEnvironment.test.ts @@ -0,0 +1,113 @@ +import { assert, describe, it } from "@effect/vitest"; +import { Effect, Layer, Option } from "effect"; +import * as EffectPath from "effect/Path"; + +import { + DesktopEnvironment, + layer as makeDesktopEnvironmentLayer, + type MakeDesktopEnvironmentInput, +} from "./DesktopEnvironment.ts"; +import * as DesktopConfig from "./DesktopConfig.ts"; + +const defaultInput = { + dirname: "/repo/apps/desktop/dist-electron", + cwd: "/cwd", + platform: "darwin", + processArch: "arm64", + appVersion: "0.0.22", + appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar", + isPackaged: false, + resourcesPath: "/Applications/T3 Code.app/Contents/Resources", + runningUnderArm64Translation: false, +} satisfies MakeDesktopEnvironmentInput; + +const makeEnvironmentLayer = ( + overrides: Partial = {}, + env: Record = {}, +) => + makeDesktopEnvironmentLayer({ + ...defaultInput, + ...overrides, + }).pipe(Layer.provide(Layer.mergeAll(EffectPath.layer, DesktopConfig.layerTest(env)))); + +const makeEnvironment = ( + overrides: Partial = {}, + env: Record = {}, +) => + Effect.gen(function* () { + return yield* DesktopEnvironment; + }).pipe(Effect.provide(makeEnvironmentLayer(overrides, env))); + +describe("DesktopEnvironment", () => { + it.effect("resolves home directory from platform env with cwd fallback", () => + Effect.gen(function* () { + assert.equal( + (yield* makeEnvironment({}, { HOME: " /Users/alice " })).homeDirectory, + "/Users/alice", + ); + assert.equal((yield* makeEnvironment({ cwd: "/cwd" })).homeDirectory, "/cwd"); + }), + ); + + it.effect("derives state paths and development identity inside Effect", () => + Effect.gen(function* () { + const environment = yield* makeEnvironment( + {}, + { + HOME: "/Users/alice", + T3CODE_HOME: " /tmp/t3 ", + T3CODE_COMMIT_HASH: " 0123456789abcdef ", + T3CODE_PORT: "4949", + VITE_DEV_SERVER_URL: "http://localhost:5173", + T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH: " /remote/server.mjs ", + }, + ); + + assert.equal(environment.isDevelopment, true); + assert.equal(environment.appDataDirectory, "/Users/alice/Library/Application Support"); + assert.equal(environment.baseDir, "/tmp/t3"); + assert.equal(environment.stateDir, "/tmp/t3/userdata"); + assert.equal(environment.desktopSettingsPath, "/tmp/t3/userdata/desktop-settings.json"); + assert.equal(environment.clientSettingsPath, "/tmp/t3/userdata/client-settings.json"); + assert.equal( + environment.savedEnvironmentRegistryPath, + "/tmp/t3/userdata/saved-environments.json", + ); + assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json"); + assert.equal(environment.logDir, "/tmp/t3/userdata/logs"); + assert.equal(environment.rootDir, "/repo"); + assert.equal(environment.appRoot, "/repo"); + assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs"); + assert.equal(environment.backendCwd, "/repo"); + assert.equal(environment.appUserModelId, "com.t3tools.t3code.dev"); + assert.equal(environment.linuxWmClass, "t3code-dev"); + assert.deepEqual( + Option.map(environment.devServerUrl, (url) => url.href), + Option.some("http://localhost:5173/"), + ); + assert.deepEqual(environment.devRemoteT3ServerEntryPath, Option.some("/remote/server.mjs")); + assert.deepEqual(environment.configuredBackendPort, Option.some(4949)); + assert.deepEqual(environment.commitHashOverride, Option.some("0123456789abcdef")); + }), + ); + + it.effect("resolves picker defaults without nullish sentinels", () => + Effect.gen(function* () { + const environment = yield* makeEnvironment({}, { HOME: "/Users/alice" }); + + assert.deepEqual(environment.resolvePickFolderDefaultPath(null), Option.none()); + assert.deepEqual( + environment.resolvePickFolderDefaultPath({ initialPath: " " }), + Option.none(), + ); + assert.deepEqual( + environment.resolvePickFolderDefaultPath({ initialPath: "~" }), + Option.some("/Users/alice"), + ); + assert.deepEqual( + environment.resolvePickFolderDefaultPath({ initialPath: "~/project" }), + Option.some("/Users/alice/project"), + ); + }), + ); +}); diff --git a/apps/desktop/src/desktopEnvironment.ts b/apps/desktop/src/main/DesktopEnvironment.ts similarity index 63% rename from apps/desktop/src/desktopEnvironment.ts rename to apps/desktop/src/main/DesktopEnvironment.ts index f969b9c3869..58e27f13062 100644 --- a/apps/desktop/src/desktopEnvironment.ts +++ b/apps/desktop/src/main/DesktopEnvironment.ts @@ -1,14 +1,18 @@ -import type { DesktopAppBranding, DesktopRuntimeInfo } from "@t3tools/contracts"; -import { Context, Effect, Option } from "effect"; +import type { + DesktopAppBranding, + DesktopAppStageLabel, + DesktopRuntimeArch, + DesktopRuntimeInfo, +} from "@t3tools/contracts"; +import { Context, Effect, Layer, Option } from "effect"; import * as EffectPath from "effect/Path"; -import { resolveDesktopAppBranding } from "./appBranding.ts"; -import { type DesktopSettings, resolveDefaultDesktopSettings } from "./desktopSettings.ts"; -import { resolveDesktopRuntimeInfo } from "./runtimeArch.ts"; +import { type DesktopSettings, resolveDefaultDesktopSettings } from "../desktopSettings.ts"; +import * as DesktopConfig from "./DesktopConfig.ts"; +import { isNightlyDesktopVersion } from "../updateChannels.ts"; export interface MakeDesktopEnvironmentInput { readonly dirname: string; - readonly env: NodeJS.ProcessEnv; readonly cwd: string; readonly platform: NodeJS.Platform; readonly processArch: string; @@ -30,6 +34,7 @@ export interface DesktopEnvironmentShape { readonly appPath: string; readonly resourcesPath: string; readonly homeDirectory: string; + readonly appDataDirectory: string; readonly baseDir: string; readonly stateDir: string; readonly desktopSettingsPath: string; @@ -43,8 +48,10 @@ export interface DesktopEnvironmentShape { readonly backendCwd: string; readonly preloadPath: string; readonly appUpdateYmlPath: string; - readonly devServerUrl: Option.Option; + readonly devServerUrl: Option.Option; readonly devRemoteT3ServerEntryPath: Option.Option; + readonly configuredBackendPort: Option.Option; + readonly commitHashOverride: Option.Option; readonly branding: DesktopAppBranding; readonly displayName: string; readonly appUserModelId: string; @@ -64,37 +71,97 @@ export class DesktopEnvironment extends Context.Service< DesktopEnvironmentShape >()("t3/desktop/Environment") {} -const trimmedEnvOption = (env: NodeJS.ProcessEnv, name: string): Option.Option => - (() => { - const value = env[name]?.trim(); - return value && value.length > 0 ? Option.some(value) : Option.none(); - })(); - -export function resolveDesktopHomeDirectory(input: { - readonly env: NodeJS.ProcessEnv; +function resolveDesktopHomeDirectory(input: { + readonly config: DesktopConfig.DesktopConfigShape; readonly cwd: string; }): string { - const home = - input.env.HOME?.trim() || - input.env.USERPROFILE?.trim() || - `${input.env.HOMEDRIVE ?? ""}${input.env.HOMEPATH ?? ""}`.trim(); - return home.length > 0 ? home : input.cwd; + const driveHome = Option.zipWith( + input.config.homeDrive, + input.config.homePath, + (drive, homePath) => `${drive}${homePath}`, + ); + return Option.getOrElse( + Option.firstSomeOf([input.config.home, input.config.userProfile, driveHome]), + () => input.cwd, + ); +} + +const APP_BASE_NAME = "T3 Code"; + +function resolveDesktopAppStageLabel(input: { + readonly isDevelopment: boolean; + readonly appVersion: string; +}): DesktopAppStageLabel { + if (input.isDevelopment) { + return "Dev"; + } + + return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : "Alpha"; +} + +function resolveDesktopAppBranding(input: { + readonly isDevelopment: boolean; + readonly appVersion: string; +}): DesktopAppBranding { + const stageLabel = resolveDesktopAppStageLabel(input); + return { + baseName: APP_BASE_NAME, + stageLabel, + displayName: `${APP_BASE_NAME} (${stageLabel})`, + }; } -export const makeDesktopEnvironment = ( +function normalizeDesktopArch(arch: string): DesktopRuntimeArch { + if (arch === "arm64") return "arm64"; + if (arch === "x64") return "x64"; + return "other"; +} + +function resolveDesktopRuntimeInfo(input: { + readonly platform: NodeJS.Platform; + readonly processArch: string; + readonly runningUnderArm64Translation: boolean; +}): DesktopRuntimeInfo { + const appArch = normalizeDesktopArch(input.processArch); + + if (input.platform !== "darwin") { + return { + hostArch: appArch, + appArch, + runningUnderArm64Translation: false, + }; + } + + const hostArch = appArch === "arm64" || input.runningUnderArm64Translation ? "arm64" : appArch; + + return { + hostArch, + appArch, + runningUnderArm64Translation: input.runningUnderArm64Translation, + }; +} + +const makeDesktopEnvironment = ( input: MakeDesktopEnvironmentInput, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const path = yield* EffectPath.Path; + const config = yield* DesktopConfig.DesktopConfig; const homeDirectory = resolveDesktopHomeDirectory({ - env: input.env, + config, cwd: input.cwd, }); - const devServerUrl = trimmedEnvOption(input.env, "VITE_DEV_SERVER_URL"); + const devServerUrl = config.devServerUrl; const isDevelopment = Option.isSome(devServerUrl); - const baseDir = Option.getOrElse(trimmedEnvOption(input.env, "T3CODE_HOME"), () => - path.join(homeDirectory, ".t3"), - ); + const appDataDirectory = + input.platform === "win32" + ? Option.getOrElse(config.appDataDirectory, () => + path.join(homeDirectory, "AppData", "Roaming"), + ) + : input.platform === "darwin" + ? path.join(homeDirectory, "Library", "Application Support") + : Option.getOrElse(config.xdgConfigHome, () => path.join(homeDirectory, ".config")); + const baseDir = Option.getOrElse(config.t3Home, () => path.join(homeDirectory, ".t3")); const stateDir = path.join(baseDir, "userdata"); const rootDir = path.resolve(input.dirname, "../../.."); const appRoot = input.isPackaged ? input.appPath : rootDir; @@ -118,6 +185,7 @@ export const makeDesktopEnvironment = ( appPath: input.appPath, resourcesPath, homeDirectory, + appDataDirectory, baseDir, stateDir, desktopSettingsPath: path.join(stateDir, "desktop-settings.json"), @@ -134,10 +202,9 @@ export const makeDesktopEnvironment = ( ? path.join(resourcesPath, "app-update.yml") : path.join(input.appPath, "dev-app-update.yml"), devServerUrl, - devRemoteT3ServerEntryPath: trimmedEnvOption( - input.env, - "T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH", - ), + devRemoteT3ServerEntryPath: config.devRemoteT3ServerEntryPath, + configuredBackendPort: config.configuredBackendPort, + commitHashOverride: config.commitHashOverride, branding, displayName, appUserModelId: isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code", @@ -185,3 +252,6 @@ export const makeDesktopEnvironment = ( developmentDockIconPath: path.join(rootDir, "assets", "dev", "blueprint-macos-1024.png"), }); }); + +export const layer = (input: MakeDesktopEnvironmentInput) => + Layer.effect(DesktopEnvironment, makeDesktopEnvironment(input)); diff --git a/apps/desktop/src/main/DesktopLifecycle.ts b/apps/desktop/src/main/DesktopLifecycle.ts index 625aee1bf03..f32c6e40b93 100644 --- a/apps/desktop/src/main/DesktopLifecycle.ts +++ b/apps/desktop/src/main/DesktopLifecycle.ts @@ -1,3 +1,4 @@ +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -6,13 +7,17 @@ import * as Scope from "effect/Scope"; import type * as Electron from "electron"; -import { DesktopShutdown } from "../desktopShutdown.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopRun from "./DesktopRun.ts"; +import { DesktopShutdown } from "./DesktopShutdown.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as DesktopState from "./DesktopState.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; export type DesktopLifecycleRuntimeServices = + | DesktopEnvironment.DesktopEnvironment + | DesktopRun.DesktopRun | DesktopShutdown | DesktopState.DesktopState | DesktopWindow.DesktopWindow @@ -20,6 +25,9 @@ export type DesktopLifecycleRuntimeServices = | ElectronTheme.ElectronTheme; export interface DesktopLifecycleShape { + readonly relaunch: ( + reason: string, + ) => Effect.Effect; readonly register: Effect.Effect; } @@ -126,10 +134,41 @@ function quitFromSignal( export const layer = Layer.succeed( DesktopLifecycle, DesktopLifecycle.of({ + relaunch: (reason) => + Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const run = yield* DesktopRun.DesktopRun; + const state = yield* DesktopState.DesktopState; + yield* run.logInfo("desktop relaunch requested", { reason }); + yield* Effect.gen(function* () { + yield* Effect.yieldNow; + yield* Ref.set(state.quitting, true); + yield* requestDesktopShutdownAndWait(); + if (environment.isDevelopment) { + yield* electronApp.exit(75); + return; + } + yield* electronApp.relaunch({ + execPath: process.execPath, + args: process.argv.slice(1), + }); + yield* electronApp.exit(0); + }).pipe( + Effect.catchCause((cause) => + run.logError("desktop relaunch failed", { + cause: Cause.pretty(cause), + }), + ), + Effect.forkDetach, + Effect.asVoid, + ); + }), register: Effect.gen(function* () { const desktopWindow = yield* DesktopWindow.DesktopWindow; const electronApp = yield* ElectronApp.ElectronApp; const electronTheme = yield* ElectronTheme.ElectronTheme; + const environment = yield* DesktopEnvironment.DesktopEnvironment; const context = yield* Effect.context(); const runEffect = makeDesktopEffectRunner(context); let quitAllowed = false; @@ -154,14 +193,14 @@ export const layer = Layer.succeed( Effect.gen(function* () { const app = yield* ElectronApp.ElectronApp; const state = yield* DesktopState.DesktopState; - if (process.platform !== "darwin" && !(yield* Ref.get(state.quitting))) { + if (environment.platform !== "darwin" && !(yield* Ref.get(state.quitting))) { yield* app.quit; } }), ); }); - if (process.platform !== "win32") { + if (environment.platform !== "win32") { yield* addScopedListener(process, "SIGINT", () => { quitFromSignal("SIGINT", runEffect); }); diff --git a/apps/desktop/src/main/DesktopLocalEnvironment.test.ts b/apps/desktop/src/main/DesktopLocalEnvironment.test.ts deleted file mode 100644 index 57eca501593..00000000000 --- a/apps/desktop/src/main/DesktopLocalEnvironment.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import { Effect, Layer, Option } from "effect"; - -import * as DesktopBackendManager from "../desktopBackendManager.ts"; -import * as DesktopLocalEnvironment from "./DesktopLocalEnvironment.ts"; - -const backendConfig: DesktopBackendManager.DesktopBackendStartConfig = { - executablePath: "/electron", - entryPath: "/server/bin.mjs", - cwd: "/server", - env: { ELECTRON_RUN_AS_NODE: "1" }, - bootstrap: { - mode: "desktop", - noBrowser: true, - port: 3773, - t3Home: "/tmp/t3", - host: "127.0.0.1", - desktopBootstrapToken: "token", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }, - httpBaseUrl: new URL("http://127.0.0.1:3773"), - captureOutput: true, -}; - -const makeLayer = (currentConfig: Option.Option) => - DesktopLocalEnvironment.layer.pipe( - Layer.provide( - Layer.succeed( - DesktopBackendManager.DesktopBackendManager, - DesktopBackendManager.DesktopBackendManager.of({ - start: Effect.void, - stop: () => Effect.void, - shutdown: Effect.void, - currentConfig: Effect.succeed(currentConfig), - snapshot: Effect.succeed({ - desiredRunning: false, - ready: false, - activePid: Option.none(), - restartAttempt: 0, - restartScheduled: false, - shuttingDown: false, - }), - }), - ), - ), - ); - -describe("DesktopLocalEnvironment", () => { - it.effect("returns none before the backend config has been resolved", () => - Effect.gen(function* () { - const localEnvironment = yield* DesktopLocalEnvironment.DesktopLocalEnvironment; - - assert.isTrue(Option.isNone(yield* localEnvironment.bootstrap)); - }).pipe(Effect.provide(makeLayer(Option.none()))), - ); - - it.effect("derives the local bootstrap from the current backend config", () => - Effect.gen(function* () { - const localEnvironment = yield* DesktopLocalEnvironment.DesktopLocalEnvironment; - const bootstrap = yield* localEnvironment.bootstrap; - - assert.deepEqual(Option.getOrThrow(bootstrap), { - label: "Local environment", - httpBaseUrl: "http://127.0.0.1:3773/", - wsBaseUrl: "ws://127.0.0.1:3773/", - bootstrapToken: "token", - }); - }).pipe(Effect.provide(makeLayer(Option.some(backendConfig)))), - ); - - it.effect("uses wss when the backend base URL is https", () => - Effect.gen(function* () { - const localEnvironment = yield* DesktopLocalEnvironment.DesktopLocalEnvironment; - const bootstrap = yield* localEnvironment.bootstrap; - - assert.equal(Option.getOrThrow(bootstrap).wsBaseUrl, "wss://example.test/"); - }).pipe( - Effect.provide( - makeLayer( - Option.some({ - ...backendConfig, - httpBaseUrl: new URL("https://example.test"), - }), - ), - ), - ), - ); -}); diff --git a/apps/desktop/src/main/DesktopLocalEnvironment.ts b/apps/desktop/src/main/DesktopLocalEnvironment.ts deleted file mode 100644 index 74c32d41e90..00000000000 --- a/apps/desktop/src/main/DesktopLocalEnvironment.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts"; -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; - -import * as DesktopBackendManager from "../desktopBackendManager.ts"; - -export interface DesktopLocalEnvironmentShape { - readonly bootstrap: Effect.Effect>; -} - -export class DesktopLocalEnvironment extends Context.Service< - DesktopLocalEnvironment, - DesktopLocalEnvironmentShape ->()("t3/desktop/LocalEnvironment") {} - -function toWebSocketBaseUrl(httpBaseUrl: URL): string { - const url = new URL(httpBaseUrl.href); - url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; - return url.href; -} - -export const layer = Layer.effect( - DesktopLocalEnvironment, - Effect.gen(function* () { - const backendManager = yield* DesktopBackendManager.DesktopBackendManager; - - return DesktopLocalEnvironment.of({ - bootstrap: backendManager.currentConfig.pipe( - Effect.map( - // oxlint-disable-next-line oxc/no-map-spread - Option.map((config) => { - const bootstrap = config.bootstrap; - return { - label: "Local environment", - httpBaseUrl: config.httpBaseUrl.href, - wsBaseUrl: toWebSocketBaseUrl(config.httpBaseUrl), - ...(bootstrap.desktopBootstrapToken - ? { bootstrapToken: bootstrap.desktopBootstrapToken } - : {}), - }; - }), - ), - ), - }); - }), -); diff --git a/apps/desktop/src/desktopLogger.ts b/apps/desktop/src/main/DesktopLogging.ts similarity index 96% rename from apps/desktop/src/desktopLogger.ts rename to apps/desktop/src/main/DesktopLogging.ts index 73f89fde384..6cb118a7ca9 100644 --- a/apps/desktop/src/desktopLogger.ts +++ b/apps/desktop/src/main/DesktopLogging.ts @@ -13,7 +13,7 @@ import { Semaphore, } from "effect"; -import { DesktopEnvironment } from "./desktopEnvironment.ts"; +import { DesktopEnvironment } from "./DesktopEnvironment.ts"; const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; const DESKTOP_LOG_FILE_MAX_FILES = 10; @@ -45,7 +45,7 @@ const textEncoder = new TextEncoder(); const sanitizeLogValue = (value: string): string => value.replace(/\s+/g, " ").trim(); -export const DesktopBackendOutputLogNoop: DesktopBackendOutputLogShape = { +const DesktopBackendOutputLogNoop: DesktopBackendOutputLogShape = { writeSessionBoundary: () => Effect.void, writeOutputChunk: () => Effect.void, }; @@ -61,7 +61,7 @@ const refreshFileSize = ( ); }); -export const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(function* (input: { +const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(function* (input: { readonly filePath: string; readonly maxBytes?: number; readonly maxFiles?: number; diff --git a/apps/desktop/src/main/DesktopRun.ts b/apps/desktop/src/main/DesktopRun.ts new file mode 100644 index 00000000000..e752bfa2193 --- /dev/null +++ b/apps/desktop/src/main/DesktopRun.ts @@ -0,0 +1,65 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Random from "effect/Random"; +import * as Ref from "effect/Ref"; + +const INITIAL_RUN_ID = "startup"; + +const randomHexString = (length: number): Effect.Effect => + Effect.gen(function* () { + let value = ""; + while (value.length < length) { + value += (yield* Random.nextUUIDv4).replace(/-/g, ""); + } + return value.slice(0, length); + }); + +export interface DesktopRunShape { + readonly id: Effect.Effect; + readonly refreshId: Effect.Effect; + readonly logInfo: (message: string, annotations?: Record) => Effect.Effect; + readonly logWarning: ( + message: string, + annotations?: Record, + ) => Effect.Effect; + readonly logError: ( + message: string, + annotations?: Record, + ) => Effect.Effect; +} + +export class DesktopRun extends Context.Service()("t3/desktop/Run") {} + +const make = Effect.gen(function* () { + const idRef = yield* Ref.make(INITIAL_RUN_ID); + + const annotate = ( + effect: Effect.Effect, + annotations?: Record, + ): Effect.Effect => + Effect.gen(function* () { + const runId = yield* Ref.get(idRef); + return yield* effect.pipe( + Effect.annotateLogs({ + scope: "desktop", + runId, + ...annotations, + }), + ); + }); + + return DesktopRun.of({ + id: Ref.get(idRef), + refreshId: Effect.gen(function* () { + const runId = yield* randomHexString(12); + yield* Ref.set(idRef, runId); + return runId; + }), + logInfo: (message, annotations) => annotate(Effect.logInfo(message), annotations), + logWarning: (message, annotations) => annotate(Effect.logWarning(message), annotations), + logError: (message, annotations) => annotate(Effect.logError(message), annotations), + }); +}); + +export const layer = Layer.effect(DesktopRun, make); diff --git a/apps/desktop/src/main/DesktopServerExposure.test.ts b/apps/desktop/src/main/DesktopServerExposure.test.ts index 3db3c1d89bf..0e50ae7eae3 100644 --- a/apps/desktop/src/main/DesktopServerExposure.test.ts +++ b/apps/desktop/src/main/DesktopServerExposure.test.ts @@ -12,10 +12,10 @@ import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; import { DEFAULT_DESKTOP_SETTINGS, readDesktopSettingsEffect } from "../desktopSettings.ts"; -import { makeDesktopEnvironment, DesktopEnvironment } from "../desktopEnvironment.ts"; -import { DesktopNetworkInterfacesService } from "../desktopNetworkInterfaces.ts"; -import type { DesktopNetworkInterfaces } from "../serverExposure.ts"; +import { DesktopEnvironment, layer as makeDesktopEnvironmentLayer } from "./DesktopEnvironment.ts"; +import * as DesktopConfig from "./DesktopConfig.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; +import type { DesktopNetworkInterfaces } from "./DesktopServerExposure.ts"; import * as DesktopSettingsState from "./DesktopSettingsState.ts"; const encoder = new TextEncoder(); @@ -64,10 +64,9 @@ function mockSpawnerLayer(statusJson = "{}") { ); } -function makeEnvironment(baseDir: string) { - return makeDesktopEnvironment({ +function makeEnvironmentLayer(baseDir: string, env: Record = {}) { + return makeDesktopEnvironmentLayer({ dirname: "/repo/apps/desktop/src", - env: { T3CODE_HOME: baseDir }, cwd: "/repo", platform: "darwin", processArch: "x64", @@ -76,17 +75,21 @@ function makeEnvironment(baseDir: string) { isPackaged: true, resourcesPath: "/missing/resources", runningUnderArm64Translation: false, - }); + }).pipe( + Layer.provide( + Layer.mergeAll(EffectPath.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir, ...env })), + ), + ); } function makeLayer(input: { readonly baseDir: string; readonly networkInterfaces?: DesktopNetworkInterfaces; + readonly env?: Record; }) { - const environmentLayer = Layer.effect(DesktopEnvironment, makeEnvironment(input.baseDir)).pipe( - Layer.provide(EffectPath.layer), - ); - const networkLayer = Layer.succeed(DesktopNetworkInterfacesService, { + const env = { T3CODE_HOME: input.baseDir, ...input.env }; + const environmentLayer = makeEnvironmentLayer(input.baseDir, env); + const networkLayer = Layer.succeed(DesktopServerExposure.DesktopNetworkInterfacesService, { read: Effect.succeed(input.networkInterfaces ?? emptyNetworkInterfaces), }); @@ -97,6 +100,7 @@ function makeLayer(input: { Layer.provideMerge(NodeHttpClient.layerUndici), Layer.provideMerge(mockSpawnerLayer()), Layer.provideMerge(networkLayer), + Layer.provideMerge(DesktopConfig.layerTest(env)), Layer.provideMerge(environmentLayer), ); } @@ -112,13 +116,14 @@ const withHarness = ( | DesktopServerExposure.DesktopServerExposure | DesktopSettingsState.DesktopSettingsState >, + env: Record = {}, ) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-server-exposure-test-", }); - return yield* effect.pipe(Effect.provide(makeLayer({ baseDir, networkInterfaces }))); + return yield* effect.pipe(Effect.provide(makeLayer({ baseDir, networkInterfaces, env }))); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); describe("DesktopServerExposure", () => { @@ -245,4 +250,128 @@ describe("DesktopServerExposure", () => { }), ), ); + + it.effect("uses ConfigProvider desktop exposure overrides", () => + withHarness( + lanNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 4173 }); + const change = yield* serverExposure.setMode("network-accessible"); + + assert.equal(change.state.advertisedHost, "10.0.0.7"); + assert.equal(change.state.endpointUrl, "http://10.0.0.7:4173"); + + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + assert.deepEqual( + endpoints.map((endpoint) => endpoint.httpBaseUrl), + ["http://127.0.0.1:4173/", "http://10.0.0.7:4173/", "https://public.example.test/"], + ); + }), + { + T3CODE_DESKTOP_LAN_HOST: "10.0.0.7", + T3CODE_DESKTOP_HTTPS_ENDPOINTS: "https://public.example.test", + }, + ), + ); + + it.effect("advertises loopback, LAN, and configured manual endpoints from runtime state", () => + withHarness( + lanNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 3773 }); + yield* serverExposure.setMode("network-accessible"); + + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + assert.deepEqual(endpoints, [ + { + id: "desktop-loopback:3773", + label: "This machine", + provider: { + id: "desktop-core", + label: "Desktop", + kind: "core", + isAddon: false, + }, + httpBaseUrl: "http://127.0.0.1:3773/", + wsBaseUrl: "ws://127.0.0.1:3773/", + reachability: "loopback", + compatibility: { + hostedHttpsApp: "mixed-content-blocked", + desktopApp: "compatible", + }, + source: "desktop-core", + status: "available", + description: "Loopback endpoint for this desktop app.", + }, + { + id: "desktop-lan:http://192.168.1.20:3773", + label: "Local network", + provider: { + id: "desktop-core", + label: "Desktop", + kind: "core", + isAddon: false, + }, + httpBaseUrl: "http://192.168.1.20:3773/", + wsBaseUrl: "ws://192.168.1.20:3773/", + reachability: "lan", + compatibility: { + hostedHttpsApp: "mixed-content-blocked", + desktopApp: "compatible", + }, + source: "desktop-core", + status: "available", + isDefault: true, + description: "Reachable from devices on the same network.", + }, + { + id: "manual:https://desktop.example.ts.net", + label: "Custom HTTPS", + provider: { + id: "manual", + label: "Manual", + kind: "manual", + isAddon: false, + }, + httpBaseUrl: "https://desktop.example.ts.net/", + wsBaseUrl: "wss://desktop.example.ts.net/", + reachability: "public", + compatibility: { + hostedHttpsApp: "compatible", + desktopApp: "compatible", + }, + source: "user", + status: "unknown", + description: "User-configured HTTPS endpoint for this desktop backend.", + }, + { + id: "manual:http://desktop.example.test:3773", + label: "Custom endpoint", + provider: { + id: "manual", + label: "Manual", + kind: "manual", + isAddon: false, + }, + httpBaseUrl: "http://desktop.example.test:3773/", + wsBaseUrl: "ws://desktop.example.test:3773/", + reachability: "public", + compatibility: { + hostedHttpsApp: "mixed-content-blocked", + desktopApp: "compatible", + }, + source: "user", + status: "unknown", + description: "User-configured endpoint for this desktop backend.", + }, + ]); + }), + { + T3CODE_DESKTOP_HTTPS_ENDPOINTS: + "https://desktop.example.ts.net,http://desktop.example.test:3773,not-a-url", + }, + ), + ); }); diff --git a/apps/desktop/src/main/DesktopServerExposure.ts b/apps/desktop/src/main/DesktopServerExposure.ts index c7cb31243ab..7ac838f12f3 100644 --- a/apps/desktop/src/main/DesktopServerExposure.ts +++ b/apps/desktop/src/main/DesktopServerExposure.ts @@ -1,5 +1,12 @@ +import * as NodeOS from "node:os"; + +import { + createAdvertisedEndpoint, + type CreateAdvertisedEndpointInput, +} from "@t3tools/client-runtime"; import type { AdvertisedEndpoint, + AdvertisedEndpointProvider, DesktopServerExposureMode, DesktopServerExposureState, } from "@t3tools/contracts"; @@ -20,20 +27,200 @@ import { setDesktopServerExposurePreference, setDesktopTailscaleServePreference, } from "../desktopSettings.ts"; -import * as DesktopEnvironment from "../desktopEnvironment.ts"; -import * as DesktopNetwork from "../desktopNetworkInterfaces.ts"; -import { - DESKTOP_LOOPBACK_HOST, - resolveDesktopCoreAdvertisedEndpoints, - resolveDesktopServerExposure, - type DesktopNetworkInterfaces, - type DesktopServerExposure as ResolvedDesktopServerExposure, -} from "../serverExposure.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopConfig from "./DesktopConfig.ts"; import { resolveTailscaleAdvertisedEndpoints } from "../tailscaleEndpointProvider.ts"; import * as DesktopSettingsState from "./DesktopSettingsState.ts"; -export { DESKTOP_LOOPBACK_HOST } from "../serverExposure.ts"; -export const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const; +export const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; +const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; + +export interface DesktopNetworkInterfaceInfo { + readonly address: string; + readonly family: string | number; + readonly internal: boolean; + readonly netmask?: string; + readonly mac?: string; + readonly cidr?: string | null; + readonly scopeid?: number; +} + +export type DesktopNetworkInterfaces = Readonly< + Record +>; + +interface ResolvedDesktopServerExposure { + readonly mode: DesktopServerExposureMode; + readonly bindHost: string; + readonly localHttpUrl: string; + readonly localWsUrl: string; + readonly endpointUrl: string | null; + readonly advertisedHost: string | null; +} + +interface DesktopAdvertisedEndpointInput { + readonly port: number; + readonly exposure: ResolvedDesktopServerExposure; + readonly customHttpsEndpointUrls?: readonly string[]; +} + +const DESKTOP_CORE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { + id: "desktop-core", + label: "Desktop", + kind: "core", + isAddon: false, +}; + +const DESKTOP_MANUAL_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { + id: "manual", + label: "Manual", + kind: "manual", + isAddon: false, +}; + +const normalizeOptionalHost = (value: string | undefined): string | undefined => { + const normalized = value?.trim(); + return normalized && normalized.length > 0 ? normalized : undefined; +}; + +const isUsableLanIpv4Address = (address: string): boolean => + !address.startsWith("127.") && !address.startsWith("169.254."); + +const isHttpsEndpointUrl = (value: string): boolean => { + try { + return new URL(value).protocol === "https:"; + } catch { + return false; + } +}; + +const resolveLanAdvertisedHost = ( + networkInterfaces: DesktopNetworkInterfaces, + explicitHost: string | undefined, +): string | null => { + const normalizedExplicitHost = normalizeOptionalHost(explicitHost); + if (normalizedExplicitHost) { + return normalizedExplicitHost; + } + + for (const interfaceAddresses of Object.values(networkInterfaces)) { + if (!interfaceAddresses) continue; + + for (const address of interfaceAddresses) { + if (address.internal) continue; + if (address.family !== "IPv4") continue; + if (!isUsableLanIpv4Address(address.address)) continue; + return address.address; + } + } + + return null; +}; + +const resolveDesktopServerExposure = (input: { + readonly mode: DesktopServerExposureMode; + readonly port: number; + readonly networkInterfaces: DesktopNetworkInterfaces; + readonly advertisedHostOverride?: string; +}): ResolvedDesktopServerExposure => { + const localHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${input.port}`; + const localWsUrl = `ws://${DESKTOP_LOOPBACK_HOST}:${input.port}`; + + if (input.mode === "local-only") { + return { + mode: input.mode, + bindHost: DESKTOP_LOOPBACK_HOST, + localHttpUrl, + localWsUrl, + endpointUrl: null, + advertisedHost: null, + }; + } + + const advertisedHost = resolveLanAdvertisedHost( + input.networkInterfaces, + input.advertisedHostOverride, + ); + + return { + mode: input.mode, + bindHost: DESKTOP_LAN_BIND_HOST, + localHttpUrl, + localWsUrl, + endpointUrl: advertisedHost ? `http://${advertisedHost}:${input.port}` : null, + advertisedHost, + }; +}; + +const createDesktopEndpoint = ( + input: Omit, +): AdvertisedEndpoint => + createAdvertisedEndpoint({ + ...input, + provider: DESKTOP_CORE_ENDPOINT_PROVIDER, + source: "desktop-core", + }); + +const createManualEndpoint = ( + input: Omit, +): AdvertisedEndpoint => + createAdvertisedEndpoint({ + ...input, + provider: DESKTOP_MANUAL_ENDPOINT_PROVIDER, + source: "user", + }); + +const resolveDesktopCoreAdvertisedEndpoints = ( + input: DesktopAdvertisedEndpointInput, +): readonly AdvertisedEndpoint[] => { + const endpoints: AdvertisedEndpoint[] = [ + createDesktopEndpoint({ + id: `desktop-loopback:${input.port}`, + label: "This machine", + httpBaseUrl: input.exposure.localHttpUrl, + reachability: "loopback", + status: "available", + description: "Loopback endpoint for this desktop app.", + }), + ]; + + if (input.exposure.endpointUrl) { + endpoints.push( + createDesktopEndpoint({ + id: `desktop-lan:${input.exposure.endpointUrl}`, + label: "Local network", + httpBaseUrl: input.exposure.endpointUrl, + reachability: "lan", + status: "available", + isDefault: true, + description: "Reachable from devices on the same network.", + }), + ); + } + + for (const customEndpointUrl of input.customHttpsEndpointUrls ?? []) { + try { + const isHttpsEndpoint = isHttpsEndpointUrl(customEndpointUrl); + endpoints.push( + createManualEndpoint({ + id: `manual:${customEndpointUrl}`, + label: isHttpsEndpoint ? "Custom HTTPS" : "Custom endpoint", + httpBaseUrl: customEndpointUrl, + reachability: "public", + ...(isHttpsEndpoint ? ({ hostedHttpsCompatibility: "compatible" } as const) : {}), + status: "unknown", + description: isHttpsEndpoint + ? "User-configured HTTPS endpoint for this desktop backend." + : "User-configured endpoint for this desktop backend.", + }), + ); + } catch { + // Ignore malformed user-configured endpoints without dropping valid endpoints. + } + } + + return endpoints; +}; type DesktopServerExposurePersistenceOperation = "server-exposure-mode" | "tailscale-serve"; @@ -98,6 +285,15 @@ export class DesktopServerExposure extends Context.Service< DesktopServerExposureShape >()("t3/desktop/ServerExposure") {} +export interface DesktopNetworkInterfacesServiceShape { + readonly read: Effect.Effect; +} + +export class DesktopNetworkInterfacesService extends Context.Service< + DesktopNetworkInterfacesService, + DesktopNetworkInterfacesServiceShape +>()("t3/desktop/ServerExposure/NetworkInterfaces") {} + interface RuntimeState { readonly requestedMode: DesktopServerExposureMode; readonly mode: DesktopServerExposureMode; @@ -154,17 +350,6 @@ const toResolvedExposure = (state: RuntimeState): ResolvedDesktopServerExposure advertisedHost: Option.getOrNull(state.advertisedHost), }); -const resolveAdvertisedHostOverride = (): Option.Option => { - const override = process.env.T3CODE_DESKTOP_LAN_HOST?.trim(); - return override && override.length > 0 ? Option.some(override) : Option.none(); -}; - -const resolveCustomHttpsEndpointUrls = (): readonly string[] => - (process.env.T3CODE_DESKTOP_HTTPS_ENDPOINTS ?? "") - .split(",") - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); - function runtimeStateFromResolvedExposure(input: { readonly requestedMode: DesktopServerExposureMode; readonly settings: DesktopSettings; @@ -191,8 +376,9 @@ function resolveRuntimeState(input: { readonly settings: DesktopSettings; readonly port: number; readonly networkInterfaces: DesktopNetworkInterfaces; + readonly advertisedHostOverride: Option.Option; }): ResolvedRuntimeState { - const advertisedHostOverride = Option.getOrUndefined(resolveAdvertisedHostOverride()); + const advertisedHostOverride = Option.getOrUndefined(input.advertisedHostOverride); const requestedExposure = resolveDesktopServerExposure({ mode: input.requestedMode, port: input.port, @@ -227,10 +413,11 @@ const requiresBackendRelaunch = (previous: RuntimeState, next: RuntimeState): bo previous.localHttpUrl !== next.localHttpUrl; const make = Effect.gen(function* () { + const config = yield* DesktopConfig.DesktopConfig; const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const networkInterfaces = yield* DesktopNetwork.DesktopNetworkInterfacesService; + const networkInterfaces = yield* DesktopNetworkInterfacesService; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const settingsState = yield* DesktopSettingsState.DesktopSettingsState; @@ -265,6 +452,7 @@ const make = Effect.gen(function* () { settings, port, networkInterfaces: currentNetworkInterfaces, + advertisedHostOverride: config.desktopLanHostOverride, }); yield* Ref.set(stateRef, resolved.state); return toContractState(resolved.state); @@ -281,6 +469,7 @@ const make = Effect.gen(function* () { settings: nextSettings, port: previous.port, networkInterfaces: currentNetworkInterfaces, + advertisedHostOverride: config.desktopLanHostOverride, }); if (resolved.unavailable) { @@ -339,7 +528,7 @@ const make = Effect.gen(function* () { const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({ port: state.port, exposure: toResolvedExposure(state), - customHttpsEndpointUrls: resolveCustomHttpsEndpointUrls(), + customHttpsEndpointUrls: config.desktopHttpsEndpointUrls, }); const tailscaleEndpoints = yield* resolveTailscaleAdvertisedEndpoints({ port: state.port, @@ -364,3 +553,10 @@ const make = Effect.gen(function* () { }); export const layer = Layer.effect(DesktopServerExposure, make); + +export const networkInterfacesLayer = Layer.succeed( + DesktopNetworkInterfacesService, + DesktopNetworkInterfacesService.of({ + read: Effect.sync(() => NodeOS.networkInterfaces()), + }), +); diff --git a/apps/desktop/src/main/DesktopSettingsState.ts b/apps/desktop/src/main/DesktopSettingsState.ts index 30b4596e749..75504be4a50 100644 --- a/apps/desktop/src/main/DesktopSettingsState.ts +++ b/apps/desktop/src/main/DesktopSettingsState.ts @@ -13,7 +13,7 @@ import { readDesktopSettingsEffect, writeDesktopSettingsEffect, } from "../desktopSettings.ts"; -import { DesktopEnvironment } from "../desktopEnvironment.ts"; +import { DesktopEnvironment } from "./DesktopEnvironment.ts"; export type DesktopSettingsPersistenceError = PlatformError.PlatformError | Schema.SchemaError; diff --git a/apps/desktop/src/syncShellEnvironment.test.ts b/apps/desktop/src/main/DesktopShellEnvironment.test.ts similarity index 56% rename from apps/desktop/src/syncShellEnvironment.test.ts rename to apps/desktop/src/main/DesktopShellEnvironment.test.ts index f9216c9f7c7..efe04eb383f 100644 --- a/apps/desktop/src/syncShellEnvironment.test.ts +++ b/apps/desktop/src/main/DesktopShellEnvironment.test.ts @@ -1,19 +1,11 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; -import { Effect, Layer, Logger, Option, Sink, Stream } from "effect"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Option from "effect/Option"; -import { - DesktopShellEnvironment, - DesktopShellEnvironmentConfig, - DesktopShellEnvironmentLive, - DesktopShellEnvironmentProbe, - DesktopShellEnvironmentProbeLive, - type DesktopShellEnvironmentProbeShape, - type WindowsEnvironmentProbeOptions, -} from "./syncShellEnvironment.ts"; +import * as DesktopShellEnvironment from "./DesktopShellEnvironment.ts"; -const textEncoder = new TextEncoder(); const LOGIN_SHELL_ENV_NAMES = [ "PATH", "SSH_AUTH_SOCK", @@ -24,57 +16,21 @@ const LOGIN_SHELL_ENV_NAMES = [ "XDG_DATA_HOME", ] as const; -function makeProcess(options?: { - readonly stdout?: Stream.Stream; - readonly stderr?: Stream.Stream; - readonly exitCode?: Effect.Effect; -}): ChildProcessSpawner.ChildProcessHandle { - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(123), - stdout: options?.stdout ?? Stream.empty, - stderr: options?.stderr ?? Stream.empty, - all: Stream.merge(options?.stdout ?? Stream.empty, options?.stderr ?? Stream.empty), - exitCode: options?.exitCode ?? Effect.succeed(ChildProcessSpawner.ExitCode(0)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - stdin: Sink.drain, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - unref: Effect.succeed(Effect.void), - }); -} - -const defaultProbe: DesktopShellEnvironmentProbeShape = { - readLoginShellEnvironment: () => Effect.succeed({}), - readLaunchctlPath: Effect.succeed(Option.none()), - readWindowsShellEnvironment: () => Effect.succeed({}), - isWindowsCommandAvailable: () => Effect.succeed(true), -}; - -function probeLayer(probe: Partial) { - return Layer.succeed(DesktopShellEnvironmentProbe, { - ...defaultProbe, - ...probe, - }); -} +type ProbeOverrides = NonNullable[0]["probe"]>; function runShellEnvironment(input: { readonly env: NodeJS.ProcessEnv; readonly platform: NodeJS.Platform; readonly userShell?: string; - readonly probe: Partial; + readonly probe: ProbeOverrides; readonly logger?: Logger.Logger; }) { - const dependencyLayer = Layer.mergeAll( - Layer.succeed(DesktopShellEnvironmentConfig, { - env: input.env, - platform: input.platform, - userShell: - input.userShell === undefined ? Option.none() : Option.some(input.userShell), - }), - probeLayer(input.probe), - ); - const shellEnvironmentLayer = DesktopShellEnvironmentLive.pipe(Layer.provide(dependencyLayer)); + const shellEnvironmentLayer = DesktopShellEnvironment.layerTest({ + env: input.env, + platform: input.platform, + ...(input.userShell === undefined ? {} : { userShell: input.userShell }), + probe: input.probe, + }); const layer = input.logger === undefined ? shellEnvironmentLayer @@ -84,8 +40,8 @@ function runShellEnvironment(input: { ); return Effect.gen(function* () { - const shellEnvironment = yield* DesktopShellEnvironment; - yield* shellEnvironment.sync; + const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; + yield* shellEnvironment.installIntoProcess; }).pipe(Effect.provide(layer)); } @@ -121,7 +77,7 @@ describe("DesktopShellEnvironment", () => { }), ); - it.effect("preserves an inherited SSH_AUTH_SOCK value", () => + it.effect("preserves inherited POSIX values when present", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", @@ -146,30 +102,6 @@ describe("DesktopShellEnvironment", () => { }), ); - it.effect("preserves inherited values when the login shell omits them", () => - Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { - SHELL: "/bin/zsh", - PATH: "/usr/bin", - SSH_AUTH_SOCK: "/tmp/inherited.sock", - }; - - yield* runShellEnvironment({ - env, - platform: "darwin", - probe: { - readLoginShellEnvironment: () => - Effect.succeed({ - PATH: "/opt/homebrew/bin:/usr/bin", - }), - }, - }); - - assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin"); - assert.equal(env.SSH_AUTH_SOCK, "/tmp/inherited.sock"); - }), - ); - it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on linux", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { @@ -206,7 +138,6 @@ describe("DesktopShellEnvironment", () => { PATH: "/usr/bin", }; const calls: Array<{ readonly shell: string; readonly names: ReadonlyArray }> = []; - let launchctlReadCount = 0; const messages: string[] = []; const logger = Logger.make(({ message }) => { messages.push(String(message)); @@ -226,10 +157,7 @@ describe("DesktopShellEnvironment", () => { } return {}; }), - readLaunchctlPath: Effect.sync(() => { - launchctlReadCount += 1; - return Option.some("/opt/homebrew/bin:/usr/bin"); - }), + readLaunchctlPath: Effect.succeed(Option.some("/opt/homebrew/bin:/usr/bin")), }, }); @@ -237,7 +165,6 @@ describe("DesktopShellEnvironment", () => { { shell: "/opt/homebrew/bin/nu", names: LOGIN_SHELL_ENV_NAMES }, { shell: "/bin/zsh", names: LOGIN_SHELL_ENV_NAMES }, ]); - assert.equal(launchctlReadCount, 1); assert.isTrue( messages.some((message) => message.includes("failed to read login shell environment")), ); @@ -275,7 +202,7 @@ describe("DesktopShellEnvironment", () => { }), ); - it.effect("hydrates PATH on Windows by merging PowerShell PATH with inherited PATH", () => + it.effect("hydrates PATH on Windows from PowerShell and common CLI directories", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { PATH: "C:\\Windows\\System32", @@ -285,28 +212,25 @@ describe("DesktopShellEnvironment", () => { }; const windowsReads: Array<{ readonly names: ReadonlyArray; - readonly options: WindowsEnvironmentProbeOptions; + readonly loadProfile: boolean; }> = []; - let commandAvailabilityChecks = 0; yield* runShellEnvironment({ env, platform: "win32", probe: { - readWindowsShellEnvironment: (names, options) => + readWindowsEnvironment: (names, options) => Effect.sync(() => { - windowsReads.push({ names, options }); - return { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }; - }), - isWindowsCommandAvailable: () => - Effect.sync(() => { - commandAvailabilityChecks += 1; - return true; + windowsReads.push({ names, loadProfile: options.loadProfile }); + return options.loadProfile ? {} : { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }; }), }, }); - assert.deepEqual(windowsReads, [{ names: ["PATH"], options: { loadProfile: false } }]); + assert.deepEqual(windowsReads, [ + { names: ["PATH"], loadProfile: false }, + { names: ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], loadProfile: true }, + ]); assert.equal( env.PATH, [ @@ -320,11 +244,10 @@ describe("DesktopShellEnvironment", () => { "C:\\Windows\\System32", ].join(";"), ); - assert.equal(commandAvailabilityChecks, 1); }), ); - it.effect("loads the PowerShell profile on Windows when node is not available", () => + it.effect("loads PowerShell profile environment on Windows", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { PATH: "C:\\Windows\\System32", @@ -332,28 +255,22 @@ describe("DesktopShellEnvironment", () => { LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", USERPROFILE: "C:\\Users\\testuser", }; - const windowsReads: Array<{ - readonly names: ReadonlyArray; - readonly options: WindowsEnvironmentProbeOptions; - }> = []; yield* runShellEnvironment({ env, platform: "win32", probe: { - readWindowsShellEnvironment: (names, options) => - Effect.sync(() => { - windowsReads.push({ names, options }); - return options.loadProfile + readWindowsEnvironment: (_names, options) => + Effect.succeed( + options.loadProfile ? { PATH: "C:\\Profile\\Node;C:\\Windows\\System32", FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", } - : { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }; - }), - isWindowsCommandAvailable: () => Effect.succeed(false), + : { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }, + ), }, }); @@ -376,10 +293,6 @@ describe("DesktopShellEnvironment", () => { env.FNM_MULTISHELL_PATH, "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", ); - assert.deepEqual(windowsReads, [ - { names: ["PATH"], options: { loadProfile: false } }, - { names: ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], options: { loadProfile: true } }, - ]); }), ); @@ -395,14 +308,10 @@ describe("DesktopShellEnvironment", () => { env, platform: "win32", probe: { - readWindowsShellEnvironment: (_names, options) => - Effect.gen(function* () { - if (options.loadProfile) { - return yield* Effect.fail(new Error("profile load failed")); - } - return { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }; - }), - isWindowsCommandAvailable: () => Effect.succeed(false), + readWindowsEnvironment: (_names, options) => + options.loadProfile + ? Effect.fail(new Error("profile load failed")) + : Effect.succeed({ PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }), }, }); @@ -419,57 +328,4 @@ describe("DesktopShellEnvironment", () => { assert.isUndefined(env.SSH_AUTH_SOCK); }), ); - - it.effect("live probe reads login shell variables through ChildProcessSpawner", () => - Effect.gen(function* () { - let spawnedCommand: ChildProcess.Command | undefined; - const spawnerLayer = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => - Effect.sync(() => { - spawnedCommand = command; - return makeProcess({ - stdout: Stream.make( - textEncoder.encode( - [ - "__T3CODE_ENV_PATH_START__", - "/opt/homebrew/bin:/usr/bin", - "__T3CODE_ENV_PATH_END__", - "__T3CODE_ENV_SSH_AUTH_SOCK_START__", - "/tmp/live.sock", - "__T3CODE_ENV_SSH_AUTH_SOCK_END__", - ].join("\n"), - ), - ), - }); - }), - ), - ); - - const result = yield* Effect.gen(function* () { - const probe = yield* DesktopShellEnvironmentProbe; - return yield* probe.readLoginShellEnvironment("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"]); - }).pipe( - Effect.provide( - DesktopShellEnvironmentProbeLive.pipe( - Layer.provide(Layer.merge(NodeServices.layer, spawnerLayer)), - ), - ), - Effect.scoped, - ); - - assert.deepEqual(result, { - PATH: "/opt/homebrew/bin:/usr/bin", - SSH_AUTH_SOCK: "/tmp/live.sock", - }); - assert.isDefined(spawnedCommand); - if (spawnedCommand?._tag === "StandardCommand") { - assert.equal(spawnedCommand.command, "/bin/zsh"); - assert.equal(spawnedCommand.args[0], "-ilc"); - assert.include(spawnedCommand.args[1] ?? "", "__T3CODE_ENV_PATH_START__"); - assert.equal(spawnedCommand.options.stdout, "pipe"); - assert.equal(spawnedCommand.options.stderr, "pipe"); - } - }), - ); }); diff --git a/apps/desktop/src/main/DesktopShellEnvironment.ts b/apps/desktop/src/main/DesktopShellEnvironment.ts new file mode 100644 index 00000000000..381321d0123 --- /dev/null +++ b/apps/desktop/src/main/DesktopShellEnvironment.ts @@ -0,0 +1,414 @@ +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +type EnvironmentPatch = Record; + +interface ShellEnvironmentConfig { + readonly env: NodeJS.ProcessEnv; + readonly platform: NodeJS.Platform; + readonly userShell: Option.Option; +} + +interface WindowsProbeOptions { + readonly loadProfile: boolean; +} + +interface ShellProbe { + readonly readLoginShellEnvironment: ( + shell: string, + names: ReadonlyArray, + ) => Effect.Effect; + readonly readLaunchctlPath: Effect.Effect, unknown>; + readonly readWindowsEnvironment: ( + names: ReadonlyArray, + options: WindowsProbeOptions, + ) => Effect.Effect; +} + +export interface DesktopShellEnvironmentShape { + readonly installIntoProcess: Effect.Effect; +} + +export class DesktopShellEnvironment extends Context.Service< + DesktopShellEnvironment, + DesktopShellEnvironmentShape +>()("t3/desktop/ShellEnvironment") {} + +const LOGIN_SHELL_ENV_NAMES = [ + "PATH", + "SSH_AUTH_SOCK", + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", +] as const; +const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const; +const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; +const LOGIN_SHELL_TIMEOUT = Duration.seconds(5); +const LAUNCHCTL_TIMEOUT = Duration.seconds(2); +const PROCESS_TERMINATE_GRACE = Duration.seconds(1); + +const noneProbe: ShellProbe = { + readLoginShellEnvironment: () => Effect.succeed({}), + readLaunchctlPath: Effect.succeed(Option.none()), + readWindowsEnvironment: () => Effect.succeed({}), +}; + +const trimNonEmpty = (value: string | null | undefined): Option.Option => + Option.fromNullishOr(value).pipe( + Option.map((entry) => entry.trim()), + Option.filter((entry) => entry.length > 0), + ); + +const pathDelimiter = (platform: NodeJS.Platform) => (platform === "win32" ? ";" : ":"); + +const readEnvPath = (env: NodeJS.ProcessEnv): Option.Option => + trimNonEmpty(env.PATH ?? env.Path ?? env.path); + +const pathComparisonKey = (entry: string, platform: NodeJS.Platform) => { + const normalized = entry.trim().replace(/^"+|"+$/g, ""); + return platform === "win32" ? normalized.toLowerCase() : normalized; +}; + +const mergePaths = ( + platform: NodeJS.Platform, + values: ReadonlyArray>, +): Option.Option => { + const delimiter = pathDelimiter(platform); + const entries: string[] = []; + const seen = new Set(); + + for (const value of values) { + if (Option.isNone(value)) continue; + + for (const entry of value.value.split(delimiter)) { + const trimmed = entry.trim(); + if (trimmed.length === 0) continue; + + const key = pathComparisonKey(trimmed, platform); + if (key.length === 0 || seen.has(key)) continue; + + seen.add(key); + entries.push(trimmed); + } + } + + return entries.length > 0 ? Option.some(entries.join(delimiter)) : Option.none(); +}; + +const listLoginShellCandidates = (config: ShellEnvironmentConfig): ReadonlyArray => { + const fallback = + config.platform === "darwin" ? "/bin/zsh" : config.platform === "linux" ? "/bin/bash" : ""; + const seen = new Set(); + const candidates: string[] = []; + + for (const candidate of [ + trimNonEmpty(config.env.SHELL), + config.userShell, + trimNonEmpty(fallback), + ]) { + if (Option.isNone(candidate) || seen.has(candidate.value)) continue; + seen.add(candidate.value); + candidates.push(candidate.value); + } + + return candidates; +}; + +const knownWindowsCliDirs = (env: NodeJS.ProcessEnv): ReadonlyArray => [ + ...trimNonEmpty(env.APPDATA).pipe( + Option.match({ + onNone: () => [], + onSome: (value) => [`${value}\\npm`], + }), + ), + ...trimNonEmpty(env.LOCALAPPDATA).pipe( + Option.match({ + onNone: () => [], + onSome: (value) => [`${value}\\Programs\\nodejs`, `${value}\\Volta\\bin`, `${value}\\pnpm`], + }), + ), + ...trimNonEmpty(env.USERPROFILE).pipe( + Option.match({ + onNone: () => [], + onSome: (value) => [`${value}\\.bun\\bin`, `${value}\\scoop\\shims`], + }), + ), +]; + +const startMarker = (name: string) => `__T3CODE_ENV_${name}_START__`; +const endMarker = (name: string) => `__T3CODE_ENV_${name}_END__`; + +const capturePosixEnvironmentCommand = (names: ReadonlyArray) => + names + .map((name) => { + return [ + `printf '%s\\n' '${startMarker(name)}'`, + `printenv ${name} || true`, + `printf '%s\\n' '${endMarker(name)}'`, + ].join("; "); + }) + .join("; "); + +const captureWindowsEnvironmentCommand = (names: ReadonlyArray) => + [ + "$ErrorActionPreference = 'Stop'", + ...names.flatMap((name) => { + return [ + `Write-Output '${startMarker(name)}'`, + `$value = [Environment]::GetEnvironmentVariable('${name}')`, + "if ($null -ne $value -and $value.Length -gt 0) { Write-Output $value }", + `Write-Output '${endMarker(name)}'`, + ]; + }), + ].join("; "); + +const extractEnvironment = (output: string, names: ReadonlyArray): EnvironmentPatch => { + const environment: EnvironmentPatch = {}; + + for (const name of names) { + const start = output.indexOf(startMarker(name)); + if (start === -1) continue; + + const valueStart = start + startMarker(name).length; + const end = output.indexOf(endMarker(name), valueStart); + if (end === -1) continue; + + const value = output + .slice(valueStart, end) + .replace(/^\r?\n/, "") + .replace(/\r?\n$/, ""); + if (value.length > 0) { + environment[name] = value; + } + } + + return environment; +}; + +const runCommandOutput = ( + spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], + input: { + readonly command: string; + readonly args: ReadonlyArray; + readonly timeout: Duration.Duration; + readonly shell?: boolean; + }, +): Effect.Effect => + spawner + .string( + ChildProcess.make(input.command, input.args, { + shell: input.shell ?? false, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + killSignal: "SIGTERM", + forceKillAfter: PROCESS_TERMINATE_GRACE, + }), + ) + .pipe( + Effect.timeoutOption(input.timeout), + Effect.map(Option.getOrElse(() => "")), + Effect.catch(() => Effect.succeed("")), + ); + +const makeProcessProbe = ( + spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], +): ShellProbe => ({ + readLoginShellEnvironment: (shell, names) => + names.length === 0 + ? Effect.succeed({}) + : runCommandOutput(spawner, { + command: shell, + args: ["-ilc", capturePosixEnvironmentCommand(names)], + timeout: LOGIN_SHELL_TIMEOUT, + }).pipe(Effect.map((output) => extractEnvironment(output, names))), + readLaunchctlPath: runCommandOutput(spawner, { + command: "/bin/launchctl", + args: ["getenv", "PATH"], + timeout: LAUNCHCTL_TIMEOUT, + }).pipe(Effect.map(trimNonEmpty)), + readWindowsEnvironment: (names, options) => { + if (names.length === 0) return Effect.succeed({}); + + const args = [ + "-NoLogo", + ...(options.loadProfile ? ([] as const) : (["-NoProfile"] as const)), + "-NonInteractive", + "-Command", + captureWindowsEnvironmentCommand(names), + ]; + + return Effect.gen(function* () { + for (const command of WINDOWS_SHELL_CANDIDATES) { + const output = yield* runCommandOutput(spawner, { + command, + args, + shell: true, + timeout: LOGIN_SHELL_TIMEOUT, + }); + const environment = extractEnvironment(output, names); + if (Object.keys(environment).length > 0) { + return environment; + } + } + + return {}; + }); + }, +}); + +const readWindowsEnvironment = ( + probe: ShellProbe, + names: ReadonlyArray, + options: WindowsProbeOptions, +): Effect.Effect => + probe.readWindowsEnvironment(names, options).pipe(Effect.catch(() => Effect.succeed({}))); + +const installWindowsEnvironment = ( + config: ShellEnvironmentConfig, + probe: ShellProbe, +): Effect.Effect => + Effect.gen(function* () { + const noProfile = yield* readWindowsEnvironment(probe, ["PATH"], { loadProfile: false }); + const profile = yield* readWindowsEnvironment(probe, WINDOWS_PROFILE_ENV_NAMES, { + loadProfile: true, + }); + const mergedPath = mergePaths("win32", [ + trimNonEmpty(profile.PATH), + trimNonEmpty(knownWindowsCliDirs(config.env).join(";")), + trimNonEmpty(noProfile.PATH), + readEnvPath(config.env), + ]); + + if (Option.isSome(mergedPath)) { + config.env.PATH = mergedPath.value; + } + if (!config.env.FNM_DIR && profile.FNM_DIR) { + config.env.FNM_DIR = profile.FNM_DIR; + } + if (!config.env.FNM_MULTISHELL_PATH && profile.FNM_MULTISHELL_PATH) { + config.env.FNM_MULTISHELL_PATH = profile.FNM_MULTISHELL_PATH; + } + }); + +const installPosixEnvironment = ( + config: ShellEnvironmentConfig, + probe: ShellProbe, +): Effect.Effect => + Effect.gen(function* () { + const shellEnvironment: EnvironmentPatch = {}; + + for (const shell of listLoginShellCandidates(config)) { + const result = yield* probe.readLoginShellEnvironment(shell, LOGIN_SHELL_ENV_NAMES).pipe( + Effect.option, + Effect.tap((environment) => + Option.isNone(environment) + ? Effect.logWarning("failed to read login shell environment", { shell }) + : Effect.void, + ), + ); + + if (Option.isSome(result)) { + Object.assign(shellEnvironment, result.value); + if (shellEnvironment.PATH) break; + } + } + + const launchctlPath = + config.platform === "darwin" && !shellEnvironment.PATH + ? yield* probe.readLaunchctlPath.pipe( + Effect.catch(() => Effect.succeed(Option.none())), + ) + : Option.none(); + const mergedPath = mergePaths(config.platform, [ + trimNonEmpty(shellEnvironment.PATH).pipe(Option.orElse(() => launchctlPath)), + readEnvPath(config.env), + ]); + + if (Option.isSome(mergedPath)) { + config.env.PATH = mergedPath.value; + } + if (!config.env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) { + config.env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK; + } + + for (const name of [ + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + ] as const) { + if (!config.env[name] && shellEnvironment[name]) { + config.env[name] = shellEnvironment[name]; + } + } + }); + +const installShellEnvironment = ( + config: ShellEnvironmentConfig, + probe: ShellProbe, +): Effect.Effect => { + if (config.platform === "win32") { + return installWindowsEnvironment(config, probe); + } + if (config.platform === "darwin" || config.platform === "linux") { + return installPosixEnvironment(config, probe); + } + return Effect.void; +}; + +export const layer = Layer.effect( + DesktopShellEnvironment, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return DesktopShellEnvironment.of({ + installIntoProcess: installShellEnvironment( + { + env: process.env, + platform: environment.platform, + userShell: Option.none(), + }, + makeProcessProbe(spawner), + ), + }); + }), +); + +export const layerTest = (input: { + readonly env: NodeJS.ProcessEnv; + readonly platform: NodeJS.Platform; + readonly userShell?: string; + readonly probe?: { + readonly readLoginShellEnvironment?: ( + shell: string, + names: ReadonlyArray, + ) => Effect.Effect; + readonly readLaunchctlPath?: Effect.Effect, unknown>; + readonly readWindowsEnvironment?: ( + names: ReadonlyArray, + options: WindowsProbeOptions, + ) => Effect.Effect; + }; +}) => { + const config: ShellEnvironmentConfig = { + env: input.env, + platform: input.platform, + userShell: trimNonEmpty(input.userShell), + }; + return Layer.succeed( + DesktopShellEnvironment, + DesktopShellEnvironment.of({ + installIntoProcess: installShellEnvironment(config, { ...noneProbe, ...input.probe }), + }), + ); +}; diff --git a/apps/desktop/src/main/DesktopShutdown.test.ts b/apps/desktop/src/main/DesktopShutdown.test.ts new file mode 100644 index 00000000000..1d1257f67a8 --- /dev/null +++ b/apps/desktop/src/main/DesktopShutdown.test.ts @@ -0,0 +1,52 @@ +import { assert, describe, it } from "@effect/vitest"; +import { Effect, Fiber } from "effect"; + +import { DesktopShutdown, layer as desktopShutdownLayer } from "./DesktopShutdown.ts"; + +const withShutdown = (effect: Effect.Effect) => + effect.pipe(Effect.provide(desktopShutdownLayer)); + +describe("DesktopShutdown", () => { + it.effect("unblocks request waiters when shutdown is requested", () => + withShutdown( + Effect.gen(function* () { + const shutdown = yield* DesktopShutdown; + const waiter = yield* shutdown.awaitRequest.pipe(Effect.as("requested"), Effect.forkChild); + + yield* shutdown.request; + + assert.equal(yield* Fiber.join(waiter), "requested"); + }), + ), + ); + + it.effect("tracks completion after resources finish closing", () => + withShutdown( + Effect.gen(function* () { + const shutdown = yield* DesktopShutdown; + const waiter = yield* shutdown.awaitComplete.pipe(Effect.as("complete"), Effect.forkChild); + + assert.equal(yield* shutdown.isComplete, false); + yield* shutdown.markComplete; + + assert.equal(yield* shutdown.isComplete, true); + assert.equal(yield* Fiber.join(waiter), "complete"); + }), + ), + ); + + it.effect("allows repeated requests and completion marks", () => + withShutdown( + Effect.gen(function* () { + const shutdown = yield* DesktopShutdown; + + yield* shutdown.request; + yield* shutdown.request; + yield* shutdown.markComplete; + yield* shutdown.markComplete; + + assert.equal(yield* shutdown.isComplete, true); + }), + ), + ); +}); diff --git a/apps/desktop/src/desktopShutdown.ts b/apps/desktop/src/main/DesktopShutdown.ts similarity index 85% rename from apps/desktop/src/desktopShutdown.ts rename to apps/desktop/src/main/DesktopShutdown.ts index db464ffb37b..acdcf14b990 100644 --- a/apps/desktop/src/desktopShutdown.ts +++ b/apps/desktop/src/main/DesktopShutdown.ts @@ -1,4 +1,4 @@ -import { Context, Deferred, Effect, Ref } from "effect"; +import { Context, Deferred, Effect, Layer, Ref } from "effect"; export interface DesktopShutdownShape { readonly request: Effect.Effect; @@ -12,7 +12,7 @@ export class DesktopShutdown extends Context.Service(); const completed = yield* Deferred.make(); const completedRef = yield* Ref.make(false); @@ -28,3 +28,5 @@ export const makeDesktopShutdown = Effect.gen(function* () { isComplete: Ref.get(completedRef), }); }); + +export const layer = Layer.effect(DesktopShutdown, make); diff --git a/apps/desktop/src/sshEnvironment.test.ts b/apps/desktop/src/main/DesktopSshEnvironment.test.ts similarity index 82% rename from apps/desktop/src/sshEnvironment.test.ts rename to apps/desktop/src/main/DesktopSshEnvironment.test.ts index 4e97e937fe5..8573b57f88b 100644 --- a/apps/desktop/src/sshEnvironment.test.ts +++ b/apps/desktop/src/main/DesktopSshEnvironment.test.ts @@ -1,13 +1,15 @@ +import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import * as NetService from "@t3tools/shared/Net"; import { SshPasswordPromptError } from "@t3tools/ssh/errors"; import { Effect, FileSystem, Layer, Path } from "effect"; -import * as DesktopSshEnvironment from "./main/DesktopSshEnvironment.ts"; -import * as DesktopSshPasswordPrompts from "./main/DesktopSshPasswordPrompts.ts"; -import * as DesktopIpc from "./ipc/DesktopIpc.ts"; -import { SSH_PASSWORD_PROMPT_CANCELLED_RESULT } from "./ipc/channels.ts"; -import { discoverSshHosts, ensureSshEnvironment } from "./ipc/methods/sshEnvironment.ts"; +import * as DesktopSshEnvironment from "./DesktopSshEnvironment.ts"; +import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; +import * as DesktopIpc from "../ipc/DesktopIpc.ts"; +import { SSH_PASSWORD_PROMPT_CANCELLED_RESULT } from "../ipc/channels.ts"; +import { discoverSshHosts, ensureSshEnvironment } from "../ipc/methods/sshEnvironment.ts"; function makeTempHomeDir() { return Effect.gen(function* () { @@ -79,7 +81,8 @@ describe("sshEnvironment", () => { ].join("\n"), ); - const hosts = yield* DesktopSshEnvironment.discoverDesktopSshHostsEffect({ homeDir }); + const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; + const hosts = yield* sshEnvironment.discoverHosts({ homeDir }); assert.deepEqual(hosts, [ { alias: "bastion.example.com", @@ -110,7 +113,23 @@ describe("sshEnvironment", () => { source: "ssh-config", }, ]); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe( + Effect.provide( + DesktopSshEnvironment.layer().pipe( + Layer.provideMerge( + Layer.succeed(DesktopSshPasswordPrompts.DesktopSshPasswordPrompts, { + request: () => Effect.die("unexpected password prompt request"), + resolve: () => Effect.die("unexpected password prompt resolution"), + cancelPending: () => Effect.void, + }), + ), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(NodeHttpClient.layerUndici), + Layer.provideMerge(NetService.layer), + ), + ), + Effect.scoped, + ), ); it.effect("runs SSH IPC handlers with the captured Effect context", () => diff --git a/apps/desktop/src/main/DesktopSshEnvironment.ts b/apps/desktop/src/main/DesktopSshEnvironment.ts index 1c36b299708..3acace1e210 100644 --- a/apps/desktop/src/main/DesktopSshEnvironment.ts +++ b/apps/desktop/src/main/DesktopSshEnvironment.ts @@ -77,7 +77,7 @@ export interface DesktopSshEnvironmentLayerOptions { readonly resolveCliRunner?: Effect.Effect; } -export function discoverDesktopSshHostsEffect(input?: { readonly homeDir?: string }) { +function discoverDesktopSshHostsEffect(input?: { readonly homeDir?: string }) { return discoverSshHosts(input ?? {}); } diff --git a/apps/desktop/src/main/DesktopUpdates.test.ts b/apps/desktop/src/main/DesktopUpdates.test.ts index 899b7c40a9f..18fdb3933b6 100644 --- a/apps/desktop/src/main/DesktopUpdates.test.ts +++ b/apps/desktop/src/main/DesktopUpdates.test.ts @@ -10,10 +10,10 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { TestClock } from "effect/testing"; -import { afterEach, beforeEach } from "vitest"; -import { DesktopBackendManager } from "../desktopBackendManager.ts"; -import { makeDesktopEnvironment, DesktopEnvironment } from "../desktopEnvironment.ts"; +import * as DesktopBackendManager from "./DesktopBackendManager.ts"; +import * as DesktopConfig from "./DesktopConfig.ts"; +import { layer as makeDesktopEnvironmentLayer } from "./DesktopEnvironment.ts"; import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import { DEFAULT_DESKTOP_SETTINGS } from "../desktopSettings.ts"; @@ -21,32 +21,17 @@ import * as DesktopSettingsState from "./DesktopSettingsState.ts"; import * as DesktopState from "./DesktopState.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; -const originalMockUpdates = process.env.T3CODE_DESKTOP_MOCK_UPDATES; -const originalMockUpdatePort = process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT; - -interface UpdatesHarness { - readonly layer: Layer.Layer< - DesktopUpdates.DesktopUpdates | DesktopSettingsState.DesktopSettingsState - >; - readonly checkCount: () => number; - readonly feedUrls: () => readonly ElectronUpdater.ElectronUpdaterFeedUrl[]; - readonly listenerCount: () => number; - readonly sentStates: readonly DesktopUpdateState[]; - readonly emit: (eventName: string, payload?: unknown) => void; -} - interface UpdatesHarnessOptions { readonly checkForUpdates?: Effect.Effect< void, ElectronUpdater.ElectronUpdaterCheckForUpdatesError >; + readonly env?: Record; } -const flushCallbacks = Effect.callback((resume) => { - setImmediate(() => resume(Effect.void)); -}); +const flushCallbacks = Effect.yieldNow; -function makeHarness(options: UpdatesHarnessOptions = {}): UpdatesHarness { +function makeHarness(options: UpdatesHarnessOptions = {}) { let checkCount = 0; let allowDowngrade = false; const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = []; @@ -118,7 +103,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}): UpdatesHarness { syncAllAppearance: () => Effect.void, } satisfies ElectronWindow.ElectronWindowShape); - const backendLayer = Layer.succeed(DesktopBackendManager, { + const backendLayer = Layer.succeed(DesktopBackendManager.DesktopBackendManager, { start: Effect.void, stop: () => Effect.void, shutdown: Effect.void, @@ -133,21 +118,29 @@ function makeHarness(options: UpdatesHarnessOptions = {}): UpdatesHarness { }), }); - const environmentLayer = Layer.effect( - DesktopEnvironment, - makeDesktopEnvironment({ - dirname: "/repo/apps/desktop/src", - env: { T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}` }, - cwd: "/repo", - platform: "darwin", - processArch: "x64", - appVersion: "1.2.3", - appPath: "/repo", - isPackaged: true, - resourcesPath: "/missing/resources", - runningUnderArm64Translation: false, - }), - ).pipe(Layer.provide(EffectPath.layer)); + const environmentLayer = makeDesktopEnvironmentLayer({ + dirname: "/repo/apps/desktop/src", + cwd: "/repo", + platform: "darwin", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll( + EffectPath.layer, + DesktopConfig.layerTest({ + T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`, + T3CODE_DESKTOP_MOCK_UPDATES: "true", + T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141", + ...options.env, + }), + ), + ), + ); const layer = DesktopUpdates.layer.pipe( Layer.provideMerge(updaterLayer), @@ -155,6 +148,14 @@ function makeHarness(options: UpdatesHarnessOptions = {}): UpdatesHarness { Layer.provideMerge(backendLayer), Layer.provideMerge(DesktopState.layer), Layer.provideMerge(DesktopSettingsState.layer), + Layer.provideMerge( + DesktopConfig.layerTest({ + T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`, + T3CODE_DESKTOP_MOCK_UPDATES: "true", + T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141", + ...options.env, + }), + ), Layer.provideMerge(environmentLayer), Layer.provideMerge(NodeServices.layer), ); @@ -169,7 +170,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}): UpdatesHarness { 0, ), sentStates, - emit: (eventName, payload) => { + emit: (eventName: string, payload?: unknown) => { for (const listener of listeners.get(eventName) ?? []) { listener(payload); } @@ -178,25 +179,6 @@ function makeHarness(options: UpdatesHarnessOptions = {}): UpdatesHarness { } describe("DesktopUpdates", () => { - beforeEach(() => { - process.env.T3CODE_DESKTOP_MOCK_UPDATES = "1"; - process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT = "4141"; - }); - - afterEach(() => { - if (originalMockUpdates === undefined) { - delete process.env.T3CODE_DESKTOP_MOCK_UPDATES; - } else { - process.env.T3CODE_DESKTOP_MOCK_UPDATES = originalMockUpdates; - } - - if (originalMockUpdatePort === undefined) { - delete process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT; - } else { - process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT = originalMockUpdatePort; - } - }); - it.effect("configures the updater and runs startup checks on the test clock", () => { const harness = makeHarness(); diff --git a/apps/desktop/src/main/DesktopUpdates.ts b/apps/desktop/src/main/DesktopUpdates.ts index 5505cee47c7..93e70338d38 100644 --- a/apps/desktop/src/main/DesktopUpdates.ts +++ b/apps/desktop/src/main/DesktopUpdates.ts @@ -1,4 +1,5 @@ import type { + DesktopRuntimeInfo, DesktopUpdateActionResult, DesktopUpdateChannel, DesktopUpdateCheckResult, @@ -19,13 +20,15 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; -import { DesktopBackendManager } from "../desktopBackendManager.ts"; -import { type DesktopSettings, setDesktopUpdateChannelPreference } from "../desktopSettings.ts"; -import { DesktopEnvironment, type DesktopEnvironmentShape } from "../desktopEnvironment.ts"; import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopBackendManager from "./DesktopBackendManager.ts"; +import * as DesktopConfig from "./DesktopConfig.ts"; +import * as DesktopSettingsState from "./DesktopSettingsState.ts"; +import * as DesktopState from "./DesktopState.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import { type DesktopSettings, setDesktopUpdateChannelPreference } from "../desktopSettings.ts"; import { UPDATE_STATE_CHANNEL } from "../ipc/channels.ts"; -import { isArm64HostRunningIntelBuild } from "../runtimeArch.ts"; import { doesVersionMatchDesktopUpdateChannel } from "../updateChannels.ts"; import { createInitialDesktopUpdateState, @@ -39,13 +42,9 @@ import { reduceDesktopUpdateStateOnNoUpdate, reduceDesktopUpdateStateOnUpdateAvailable, } from "../updateMachine.ts"; -import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "../updateState.ts"; -import { formatErrorMessage } from "./DesktopErrors.ts"; -import * as DesktopSettingsState from "./DesktopSettingsState.ts"; -import * as DesktopState from "./DesktopState.ts"; -const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000; -const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; +const AUTO_UPDATE_STARTUP_DELAY = "15 seconds"; +const AUTO_UPDATE_POLL_INTERVAL = "4 minutes"; const AppUpdateYmlConfig = Schema.Record(Schema.String, Schema.String); type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type; @@ -149,7 +148,7 @@ function parseAppUpdateYml(raw: string): Effect.Effect => settingsState.updatePersisted(f).pipe( Effect.mapError((cause) => new DesktopUpdatePersistenceError({ cause })), - Effect.provideService(DesktopEnvironment, environment), + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), ); @@ -224,10 +269,7 @@ const make = Effect.gen(function* () { ); const hasUpdateFeedConfig = Ref.get(appUpdateYmlConfigRef).pipe( - Effect.map( - (appUpdateYmlConfig) => - Option.isSome(appUpdateYmlConfig) || Boolean(process.env.T3CODE_DESKTOP_MOCK_UPDATES), - ), + Effect.map((appUpdateYmlConfig) => Option.isSome(appUpdateYmlConfig) || config.mockUpdates), ); const resolveDisabledReason = Effect.gen(function* () { @@ -236,9 +278,9 @@ const make = Effect.gen(function* () { getAutoUpdateDisabledReason({ isDevelopment: environment.isDevelopment, isPackaged: environment.isPackaged, - platform: process.platform, - appImage: process.env.APPIMAGE, - disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1", + platform: environment.platform, + appImage: Option.getOrUndefined(config.appImagePath), + disabledByEnv: config.disableAutoUpdate, hasUpdateFeedConfig: hasFeedConfig, }), ); @@ -306,11 +348,10 @@ const make = Effect.gen(function* () { Effect.catch((error) => Effect.gen(function* () { const failedAt = yield* currentIsoTimestamp; - const message = formatErrorMessage(error); yield* updateState((current) => - reduceDesktopUpdateStateOnCheckFailure(current, message, failedAt), + reduceDesktopUpdateStateOnCheckFailure(current, error.message, failedAt), ); - yield* logUpdaterError("failed to check for updates", { message }); + yield* logUpdaterError("failed to check for updates", { message: error.message }); return true; }), ), @@ -340,11 +381,10 @@ const make = Effect.gen(function* () { }).pipe( Effect.catch((error) => Effect.gen(function* () { - const message = formatErrorMessage(error); yield* updateState((current) => - reduceDesktopUpdateStateOnDownloadFailure(current, message), + reduceDesktopUpdateStateOnDownloadFailure(current, error.message), ); - yield* logUpdaterError("failed to download update", { message }); + yield* logUpdaterError("failed to download update", { message: error.message }); return { accepted: true, completed: false }; }), ), @@ -377,13 +417,12 @@ const make = Effect.gen(function* () { }).pipe( Effect.catch((error) => Effect.gen(function* () { - const message = formatErrorMessage(error); yield* Ref.set(updateInstallInFlightRef, false); yield* updateState((current) => - reduceDesktopUpdateStateOnInstallFailure(current, message), + reduceDesktopUpdateStateOnInstallFailure(current, error.message), ); yield* Ref.set(desktopState.quitting, false); - yield* logUpdaterError("failed to install update", { message }); + yield* logUpdaterError("failed to install update", { message: error.message }); return { accepted: true, completed: false }; }), ), @@ -397,14 +436,14 @@ const make = Effect.gen(function* () { yield* Ref.set(updatePollerScopeRef, Option.some(scope)); yield* Scope.addFinalizer(parentScope, Scope.close(scope, Exit.void)); - yield* Effect.sleep(Duration.millis(AUTO_UPDATE_STARTUP_DELAY_MS)).pipe( + yield* Effect.sleep(AUTO_UPDATE_STARTUP_DELAY).pipe( Effect.andThen(checkForUpdates("startup")), Effect.catchCause((cause) => logUpdaterError("startup update check failed", { cause: Cause.pretty(cause) }), ), Effect.forkIn(scope), ); - yield* Effect.sleep(Duration.millis(AUTO_UPDATE_POLL_INTERVAL_MS)).pipe( + yield* Effect.sleep(AUTO_UPDATE_POLL_INTERVAL).pipe( Effect.andThen(checkForUpdates("poll")), Effect.forever, Effect.catchCause((cause) => @@ -455,7 +494,7 @@ const make = Effect.gen(function* () { const handleUpdaterError = (error: unknown) => Effect.gen(function* () { - const message = formatErrorMessage(error); + const message = error instanceof Error ? error.message : String(error); if (yield* Ref.get(updateInstallInFlightRef)) { yield* Ref.set(updateInstallInFlightRef, false); yield* Ref.set(desktopState.quitting, false); @@ -537,26 +576,22 @@ const make = Effect.gen(function* () { const appUpdateYmlConfig = yield* readAppUpdateYml; yield* Ref.set(appUpdateYmlConfigRef, appUpdateYmlConfig); - const githubToken = - process.env.T3CODE_DESKTOP_UPDATE_GITHUB_TOKEN?.trim() || - process.env.GH_TOKEN?.trim() || - ""; - if (githubToken) { - const config = Option.getOrUndefined(appUpdateYmlConfig); - if (config?.provider === "github") { + if (Option.isSome(config.desktopUpdateGithubToken)) { + const appUpdateConfig = Option.getOrUndefined(appUpdateYmlConfig); + if (appUpdateConfig?.provider === "github") { yield* electronUpdater.setFeedURL({ - ...config, + ...appUpdateConfig, provider: "github", private: true, - token: githubToken, + token: config.desktopUpdateGithubToken.value, } as ElectronUpdater.ElectronUpdaterFeedUrl); } } - if (process.env.T3CODE_DESKTOP_MOCK_UPDATES) { + if (config.mockUpdates) { yield* electronUpdater.setFeedURL({ provider: "generic", - url: `http://localhost:${process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT ?? 3000}`, + url: `http://localhost:${config.mockUpdateServerPort}`, } as ElectronUpdater.ElectronUpdaterFeedUrl); } diff --git a/apps/desktop/src/main/DesktopWindow.ts b/apps/desktop/src/main/DesktopWindow.ts index 3293cf90937..78d6f469aa1 100644 --- a/apps/desktop/src/main/DesktopWindow.ts +++ b/apps/desktop/src/main/DesktopWindow.ts @@ -7,14 +7,12 @@ import * as Ref from "effect/Ref"; import type * as Electron from "electron"; -import type { DesktopEnvironmentShape } from "../desktopEnvironment.ts"; -import { DesktopEnvironment } from "../desktopEnvironment.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts"; -import { bindFirstRevealTrigger, type RevealSubscription } from "../windowReveal.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; import * as DesktopState from "./DesktopState.ts"; import * as DesktopAssets from "./DesktopAssets.ts"; @@ -30,7 +28,7 @@ type WindowTitleBarOptions = Pick< >; type DesktopWindowRuntimeServices = - | DesktopEnvironment + | DesktopEnvironment.DesktopEnvironment | DesktopAssets.DesktopAssets | DesktopServerExposure.DesktopServerExposure | DesktopState.DesktopState @@ -76,11 +74,11 @@ const logWindowInfo = (message: string, annotations?: Record) = ); function resolveDesktopDevServerUrl( - environment: DesktopEnvironmentShape, + environment: DesktopEnvironment.DesktopEnvironmentShape, ): Effect.Effect { return Option.match(environment.devServerUrl, { onNone: () => Effect.fail(new DesktopWindowDevServerUrlMissingError()), - onSome: Effect.succeed, + onSome: (url) => Effect.succeed(url.href), }); } @@ -134,8 +132,25 @@ function syncWindowAppearance( }); } +type RevealSubscription = (listener: () => void) => void; + +function bindFirstRevealTrigger( + subscribers: readonly RevealSubscription[], + reveal: () => void, +): void { + let revealed = false; + const fire = () => { + if (revealed) return; + revealed = true; + reveal(); + }; + for (const subscribe of subscribers) { + subscribe(fire); + } +} + const make = Effect.gen(function* () { - const environment = yield* DesktopEnvironment; + const environment = yield* DesktopEnvironment.DesktopEnvironment; const assets = yield* DesktopAssets.DesktopAssets; const electronMenu = yield* ElectronMenu.ElectronMenu; const electronShell = yield* ElectronShell.ElectronShell; diff --git a/apps/desktop/src/rotatingFileSink.test.ts b/apps/desktop/src/rotatingFileSink.test.ts deleted file mode 100644 index 10ac4372b60..00000000000 --- a/apps/desktop/src/rotatingFileSink.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, describe, it } from "@effect/vitest"; -import { RotatingFileSink } from "@t3tools/shared/logging"; -import { Effect, FileSystem, Path } from "effect"; - -function makeTempDir() { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - return yield* fs.makeTempDirectoryScoped({ prefix: "t3-rotating-log-" }); - }); -} - -describe("RotatingFileSink", () => { - it.effect("rotates when writes exceed max bytes", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* makeTempDir(); - const logPath = path.join(dir, "desktop-main.log"); - const sink = new RotatingFileSink({ - filePath: logPath, - maxBytes: 10, - maxFiles: 3, - }); - - yield* Effect.sync(() => { - sink.write("12345"); - sink.write("67890"); - sink.write("abc"); - }); - - assert.equal(yield* fs.readFileString(path.join(dir, "desktop-main.log")), "abc"); - assert.equal(yield* fs.readFileString(path.join(dir, "desktop-main.log.1")), "1234567890"); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - - it.effect("retains only maxFiles backups", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* makeTempDir(); - const logPath = path.join(dir, "server-child.log"); - const sink = new RotatingFileSink({ - filePath: logPath, - maxBytes: 4, - maxFiles: 2, - }); - - yield* Effect.sync(() => { - sink.write("aaaa"); - sink.write("bbbb"); - sink.write("cccc"); - sink.write("dddd"); - }); - - assert.equal(yield* fs.exists(path.join(dir, "server-child.log.1")), true); - assert.equal(yield* fs.exists(path.join(dir, "server-child.log.2")), true); - assert.equal(yield* fs.exists(path.join(dir, "server-child.log.3")), false); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - - it.effect("prunes stale backups above maxFiles on startup", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* makeTempDir(); - const logPath = path.join(dir, "desktop-main.log"); - yield* fs.writeFileString(path.join(dir, "desktop-main.log.1"), "first"); - yield* fs.writeFileString(path.join(dir, "desktop-main.log.4"), "stale"); - - yield* Effect.sync(() => { - const sink = new RotatingFileSink({ - filePath: logPath, - maxBytes: 16, - maxFiles: 2, - }); - sink.write("hello"); - }); - - assert.equal(yield* fs.exists(path.join(dir, "desktop-main.log.4")), false); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); -}); diff --git a/apps/desktop/src/runtimeArch.test.ts b/apps/desktop/src/runtimeArch.test.ts deleted file mode 100644 index a3173598949..00000000000 --- a/apps/desktop/src/runtimeArch.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts"; - -describe("resolveDesktopRuntimeInfo", () => { - it("detects Rosetta-translated Intel builds on Apple Silicon", () => { - const runtimeInfo = resolveDesktopRuntimeInfo({ - platform: "darwin", - processArch: "x64", - runningUnderArm64Translation: true, - }); - - expect(runtimeInfo).toEqual({ - hostArch: "arm64", - appArch: "x64", - runningUnderArm64Translation: true, - }); - expect(isArm64HostRunningIntelBuild(runtimeInfo)).toBe(true); - }); - - it("detects native Apple Silicon builds", () => { - const runtimeInfo = resolveDesktopRuntimeInfo({ - platform: "darwin", - processArch: "arm64", - runningUnderArm64Translation: false, - }); - - expect(runtimeInfo).toEqual({ - hostArch: "arm64", - appArch: "arm64", - runningUnderArm64Translation: false, - }); - expect(isArm64HostRunningIntelBuild(runtimeInfo)).toBe(false); - }); - - it("passes through non-mac builds without translation", () => { - const runtimeInfo = resolveDesktopRuntimeInfo({ - platform: "linux", - processArch: "x64", - runningUnderArm64Translation: true, - }); - - expect(runtimeInfo).toEqual({ - hostArch: "x64", - appArch: "x64", - runningUnderArm64Translation: false, - }); - }); -}); diff --git a/apps/desktop/src/runtimeArch.ts b/apps/desktop/src/runtimeArch.ts deleted file mode 100644 index 127abf51ab8..00000000000 --- a/apps/desktop/src/runtimeArch.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { DesktopRuntimeArch, DesktopRuntimeInfo } from "@t3tools/contracts"; - -interface ResolveDesktopRuntimeInfoInput { - readonly platform: NodeJS.Platform; - readonly processArch: string; - readonly runningUnderArm64Translation: boolean; -} - -function normalizeDesktopArch(arch: string): DesktopRuntimeArch { - if (arch === "arm64") return "arm64"; - if (arch === "x64") return "x64"; - return "other"; -} - -export function resolveDesktopRuntimeInfo( - input: ResolveDesktopRuntimeInfoInput, -): DesktopRuntimeInfo { - const appArch = normalizeDesktopArch(input.processArch); - - if (input.platform !== "darwin") { - return { - hostArch: appArch, - appArch, - runningUnderArm64Translation: false, - }; - } - - const hostArch = appArch === "arm64" || input.runningUnderArm64Translation ? "arm64" : appArch; - - return { - hostArch, - appArch, - runningUnderArm64Translation: input.runningUnderArm64Translation, - }; -} - -export function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean { - return runtimeInfo.hostArch === "arm64" && runtimeInfo.appArch === "x64"; -} diff --git a/apps/desktop/src/serverExposure.test.ts b/apps/desktop/src/serverExposure.test.ts deleted file mode 100644 index 4e284ef42bd..00000000000 --- a/apps/desktop/src/serverExposure.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - resolveDesktopCoreAdvertisedEndpoints, - resolveDesktopServerExposure, - resolveLanAdvertisedHost, -} from "./serverExposure.ts"; - -describe("resolveLanAdvertisedHost", () => { - it("prefers an explicit host override", () => { - expect( - resolveLanAdvertisedHost( - { - en0: [ - { - address: "192.168.1.44", - family: "IPv4", - internal: false, - netmask: "255.255.255.0", - cidr: "192.168.1.44/24", - mac: "00:00:00:00:00:00", - }, - ], - }, - "10.0.0.9", - ), - ).toBe("10.0.0.9"); - }); - - it("returns the first usable non-internal IPv4 address", () => { - expect( - resolveLanAdvertisedHost( - { - lo0: [ - { - address: "127.0.0.1", - family: "IPv4", - internal: true, - netmask: "255.0.0.0", - cidr: "127.0.0.1/8", - mac: "00:00:00:00:00:00", - }, - ], - en0: [ - { - address: "192.168.1.44", - family: "IPv4", - internal: false, - netmask: "255.255.255.0", - cidr: "192.168.1.44/24", - mac: "00:00:00:00:00:00", - }, - ], - }, - undefined, - ), - ).toBe("192.168.1.44"); - }); - - it("returns null when no usable network address is available", () => { - expect( - resolveLanAdvertisedHost( - { - lo0: [ - { - address: "127.0.0.1", - family: "IPv4", - internal: true, - netmask: "255.0.0.0", - cidr: "127.0.0.1/8", - mac: "00:00:00:00:00:00", - }, - ], - }, - undefined, - ), - ).toBeNull(); - }); -}); - -describe("resolveDesktopCoreAdvertisedEndpoints", () => { - it("advertises loopback and LAN endpoints without provider-specific assumptions", () => { - const exposure = resolveDesktopServerExposure({ - mode: "network-accessible", - port: 3773, - networkInterfaces: { - en0: [ - { - address: "192.168.1.44", - family: "IPv4", - internal: false, - netmask: "255.255.255.0", - cidr: "192.168.1.44/24", - mac: "00:00:00:00:00:00", - }, - ], - }, - }); - - expect( - resolveDesktopCoreAdvertisedEndpoints({ - port: 3773, - exposure, - customHttpsEndpointUrls: [ - "https://desktop.example.ts.net", - "http://desktop.example.test:3773", - "not-a-url", - ], - }), - ).toEqual([ - { - id: "desktop-loopback:3773", - label: "This machine", - provider: { - id: "desktop-core", - label: "Desktop", - kind: "core", - isAddon: false, - }, - httpBaseUrl: "http://127.0.0.1:3773/", - wsBaseUrl: "ws://127.0.0.1:3773/", - reachability: "loopback", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "desktop-core", - status: "available", - description: "Loopback endpoint for this desktop app.", - }, - { - id: "desktop-lan:http://192.168.1.44:3773", - label: "Local network", - provider: { - id: "desktop-core", - label: "Desktop", - kind: "core", - isAddon: false, - }, - httpBaseUrl: "http://192.168.1.44:3773/", - wsBaseUrl: "ws://192.168.1.44:3773/", - reachability: "lan", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "desktop-core", - status: "available", - isDefault: true, - description: "Reachable from devices on the same network.", - }, - { - id: "manual:https://desktop.example.ts.net", - label: "Custom HTTPS", - provider: { - id: "manual", - label: "Manual", - kind: "manual", - isAddon: false, - }, - httpBaseUrl: "https://desktop.example.ts.net/", - wsBaseUrl: "wss://desktop.example.ts.net/", - reachability: "public", - compatibility: { - hostedHttpsApp: "compatible", - desktopApp: "compatible", - }, - source: "user", - status: "unknown", - description: "User-configured HTTPS endpoint for this desktop backend.", - }, - { - id: "manual:http://desktop.example.test:3773", - label: "Custom endpoint", - provider: { - id: "manual", - label: "Manual", - kind: "manual", - isAddon: false, - }, - httpBaseUrl: "http://desktop.example.test:3773/", - wsBaseUrl: "ws://desktop.example.test:3773/", - reachability: "public", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "user", - status: "unknown", - description: "User-configured endpoint for this desktop backend.", - }, - ]); - }); -}); - -describe("resolveDesktopServerExposure", () => { - it("keeps the desktop server loopback-only when local-only mode is selected", () => { - expect( - resolveDesktopServerExposure({ - mode: "local-only", - port: 3773, - networkInterfaces: {}, - }), - ).toEqual({ - mode: "local-only", - bindHost: "127.0.0.1", - localHttpUrl: "http://127.0.0.1:3773", - localWsUrl: "ws://127.0.0.1:3773", - endpointUrl: null, - advertisedHost: null, - }); - }); - - it("binds to all interfaces in network-accessible mode", () => { - expect( - resolveDesktopServerExposure({ - mode: "network-accessible", - port: 3773, - networkInterfaces: { - en0: [ - { - address: "192.168.1.44", - family: "IPv4", - internal: false, - netmask: "255.255.255.0", - cidr: "192.168.1.44/24", - mac: "00:00:00:00:00:00", - }, - ], - }, - }), - ).toEqual({ - mode: "network-accessible", - bindHost: "0.0.0.0", - localHttpUrl: "http://127.0.0.1:3773", - localWsUrl: "ws://127.0.0.1:3773", - endpointUrl: "http://192.168.1.44:3773", - advertisedHost: "192.168.1.44", - }); - }); - - it("stays network-accessible even when no LAN address is currently detectable", () => { - expect( - resolveDesktopServerExposure({ - mode: "network-accessible", - port: 3773, - networkInterfaces: {}, - }), - ).toEqual({ - mode: "network-accessible", - bindHost: "0.0.0.0", - localHttpUrl: "http://127.0.0.1:3773", - localWsUrl: "ws://127.0.0.1:3773", - endpointUrl: null, - advertisedHost: null, - }); - }); -}); diff --git a/apps/desktop/src/serverExposure.ts b/apps/desktop/src/serverExposure.ts deleted file mode 100644 index fbf06d391db..00000000000 --- a/apps/desktop/src/serverExposure.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { - createAdvertisedEndpoint, - type CreateAdvertisedEndpointInput, -} from "@t3tools/client-runtime"; -import type { - AdvertisedEndpoint, - AdvertisedEndpointProvider, - DesktopServerExposureMode, -} from "@t3tools/contracts"; - -export const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; -export const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; - -export interface DesktopNetworkInterfaceInfo { - readonly address: string; - readonly family: string | number; - readonly internal: boolean; - readonly netmask?: string; - readonly mac?: string; - readonly cidr?: string | null; - readonly scopeid?: number; -} - -export type DesktopNetworkInterfaces = Readonly< - Record ->; - -export interface DesktopServerExposure { - readonly mode: DesktopServerExposureMode; - readonly bindHost: string; - readonly localHttpUrl: string; - readonly localWsUrl: string; - readonly endpointUrl: string | null; - readonly advertisedHost: string | null; -} - -export interface DesktopAdvertisedEndpointInput { - readonly port: number; - readonly exposure: DesktopServerExposure; - readonly customHttpsEndpointUrls?: readonly string[]; -} - -const DESKTOP_CORE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { - id: "desktop-core", - label: "Desktop", - kind: "core", - isAddon: false, -}; - -const DESKTOP_MANUAL_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { - id: "manual", - label: "Manual", - kind: "manual", - isAddon: false, -}; - -const normalizeOptionalHost = (value: string | undefined): string | undefined => { - const normalized = value?.trim(); - return normalized && normalized.length > 0 ? normalized : undefined; -}; - -const isUsableLanIpv4Address = (address: string): boolean => - !address.startsWith("127.") && !address.startsWith("169.254."); - -function isHttpsEndpointUrl(value: string): boolean { - try { - return new URL(value).protocol === "https:"; - } catch { - return false; - } -} - -export function resolveLanAdvertisedHost( - networkInterfaces: DesktopNetworkInterfaces, - explicitHost: string | undefined, -): string | null { - const normalizedExplicitHost = normalizeOptionalHost(explicitHost); - if (normalizedExplicitHost) { - return normalizedExplicitHost; - } - - for (const interfaceAddresses of Object.values(networkInterfaces)) { - if (!interfaceAddresses) continue; - - for (const address of interfaceAddresses) { - if (address.internal) continue; - if (address.family !== "IPv4") continue; - if (!isUsableLanIpv4Address(address.address)) continue; - return address.address; - } - } - - return null; -} - -export function resolveDesktopServerExposure(input: { - readonly mode: DesktopServerExposureMode; - readonly port: number; - readonly networkInterfaces: DesktopNetworkInterfaces; - readonly advertisedHostOverride?: string; -}): DesktopServerExposure { - const localHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${input.port}`; - const localWsUrl = `ws://${DESKTOP_LOOPBACK_HOST}:${input.port}`; - - if (input.mode === "local-only") { - return { - mode: input.mode, - bindHost: DESKTOP_LOOPBACK_HOST, - localHttpUrl, - localWsUrl, - endpointUrl: null, - advertisedHost: null, - }; - } - - const advertisedHost = resolveLanAdvertisedHost( - input.networkInterfaces, - input.advertisedHostOverride, - ); - - return { - mode: input.mode, - bindHost: DESKTOP_LAN_BIND_HOST, - localHttpUrl, - localWsUrl, - endpointUrl: advertisedHost ? `http://${advertisedHost}:${input.port}` : null, - advertisedHost, - }; -} - -function createDesktopEndpoint( - input: Omit, -): AdvertisedEndpoint { - return createAdvertisedEndpoint({ - ...input, - provider: DESKTOP_CORE_ENDPOINT_PROVIDER, - source: "desktop-core", - }); -} - -function createManualEndpoint( - input: Omit, -): AdvertisedEndpoint { - return createAdvertisedEndpoint({ - ...input, - provider: DESKTOP_MANUAL_ENDPOINT_PROVIDER, - source: "user", - }); -} - -export function resolveDesktopCoreAdvertisedEndpoints( - input: DesktopAdvertisedEndpointInput, -): readonly AdvertisedEndpoint[] { - const endpoints: AdvertisedEndpoint[] = [ - createDesktopEndpoint({ - id: `desktop-loopback:${input.port}`, - label: "This machine", - httpBaseUrl: input.exposure.localHttpUrl, - reachability: "loopback", - status: "available", - description: "Loopback endpoint for this desktop app.", - }), - ]; - - if (input.exposure.endpointUrl) { - endpoints.push( - createDesktopEndpoint({ - id: `desktop-lan:${input.exposure.endpointUrl}`, - label: "Local network", - httpBaseUrl: input.exposure.endpointUrl, - reachability: "lan", - status: "available", - isDefault: true, - description: "Reachable from devices on the same network.", - }), - ); - } - - for (const customEndpointUrl of input.customHttpsEndpointUrls ?? []) { - try { - const isHttpsEndpoint = isHttpsEndpointUrl(customEndpointUrl); - endpoints.push( - createManualEndpoint({ - id: `manual:${customEndpointUrl}`, - label: isHttpsEndpoint ? "Custom HTTPS" : "Custom endpoint", - httpBaseUrl: customEndpointUrl, - reachability: "public", - ...(isHttpsEndpoint ? ({ hostedHttpsCompatibility: "compatible" } as const) : {}), - status: "unknown", - description: isHttpsEndpoint - ? "User-configured HTTPS endpoint for this desktop backend." - : "User-configured endpoint for this desktop backend.", - }), - ); - } catch { - // Ignore malformed user-configured endpoints without dropping valid endpoints. - } - } - - return endpoints; -} diff --git a/apps/desktop/src/syncShellEnvironment.ts b/apps/desktop/src/syncShellEnvironment.ts deleted file mode 100644 index 1716c6d9dd7..00000000000 --- a/apps/desktop/src/syncShellEnvironment.ts +++ /dev/null @@ -1,764 +0,0 @@ -import { - Context, - Data, - Duration, - Effect, - FileSystem, - Layer, - Option, - Path, - Scope, - Stream, -} from "effect"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; - -import { DesktopEnvironment } from "./desktopEnvironment.ts"; - -type EnvironmentPatch = Partial>; - -export interface WindowsEnvironmentProbeOptions { - readonly loadProfile?: boolean; -} - -export interface CommandAvailabilityOptions { - readonly platform: NodeJS.Platform; - readonly env: NodeJS.ProcessEnv; -} - -const LOGIN_SHELL_ENV_NAMES = [ - "PATH", - "SSH_AUTH_SOCK", - "HOMEBREW_PREFIX", - "HOMEBREW_CELLAR", - "HOMEBREW_REPOSITORY", - "XDG_CONFIG_HOME", - "XDG_DATA_HOME", -] as const; -const SHELL_ENV_NAME_PATTERN = /^[A-Z0-9_]+$/; -const WINDOWS_PATH_DELIMITER = ";"; -const POSIX_PATH_DELIMITER = ":"; -const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; -const LOGIN_SHELL_TIMEOUT = Duration.seconds(5); -const LAUNCHCTL_TIMEOUT = Duration.seconds(2); -const PROCESS_TERMINATE_GRACE = Duration.seconds(1); - -export class DesktopShellEnvironmentCommandError extends Data.TaggedError( - "DesktopShellEnvironmentCommandError", -)<{ - readonly command: readonly string[]; - readonly message: string; - readonly exitCode: number | null; - readonly stderr: string; -}> {} - -export interface DesktopShellEnvironmentConfigShape { - readonly env: NodeJS.ProcessEnv; - readonly platform: NodeJS.Platform; - readonly userShell: Option.Option; -} - -export class DesktopShellEnvironmentConfig extends Context.Service< - DesktopShellEnvironmentConfig, - DesktopShellEnvironmentConfigShape ->()("t3/desktop/ShellEnvironmentConfig") {} - -export interface DesktopShellEnvironmentProbeShape { - readonly readLoginShellEnvironment: ( - shell: string, - names: ReadonlyArray, - ) => Effect.Effect; - readonly readLaunchctlPath: Effect.Effect, unknown>; - readonly readWindowsShellEnvironment: ( - names: ReadonlyArray, - options: WindowsEnvironmentProbeOptions, - ) => Effect.Effect; - readonly isWindowsCommandAvailable: ( - command: string, - options: CommandAvailabilityOptions, - ) => Effect.Effect; -} - -export class DesktopShellEnvironmentProbe extends Context.Service< - DesktopShellEnvironmentProbe, - DesktopShellEnvironmentProbeShape ->()("t3/desktop/ShellEnvironmentProbe") {} - -export interface DesktopShellEnvironmentShape { - readonly sync: Effect.Effect; -} - -export class DesktopShellEnvironment extends Context.Service< - DesktopShellEnvironment, - DesktopShellEnvironmentShape ->()("t3/desktop/ShellEnvironment") {} - -const trimNonEmptyOption = (value: string | null | undefined): Option.Option => { - const trimmed = value?.trim(); - return trimmed && trimmed.length > 0 ? Option.some(trimmed) : Option.none(); -}; - -function listLoginShellCandidates(input: { - readonly platform: NodeJS.Platform; - readonly shell: string | undefined; - readonly userShell: Option.Option; -}): ReadonlyArray { - const fallbackShell = - input.platform === "darwin" ? "/bin/zsh" : input.platform === "linux" ? "/bin/bash" : ""; - const seen = new Set(); - const candidates: string[] = []; - - for (const candidate of [ - trimNonEmptyOption(input.shell), - input.userShell, - trimNonEmptyOption(fallbackShell), - ]) { - if (Option.isNone(candidate) || seen.has(candidate.value)) { - continue; - } - seen.add(candidate.value); - candidates.push(candidate.value); - } - - return candidates; -} - -function pathDelimiterForPlatform(platform: NodeJS.Platform): string { - return platform === "win32" ? WINDOWS_PATH_DELIMITER : POSIX_PATH_DELIMITER; -} - -function stripWrappingQuotes(value: string): string { - return value.replace(/^"+|"+$/g, ""); -} - -function normalizePathEntryForComparison(entry: string, platform: NodeJS.Platform): string { - const normalized = stripWrappingQuotes(entry.trim()); - return platform === "win32" ? normalized.toLowerCase() : normalized; -} - -function mergePathValues( - preferredPath: Option.Option, - inheritedPath: Option.Option, - platform: NodeJS.Platform, -): Option.Option { - const delimiter = pathDelimiterForPlatform(platform); - const merged: string[] = []; - const seen = new Set(); - - for (const rawValue of [preferredPath, inheritedPath]) { - if (Option.isNone(rawValue)) continue; - - for (const entry of rawValue.value.split(delimiter)) { - const trimmed = entry.trim(); - if (trimmed.length === 0) continue; - - const normalized = normalizePathEntryForComparison(trimmed, platform); - if (normalized.length === 0 || seen.has(normalized)) continue; - - seen.add(normalized); - merged.push(trimmed); - } - } - - return merged.length > 0 ? Option.some(merged.join(delimiter)) : Option.none(); -} - -function readEnvPath(env: NodeJS.ProcessEnv): Option.Option { - return trimNonEmptyOption(env.PATH ?? env.Path ?? env.path); -} - -function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray { - const appData = env.APPDATA?.trim(); - const localAppData = env.LOCALAPPDATA?.trim(); - const userProfile = env.USERPROFILE?.trim(); - - return [ - ...(appData ? [`${appData}\\npm`] : []), - ...(localAppData ? [`${localAppData}\\Programs\\nodejs`, `${localAppData}\\Volta\\bin`] : []), - ...(localAppData ? [`${localAppData}\\pnpm`] : []), - ...(userProfile ? [`${userProfile}\\.bun\\bin`, `${userProfile}\\scoop\\shims`] : []), - ]; -} - -function mergeWindowsEnv( - currentEnv: NodeJS.ProcessEnv, - patch: Partial, -): NodeJS.ProcessEnv { - const nextEnv: NodeJS.ProcessEnv = { ...currentEnv }; - for (const [key, value] of Object.entries(patch)) { - if (value !== undefined) { - nextEnv[key] = value; - } - } - return nextEnv; -} - -function envCaptureStart(name: string): string { - return `__T3CODE_ENV_${name}_START__`; -} - -function envCaptureEnd(name: string): string { - return `__T3CODE_ENV_${name}_END__`; -} - -function buildEnvironmentCaptureCommand(names: ReadonlyArray): string { - return names - .map((name) => { - if (!SHELL_ENV_NAME_PATTERN.test(name)) { - throw new Error(`Unsupported environment variable name: ${name}`); - } - - return [ - `printf '%s\\n' '${envCaptureStart(name)}'`, - `printenv ${name} || true`, - `printf '%s\\n' '${envCaptureEnd(name)}'`, - ].join("; "); - }) - .join("; "); -} - -function buildWindowsEnvironmentCaptureCommand(names: ReadonlyArray): string { - return [ - "$ErrorActionPreference = 'Stop'", - ...names.flatMap((name) => { - if (!SHELL_ENV_NAME_PATTERN.test(name)) { - throw new Error(`Unsupported environment variable name: ${name}`); - } - - return [ - `Write-Output '${envCaptureStart(name)}'`, - `$value = [Environment]::GetEnvironmentVariable('${name}')`, - "if ($null -ne $value -and $value.Length -gt 0) { Write-Output $value }", - `Write-Output '${envCaptureEnd(name)}'`, - ]; - }), - ].join("; "); -} - -function extractEnvironmentValue(output: string, name: string): Option.Option { - const startMarker = envCaptureStart(name); - const endMarker = envCaptureEnd(name); - const startIndex = output.indexOf(startMarker); - if (startIndex === -1) return Option.none(); - - const valueStartIndex = startIndex + startMarker.length; - const endIndex = output.indexOf(endMarker, valueStartIndex); - if (endIndex === -1) return Option.none(); - - const value = output - .slice(valueStartIndex, endIndex) - .replace(/^\r?\n/, "") - .replace(/\r?\n$/, ""); - - return value.length > 0 ? Option.some(value) : Option.none(); -} - -function extractEnvironment(output: string, names: ReadonlyArray): EnvironmentPatch { - const environment: EnvironmentPatch = {}; - for (const name of names) { - const value = extractEnvironmentValue(output, name); - if (Option.isSome(value)) { - environment[name] = value.value; - } - } - return environment; -} - -const collectProcessOutput = (stream: Stream.Stream): Effect.Effect => - stream.pipe( - Stream.decodeText(), - Stream.runFold( - () => "", - (acc, chunk) => acc + chunk, - ), - ); - -function commandError(input: { - readonly command: readonly string[]; - readonly message: string; - readonly exitCode: number | null; - readonly stderr?: string; -}): DesktopShellEnvironmentCommandError { - return new DesktopShellEnvironmentCommandError({ - command: input.command, - message: input.message, - exitCode: input.exitCode, - stderr: input.stderr ?? "", - }); -} - -const runCommandOnce = Effect.fn("desktop.shellEnvironment.runCommandOnce")(function* (input: { - readonly command: string; - readonly args: ReadonlyArray; - readonly shell?: boolean; -}): Effect.fn.Return< - string, - DesktopShellEnvironmentCommandError, - ChildProcessSpawner.ChildProcessSpawner | Scope.Scope -> { - const command = [input.command, ...input.args]; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const child = yield* spawner - .spawn( - ChildProcess.make(input.command, input.args, { - shell: input.shell ?? false, - stdout: "pipe", - stderr: "pipe", - stdin: "ignore", - killSignal: "SIGTERM", - forceKillAfter: PROCESS_TERMINATE_GRACE, - }), - ) - .pipe( - Effect.mapError((cause) => - commandError({ - command, - message: cause instanceof Error ? cause.message : "Failed to spawn shell probe.", - exitCode: null, - }), - ), - ); - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectProcessOutput(child.stdout), - collectProcessOutput(child.stderr), - child.exitCode.pipe(Effect.map(Number)), - ], - { concurrency: "unbounded" }, - ).pipe( - Effect.mapError((cause) => - commandError({ - command, - message: cause instanceof Error ? cause.message : "Failed to run shell probe.", - exitCode: null, - }), - ), - ); - - if (exitCode !== 0) { - return yield* commandError({ - command, - message: `Shell probe exited with code ${exitCode}.`, - exitCode, - stderr, - }); - } - - return stdout; -}); - -const runCommand = (input: { - readonly command: string; - readonly args: ReadonlyArray; - readonly shell?: boolean; - readonly timeout: Duration.Duration; -}): Effect.Effect< - string, - DesktopShellEnvironmentCommandError, - ChildProcessSpawner.ChildProcessSpawner | Scope.Scope -> => - runCommandOnce(input).pipe( - Effect.timeoutOption(input.timeout), - Effect.flatMap( - Option.match({ - onNone: () => - Effect.fail( - commandError({ - command: [input.command, ...input.args], - message: `Shell probe timed out after ${Duration.format(input.timeout)}.`, - exitCode: null, - }), - ), - onSome: Effect.succeed, - }), - ), - ); - -const readLoginShellEnvironmentEffect = ( - shell: string, - names: ReadonlyArray, -): Effect.Effect< - EnvironmentPatch, - DesktopShellEnvironmentCommandError, - ChildProcessSpawner.ChildProcessSpawner -> => { - if (names.length === 0) { - return Effect.succeed({}); - } - - return runCommand({ - command: shell, - args: ["-ilc", buildEnvironmentCaptureCommand(names)], - timeout: LOGIN_SHELL_TIMEOUT, - }).pipe( - Effect.map((output) => extractEnvironment(output, names)), - Effect.scoped, - ); -}; - -const readLaunchctlPathEffect: Effect.Effect< - Option.Option, - never, - ChildProcessSpawner.ChildProcessSpawner -> = runCommand({ - command: "/bin/launchctl", - args: ["getenv", "PATH"], - timeout: LAUNCHCTL_TIMEOUT, -}).pipe( - Effect.map((output) => trimNonEmptyOption(output)), - Effect.catch(() => Effect.succeed(Option.none())), - Effect.scoped, -); - -const readWindowsShellEnvironmentEffect = ( - names: ReadonlyArray, - options: WindowsEnvironmentProbeOptions, -): Effect.Effect => { - if (names.length === 0) { - return Effect.succeed({}); - } - - const command = buildWindowsEnvironmentCaptureCommand(names); - const args = [ - "-NoLogo", - ...(options.loadProfile ? ([] as const) : (["-NoProfile"] as const)), - "-NonInteractive", - "-Command", - command, - ]; - - return Effect.gen(function* () { - for (const shell of WINDOWS_SHELL_CANDIDATES) { - const output = yield* runCommand({ - command: shell, - args, - shell: true, - timeout: LOGIN_SHELL_TIMEOUT, - }).pipe(Effect.option, Effect.scoped); - if (Option.isSome(output)) { - return extractEnvironment(output.value, names); - } - } - - return {}; - }); -}; - -function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray { - const rawValue = env.PATHEXT; - const fallback = [".COM", ".EXE", ".BAT", ".CMD"]; - if (!rawValue) return fallback; - - const parsed = rawValue - .split(WINDOWS_PATH_DELIMITER) - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0) - .map((entry) => (entry.startsWith(".") ? entry.toUpperCase() : `.${entry.toUpperCase()}`)); - return parsed.length > 0 ? Array.from(new Set(parsed)) : fallback; -} - -function resolveCommandCandidates(input: { - readonly command: string; - readonly platform: NodeJS.Platform; - readonly windowsPathExtensions: ReadonlyArray; -}): ReadonlyArray { - if (input.platform !== "win32") return [input.command]; - const extension = input.command.slice(input.command.lastIndexOf(".")).toUpperCase(); - - if (input.command.includes(".") && input.windowsPathExtensions.includes(extension)) { - const commandWithoutExtension = input.command.slice(0, -extension.length); - return Array.from( - new Set([ - input.command, - `${commandWithoutExtension}${extension}`, - `${commandWithoutExtension}${extension.toLowerCase()}`, - ]), - ); - } - - const candidates: string[] = []; - for (const candidateExtension of input.windowsPathExtensions) { - candidates.push(`${input.command}${candidateExtension}`); - candidates.push(`${input.command}${candidateExtension.toLowerCase()}`); - } - return Array.from(new Set(candidates)); -} - -function isPathCommand(command: string): boolean { - return command.includes("/") || command.includes("\\"); -} - -const isExecutableFile = Effect.fn("desktop.shellEnvironment.isExecutableFile")(function* ( - filePath: string, - platform: NodeJS.Platform, - windowsPathExtensions: ReadonlyArray, -): Effect.fn.Return { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const stat = yield* fileSystem.stat(filePath).pipe(Effect.option); - if (Option.isNone(stat) || stat.value.type !== "File") { - return false; - } - - if (platform !== "win32") { - return yield* fileSystem.access(filePath, { ok: true }).pipe( - Effect.as(true), - Effect.catch(() => Effect.succeed(false)), - ); - } - - const extension = path.extname(filePath).toUpperCase(); - return extension.length > 0 && windowsPathExtensions.includes(extension); -}); - -const resolveCommandPathEffect = Effect.fn("desktop.shellEnvironment.resolveCommandPath")( - function* ( - command: string, - options: CommandAvailabilityOptions, - ): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { - const path = yield* Path.Path; - const windowsPathExtensions = - options.platform === "win32" ? resolveWindowsPathExtensions(options.env) : []; - const commandCandidates = resolveCommandCandidates({ - command, - platform: options.platform, - windowsPathExtensions, - }); - - if (isPathCommand(command)) { - for (const candidate of commandCandidates) { - if (yield* isExecutableFile(candidate, options.platform, windowsPathExtensions)) { - return Option.some(candidate); - } - } - return Option.none(); - } - - const pathValue = readEnvPath(options.env); - if (Option.isNone(pathValue)) return Option.none(); - - const pathEntries = pathValue.value - .split(pathDelimiterForPlatform(options.platform)) - .map((entry) => stripWrappingQuotes(entry.trim())) - .filter((entry) => entry.length > 0); - - for (const pathEntry of pathEntries) { - for (const candidate of commandCandidates) { - const candidatePath = path.join(pathEntry, candidate); - if (yield* isExecutableFile(candidatePath, options.platform, windowsPathExtensions)) { - return Option.some(candidatePath); - } - } - } - - return Option.none(); - }, -); - -const isWindowsCommandAvailableEffect = ( - command: string, - options: CommandAvailabilityOptions, -): Effect.Effect => - resolveCommandPathEffect(command, options).pipe(Effect.map(Option.isSome)); - -export const DesktopShellEnvironmentProbeLive = Layer.effect( - DesktopShellEnvironmentProbe, - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - return { - readLoginShellEnvironment: (shell, names) => - readLoginShellEnvironmentEffect(shell, names).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), - ), - readLaunchctlPath: readLaunchctlPathEffect.pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), - ), - readWindowsShellEnvironment: (names, options) => - readWindowsShellEnvironmentEffect(names, options).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), - ), - isWindowsCommandAvailable: (command, options) => - isWindowsCommandAvailableEffect(command, options).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ), - } satisfies DesktopShellEnvironmentProbeShape; - }), -); - -export const DesktopShellEnvironmentConfigLive = Layer.effect( - DesktopShellEnvironmentConfig, - Effect.gen(function* () { - const environment = yield* DesktopEnvironment; - return { - env: process.env, - platform: environment.platform, - userShell: Option.none(), - } satisfies DesktopShellEnvironmentConfigShape; - }), -); - -const applyEnvironmentPatch = (env: NodeJS.ProcessEnv, patch: EnvironmentPatch): void => { - for (const [key, value] of Object.entries(patch)) { - if (value !== undefined) { - env[key] = value; - } - } -}; - -const readWindowsEnvironmentSafely = ( - probe: DesktopShellEnvironmentProbeShape, - names: ReadonlyArray, - options: WindowsEnvironmentProbeOptions, -): Effect.Effect => - probe.readWindowsShellEnvironment(names, options).pipe(Effect.catch(() => Effect.succeed({}))); - -const resolveWindowsEnvironmentEffect = Effect.fn( - "desktop.shellEnvironment.resolveWindowsEnvironment", -)(function* ( - env: NodeJS.ProcessEnv, -): Effect.fn.Return, never, DesktopShellEnvironmentProbe> { - const probe = yield* DesktopShellEnvironmentProbe; - const shellPath = yield* readWindowsEnvironmentSafely(probe, ["PATH"], { - loadProfile: false, - }).pipe(Effect.map((environment) => trimNonEmptyOption(environment.PATH))); - const mergedPath = mergePathValues(shellPath, readEnvPath(env), "win32"); - const knownCliPath = trimNonEmptyOption( - resolveKnownWindowsCliDirs(env).join(WINDOWS_PATH_DELIMITER), - ); - const baselinePath = mergePathValues(knownCliPath, mergedPath, "win32"); - const baselinePatch: Partial = Option.match(baselinePath, { - onNone: () => ({}), - onSome: (value) => ({ PATH: value }), - }); - const baselineEnv = mergeWindowsEnv(env, baselinePatch); - - const nodeAvailable = yield* probe - .isWindowsCommandAvailable("node", { platform: "win32", env: baselineEnv }) - .pipe(Effect.catch(() => Effect.succeed(false))); - if (nodeAvailable) { - return baselinePatch; - } - - const profiledEnvironment = yield* readWindowsEnvironmentSafely( - probe, - ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], - { loadProfile: true }, - ); - const profiledPath = mergePathValues( - trimNonEmptyOption(profiledEnvironment.PATH), - baselinePath, - "win32", - ); - const profiledPatch: Partial = { - ...Option.match(profiledPath, { - onNone: () => ({}), - onSome: (value) => ({ PATH: value }), - }), - ...(profiledEnvironment.FNM_DIR ? { FNM_DIR: profiledEnvironment.FNM_DIR } : {}), - ...(profiledEnvironment.FNM_MULTISHELL_PATH - ? { FNM_MULTISHELL_PATH: profiledEnvironment.FNM_MULTISHELL_PATH } - : {}), - }; - - return Object.keys(profiledPatch).length > 0 - ? { ...baselinePatch, ...profiledPatch } - : baselinePatch; -}); - -const syncPosixShellEnvironment = Effect.fn("desktop.shellEnvironment.syncPosix")(function* ( - config: DesktopShellEnvironmentConfigShape, -): Effect.fn.Return { - const probe = yield* DesktopShellEnvironmentProbe; - const shellEnvironment: EnvironmentPatch = {}; - - for (const shell of listLoginShellCandidates({ - platform: config.platform, - shell: config.env.SHELL, - userShell: config.userShell, - })) { - const result = yield* probe.readLoginShellEnvironment(shell, LOGIN_SHELL_ENV_NAMES).pipe( - Effect.option, - Effect.tap((environment) => - Option.isNone(environment) - ? Effect.logWarning("failed to read login shell environment", { shell }) - : Effect.void, - ), - ); - - if (Option.isSome(result)) { - Object.assign(shellEnvironment, result.value); - if (shellEnvironment.PATH) { - break; - } - } - } - - const launchctlPath = - config.platform === "darwin" && !shellEnvironment.PATH - ? yield* probe.readLaunchctlPath.pipe( - Effect.catch(() => Effect.succeed(Option.none())), - ) - : Option.none(); - const mergedPath = mergePathValues( - trimNonEmptyOption(shellEnvironment.PATH).pipe(Option.orElse(() => launchctlPath)), - readEnvPath(config.env), - config.platform, - ); - if (Option.isSome(mergedPath)) { - config.env.PATH = mergedPath.value; - } - - if (!config.env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) { - config.env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK; - } - - for (const name of [ - "HOMEBREW_PREFIX", - "HOMEBREW_CELLAR", - "HOMEBREW_REPOSITORY", - "XDG_CONFIG_HOME", - "XDG_DATA_HOME", - ] as const) { - if (!config.env[name] && shellEnvironment[name]) { - config.env[name] = shellEnvironment[name]; - } - } -}); - -export const syncShellEnvironmentEffect: Effect.Effect< - void, - never, - DesktopShellEnvironmentConfig | DesktopShellEnvironmentProbe -> = Effect.gen(function* () { - const config = yield* DesktopShellEnvironmentConfig; - - yield* Effect.gen(function* () { - if (config.platform === "win32") { - applyEnvironmentPatch(config.env, yield* resolveWindowsEnvironmentEffect(config.env)); - return; - } - - if (config.platform !== "darwin" && config.platform !== "linux") { - return; - } - - yield* syncPosixShellEnvironment(config); - }); -}); - -export const DesktopShellEnvironmentLive = Layer.effect( - DesktopShellEnvironment, - Effect.gen(function* () { - const config = yield* DesktopShellEnvironmentConfig; - const probe = yield* DesktopShellEnvironmentProbe; - return { - sync: syncShellEnvironmentEffect.pipe( - Effect.provideService(DesktopShellEnvironmentConfig, config), - Effect.provideService(DesktopShellEnvironmentProbe, probe), - ), - } satisfies DesktopShellEnvironmentShape; - }), -); diff --git a/apps/desktop/src/tailscaleEndpointProvider.ts b/apps/desktop/src/tailscaleEndpointProvider.ts index fbadf495bad..8d4df2f91ab 100644 --- a/apps/desktop/src/tailscaleEndpointProvider.ts +++ b/apps/desktop/src/tailscaleEndpointProvider.ts @@ -1,7 +1,4 @@ -import { - createAdvertisedEndpoint, - type CreateAdvertisedEndpointInput, -} from "@t3tools/client-runtime"; +import { createAdvertisedEndpoint } from "@t3tools/client-runtime"; import type { AdvertisedEndpoint, AdvertisedEndpointProvider } from "@t3tools/contracts"; import { buildTailscaleHttpsBaseUrl, @@ -14,7 +11,7 @@ import { Effect, Option } from "effect"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; -import type { DesktopNetworkInterfaces } from "./serverExposure.ts"; +import type { DesktopNetworkInterfaces } from "./main/DesktopServerExposure.ts"; export { isTailscaleIpv4Address, parseTailscaleMagicDnsName } from "@t3tools/tailscale"; @@ -25,17 +22,7 @@ const TAILSCALE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { isAddon: true, }; -function createTailscaleEndpoint( - input: Omit, -): AdvertisedEndpoint { - return createAdvertisedEndpoint({ - ...input, - provider: TAILSCALE_ENDPOINT_PROVIDER, - source: "desktop-addon", - }); -} - -export function resolveTailscaleIpAdvertisedEndpoints(input: { +function resolveTailscaleIpAdvertisedEndpoints(input: { readonly port: number; readonly networkInterfaces: DesktopNetworkInterfaces; }): readonly AdvertisedEndpoint[] { @@ -53,7 +40,9 @@ export function resolveTailscaleIpAdvertisedEndpoints(input: { seen.add(address.address); endpoints.push( - createTailscaleEndpoint({ + createAdvertisedEndpoint({ + provider: TAILSCALE_ENDPOINT_PROVIDER, + source: "desktop-addon", id: `tailscale-ip:http://${address.address}:${input.port}`, label: "Tailscale IP", httpBaseUrl: `http://${address.address}:${input.port}`, @@ -68,7 +57,7 @@ export function resolveTailscaleIpAdvertisedEndpoints(input: { return endpoints; } -export const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn( +const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn( "resolveTailscaleMagicDnsAdvertisedEndpoint", )(function* (input: { readonly dnsName: string | null; @@ -93,7 +82,9 @@ export const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn( : false; return Option.some( - createTailscaleEndpoint({ + createAdvertisedEndpoint({ + provider: TAILSCALE_ENDPOINT_PROVIDER, + source: "desktop-addon", id: `tailscale-magicdns:${httpBaseUrl}`, label: "Tailscale HTTPS", httpBaseUrl, diff --git a/apps/desktop/src/updateChannels.test.ts b/apps/desktop/src/updateChannels.test.ts deleted file mode 100644 index f815fbd81cc..00000000000 --- a/apps/desktop/src/updateChannels.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - doesVersionMatchDesktopUpdateChannel, - isNightlyDesktopVersion, - resolveDefaultDesktopUpdateChannel, -} from "./updateChannels.ts"; - -describe("isNightlyDesktopVersion", () => { - it("detects packaged nightly versions", () => { - expect(isNightlyDesktopVersion("0.0.17-nightly.20260415.1")).toBe(true); - }); - - it("does not flag stable versions as nightly", () => { - expect(isNightlyDesktopVersion("0.0.17")).toBe(false); - }); -}); - -describe("resolveDefaultDesktopUpdateChannel", () => { - it("defaults stable builds to latest", () => { - expect(resolveDefaultDesktopUpdateChannel("0.0.17")).toBe("latest"); - }); - - it("defaults nightly builds to nightly", () => { - expect(resolveDefaultDesktopUpdateChannel("0.0.17-nightly.20260415.1")).toBe("nightly"); - }); -}); - -describe("doesVersionMatchDesktopUpdateChannel", () => { - it("accepts nightly releases on the nightly channel", () => { - expect(doesVersionMatchDesktopUpdateChannel("0.0.17-nightly.20260416.1", "nightly")).toBe(true); - }); - - it("rejects stable releases on the nightly channel", () => { - expect(doesVersionMatchDesktopUpdateChannel("0.0.17", "nightly")).toBe(false); - }); - - it("rejects nightly releases on the stable channel", () => { - expect(doesVersionMatchDesktopUpdateChannel("0.0.17-nightly.20260416.1", "latest")).toBe(false); - }); -}); diff --git a/apps/desktop/src/updateMachine.ts b/apps/desktop/src/updateMachine.ts index 7d5ed271e05..b5037225774 100644 --- a/apps/desktop/src/updateMachine.ts +++ b/apps/desktop/src/updateMachine.ts @@ -4,7 +4,15 @@ import type { DesktopUpdateState, } from "@t3tools/contracts"; -import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState.ts"; +export function nextStatusAfterDownloadFailure( + currentState: DesktopUpdateState, +): DesktopUpdateState["status"] { + return currentState.availableVersion ? "available" : "error"; +} + +export function getCanRetryAfterDownloadFailure(currentState: DesktopUpdateState): boolean { + return currentState.availableVersion !== null; +} export function createInitialDesktopUpdateState( currentVersion: string, diff --git a/apps/desktop/src/updateState.test.ts b/apps/desktop/src/updateState.test.ts deleted file mode 100644 index c2bb4ba12dd..00000000000 --- a/apps/desktop/src/updateState.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { DesktopUpdateState } from "@t3tools/contracts"; - -import { - getCanRetryAfterDownloadFailure, - getAutoUpdateDisabledReason, - nextStatusAfterDownloadFailure, - shouldBroadcastDownloadProgress, -} from "./updateState.ts"; - -const baseState: DesktopUpdateState = { - enabled: true, - status: "idle", - channel: "latest", - currentVersion: "1.0.0", - hostArch: "x64", - appArch: "x64", - runningUnderArm64Translation: false, - availableVersion: null, - downloadedVersion: null, - downloadPercent: null, - checkedAt: null, - message: null, - errorContext: null, - canRetry: false, -}; - -describe("shouldBroadcastDownloadProgress", () => { - it("broadcasts the first downloading progress update", () => { - expect( - shouldBroadcastDownloadProgress( - { ...baseState, status: "downloading", downloadPercent: null }, - 1, - ), - ).toBe(true); - }); - - it("skips progress updates within the same 10% bucket", () => { - expect( - shouldBroadcastDownloadProgress( - { ...baseState, status: "downloading", downloadPercent: 11.2 }, - 18.7, - ), - ).toBe(false); - }); - - it("broadcasts progress updates when a new 10% bucket is reached", () => { - expect( - shouldBroadcastDownloadProgress( - { ...baseState, status: "downloading", downloadPercent: 19.9 }, - 20.1, - ), - ).toBe(true); - }); - - it("broadcasts progress updates when a retry resets the download percentage", () => { - expect( - shouldBroadcastDownloadProgress( - { ...baseState, status: "downloading", downloadPercent: 50.4 }, - 0.2, - ), - ).toBe(true); - }); -}); - -describe("getAutoUpdateDisabledReason", () => { - it("reports development builds as disabled", () => { - expect( - getAutoUpdateDisabledReason({ - isDevelopment: true, - isPackaged: false, - platform: "darwin", - appImage: undefined, - disabledByEnv: false, - hasUpdateFeedConfig: true, - }), - ).toContain("packaged production builds"); - }); - - it("reports packaged local builds without an update feed as disabled", () => { - expect( - getAutoUpdateDisabledReason({ - isDevelopment: false, - isPackaged: true, - platform: "darwin", - appImage: undefined, - disabledByEnv: false, - hasUpdateFeedConfig: false, - }), - ).toContain("no update feed"); - }); - - it("allows packaged builds with an update feed", () => { - expect( - getAutoUpdateDisabledReason({ - isDevelopment: false, - isPackaged: true, - platform: "darwin", - appImage: undefined, - disabledByEnv: false, - hasUpdateFeedConfig: true, - }), - ).toBeNull(); - }); - - it("reports env-disabled auto updates", () => { - expect( - getAutoUpdateDisabledReason({ - isDevelopment: false, - isPackaged: true, - platform: "darwin", - appImage: undefined, - disabledByEnv: true, - hasUpdateFeedConfig: true, - }), - ).toContain("T3CODE_DISABLE_AUTO_UPDATE"); - }); - - it("reports linux non-AppImage builds as disabled", () => { - expect( - getAutoUpdateDisabledReason({ - isDevelopment: false, - isPackaged: true, - platform: "linux", - appImage: undefined, - disabledByEnv: false, - hasUpdateFeedConfig: true, - }), - ).toContain("AppImage"); - }); -}); - -describe("nextStatusAfterDownloadFailure", () => { - it("returns available when an update version is still known", () => { - expect( - nextStatusAfterDownloadFailure({ - ...baseState, - status: "downloading", - availableVersion: "1.1.0", - }), - ).toBe("available"); - }); - - it("returns error when no update version can be retried", () => { - expect( - nextStatusAfterDownloadFailure({ - ...baseState, - status: "downloading", - availableVersion: null, - }), - ).toBe("error"); - }); -}); - -describe("getCanRetryAfterDownloadFailure", () => { - it("returns true when an available version is still present", () => { - expect( - getCanRetryAfterDownloadFailure({ - ...baseState, - status: "downloading", - availableVersion: "1.1.0", - }), - ).toBe(true); - }); - - it("returns false when no version is available to retry", () => { - expect( - getCanRetryAfterDownloadFailure({ - ...baseState, - status: "downloading", - availableVersion: null, - }), - ).toBe(false); - }); -}); diff --git a/apps/desktop/src/updateState.ts b/apps/desktop/src/updateState.ts deleted file mode 100644 index 928bb408865..00000000000 --- a/apps/desktop/src/updateState.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { DesktopUpdateState } from "@t3tools/contracts"; - -export function shouldBroadcastDownloadProgress( - currentState: DesktopUpdateState, - nextPercent: number, -): boolean { - if (currentState.status !== "downloading") { - return true; - } - - const currentPercent = currentState.downloadPercent; - if (currentPercent === null) { - return true; - } - - const previousStep = Math.floor(currentPercent / 10); - const nextStep = Math.floor(nextPercent / 10); - return nextStep !== previousStep || nextPercent === 100; -} - -export function nextStatusAfterDownloadFailure( - currentState: DesktopUpdateState, -): DesktopUpdateState["status"] { - return currentState.availableVersion ? "available" : "error"; -} - -export function getCanRetryAfterDownloadFailure(currentState: DesktopUpdateState): boolean { - return currentState.availableVersion !== null; -} - -export function getAutoUpdateDisabledReason(args: { - isDevelopment: boolean; - isPackaged: boolean; - platform: NodeJS.Platform; - appImage?: string | undefined; - disabledByEnv: boolean; - hasUpdateFeedConfig: boolean; -}): string | null { - if (!args.hasUpdateFeedConfig) { - return "Automatic updates are not available because no update feed is configured."; - } - if (args.isDevelopment || !args.isPackaged) { - return "Automatic updates are only available in packaged production builds."; - } - if (args.disabledByEnv) { - return "Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting."; - } - if (args.platform === "linux" && !args.appImage) { - return "Automatic updates on Linux require running the AppImage build."; - } - return null; -} diff --git a/apps/desktop/src/windowReveal.test.ts b/apps/desktop/src/windowReveal.test.ts deleted file mode 100644 index 88285fec0cb..00000000000 --- a/apps/desktop/src/windowReveal.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { EventEmitter } from "node:events"; - -import { describe, expect, it, vi } from "vitest"; - -import { bindFirstRevealTrigger } from "./windowReveal.ts"; - -describe("bindFirstRevealTrigger", () => { - it("reveals when the first trigger fires", () => { - const window = new EventEmitter(); - const webContents = new EventEmitter(); - const reveal = vi.fn(); - - bindFirstRevealTrigger( - [ - (fire) => window.once("ready-to-show", fire), - (fire) => webContents.once("did-finish-load", fire), - ], - reveal, - ); - - window.emit("ready-to-show"); - - expect(reveal).toHaveBeenCalledTimes(1); - }); - - it("reveals when only the fallback trigger fires (Wayland deadlock case)", () => { - const window = new EventEmitter(); - const webContents = new EventEmitter(); - const reveal = vi.fn(); - - bindFirstRevealTrigger( - [ - (fire) => window.once("ready-to-show", fire), - (fire) => webContents.once("did-finish-load", fire), - ], - reveal, - ); - - webContents.emit("did-finish-load"); - - expect(reveal).toHaveBeenCalledTimes(1); - }); - - it("only reveals once when multiple triggers fire", () => { - const window = new EventEmitter(); - const webContents = new EventEmitter(); - const reveal = vi.fn(); - - bindFirstRevealTrigger( - [ - (fire) => window.once("ready-to-show", fire), - (fire) => webContents.once("did-finish-load", fire), - ], - reveal, - ); - - webContents.emit("did-finish-load"); - window.emit("ready-to-show"); - - expect(reveal).toHaveBeenCalledTimes(1); - }); - - it("subscribers using `once` ignore re-emitted events after reveal", () => { - const window = new EventEmitter(); - const reveal = vi.fn(); - - bindFirstRevealTrigger([(fire) => window.once("ready-to-show", fire)], reveal); - - window.emit("ready-to-show"); - window.emit("ready-to-show"); - - expect(reveal).toHaveBeenCalledTimes(1); - }); -}); diff --git a/apps/desktop/src/windowReveal.ts b/apps/desktop/src/windowReveal.ts deleted file mode 100644 index 8faf65aeb15..00000000000 --- a/apps/desktop/src/windowReveal.ts +++ /dev/null @@ -1,28 +0,0 @@ -export type RevealSubscription = (listener: () => void) => void; - -/** - * Wire a reveal callback to fire exactly once, on whichever of the provided - * event subscribers fires first. Each subscriber is responsible for binding - * its own event source. - * - * Used by the desktop main window's first-paint reveal logic. The standard - * Electron pattern is to wait for `ready-to-show` before calling `show()`, - * but on Linux/Wayland with `show: false`, `ready-to-show` only fires after - * `show()` is called, deadlocking that pattern. Subscribing to both - * `ready-to-show` and `did-finish-load` (or any other "renderer is alive" - * signal) lets the window surface reliably across platforms. - */ -export function bindFirstRevealTrigger( - subscribers: readonly RevealSubscription[], - reveal: () => void, -): void { - let revealed = false; - const fire = () => { - if (revealed) return; - revealed = true; - reveal(); - }; - for (const subscribe of subscribers) { - subscribe(fire); - } -} From ac4ff5ebe0586170747a0f2e3e7793f1503234a6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 20:49:20 -0700 Subject: [PATCH 17/43] Refactor desktop IPC to use main service layers - Remove legacy IPC action layers and wire handlers directly to main services - Fold relaunch handling into server exposure changes and update checks - Update desktop tests and shared service imports to match the new layering --- .../desktop/src/ipc/methods/serverExposure.ts | 60 +++--- apps/desktop/src/ipc/methods/updates.ts | 34 +--- apps/desktop/src/ipc/methods/window.ts | 35 +--- .../src/ipc/methods/windowLive.test.ts | 153 ---------------- apps/desktop/src/ipc/methods/windowLive.ts | 82 --------- apps/desktop/src/main.ts | 172 +++++------------- apps/desktop/src/main/DesktopApp.ts | 7 +- .../src/main/DesktopAppIdentity.test.ts | 17 +- .../main/DesktopBackendConfiguration.test.ts | 10 +- apps/desktop/src/main/DesktopBackendEvents.ts | 14 +- .../src/main/DesktopBackendManager.test.ts | 24 ++- .../desktop/src/main/DesktopBackendManager.ts | 36 ++-- .../src/main/DesktopEnvironment.test.ts | 16 +- apps/desktop/src/main/DesktopErrors.ts | 3 - apps/desktop/src/main/DesktopLifecycle.ts | 33 +++- apps/desktop/src/main/DesktopLogging.ts | 8 +- .../src/main/DesktopServerExposure.test.ts | 2 - apps/desktop/src/main/DesktopSettingsState.ts | 16 +- apps/desktop/src/main/DesktopShutdown.test.ts | 52 ------ apps/desktop/src/main/DesktopShutdown.ts | 32 ---- .../src/main/DesktopSshEnvironment.test.ts | 108 ----------- .../desktop/src/main/DesktopSshEnvironment.ts | 2 - .../main/DesktopSshPasswordPrompts.test.ts | 4 +- .../src/main/DesktopSshPasswordPrompts.ts | 4 +- apps/desktop/src/main/DesktopUpdates.test.ts | 4 +- apps/desktop/src/main/DesktopUpdates.ts | 4 +- apps/desktop/src/main/DesktopWindow.ts | 4 +- .../src/main/DesktopWindowIpcActions.ts | 113 ++++++++++++ 28 files changed, 307 insertions(+), 742 deletions(-) delete mode 100644 apps/desktop/src/ipc/methods/windowLive.test.ts delete mode 100644 apps/desktop/src/ipc/methods/windowLive.ts delete mode 100644 apps/desktop/src/main/DesktopErrors.ts delete mode 100644 apps/desktop/src/main/DesktopShutdown.test.ts delete mode 100644 apps/desktop/src/main/DesktopShutdown.ts create mode 100644 apps/desktop/src/main/DesktopWindowIpcActions.ts diff --git a/apps/desktop/src/ipc/methods/serverExposure.ts b/apps/desktop/src/ipc/methods/serverExposure.ts index 82ba7456cb4..9a77db31e9d 100644 --- a/apps/desktop/src/ipc/methods/serverExposure.ts +++ b/apps/desktop/src/ipc/methods/serverExposure.ts @@ -2,14 +2,12 @@ import { AdvertisedEndpoint, DesktopServerExposureModeSchema, DesktopServerExposureStateSchema, - type DesktopServerExposureMode, - type DesktopServerExposureState, } from "@t3tools/contracts"; -import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { DesktopShutdown } from "../../main/DesktopShutdown.ts"; +import * as DesktopLifecycle from "../../main/DesktopLifecycle.ts"; +import * as DesktopServerExposure from "../../main/DesktopServerExposure.ts"; import { GET_ADVERTISED_ENDPOINTS_CHANNEL, GET_SERVER_EXPOSURE_STATE_CHANNEL, @@ -17,47 +15,19 @@ import { SET_TAILSCALE_SERVE_ENABLED_CHANNEL, } from "../channels.ts"; import { makeIpcMethod } from "../DesktopIpc.ts"; -import type { - DesktopServerExposurePersistenceError, - DesktopServerExposureSetModeError, -} from "../../main/DesktopServerExposure.ts"; const SetTailscaleServeEnabledInput = Schema.Struct({ enabled: Schema.Boolean, port: Schema.optionalKey(Schema.Number), }); -export interface DesktopServerExposureIpcActionsShape { - readonly getState: Effect.Effect; - readonly setMode: ( - mode: DesktopServerExposureMode, - ) => Effect.Effect< - DesktopServerExposureState, - DesktopServerExposureSetModeError, - DesktopShutdown - >; - readonly setTailscaleServeEnabled: ( - input: typeof SetTailscaleServeEnabledInput.Type, - ) => Effect.Effect< - DesktopServerExposureState, - DesktopServerExposurePersistenceError, - DesktopShutdown - >; - readonly getAdvertisedEndpoints: Effect.Effect; -} - -export class DesktopServerExposureIpcActions extends Context.Service< - DesktopServerExposureIpcActions, - DesktopServerExposureIpcActionsShape ->()("t3/desktop/Ipc/ServerExposure") {} - export const getServerExposureState = makeIpcMethod({ channel: GET_SERVER_EXPOSURE_STATE_CHANNEL, payload: Schema.Void, result: DesktopServerExposureStateSchema, handler: () => Effect.gen(function* () { - const serverExposure = yield* DesktopServerExposureIpcActions; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; return yield* serverExposure.getState; }), }); @@ -68,8 +38,13 @@ export const setServerExposureMode = makeIpcMethod({ result: DesktopServerExposureStateSchema, handler: (mode) => Effect.gen(function* () { - const serverExposure = yield* DesktopServerExposureIpcActions; - return yield* serverExposure.setMode(mode); + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const change = yield* serverExposure.setMode(mode); + if (change.requiresRelaunch) { + yield* lifecycle.relaunch(`serverExposureMode=${mode}`); + } + return change.state; }), }); @@ -79,8 +54,17 @@ export const setTailscaleServeEnabled = makeIpcMethod({ result: DesktopServerExposureStateSchema, handler: (input) => Effect.gen(function* () { - const serverExposure = yield* DesktopServerExposureIpcActions; - return yield* serverExposure.setTailscaleServeEnabled(input); + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const change = yield* serverExposure.setTailscaleServeEnabled(input); + if (change.requiresRelaunch) { + yield* lifecycle.relaunch( + change.state.tailscaleServeEnabled + ? "tailscale-serve-enabled" + : "tailscale-serve-disabled", + ); + } + return change.state; }), }); @@ -90,7 +74,7 @@ export const getAdvertisedEndpoints = makeIpcMethod({ result: Schema.Array(AdvertisedEndpoint), handler: () => Effect.gen(function* () { - const serverExposure = yield* DesktopServerExposureIpcActions; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; return yield* serverExposure.getAdvertisedEndpoints; }), }); diff --git a/apps/desktop/src/ipc/methods/updates.ts b/apps/desktop/src/ipc/methods/updates.ts index 1f977f4a76a..a7a44e1d1d6 100644 --- a/apps/desktop/src/ipc/methods/updates.ts +++ b/apps/desktop/src/ipc/methods/updates.ts @@ -3,15 +3,11 @@ import { DesktopUpdateChannelSchema, DesktopUpdateCheckResultSchema, DesktopUpdateStateSchema, - type DesktopUpdateActionResult, - type DesktopUpdateChannel, - type DesktopUpdateCheckResult, - type DesktopUpdateState, } from "@t3tools/contracts"; -import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import * as DesktopUpdates from "../../main/DesktopUpdates.ts"; import { UPDATE_CHECK_CHANNEL, UPDATE_DOWNLOAD_CHANNEL, @@ -20,22 +16,6 @@ import { UPDATE_SET_CHANNEL_CHANNEL, } from "../channels.ts"; import { makeIpcMethod } from "../DesktopIpc.ts"; -import type * as DesktopUpdates from "../../main/DesktopUpdates.ts"; - -export interface DesktopUpdateIpcActionsShape { - readonly getState: Effect.Effect; - readonly setChannel: ( - channel: DesktopUpdateChannel, - ) => Effect.Effect; - readonly download: Effect.Effect; - readonly install: Effect.Effect; - readonly check: Effect.Effect; -} - -export class DesktopUpdateIpcActions extends Context.Service< - DesktopUpdateIpcActions, - DesktopUpdateIpcActionsShape ->()("t3/desktop/Ipc/Updates") {} export const getUpdateState = makeIpcMethod({ channel: UPDATE_GET_STATE_CHANNEL, @@ -43,7 +23,7 @@ export const getUpdateState = makeIpcMethod({ result: DesktopUpdateStateSchema, handler: () => Effect.gen(function* () { - const updates = yield* DesktopUpdateIpcActions; + const updates = yield* DesktopUpdates.DesktopUpdates; return yield* updates.getState; }), }); @@ -54,7 +34,7 @@ export const setUpdateChannel = makeIpcMethod({ result: DesktopUpdateStateSchema, handler: (channel) => Effect.gen(function* () { - const updates = yield* DesktopUpdateIpcActions; + const updates = yield* DesktopUpdates.DesktopUpdates; return yield* updates.setChannel(channel); }), }); @@ -65,7 +45,7 @@ export const downloadUpdate = makeIpcMethod({ result: DesktopUpdateActionResultSchema, handler: () => Effect.gen(function* () { - const updates = yield* DesktopUpdateIpcActions; + const updates = yield* DesktopUpdates.DesktopUpdates; return yield* updates.download; }), }); @@ -76,7 +56,7 @@ export const installUpdate = makeIpcMethod({ result: DesktopUpdateActionResultSchema, handler: () => Effect.gen(function* () { - const updates = yield* DesktopUpdateIpcActions; + const updates = yield* DesktopUpdates.DesktopUpdates; return yield* updates.install; }), }); @@ -87,7 +67,7 @@ export const checkForUpdate = makeIpcMethod({ result: DesktopUpdateCheckResultSchema, handler: () => Effect.gen(function* () { - const updates = yield* DesktopUpdateIpcActions; - return yield* updates.check; + const updates = yield* DesktopUpdates.DesktopUpdates; + return yield* updates.check("web-ui"); }), }); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 7dc153d2526..f34b402e737 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -4,15 +4,11 @@ import { DesktopEnvironmentBootstrapSchema, DesktopThemeSchema, PickFolderOptionsSchema, - type DesktopAppBranding, - type DesktopEnvironmentBootstrap, - type DesktopTheme, - type PickFolderOptions, } from "@t3tools/contracts"; -import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import * as DesktopWindowIpcActions from "../../main/DesktopWindowIpcActions.ts"; import { CONFIRM_CHANNEL, CONTEXT_MENU_CHANNEL, @@ -34,27 +30,12 @@ const ContextMenuInput = Schema.Struct({ position: Schema.optionalKey(ContextMenuPosition), }); -export interface DesktopWindowIpcActionsShape { - readonly getAppBranding: Effect.Effect; - readonly getLocalEnvironmentBootstrap: Effect.Effect; - readonly pickFolder: (options: PickFolderOptions | undefined) => Effect.Effect; - readonly confirm: (message: string) => Effect.Effect; - readonly setTheme: (theme: DesktopTheme) => Effect.Effect; - readonly showContextMenu: (input: typeof ContextMenuInput.Type) => Effect.Effect; - readonly openExternal: (url: string) => Effect.Effect; -} - -export class DesktopWindowIpcActions extends Context.Service< - DesktopWindowIpcActions, - DesktopWindowIpcActionsShape ->()("t3/desktop/Ipc/Window") {} - export const getAppBranding = makeSyncIpcMethod({ channel: GET_APP_BRANDING_CHANNEL, result: Schema.NullOr(DesktopAppBrandingSchema), handler: () => Effect.gen(function* () { - const window = yield* DesktopWindowIpcActions; + const window = yield* DesktopWindowIpcActions.DesktopWindowIpcActions; return yield* window.getAppBranding; }), }); @@ -64,7 +45,7 @@ export const getLocalEnvironmentBootstrap = makeSyncIpcMethod({ result: Schema.NullOr(DesktopEnvironmentBootstrapSchema), handler: () => Effect.gen(function* () { - const window = yield* DesktopWindowIpcActions; + const window = yield* DesktopWindowIpcActions.DesktopWindowIpcActions; return yield* window.getLocalEnvironmentBootstrap; }), }); @@ -75,7 +56,7 @@ export const pickFolder = makeIpcMethod({ result: Schema.NullOr(Schema.String), handler: (options) => Effect.gen(function* () { - const window = yield* DesktopWindowIpcActions; + const window = yield* DesktopWindowIpcActions.DesktopWindowIpcActions; return yield* window.pickFolder(options); }), }); @@ -86,7 +67,7 @@ export const confirm = makeIpcMethod({ result: Schema.Boolean, handler: (message) => Effect.gen(function* () { - const window = yield* DesktopWindowIpcActions; + const window = yield* DesktopWindowIpcActions.DesktopWindowIpcActions; return yield* window.confirm(message); }), }); @@ -97,7 +78,7 @@ export const setTheme = makeIpcMethod({ result: Schema.Void, handler: (theme) => Effect.gen(function* () { - const window = yield* DesktopWindowIpcActions; + const window = yield* DesktopWindowIpcActions.DesktopWindowIpcActions; yield* window.setTheme(theme); }), }); @@ -108,7 +89,7 @@ export const showContextMenu = makeIpcMethod({ result: Schema.NullOr(Schema.String), handler: (input) => Effect.gen(function* () { - const window = yield* DesktopWindowIpcActions; + const window = yield* DesktopWindowIpcActions.DesktopWindowIpcActions; return yield* window.showContextMenu(input); }), }); @@ -119,7 +100,7 @@ export const openExternal = makeIpcMethod({ result: Schema.Boolean, handler: (url) => Effect.gen(function* () { - const window = yield* DesktopWindowIpcActions; + const window = yield* DesktopWindowIpcActions.DesktopWindowIpcActions; return yield* window.openExternal(url); }), }); diff --git a/apps/desktop/src/ipc/methods/windowLive.test.ts b/apps/desktop/src/ipc/methods/windowLive.test.ts deleted file mode 100644 index 7d73d965a08..00000000000 --- a/apps/desktop/src/ipc/methods/windowLive.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import { Effect, Layer, Option } from "effect"; -import * as EffectPath from "effect/Path"; -import type * as Electron from "electron"; - -import * as DesktopBackendManager from "../../main/DesktopBackendManager.ts"; -import * as DesktopConfig from "../../main/DesktopConfig.ts"; -import { layer as makeDesktopEnvironmentLayer } from "../../main/DesktopEnvironment.ts"; -import * as ElectronDialog from "../../electron/ElectronDialog.ts"; -import * as ElectronMenu from "../../electron/ElectronMenu.ts"; -import * as ElectronShell from "../../electron/ElectronShell.ts"; -import * as ElectronTheme from "../../electron/ElectronTheme.ts"; -import * as ElectronWindow from "../../electron/ElectronWindow.ts"; -import * as DesktopWindowIpc from "./window.ts"; -import * as DesktopWindowIpcActionsLive from "./windowLive.ts"; - -const backendConfig: DesktopBackendManager.DesktopBackendStartConfig = { - executablePath: "/electron", - entryPath: "/server/bin.mjs", - cwd: "/server", - env: { ELECTRON_RUN_AS_NODE: "1" }, - bootstrap: { - mode: "desktop", - noBrowser: true, - port: 3773, - t3Home: "/tmp/t3", - host: "127.0.0.1", - desktopBootstrapToken: "token", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }, - httpBaseUrl: new URL("http://127.0.0.1:3773"), - captureOutput: true, -}; - -const noWindow = Effect.succeed(Option.none()); - -function makeLayer(currentConfig: Option.Option) { - return DesktopWindowIpcActionsLive.layer.pipe( - Layer.provide( - Layer.mergeAll( - makeDesktopEnvironmentLayer({ - dirname: "/repo/apps/desktop/src", - cwd: "/repo", - platform: "darwin", - processArch: "x64", - appVersion: "1.2.3", - appPath: "/repo", - isPackaged: true, - resourcesPath: "/missing/resources", - runningUnderArm64Translation: false, - }).pipe( - Layer.provide( - Layer.mergeAll(EffectPath.layer, DesktopConfig.layerTest({ T3CODE_HOME: "/tmp/t3" })), - ), - ), - Layer.succeed( - DesktopBackendManager.DesktopBackendManager, - DesktopBackendManager.DesktopBackendManager.of({ - start: Effect.void, - stop: () => Effect.void, - shutdown: Effect.void, - currentConfig: Effect.succeed(currentConfig), - snapshot: Effect.succeed({ - desiredRunning: false, - ready: false, - activePid: Option.none(), - restartAttempt: 0, - restartScheduled: false, - shuttingDown: false, - }), - }), - ), - Layer.succeed(ElectronDialog.ElectronDialog, { - pickFolder: () => Effect.succeed(Option.none()), - confirm: () => Effect.succeed(false), - showMessageBox: () => - Effect.succeed({ - response: 0, - checkboxChecked: false, - } satisfies Electron.MessageBoxReturnValue), - showErrorBox: () => Effect.void, - }), - Layer.succeed(ElectronMenu.ElectronMenu, { - setApplicationMenu: () => Effect.void, - showContextMenu: () => Effect.succeed(Option.none()), - popupTemplate: () => Effect.void, - }), - Layer.succeed(ElectronShell.ElectronShell, { - openExternal: () => Effect.succeed(false), - copyText: () => Effect.void, - }), - Layer.succeed(ElectronTheme.ElectronTheme, { - shouldUseDarkColors: Effect.succeed(false), - setSource: () => Effect.void, - onUpdated: () => Effect.void, - }), - Layer.succeed(ElectronWindow.ElectronWindow, { - create: () => Effect.die(new Error("unexpected BrowserWindow creation")), - main: noWindow, - currentMainOrFirst: noWindow, - focusedMainOrFirst: noWindow, - setMain: () => Effect.void, - clearMain: () => Effect.void, - reveal: () => Effect.void, - sendAll: () => Effect.void, - destroyAll: Effect.void, - syncAllAppearance: () => Effect.void, - }), - ), - ), - ); -} - -describe("DesktopWindowIpcActionsLive", () => { - it.effect("returns null before the backend config has been resolved", () => - Effect.gen(function* () { - const window = yield* DesktopWindowIpc.DesktopWindowIpcActions; - - assert.equal(yield* window.getLocalEnvironmentBootstrap, null); - }).pipe(Effect.provide(makeLayer(Option.none()))), - ); - - it.effect("derives the local bootstrap from the current backend config", () => - Effect.gen(function* () { - const window = yield* DesktopWindowIpc.DesktopWindowIpcActions; - - assert.deepEqual(yield* window.getLocalEnvironmentBootstrap, { - label: "Local environment", - httpBaseUrl: "http://127.0.0.1:3773/", - wsBaseUrl: "ws://127.0.0.1:3773/", - bootstrapToken: "token", - }); - }).pipe(Effect.provide(makeLayer(Option.some(backendConfig)))), - ); - - it.effect("uses wss when the backend base URL is https", () => - Effect.gen(function* () { - const window = yield* DesktopWindowIpc.DesktopWindowIpcActions; - - assert.equal((yield* window.getLocalEnvironmentBootstrap)?.wsBaseUrl, "wss://example.test/"); - }).pipe( - Effect.provide( - makeLayer( - Option.some({ - ...backendConfig, - httpBaseUrl: new URL("https://example.test"), - }), - ), - ), - ), - ); -}); diff --git a/apps/desktop/src/ipc/methods/windowLive.ts b/apps/desktop/src/ipc/methods/windowLive.ts deleted file mode 100644 index 5a4a1528c53..00000000000 --- a/apps/desktop/src/ipc/methods/windowLive.ts +++ /dev/null @@ -1,82 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; - -import * as DesktopEnvironment from "../../main/DesktopEnvironment.ts"; -import * as DesktopBackendManager from "../../main/DesktopBackendManager.ts"; -import * as ElectronDialog from "../../electron/ElectronDialog.ts"; -import * as ElectronMenu from "../../electron/ElectronMenu.ts"; -import * as ElectronShell from "../../electron/ElectronShell.ts"; -import * as ElectronTheme from "../../electron/ElectronTheme.ts"; -import * as ElectronWindow from "../../electron/ElectronWindow.ts"; -import * as DesktopWindowIpc from "./window.ts"; - -function toWebSocketBaseUrl(httpBaseUrl: URL): string { - const url = new URL(httpBaseUrl.href); - url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; - return url.href; -} - -export const layer = Layer.effect( - DesktopWindowIpc.DesktopWindowIpcActions, - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const backendManager = yield* DesktopBackendManager.DesktopBackendManager; - const electronDialog = yield* ElectronDialog.ElectronDialog; - const electronMenu = yield* ElectronMenu.ElectronMenu; - const electronShell = yield* ElectronShell.ElectronShell; - const electronTheme = yield* ElectronTheme.ElectronTheme; - const electronWindow = yield* ElectronWindow.ElectronWindow; - - return DesktopWindowIpc.DesktopWindowIpcActions.of({ - getAppBranding: Effect.succeed(environment.branding), - getLocalEnvironmentBootstrap: backendManager.currentConfig.pipe( - Effect.map( - Option.map((config) => { - const bootstrap = config.bootstrap; - return { - label: "Local environment", - httpBaseUrl: config.httpBaseUrl.href, - wsBaseUrl: toWebSocketBaseUrl(config.httpBaseUrl), - ...(bootstrap.desktopBootstrapToken - ? { bootstrapToken: bootstrap.desktopBootstrapToken } - : {}), - }; - }), - ), - Effect.map(Option.getOrNull), - ), - pickFolder: (options) => - Effect.gen(function* () { - const selectedPath = yield* electronDialog.pickFolder({ - owner: yield* electronWindow.focusedMainOrFirst, - defaultPath: environment.resolvePickFolderDefaultPath(options), - }); - return Option.getOrNull(selectedPath); - }), - confirm: (message) => - Effect.gen(function* () { - return yield* electronDialog.confirm({ - owner: yield* electronWindow.focusedMainOrFirst, - message, - }); - }), - setTheme: (theme) => electronTheme.setSource(theme), - showContextMenu: ({ items, position }) => - Effect.gen(function* () { - const window = yield* electronWindow.focusedMainOrFirst; - if (Option.isNone(window)) { - return null; - } - - const selectedItemId = yield* electronMenu.showContextMenu({ - window: window.value, - items, - position: Option.fromNullishOr(position), - }); - return Option.getOrNull(selectedItemId); - }), - openExternal: (url) => electronShell.openExternal(url), - }); - }), -); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3b174328814..b15ede1c2ab 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -14,9 +14,6 @@ import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; import type { DesktopSettings } from "./desktopSettings.ts"; import * as DesktopIpc from "./ipc/DesktopIpc.ts"; -import { DesktopServerExposureIpcActions } from "./ipc/methods/serverExposure.ts"; -import { DesktopUpdateIpcActions } from "./ipc/methods/updates.ts"; -import * as DesktopWindowIpcActionsLive from "./ipc/methods/windowLive.ts"; import * as ElectronApp from "./electron/ElectronApp.ts"; import * as ElectronDialog from "./electron/ElectronDialog.ts"; import * as ElectronMenu from "./electron/ElectronMenu.ts"; @@ -41,20 +38,19 @@ import * as DesktopRun from "./main/DesktopRun.ts"; import * as DesktopServerExposure from "./main/DesktopServerExposure.ts"; import * as DesktopSettingsState from "./main/DesktopSettingsState.ts"; import * as DesktopShellEnvironment from "./main/DesktopShellEnvironment.ts"; -import * as DesktopShutdown from "./main/DesktopShutdown.ts"; import * as DesktopSshEnvironment from "./main/DesktopSshEnvironment.ts"; import * as DesktopSshPasswordPrompts from "./main/DesktopSshPasswordPrompts.ts"; import * as DesktopSshRemoteApi from "./main/DesktopSshRemoteApi.ts"; import * as DesktopState from "./main/DesktopState.ts"; import * as DesktopUpdates from "./main/DesktopUpdates.ts"; import * as DesktopWindow from "./main/DesktopWindow.ts"; - -const desktopConfigLayer = DesktopConfig.layer; +import * as DesktopWindowIpcActions from "./main/DesktopWindowIpcActions.ts"; const desktopEnvironmentLayer = Layer.unwrap( Effect.gen(function* () { - const electronApp = yield* ElectronApp.ElectronApp; - const metadata = yield* electronApp.metadata; + const metadata = yield* Effect.service(ElectronApp.ElectronApp).pipe( + Effect.flatMap((app) => app.metadata), + ); return DesktopEnvironment.layer({ dirname: __dirname, cwd: process.cwd(), @@ -63,13 +59,7 @@ const desktopEnvironmentLayer = Layer.unwrap( ...metadata, }); }), -).pipe(Layer.provide(Layer.mergeAll(EffectPath.layer, ElectronApp.layer, desktopConfigLayer))); - -const desktopLoggerLayer = DesktopLoggerLive.pipe(Layer.provide(NodeServices.layer)); - -const desktopBackendOutputLogLayer = DesktopBackendOutputLogLive.pipe( - Layer.provide(NodeServices.layer), -); +).pipe(Layer.provideMerge(DesktopConfig.layer)); const resolveDesktopSshCliRunner = ( environment: DesktopEnvironment.DesktopEnvironmentShape, @@ -100,133 +90,61 @@ const desktopSshEnvironmentLayer = Layer.unwrap( }), ); -const desktopSshRuntimeLayer = Layer.mergeAll( - desktopSshEnvironmentLayer, - DesktopSshRemoteApi.layer, -).pipe(Layer.provideMerge(DesktopSshPasswordPrompts.layer()), Layer.provideMerge(NetService.layer)); - -const desktopShellEnvironmentLayer = DesktopShellEnvironment.layer; - -const desktopWindowLayer = DesktopWindow.layer.pipe(Layer.provideMerge(DesktopAssets.layer)); +const electronLayer = Layer.mergeAll( + ElectronApp.layer, + ElectronDialog.layer, + ElectronMenu.layer, + ElectronProtocol.layer, + DesktopSecretStorage.layer, + ElectronShell.layer, + ElectronTheme.layer, + ElectronUpdater.layer, + ElectronWindow.layer, + Layer.succeed(DesktopIpc.DesktopIpc, DesktopIpc.make(Electron.ipcMain)), +); -const desktopAppIdentityLayer = DesktopAppIdentity.layer.pipe( - Layer.provideMerge(DesktopAssets.layer), +const desktopFoundationLayer = Layer.mergeAll( + DesktopRun.layer, + DesktopState.layer, + DesktopLifecycle.layerShutdown, + DesktopSettingsState.layer, + DesktopAssets.layer, + DesktopLoggerLive, + DesktopBackendOutputLogLive, +).pipe(Layer.provideMerge(desktopEnvironmentLayer)); + +const desktopSshLayer = Layer.mergeAll(desktopSshEnvironmentLayer, DesktopSshRemoteApi.layer).pipe( + Layer.provideMerge(DesktopSshPasswordPrompts.layer()), ); const desktopServerExposureLayer = DesktopServerExposure.layer.pipe( - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(NodeHttpClient.layerUndici), Layer.provideMerge(DesktopServerExposure.networkInterfacesLayer), - Layer.provideMerge(desktopConfigLayer), - Layer.provideMerge(DesktopSettingsState.layer), - Layer.provideMerge(desktopEnvironmentLayer), -); - -const desktopServerExposureIpcActionsLayer = Layer.effect( - DesktopServerExposureIpcActions, - Effect.gen(function* () { - const context = yield* Effect.context(); - const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - return DesktopServerExposureIpcActions.of({ - getState: serverExposure.getState, - setMode: (nextMode) => - Effect.gen(function* () { - const change = yield* serverExposure.setMode(nextMode); - if (change.requiresRelaunch) { - yield* lifecycle.relaunch(`serverExposureMode=${nextMode}`); - } - return change.state; - }).pipe(Effect.provide(context)), - setTailscaleServeEnabled: (input) => - Effect.gen(function* () { - const change = yield* serverExposure.setTailscaleServeEnabled(input); - if (change.requiresRelaunch) { - yield* lifecycle.relaunch( - change.state.tailscaleServeEnabled - ? "tailscale-serve-enabled" - : "tailscale-serve-disabled", - ); - } - return change.state; - }).pipe(Effect.provide(context)), - getAdvertisedEndpoints: serverExposure.getAdvertisedEndpoints, - }); - }), -).pipe(Layer.provideMerge(DesktopLifecycle.layer), Layer.provideMerge(desktopWindowLayer)); - -const desktopUpdatesLayer = DesktopUpdates.layer.pipe( - Layer.provideMerge(ElectronUpdater.layer), - Layer.provideMerge(desktopConfigLayer), + Layer.provideMerge(desktopFoundationLayer), ); -const desktopUpdateIpcActionsLayer = Layer.effect( - DesktopUpdateIpcActions, - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - return DesktopUpdateIpcActions.of({ - getState: updates.getState, - setChannel: updates.setChannel, - download: updates.download, - install: updates.install, - check: updates.check("web-ui"), - }); - }), -).pipe(Layer.provideMerge(desktopUpdatesLayer)); +const desktopWindowLayer = DesktopWindow.layer.pipe(Layer.provideMerge(desktopServerExposureLayer)); -const desktopApplicationMenuLayer = DesktopApplicationMenu.layer.pipe( - Layer.provideMerge(desktopUpdatesLayer), +const desktopBackendLayer = DesktopBackendManager.layer.pipe( + Layer.provideMerge(DesktopAppIdentity.layer), + Layer.provideMerge(DesktopBackendConfiguration.layer), + Layer.provideMerge(DesktopBackendEvents.layer), Layer.provideMerge(desktopWindowLayer), ); -const desktopBackendDependenciesLayer = Layer.mergeAll( - NodeServices.layer, - NodeHttpClient.layerUndici, - NetService.layer, - DesktopBackendConfiguration.layer, - DesktopBackendEvents.layer.pipe( - Layer.provide(desktopBackendOutputLogLayer), - Layer.provide(desktopWindowLayer), - ), -); - -const desktopBackendManagerLayer = DesktopBackendManager.layer.pipe( - Layer.provide(desktopBackendDependenciesLayer), -); - -const desktopBackendRuntimeLayer = desktopBackendManagerLayer.pipe( - Layer.provideMerge(desktopServerExposureLayer), -); - -const desktopRuntimeLayer = Layer.mergeAll( - desktopLoggerLayer, - desktopAppIdentityLayer, - desktopApplicationMenuLayer, - desktopShellEnvironmentLayer, - desktopSshRuntimeLayer, +const desktopApplicationLayer = Layer.mergeAll( DesktopLifecycle.layer, - desktopWindowLayer, - Layer.succeed(DesktopIpc.DesktopIpc, DesktopIpc.make(Electron.ipcMain)), - desktopServerExposureIpcActionsLayer, - desktopUpdateIpcActionsLayer, - DesktopWindowIpcActionsLive.layer, - DesktopSecretStorage.layer, -).pipe( + DesktopApplicationMenu.layer, + DesktopShellEnvironment.layer, + desktopSshLayer, + DesktopWindowIpcActions.layer, +).pipe(Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopBackendLayer)); + +const desktopRuntimeLayer = desktopApplicationLayer.pipe( + Layer.provideMerge(EffectPath.layer), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(NodeHttpClient.layerUndici), - Layer.provideMerge(desktopBackendRuntimeLayer), - Layer.provideMerge(ElectronWindow.layer), - Layer.provideMerge(ElectronApp.layer), - Layer.provideMerge(ElectronDialog.layer), - Layer.provideMerge(ElectronMenu.layer), - Layer.provideMerge(ElectronProtocol.layer), - Layer.provideMerge(ElectronShell.layer), - Layer.provideMerge(ElectronTheme.layer), Layer.provideMerge(NetService.layer), - Layer.provideMerge(desktopEnvironmentLayer), - Layer.provideMerge(DesktopShutdown.layer), - Layer.provideMerge(DesktopRun.layer), - Layer.provideMerge(DesktopState.layer), + Layer.provideMerge(electronLayer), ); DesktopApp.program.pipe(Effect.provide(desktopRuntimeLayer), NodeRuntime.runMain); diff --git a/apps/desktop/src/main/DesktopApp.ts b/apps/desktop/src/main/DesktopApp.ts index c080b2c1daa..ad4d81f6463 100644 --- a/apps/desktop/src/main/DesktopApp.ts +++ b/apps/desktop/src/main/DesktopApp.ts @@ -19,7 +19,6 @@ import * as DesktopRun from "./DesktopRun.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; import * as DesktopSettingsState from "./DesktopSettingsState.ts"; import * as DesktopShellEnvironment from "./DesktopShellEnvironment.ts"; -import * as DesktopShutdown from "./DesktopShutdown.ts"; import * as DesktopState from "./DesktopState.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; @@ -84,14 +83,14 @@ const handleFatalStartupError = ( ): Effect.Effect< void, never, - | DesktopShutdown.DesktopShutdown + | DesktopLifecycle.DesktopShutdown | DesktopRun.DesktopRun | DesktopState.DesktopState | ElectronApp.ElectronApp | ElectronDialog.ElectronDialog > => Effect.gen(function* () { - const shutdown = yield* DesktopShutdown.DesktopShutdown; + const shutdown = yield* DesktopLifecycle.DesktopShutdown; const state = yield* DesktopState.DesktopState; const electronApp = yield* ElectronApp.ElectronApp; const electronDialog = yield* ElectronDialog.ElectronDialog; @@ -182,7 +181,7 @@ const bootstrap = Effect.gen(function* () { export const program = Effect.scoped( Effect.gen(function* () { - const shutdown = yield* DesktopShutdown.DesktopShutdown; + const shutdown = yield* DesktopLifecycle.DesktopShutdown; yield* Effect.gen(function* () { const appIdentity = yield* DesktopAppIdentity.DesktopAppIdentity; diff --git a/apps/desktop/src/main/DesktopAppIdentity.test.ts b/apps/desktop/src/main/DesktopAppIdentity.test.ts index a79f2565c4c..0da6d97e77b 100644 --- a/apps/desktop/src/main/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/main/DesktopAppIdentity.test.ts @@ -11,11 +11,7 @@ import * as ElectronApp from "../electron/ElectronApp.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopAssets from "./DesktopAssets.ts"; import * as DesktopConfig from "./DesktopConfig.ts"; -import { - DesktopEnvironment, - layer as makeDesktopEnvironmentLayer, - type MakeDesktopEnvironmentInput, -} from "./DesktopEnvironment.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; const defaultEnvironmentInput = { dirname: "/repo/apps/desktop/dist-electron", @@ -27,9 +23,9 @@ const defaultEnvironmentInput = { isPackaged: true, resourcesPath: "/Applications/T3 Code.app/Contents/Resources", runningUnderArm64Translation: false, -} satisfies MakeDesktopEnvironmentInput; +} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; -type TestEnvironmentInput = Partial & { +type TestEnvironmentInput = Partial & { readonly env?: Record; }; @@ -78,7 +74,7 @@ const makeAssetsLayer = (png: Option.Option) => const makeEnvironmentLayer = (overrides: TestEnvironmentInput = {}) => { const { env, ...environmentOverrides } = overrides; - return makeDesktopEnvironmentLayer({ + return DesktopEnvironment.layer({ ...defaultEnvironmentInput, ...environmentOverrides, }).pipe( @@ -98,7 +94,10 @@ const withIdentity = ( effect: Effect.Effect< A, E, - R | DesktopAppIdentity.DesktopAppIdentity | DesktopEnvironment | FileSystem.FileSystem + | R + | DesktopAppIdentity.DesktopAppIdentity + | DesktopEnvironment.DesktopEnvironment + | FileSystem.FileSystem >, input: { readonly calls?: ElectronAppCalls; diff --git a/apps/desktop/src/main/DesktopBackendConfiguration.test.ts b/apps/desktop/src/main/DesktopBackendConfiguration.test.ts index ae10d32f579..7134316ec59 100644 --- a/apps/desktop/src/main/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/main/DesktopBackendConfiguration.test.ts @@ -5,7 +5,7 @@ import * as EffectPath from "effect/Path"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import { DesktopEnvironment, layer as makeDesktopEnvironmentLayer } from "./DesktopEnvironment.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; import * as DesktopConfig from "./DesktopConfig.ts"; import * as DesktopRun from "./DesktopRun.ts"; @@ -27,7 +27,7 @@ const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExp } satisfies DesktopServerExposure.DesktopServerExposureShape); function makeEnvironmentLayer(baseDir: string) { - return makeDesktopEnvironmentLayer({ + return DesktopEnvironment.layer({ dirname: "/repo/apps/desktop/src", cwd: "/repo", platform: "darwin", @@ -57,7 +57,7 @@ const withHarness = ( A, E, | R - | DesktopEnvironment + | DesktopEnvironment.DesktopEnvironment | FileSystem.FileSystem | DesktopBackendConfiguration.DesktopBackendConfiguration >, @@ -83,7 +83,7 @@ describe("DesktopBackendConfiguration", () => { it.effect("resolves backend start config with a stable scoped bootstrap token", () => withHarness( Effect.gen(function* () { - const environment = yield* DesktopEnvironment; + const environment = yield* DesktopEnvironment.DesktopEnvironment; const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; const first = yield* configuration.resolve; @@ -115,7 +115,7 @@ describe("DesktopBackendConfiguration", () => { withHarness( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - const environment = yield* DesktopEnvironment; + const environment = yield* DesktopEnvironment.DesktopEnvironment; const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; yield* fileSystem.makeDirectory(environment.path.dirname(environment.serverSettingsPath), { diff --git a/apps/desktop/src/main/DesktopBackendEvents.ts b/apps/desktop/src/main/DesktopBackendEvents.ts index d4d6539ee17..a1249e39dc9 100644 --- a/apps/desktop/src/main/DesktopBackendEvents.ts +++ b/apps/desktop/src/main/DesktopBackendEvents.ts @@ -5,11 +5,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; -import type { - BackendTimeoutError, - BackendProcessOutputStream, - DesktopBackendStartConfig, -} from "./DesktopBackendManager.ts"; +import * as DesktopBackendManager from "./DesktopBackendManager.ts"; import { DesktopBackendOutputLog } from "./DesktopLogging.ts"; import * as DesktopRun from "./DesktopRun.ts"; import * as DesktopState from "./DesktopState.ts"; @@ -19,12 +15,14 @@ export interface DesktopBackendEventsShape { readonly onStarting: Effect.Effect; readonly onStarted: (input: { readonly pid: number; - readonly config: DesktopBackendStartConfig; + readonly config: DesktopBackendManager.DesktopBackendStartConfig; }) => Effect.Effect; readonly onReady: Effect.Effect; - readonly onReadinessFailure: (error: BackendTimeoutError) => Effect.Effect; + readonly onReadinessFailure: ( + error: DesktopBackendManager.BackendTimeoutError, + ) => Effect.Effect; readonly onOutput: ( - streamName: BackendProcessOutputStream, + streamName: DesktopBackendManager.BackendProcessOutputStream, chunk: Uint8Array, ) => Effect.Effect; readonly onExit: (input: { diff --git a/apps/desktop/src/main/DesktopBackendManager.test.ts b/apps/desktop/src/main/DesktopBackendManager.test.ts index 749a8a075e1..f425ec48104 100644 --- a/apps/desktop/src/main/DesktopBackendManager.test.ts +++ b/apps/desktop/src/main/DesktopBackendManager.test.ts @@ -3,19 +3,17 @@ import { type DesktopBackendBootstrap as DesktopBackendBootstrapValue, } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; -import { - Deferred, - Duration, - Effect, - FileSystem, - Layer, - Option, - Queue, - Schema, - Sink, - Scope, - Stream, -} from "effect"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Sink from "effect/Sink"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; diff --git a/apps/desktop/src/main/DesktopBackendManager.ts b/apps/desktop/src/main/DesktopBackendManager.ts index 9701d06ae5e..3116fd89e02 100644 --- a/apps/desktop/src/main/DesktopBackendManager.ts +++ b/apps/desktop/src/main/DesktopBackendManager.ts @@ -1,22 +1,20 @@ -import { - Context, - Data, - Duration, - Effect, - Exit, - Fiber, - FileSystem, - PlatformError, - Layer, - Option, - Ref, - Result, - Schema, - Scope, - Schedule, - Semaphore, - Stream, -} from "effect"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as Result from "effect/Result"; +import * as PlatformError from "effect/PlatformError"; +import * as Data from "effect/Data"; +import * as Context from "effect/Context"; +import * as Fiber from "effect/Fiber"; +import * as Exit from "effect/Exit"; +import * as Schedule from "effect/Schedule"; +import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; import { HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; diff --git a/apps/desktop/src/main/DesktopEnvironment.test.ts b/apps/desktop/src/main/DesktopEnvironment.test.ts index c0c4434c019..07efd187411 100644 --- a/apps/desktop/src/main/DesktopEnvironment.test.ts +++ b/apps/desktop/src/main/DesktopEnvironment.test.ts @@ -2,11 +2,7 @@ import { assert, describe, it } from "@effect/vitest"; import { Effect, Layer, Option } from "effect"; import * as EffectPath from "effect/Path"; -import { - DesktopEnvironment, - layer as makeDesktopEnvironmentLayer, - type MakeDesktopEnvironmentInput, -} from "./DesktopEnvironment.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopConfig from "./DesktopConfig.ts"; const defaultInput = { @@ -19,23 +15,23 @@ const defaultInput = { isPackaged: false, resourcesPath: "/Applications/T3 Code.app/Contents/Resources", runningUnderArm64Translation: false, -} satisfies MakeDesktopEnvironmentInput; +} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; const makeEnvironmentLayer = ( - overrides: Partial = {}, + overrides: Partial = {}, env: Record = {}, ) => - makeDesktopEnvironmentLayer({ + DesktopEnvironment.layer({ ...defaultInput, ...overrides, }).pipe(Layer.provide(Layer.mergeAll(EffectPath.layer, DesktopConfig.layerTest(env)))); const makeEnvironment = ( - overrides: Partial = {}, + overrides: Partial = {}, env: Record = {}, ) => Effect.gen(function* () { - return yield* DesktopEnvironment; + return yield* DesktopEnvironment.DesktopEnvironment; }).pipe(Effect.provide(makeEnvironmentLayer(overrides, env))); describe("DesktopEnvironment", () => { diff --git a/apps/desktop/src/main/DesktopErrors.ts b/apps/desktop/src/main/DesktopErrors.ts deleted file mode 100644 index 485da565c71..00000000000 --- a/apps/desktop/src/main/DesktopErrors.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function formatErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/apps/desktop/src/main/DesktopLifecycle.ts b/apps/desktop/src/main/DesktopLifecycle.ts index f32c6e40b93..78de85bd445 100644 --- a/apps/desktop/src/main/DesktopLifecycle.ts +++ b/apps/desktop/src/main/DesktopLifecycle.ts @@ -1,6 +1,7 @@ import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; @@ -9,12 +10,42 @@ import type * as Electron from "electron"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopRun from "./DesktopRun.ts"; -import { DesktopShutdown } from "./DesktopShutdown.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as DesktopState from "./DesktopState.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; +export interface DesktopShutdownShape { + readonly request: Effect.Effect; + readonly awaitRequest: Effect.Effect; + readonly markComplete: Effect.Effect; + readonly awaitComplete: Effect.Effect; + readonly isComplete: Effect.Effect; +} + +export class DesktopShutdown extends Context.Service()( + "t3/desktop/Shutdown", +) {} + +const makeShutdown = Effect.gen(function* () { + const requested = yield* Deferred.make(); + const completed = yield* Deferred.make(); + const completedRef = yield* Ref.make(false); + + return DesktopShutdown.of({ + request: Deferred.succeed(requested, undefined).pipe(Effect.asVoid), + awaitRequest: Deferred.await(requested), + markComplete: Ref.set(completedRef, true).pipe( + Effect.andThen(Deferred.succeed(completed, undefined)), + Effect.asVoid, + ), + awaitComplete: Deferred.await(completed), + isComplete: Ref.get(completedRef), + }); +}); + +export const layerShutdown = Layer.effect(DesktopShutdown, makeShutdown); + export type DesktopLifecycleRuntimeServices = | DesktopEnvironment.DesktopEnvironment | DesktopRun.DesktopRun diff --git a/apps/desktop/src/main/DesktopLogging.ts b/apps/desktop/src/main/DesktopLogging.ts index 6cb118a7ca9..da47296e355 100644 --- a/apps/desktop/src/main/DesktopLogging.ts +++ b/apps/desktop/src/main/DesktopLogging.ts @@ -13,7 +13,7 @@ import { Semaphore, } from "effect"; -import { DesktopEnvironment } from "./DesktopEnvironment.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; const DESKTOP_LOG_FILE_MAX_FILES = 10; @@ -156,7 +156,7 @@ const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(functio }); const makeDesktopFileLogger = Effect.gen(function* () { - const environment = yield* DesktopEnvironment; + const environment = yield* DesktopEnvironment.DesktopEnvironment; const writer = yield* makeRotatingLogFileWriter({ filePath: environment.path.join(environment.logDir, "desktop-main.log"), }); @@ -170,7 +170,7 @@ const makeDesktopFileLogger = Effect.gen(function* () { export const DesktopLoggerLive = Layer.unwrap( Effect.gen(function* () { - const environment = yield* DesktopEnvironment; + const environment = yield* DesktopEnvironment.DesktopEnvironment; const packagedFileLogger = environment.isPackaged ? yield* makeDesktopFileLogger.pipe(Effect.option) : Option.none>(); @@ -193,7 +193,7 @@ export const DesktopLoggerLive = Layer.unwrap( export const DesktopBackendOutputLogLive = Layer.effect( DesktopBackendOutputLog, Effect.gen(function* () { - const environment = yield* DesktopEnvironment; + const environment = yield* DesktopEnvironment.DesktopEnvironment; if (environment.isDevelopment) { return DesktopBackendOutputLogNoop; } diff --git a/apps/desktop/src/main/DesktopServerExposure.test.ts b/apps/desktop/src/main/DesktopServerExposure.test.ts index 0e50ae7eae3..056bf4d9143 100644 --- a/apps/desktop/src/main/DesktopServerExposure.test.ts +++ b/apps/desktop/src/main/DesktopServerExposure.test.ts @@ -1,6 +1,5 @@ import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; -import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -96,7 +95,6 @@ function makeLayer(input: { return DesktopServerExposure.layer.pipe( Layer.provideMerge(DesktopSettingsState.layer), Layer.provideMerge(NodeFileSystem.layer), - Layer.provideMerge(NodePath.layer), Layer.provideMerge(NodeHttpClient.layerUndici), Layer.provideMerge(mockSpawnerLayer()), Layer.provideMerge(networkLayer), diff --git a/apps/desktop/src/main/DesktopSettingsState.ts b/apps/desktop/src/main/DesktopSettingsState.ts index 75504be4a50..0194975cbc3 100644 --- a/apps/desktop/src/main/DesktopSettingsState.ts +++ b/apps/desktop/src/main/DesktopSettingsState.ts @@ -13,14 +13,18 @@ import { readDesktopSettingsEffect, writeDesktopSettingsEffect, } from "../desktopSettings.ts"; -import { DesktopEnvironment } from "./DesktopEnvironment.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; export type DesktopSettingsPersistenceError = PlatformError.PlatformError | Schema.SchemaError; export interface DesktopSettingsStateShape { readonly get: Effect.Effect; readonly set: (settings: DesktopSettings) => Effect.Effect; - readonly load: Effect.Effect; + readonly load: Effect.Effect< + DesktopSettings, + never, + FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment + >; readonly update: ( f: (settings: DesktopSettings) => DesktopSettings, ) => Effect.Effect; @@ -29,14 +33,14 @@ export interface DesktopSettingsStateShape { ) => Effect.Effect< DesktopSettings, DesktopSettingsPersistenceError, - FileSystem.FileSystem | Path.Path | DesktopEnvironment + FileSystem.FileSystem | Path.Path | DesktopEnvironment.DesktopEnvironment >; readonly modifyPersisted: ( f: (settings: DesktopSettings) => readonly [A, DesktopSettings], ) => Effect.Effect< A, DesktopSettingsPersistenceError, - FileSystem.FileSystem | Path.Path | DesktopEnvironment + FileSystem.FileSystem | Path.Path | DesktopEnvironment.DesktopEnvironment >; } @@ -54,7 +58,7 @@ export const layer = Layer.effect( SynchronizedRef.updateAndGet(settingsRef, f); const modifyPersisted = (f: (settings: DesktopSettings) => readonly [A, DesktopSettings]) => Effect.gen(function* () { - const environment = yield* DesktopEnvironment; + const environment = yield* DesktopEnvironment.DesktopEnvironment; return yield* SynchronizedRef.modifyEffect(settingsRef, (settings) => { const [result, nextSettings] = f(settings); if (nextSettings === settings) { @@ -71,7 +75,7 @@ export const layer = Layer.effect( get: SynchronizedRef.get(settingsRef), set: (settings) => SynchronizedRef.set(settingsRef, settings), load: Effect.gen(function* () { - const environment = yield* DesktopEnvironment; + const environment = yield* DesktopEnvironment.DesktopEnvironment; const settings = yield* readDesktopSettingsEffect( environment.desktopSettingsPath, environment.appVersion, diff --git a/apps/desktop/src/main/DesktopShutdown.test.ts b/apps/desktop/src/main/DesktopShutdown.test.ts deleted file mode 100644 index 1d1257f67a8..00000000000 --- a/apps/desktop/src/main/DesktopShutdown.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import { Effect, Fiber } from "effect"; - -import { DesktopShutdown, layer as desktopShutdownLayer } from "./DesktopShutdown.ts"; - -const withShutdown = (effect: Effect.Effect) => - effect.pipe(Effect.provide(desktopShutdownLayer)); - -describe("DesktopShutdown", () => { - it.effect("unblocks request waiters when shutdown is requested", () => - withShutdown( - Effect.gen(function* () { - const shutdown = yield* DesktopShutdown; - const waiter = yield* shutdown.awaitRequest.pipe(Effect.as("requested"), Effect.forkChild); - - yield* shutdown.request; - - assert.equal(yield* Fiber.join(waiter), "requested"); - }), - ), - ); - - it.effect("tracks completion after resources finish closing", () => - withShutdown( - Effect.gen(function* () { - const shutdown = yield* DesktopShutdown; - const waiter = yield* shutdown.awaitComplete.pipe(Effect.as("complete"), Effect.forkChild); - - assert.equal(yield* shutdown.isComplete, false); - yield* shutdown.markComplete; - - assert.equal(yield* shutdown.isComplete, true); - assert.equal(yield* Fiber.join(waiter), "complete"); - }), - ), - ); - - it.effect("allows repeated requests and completion marks", () => - withShutdown( - Effect.gen(function* () { - const shutdown = yield* DesktopShutdown; - - yield* shutdown.request; - yield* shutdown.request; - yield* shutdown.markComplete; - yield* shutdown.markComplete; - - assert.equal(yield* shutdown.isComplete, true); - }), - ), - ); -}); diff --git a/apps/desktop/src/main/DesktopShutdown.ts b/apps/desktop/src/main/DesktopShutdown.ts deleted file mode 100644 index acdcf14b990..00000000000 --- a/apps/desktop/src/main/DesktopShutdown.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Context, Deferred, Effect, Layer, Ref } from "effect"; - -export interface DesktopShutdownShape { - readonly request: Effect.Effect; - readonly awaitRequest: Effect.Effect; - readonly markComplete: Effect.Effect; - readonly awaitComplete: Effect.Effect; - readonly isComplete: Effect.Effect; -} - -export class DesktopShutdown extends Context.Service()( - "t3/desktop/Shutdown", -) {} - -const make = Effect.gen(function* () { - const requested = yield* Deferred.make(); - const completed = yield* Deferred.make(); - const completedRef = yield* Ref.make(false); - - return DesktopShutdown.of({ - request: Deferred.succeed(requested, undefined).pipe(Effect.asVoid), - awaitRequest: Deferred.await(requested), - markComplete: Ref.set(completedRef, true).pipe( - Effect.andThen(Deferred.succeed(completed, undefined)), - Effect.asVoid, - ), - awaitComplete: Deferred.await(completed), - isComplete: Ref.get(completedRef), - }); -}); - -export const layer = Layer.effect(DesktopShutdown, make); diff --git a/apps/desktop/src/main/DesktopSshEnvironment.test.ts b/apps/desktop/src/main/DesktopSshEnvironment.test.ts index 8573b57f88b..1dfb32b4ce2 100644 --- a/apps/desktop/src/main/DesktopSshEnvironment.test.ts +++ b/apps/desktop/src/main/DesktopSshEnvironment.test.ts @@ -7,9 +7,6 @@ import { Effect, FileSystem, Layer, Path } from "effect"; import * as DesktopSshEnvironment from "./DesktopSshEnvironment.ts"; import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; -import * as DesktopIpc from "../ipc/DesktopIpc.ts"; -import { SSH_PASSWORD_PROMPT_CANCELLED_RESULT } from "../ipc/channels.ts"; -import { discoverSshHosts, ensureSshEnvironment } from "../ipc/methods/sshEnvironment.ts"; function makeTempHomeDir() { return Effect.gen(function* () { @@ -18,22 +15,6 @@ function makeTempHomeDir() { }); } -class TestIpcMain implements DesktopIpc.DesktopIpcMain { - readonly handlers = new Map(); - - removeHandler(channel: string): void { - this.handlers.delete(channel); - } - - handle(channel: string, listener: DesktopIpc.DesktopIpcHandleListener): void { - this.handlers.set(channel, listener); - } - - removeAllListeners(): void {} - - on(): void {} -} - describe("sshEnvironment", () => { it("treats password prompt timeouts as cancellable authentication prompts", () => { assert.equal( @@ -131,93 +112,4 @@ describe("sshEnvironment", () => { Effect.scoped, ), ); - - it.effect("runs SSH IPC handlers with the captured Effect context", () => - Effect.gen(function* () { - const ipcMain = new TestIpcMain(); - const ipc = DesktopIpc.make(ipcMain); - - yield* ipc.handle(discoverSshHosts); - - const discoverHosts = ipcMain.handlers.get("desktop:discover-ssh-hosts"); - assert.ok(discoverHosts); - - const hosts = yield* Effect.promise(() => Promise.resolve(discoverHosts({}, undefined))); - assert.deepEqual(hosts, [ - { - alias: "devbox", - hostname: "devbox.example.com", - username: null, - port: null, - source: "ssh-config", - }, - ]); - }).pipe( - Effect.provide( - Layer.mergeAll( - Layer.succeed( - DesktopSshEnvironment.DesktopSshEnvironment, - DesktopSshEnvironment.DesktopSshEnvironment.of({ - discoverHosts: () => - Effect.succeed([ - { - alias: "devbox", - hostname: "devbox.example.com", - username: null, - port: null, - source: "ssh-config" as const, - }, - ]), - ensureEnvironment: () => Effect.die("unexpected ensureEnvironment"), - disconnectEnvironment: () => Effect.die("unexpected disconnectEnvironment"), - }), - ), - NodeServices.layer, - ), - ), - Effect.scoped, - ), - ); - - it.effect("encodes SSH password prompt cancellations as typed IPC results", () => - Effect.gen(function* () { - const result = yield* ensureSshEnvironment.handler({ - target: { - alias: "devbox", - hostname: "devbox.example.com", - username: null, - port: null, - }, - options: { issuePairingToken: true }, - }); - - assert.deepEqual(result, { - type: SSH_PASSWORD_PROMPT_CANCELLED_RESULT, - message: "SSH authentication timed out for devbox.", - }); - }).pipe( - Effect.provide( - Layer.mergeAll( - Layer.succeed( - DesktopSshEnvironment.DesktopSshEnvironment, - DesktopSshEnvironment.DesktopSshEnvironment.of({ - discoverHosts: () => Effect.die("unexpected discoverHosts"), - ensureEnvironment: () => - Effect.fail( - new SshPasswordPromptError({ - message: "SSH authentication timed out for devbox.", - cause: new DesktopSshPasswordPrompts.DesktopSshPromptTimedOutError({ - requestId: "prompt-1", - destination: "devbox", - }), - }), - ), - disconnectEnvironment: () => Effect.die("unexpected disconnectEnvironment"), - }), - ), - NodeServices.layer, - ), - ), - ), - ); }); diff --git a/apps/desktop/src/main/DesktopSshEnvironment.ts b/apps/desktop/src/main/DesktopSshEnvironment.ts index 3acace1e210..efada095021 100644 --- a/apps/desktop/src/main/DesktopSshEnvironment.ts +++ b/apps/desktop/src/main/DesktopSshEnvironment.ts @@ -30,8 +30,6 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; -export { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; - export type DesktopSshEnvironmentRuntimeServices = | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem diff --git a/apps/desktop/src/main/DesktopSshPasswordPrompts.test.ts b/apps/desktop/src/main/DesktopSshPasswordPrompts.test.ts index ff777aaa5e8..c074241a3a2 100644 --- a/apps/desktop/src/main/DesktopSshPasswordPrompts.test.ts +++ b/apps/desktop/src/main/DesktopSshPasswordPrompts.test.ts @@ -8,7 +8,7 @@ import { TestClock } from "effect/testing"; import type * as Electron from "electron"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { SSH_PASSWORD_PROMPT_CHANNEL } from "../ipc/channels.ts"; +import * as IpcChannels from "../ipc/channels.ts"; import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; interface SentMessage { @@ -109,7 +109,7 @@ describe("DesktopSshPasswordPrompts", () => { assert.equal(testWindow.sentMessages.length, 1); const sent = testWindow.sentMessages[0]; assert.ok(sent); - assert.equal(sent.channel, SSH_PASSWORD_PROMPT_CHANNEL); + assert.equal(sent.channel, IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL); const request = sent.args[0] as { readonly requestId: string; readonly destination: string }; assert.equal(request.destination, "devbox"); assert.equal(testWindow.isRestored(), true); diff --git a/apps/desktop/src/main/DesktopSshPasswordPrompts.ts b/apps/desktop/src/main/DesktopSshPasswordPrompts.ts index 34bd4d3f7d1..2404999adf4 100644 --- a/apps/desktop/src/main/DesktopSshPasswordPrompts.ts +++ b/apps/desktop/src/main/DesktopSshPasswordPrompts.ts @@ -14,7 +14,7 @@ import * as Random from "effect/Random"; import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; -import { SSH_PASSWORD_PROMPT_CHANNEL } from "../ipc/channels.ts"; +import * as IpcChannels from "../ipc/channels.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000; @@ -319,7 +319,7 @@ const make = (options: LayerOptions = {}) => throw new Error(WINDOW_UNAVAILABLE_MESSAGE); } window.value.once("closed", cancelOnWindowClosed); - window.value.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, promptRequest); + window.value.webContents.send(IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL, promptRequest); if (window.value.isDestroyed()) { throw new Error(WINDOW_UNAVAILABLE_MESSAGE); } diff --git a/apps/desktop/src/main/DesktopUpdates.test.ts b/apps/desktop/src/main/DesktopUpdates.test.ts index 18fdb3933b6..8e472105cf5 100644 --- a/apps/desktop/src/main/DesktopUpdates.test.ts +++ b/apps/desktop/src/main/DesktopUpdates.test.ts @@ -13,7 +13,7 @@ import { TestClock } from "effect/testing"; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; import * as DesktopConfig from "./DesktopConfig.ts"; -import { layer as makeDesktopEnvironmentLayer } from "./DesktopEnvironment.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import { DEFAULT_DESKTOP_SETTINGS } from "../desktopSettings.ts"; @@ -118,7 +118,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { }), }); - const environmentLayer = makeDesktopEnvironmentLayer({ + const environmentLayer = DesktopEnvironment.layer({ dirname: "/repo/apps/desktop/src", cwd: "/repo", platform: "darwin", diff --git a/apps/desktop/src/main/DesktopUpdates.ts b/apps/desktop/src/main/DesktopUpdates.ts index 93e70338d38..264df854af4 100644 --- a/apps/desktop/src/main/DesktopUpdates.ts +++ b/apps/desktop/src/main/DesktopUpdates.ts @@ -28,7 +28,7 @@ import * as DesktopSettingsState from "./DesktopSettingsState.ts"; import * as DesktopState from "./DesktopState.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import { type DesktopSettings, setDesktopUpdateChannelPreference } from "../desktopSettings.ts"; -import { UPDATE_STATE_CHANNEL } from "../ipc/channels.ts"; +import * as IpcChannels from "../ipc/channels.ts"; import { doesVersionMatchDesktopUpdateChannel } from "../updateChannels.ts"; import { createInitialDesktopUpdateState, @@ -242,7 +242,7 @@ const make = Effect.gen(function* () { ); const emitState = Ref.get(updateStateRef).pipe( - Effect.flatMap((state) => electronWindow.sendAll(UPDATE_STATE_CHANNEL, state)), + Effect.flatMap((state) => electronWindow.sendAll(IpcChannels.UPDATE_STATE_CHANNEL, state)), ); const setState = (state: DesktopUpdateState): Effect.Effect => diff --git a/apps/desktop/src/main/DesktopWindow.ts b/apps/desktop/src/main/DesktopWindow.ts index 78d6f469aa1..93aba082e27 100644 --- a/apps/desktop/src/main/DesktopWindow.ts +++ b/apps/desktop/src/main/DesktopWindow.ts @@ -12,7 +12,7 @@ import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts"; +import * as IpcChannels from "../ipc/channels.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; import * as DesktopState from "./DesktopState.ts"; import * as DesktopAssets from "./DesktopAssets.ts"; @@ -343,7 +343,7 @@ const make = Effect.gen(function* () { const send = () => { if (targetWindow.isDestroyed()) return; - targetWindow.webContents.send(MENU_ACTION_CHANNEL, action); + targetWindow.webContents.send(IpcChannels.MENU_ACTION_CHANNEL, action); void runPromise(electronWindow.reveal(targetWindow)); }; diff --git a/apps/desktop/src/main/DesktopWindowIpcActions.ts b/apps/desktop/src/main/DesktopWindowIpcActions.ts new file mode 100644 index 00000000000..c1e61aa7510 --- /dev/null +++ b/apps/desktop/src/main/DesktopWindowIpcActions.ts @@ -0,0 +1,113 @@ +import type { + ContextMenuItem, + DesktopAppBranding, + DesktopEnvironmentBootstrap, + DesktopTheme, + PickFolderOptions, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as ElectronDialog from "../electron/ElectronDialog.ts"; +import * as ElectronMenu from "../electron/ElectronMenu.ts"; +import * as ElectronShell from "../electron/ElectronShell.ts"; +import * as ElectronTheme from "../electron/ElectronTheme.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopBackendManager from "./DesktopBackendManager.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +export interface DesktopWindowIpcContextMenuInput { + readonly items: readonly ContextMenuItem[]; + readonly position?: { + readonly x: number; + readonly y: number; + }; +} + +export interface DesktopWindowIpcActionsShape { + readonly getAppBranding: Effect.Effect; + readonly getLocalEnvironmentBootstrap: Effect.Effect; + readonly pickFolder: (options: PickFolderOptions | undefined) => Effect.Effect; + readonly confirm: (message: string) => Effect.Effect; + readonly setTheme: (theme: DesktopTheme) => Effect.Effect; + readonly showContextMenu: ( + input: DesktopWindowIpcContextMenuInput, + ) => Effect.Effect; + readonly openExternal: (url: string) => Effect.Effect; +} + +export class DesktopWindowIpcActions extends Context.Service< + DesktopWindowIpcActions, + DesktopWindowIpcActionsShape +>()("t3/desktop/WindowIpcActions") {} + +function toWebSocketBaseUrl(httpBaseUrl: URL): string { + const url = new URL(httpBaseUrl.href); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.href; +} + +const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const backendManager = yield* DesktopBackendManager.DesktopBackendManager; + const electronDialog = yield* ElectronDialog.ElectronDialog; + const electronMenu = yield* ElectronMenu.ElectronMenu; + const electronShell = yield* ElectronShell.ElectronShell; + const electronTheme = yield* ElectronTheme.ElectronTheme; + const electronWindow = yield* ElectronWindow.ElectronWindow; + + return DesktopWindowIpcActions.of({ + getAppBranding: Effect.succeed(environment.branding), + getLocalEnvironmentBootstrap: backendManager.currentConfig.pipe( + Effect.map( + Option.map((config) => { + const bootstrap = config.bootstrap; + return { + label: "Local environment", + httpBaseUrl: config.httpBaseUrl.href, + wsBaseUrl: toWebSocketBaseUrl(config.httpBaseUrl), + ...(bootstrap.desktopBootstrapToken + ? { bootstrapToken: bootstrap.desktopBootstrapToken } + : {}), + }; + }), + ), + Effect.map(Option.getOrNull), + ), + pickFolder: (options) => + Effect.gen(function* () { + const selectedPath = yield* electronDialog.pickFolder({ + owner: yield* electronWindow.focusedMainOrFirst, + defaultPath: environment.resolvePickFolderDefaultPath(options), + }); + return Option.getOrNull(selectedPath); + }), + confirm: (message) => + Effect.gen(function* () { + return yield* electronDialog.confirm({ + owner: yield* electronWindow.focusedMainOrFirst, + message, + }); + }), + setTheme: (theme) => electronTheme.setSource(theme), + showContextMenu: ({ items, position }) => + Effect.gen(function* () { + const window = yield* electronWindow.focusedMainOrFirst; + if (Option.isNone(window)) { + return null; + } + + const selectedItemId = yield* electronMenu.showContextMenu({ + window: window.value, + items, + position: Option.fromNullishOr(position), + }); + return Option.getOrNull(selectedItemId); + }), + openExternal: (url) => electronShell.openExternal(url), + }); +}); + +export const layer = Layer.effect(DesktopWindowIpcActions, make); From a8b9c4bb47b198511f3a4be1e2d36ffc4a4db2ca Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 20:51:40 -0700 Subject: [PATCH 18/43] Use default update channel for desktop update filtering - Compare update info against the resolved default channel - Remove the redundant version-channel match helper --- apps/desktop/src/main/DesktopUpdates.ts | 4 ++-- apps/desktop/src/updateChannels.ts | 7 ------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/main/DesktopUpdates.ts b/apps/desktop/src/main/DesktopUpdates.ts index 264df854af4..ae7c2dd0164 100644 --- a/apps/desktop/src/main/DesktopUpdates.ts +++ b/apps/desktop/src/main/DesktopUpdates.ts @@ -29,7 +29,7 @@ import * as DesktopState from "./DesktopState.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import { type DesktopSettings, setDesktopUpdateChannelPreference } from "../desktopSettings.ts"; import * as IpcChannels from "../ipc/channels.ts"; -import { doesVersionMatchDesktopUpdateChannel } from "../updateChannels.ts"; +import { resolveDefaultDesktopUpdateChannel } from "../updateChannels.ts"; import { createInitialDesktopUpdateState, reduceDesktopUpdateStateOnCheckFailure, @@ -458,7 +458,7 @@ const make = Effect.gen(function* () { Effect.flatMap((info) => Effect.gen(function* () { const state = yield* Ref.get(updateStateRef); - if (!doesVersionMatchDesktopUpdateChannel(info.version, state.channel)) { + if (resolveDefaultDesktopUpdateChannel(info.version) !== state.channel) { yield* logUpdaterInfo("ignoring update that does not match selected channel", { version: info.version, channel: state.channel, diff --git a/apps/desktop/src/updateChannels.ts b/apps/desktop/src/updateChannels.ts index 615b8e6db66..731910e441f 100644 --- a/apps/desktop/src/updateChannels.ts +++ b/apps/desktop/src/updateChannels.ts @@ -9,10 +9,3 @@ export function isNightlyDesktopVersion(version: string): boolean { export function resolveDefaultDesktopUpdateChannel(appVersion: string): DesktopUpdateChannel { return isNightlyDesktopVersion(appVersion) ? "nightly" : "latest"; } - -export function doesVersionMatchDesktopUpdateChannel( - version: string, - channel: DesktopUpdateChannel, -): boolean { - return resolveDefaultDesktopUpdateChannel(version) === channel; -} From a122a35481c30b77b6c93ddd54e121ea77569b09 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 20:53:40 -0700 Subject: [PATCH 19/43] Namespace preload IPC channel imports - Replace individual channel imports with a single `IpcChannels` namespace - Keep preload bridge calls aligned with the shared channel module --- apps/desktop/src/preload.ts | 123 ++++++++++++++---------------------- 1 file changed, 47 insertions(+), 76 deletions(-) diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 62d0eca9c79..173be8fb54a 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,50 +1,14 @@ -import { contextBridge, ipcRenderer } from "electron"; import type { DesktopBridge } from "@t3tools/contracts"; +import { contextBridge, ipcRenderer } from "electron"; -import { - BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, - CONFIRM_CHANNEL, - CONTEXT_MENU_CHANNEL, - DISCONNECT_SSH_ENVIRONMENT_CHANNEL, - DISCOVER_SSH_HOSTS_CHANNEL, - ENSURE_SSH_ENVIRONMENT_CHANNEL, - FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, - FETCH_SSH_SESSION_STATE_CHANNEL, - GET_ADVERTISED_ENDPOINTS_CHANNEL, - GET_APP_BRANDING_CHANNEL, - GET_CLIENT_SETTINGS_CHANNEL, - GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, - GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, - GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, - GET_SERVER_EXPOSURE_STATE_CHANNEL, - ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, - MENU_ACTION_CHANNEL, - OPEN_EXTERNAL_CHANNEL, - PICK_FOLDER_CHANNEL, - REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, - RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, - SET_CLIENT_SETTINGS_CHANNEL, - SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, - SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, - SET_SERVER_EXPOSURE_MODE_CHANNEL, - SET_TAILSCALE_SERVE_ENABLED_CHANNEL, - SET_THEME_CHANNEL, - SSH_PASSWORD_PROMPT_CANCELLED_RESULT, - SSH_PASSWORD_PROMPT_CHANNEL, - UPDATE_CHECK_CHANNEL, - UPDATE_DOWNLOAD_CHANNEL, - UPDATE_GET_STATE_CHANNEL, - UPDATE_INSTALL_CHANNEL, - UPDATE_SET_CHANNEL_CHANNEL, - UPDATE_STATE_CHANNEL, -} from "./ipc/channels.ts"; +import * as IpcChannels from "./ipc/channels.ts"; function unwrapEnsureSshEnvironmentResult(result: unknown) { if ( typeof result === "object" && result !== null && "type" in result && - result.type === SSH_PASSWORD_PROMPT_CANCELLED_RESULT + result.type === IpcChannels.SSH_PASSWORD_PROMPT_CANCELLED_RESULT ) { const message = "message" in result && typeof result.message === "string" @@ -57,100 +21,107 @@ function unwrapEnsureSshEnvironmentResult(result: unknown) { contextBridge.exposeInMainWorld("desktopBridge", { getAppBranding: () => { - const result = ipcRenderer.sendSync(GET_APP_BRANDING_CHANNEL); + const result = ipcRenderer.sendSync(IpcChannels.GET_APP_BRANDING_CHANNEL); if (typeof result !== "object" || result === null) { return null; } return result as ReturnType; }, getLocalEnvironmentBootstrap: () => { - const result = ipcRenderer.sendSync(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); + const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); if (typeof result !== "object" || result === null) { return null; } return result as ReturnType; }, - getClientSettings: () => ipcRenderer.invoke(GET_CLIENT_SETTINGS_CHANNEL), - setClientSettings: (settings) => ipcRenderer.invoke(SET_CLIENT_SETTINGS_CHANNEL, settings), - getSavedEnvironmentRegistry: () => ipcRenderer.invoke(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL), + getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL), + setClientSettings: (settings) => + ipcRenderer.invoke(IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, settings), + getSavedEnvironmentRegistry: () => + ipcRenderer.invoke(IpcChannels.GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL), setSavedEnvironmentRegistry: (records) => - ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, records), + ipcRenderer.invoke(IpcChannels.SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, records), getSavedEnvironmentSecret: (environmentId) => - ipcRenderer.invoke(GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), + ipcRenderer.invoke(IpcChannels.GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), setSavedEnvironmentSecret: (environmentId, secret) => - ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, { environmentId, secret }), + ipcRenderer.invoke(IpcChannels.SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, { environmentId, secret }), removeSavedEnvironmentSecret: (environmentId) => - ipcRenderer.invoke(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), - discoverSshHosts: () => ipcRenderer.invoke(DISCOVER_SSH_HOSTS_CHANNEL), + ipcRenderer.invoke(IpcChannels.REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), + discoverSshHosts: () => ipcRenderer.invoke(IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL), ensureSshEnvironment: async (target, options) => unwrapEnsureSshEnvironmentResult( - await ipcRenderer.invoke(ENSURE_SSH_ENVIRONMENT_CHANNEL, { + await ipcRenderer.invoke(IpcChannels.ENSURE_SSH_ENVIRONMENT_CHANNEL, { target, ...(options === undefined ? {} : { options }), }), ), disconnectSshEnvironment: (target) => - ipcRenderer.invoke(DISCONNECT_SSH_ENVIRONMENT_CHANNEL, target), + ipcRenderer.invoke(IpcChannels.DISCONNECT_SSH_ENVIRONMENT_CHANNEL, target), fetchSshEnvironmentDescriptor: (httpBaseUrl) => - ipcRenderer.invoke(FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, { httpBaseUrl }), + ipcRenderer.invoke(IpcChannels.FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, { httpBaseUrl }), bootstrapSshBearerSession: (httpBaseUrl, credential) => - ipcRenderer.invoke(BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, { httpBaseUrl, credential }), + ipcRenderer.invoke(IpcChannels.BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, { + httpBaseUrl, + credential, + }), fetchSshSessionState: (httpBaseUrl, bearerToken) => - ipcRenderer.invoke(FETCH_SSH_SESSION_STATE_CHANNEL, { httpBaseUrl, bearerToken }), + ipcRenderer.invoke(IpcChannels.FETCH_SSH_SESSION_STATE_CHANNEL, { httpBaseUrl, bearerToken }), issueSshWebSocketToken: (httpBaseUrl, bearerToken) => - ipcRenderer.invoke(ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, { httpBaseUrl, bearerToken }), + ipcRenderer.invoke(IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, { httpBaseUrl, bearerToken }), onSshPasswordPrompt: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => { if (typeof request !== "object" || request === null) return; listener(request as Parameters[0]); }; - ipcRenderer.on(SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener); + ipcRenderer.on(IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener); return () => { - ipcRenderer.removeListener(SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener); + ipcRenderer.removeListener(IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener); }; }, resolveSshPasswordPrompt: (requestId, password) => - ipcRenderer.invoke(RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, { requestId, password }), - getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL), - setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), + ipcRenderer.invoke(IpcChannels.RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, { requestId, password }), + getServerExposureState: () => ipcRenderer.invoke(IpcChannels.GET_SERVER_EXPOSURE_STATE_CHANNEL), + setServerExposureMode: (mode) => + ipcRenderer.invoke(IpcChannels.SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), setTailscaleServeEnabled: (input) => - ipcRenderer.invoke(SET_TAILSCALE_SERVE_ENABLED_CHANNEL, input), - getAdvertisedEndpoints: () => ipcRenderer.invoke(GET_ADVERTISED_ENDPOINTS_CHANNEL), - pickFolder: (options) => ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options), - confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), - setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), + ipcRenderer.invoke(IpcChannels.SET_TAILSCALE_SERVE_ENABLED_CHANNEL, input), + getAdvertisedEndpoints: () => ipcRenderer.invoke(IpcChannels.GET_ADVERTISED_ENDPOINTS_CHANNEL), + pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options), + confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message), + setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), showContextMenu: (items, position) => - ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, { + ipcRenderer.invoke(IpcChannels.CONTEXT_MENU_CHANNEL, { items, ...(position === undefined ? {} : { position }), }), - openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url), + openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { if (typeof action !== "string") return; listener(action); }; - ipcRenderer.on(MENU_ACTION_CHANNEL, wrappedListener); + ipcRenderer.on(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener); return () => { - ipcRenderer.removeListener(MENU_ACTION_CHANNEL, wrappedListener); + ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener); }; }, - getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL), - setUpdateChannel: (channel) => ipcRenderer.invoke(UPDATE_SET_CHANNEL_CHANNEL, channel), - checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL), - downloadUpdate: () => ipcRenderer.invoke(UPDATE_DOWNLOAD_CHANNEL), - installUpdate: () => ipcRenderer.invoke(UPDATE_INSTALL_CHANNEL), + getUpdateState: () => ipcRenderer.invoke(IpcChannels.UPDATE_GET_STATE_CHANNEL), + setUpdateChannel: (channel) => + ipcRenderer.invoke(IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, channel), + checkForUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_CHECK_CHANNEL), + downloadUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_DOWNLOAD_CHANNEL), + installUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_INSTALL_CHANNEL), onUpdateState: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => { if (typeof state !== "object" || state === null) return; listener(state as Parameters[0]); }; - ipcRenderer.on(UPDATE_STATE_CHANNEL, wrappedListener); + ipcRenderer.on(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener); return () => { - ipcRenderer.removeListener(UPDATE_STATE_CHANNEL, wrappedListener); + ipcRenderer.removeListener(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener); }; }, } satisfies DesktopBridge); From f0c204d8271596aa43ff127cf4fa75cef0e588e5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 20:56:13 -0700 Subject: [PATCH 20/43] Refactor desktop IPC methods to use channel namespace imports - Switch method modules to import IPC channels as a namespace - Remove unused window method arrays --- .../desktop/src/ipc/methods/clientSettings.ts | 6 ++-- .../src/ipc/methods/savedEnvironments.ts | 18 ++++------ .../desktop/src/ipc/methods/serverExposure.ts | 15 +++----- .../desktop/src/ipc/methods/sshEnvironment.ts | 27 +++++---------- apps/desktop/src/ipc/methods/updates.ts | 18 ++++------ apps/desktop/src/ipc/methods/window.ts | 34 +++++-------------- 6 files changed, 37 insertions(+), 81 deletions(-) diff --git a/apps/desktop/src/ipc/methods/clientSettings.ts b/apps/desktop/src/ipc/methods/clientSettings.ts index e1162521e93..ebb93bf0f13 100644 --- a/apps/desktop/src/ipc/methods/clientSettings.ts +++ b/apps/desktop/src/ipc/methods/clientSettings.ts @@ -4,11 +4,11 @@ import * as Schema from "effect/Schema"; import * as DesktopEnvironment from "../../main/DesktopEnvironment.ts"; import { readClientSettingsEffect, writeClientSettingsEffect } from "../../clientPersistence.ts"; -import { GET_CLIENT_SETTINGS_CHANNEL, SET_CLIENT_SETTINGS_CHANNEL } from "../channels.ts"; +import * as IpcChannels from "../channels.ts"; import { makeIpcMethod } from "../DesktopIpc.ts"; export const getClientSettings = makeIpcMethod({ - channel: GET_CLIENT_SETTINGS_CHANNEL, + channel: IpcChannels.GET_CLIENT_SETTINGS_CHANNEL, payload: Schema.Void, result: Schema.NullOr(ClientSettingsSchema), handler: () => @@ -19,7 +19,7 @@ export const getClientSettings = makeIpcMethod({ }); export const setClientSettings = makeIpcMethod({ - channel: SET_CLIENT_SETTINGS_CHANNEL, + channel: IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, payload: ClientSettingsSchema, result: Schema.Void, handler: (settings) => diff --git a/apps/desktop/src/ipc/methods/savedEnvironments.ts b/apps/desktop/src/ipc/methods/savedEnvironments.ts index fc76bf85b37..9dbecadc1a3 100644 --- a/apps/desktop/src/ipc/methods/savedEnvironments.ts +++ b/apps/desktop/src/ipc/methods/savedEnvironments.ts @@ -11,13 +11,7 @@ import { } from "../../clientPersistence.ts"; import * as DesktopEnvironment from "../../main/DesktopEnvironment.ts"; import * as ElectronSafeStorage from "../../electron/ElectronSafeStorage.ts"; -import { - GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, - GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, - REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, - SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, - SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, -} from "../channels.ts"; +import * as IpcChannels from "../channels.ts"; import { makeIpcMethod } from "../DesktopIpc.ts"; const SavedEnvironmentRegistryPayload = Schema.Array(PersistedSavedEnvironmentRecordSchema); @@ -33,7 +27,7 @@ const SetSavedEnvironmentSecretInput = Schema.Struct({ }); export const getSavedEnvironmentRegistry = makeIpcMethod({ - channel: GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, + channel: IpcChannels.GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, payload: Schema.Void, result: SavedEnvironmentRegistryPayload, handler: () => @@ -44,7 +38,7 @@ export const getSavedEnvironmentRegistry = makeIpcMethod({ }); export const setSavedEnvironmentRegistry = makeIpcMethod({ - channel: SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, + channel: IpcChannels.SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, payload: SavedEnvironmentRegistryPayload, result: Schema.Void, handler: (records) => @@ -55,7 +49,7 @@ export const setSavedEnvironmentRegistry = makeIpcMethod({ }); export const getSavedEnvironmentSecret = makeIpcMethod({ - channel: GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, + channel: IpcChannels.GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, payload: EnvironmentId, result: Schema.NullOr(Schema.String), handler: (environmentId) => @@ -71,7 +65,7 @@ export const getSavedEnvironmentSecret = makeIpcMethod({ }); export const setSavedEnvironmentSecret = makeIpcMethod({ - channel: SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, + channel: IpcChannels.SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, payload: SetSavedEnvironmentSecretInput, result: Schema.Boolean, handler: ({ environmentId, secret }) => @@ -88,7 +82,7 @@ export const setSavedEnvironmentSecret = makeIpcMethod({ }); export const removeSavedEnvironmentSecret = makeIpcMethod({ - channel: REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, + channel: IpcChannels.REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, payload: EnvironmentId, result: Schema.Void, handler: (environmentId) => diff --git a/apps/desktop/src/ipc/methods/serverExposure.ts b/apps/desktop/src/ipc/methods/serverExposure.ts index 9a77db31e9d..56c7e5c3fd0 100644 --- a/apps/desktop/src/ipc/methods/serverExposure.ts +++ b/apps/desktop/src/ipc/methods/serverExposure.ts @@ -8,12 +8,7 @@ import * as Schema from "effect/Schema"; import * as DesktopLifecycle from "../../main/DesktopLifecycle.ts"; import * as DesktopServerExposure from "../../main/DesktopServerExposure.ts"; -import { - GET_ADVERTISED_ENDPOINTS_CHANNEL, - GET_SERVER_EXPOSURE_STATE_CHANNEL, - SET_SERVER_EXPOSURE_MODE_CHANNEL, - SET_TAILSCALE_SERVE_ENABLED_CHANNEL, -} from "../channels.ts"; +import * as IpcChannels from "../channels.ts"; import { makeIpcMethod } from "../DesktopIpc.ts"; const SetTailscaleServeEnabledInput = Schema.Struct({ @@ -22,7 +17,7 @@ const SetTailscaleServeEnabledInput = Schema.Struct({ }); export const getServerExposureState = makeIpcMethod({ - channel: GET_SERVER_EXPOSURE_STATE_CHANNEL, + channel: IpcChannels.GET_SERVER_EXPOSURE_STATE_CHANNEL, payload: Schema.Void, result: DesktopServerExposureStateSchema, handler: () => @@ -33,7 +28,7 @@ export const getServerExposureState = makeIpcMethod({ }); export const setServerExposureMode = makeIpcMethod({ - channel: SET_SERVER_EXPOSURE_MODE_CHANNEL, + channel: IpcChannels.SET_SERVER_EXPOSURE_MODE_CHANNEL, payload: DesktopServerExposureModeSchema, result: DesktopServerExposureStateSchema, handler: (mode) => @@ -49,7 +44,7 @@ export const setServerExposureMode = makeIpcMethod({ }); export const setTailscaleServeEnabled = makeIpcMethod({ - channel: SET_TAILSCALE_SERVE_ENABLED_CHANNEL, + channel: IpcChannels.SET_TAILSCALE_SERVE_ENABLED_CHANNEL, payload: SetTailscaleServeEnabledInput, result: DesktopServerExposureStateSchema, handler: (input) => @@ -69,7 +64,7 @@ export const setTailscaleServeEnabled = makeIpcMethod({ }); export const getAdvertisedEndpoints = makeIpcMethod({ - channel: GET_ADVERTISED_ENDPOINTS_CHANNEL, + channel: IpcChannels.GET_ADVERTISED_ENDPOINTS_CHANNEL, payload: Schema.Void, result: Schema.Array(AdvertisedEndpoint), handler: () => diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts index 74407b9586c..2b9f03942b5 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts @@ -16,23 +16,14 @@ import { import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { - BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, - DISCONNECT_SSH_ENVIRONMENT_CHANNEL, - DISCOVER_SSH_HOSTS_CHANNEL, - ENSURE_SSH_ENVIRONMENT_CHANNEL, - FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, - FETCH_SSH_SESSION_STATE_CHANNEL, - ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, - RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, -} from "../channels.ts"; +import * as IpcChannels from "../channels.ts"; import { makeIpcMethod } from "../DesktopIpc.ts"; import * as DesktopSshEnvironment from "../../main/DesktopSshEnvironment.ts"; import * as DesktopSshPasswordPrompts from "../../main/DesktopSshPasswordPrompts.ts"; import * as DesktopSshRemoteApi from "../../main/DesktopSshRemoteApi.ts"; export const discoverSshHosts = makeIpcMethod({ - channel: DISCOVER_SSH_HOSTS_CHANNEL, + channel: IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL, payload: Schema.Void, result: Schema.Array(DesktopDiscoveredSshHostSchema), handler: () => @@ -43,7 +34,7 @@ export const discoverSshHosts = makeIpcMethod({ }); export const ensureSshEnvironment = makeIpcMethod({ - channel: ENSURE_SSH_ENVIRONMENT_CHANNEL, + channel: IpcChannels.ENSURE_SSH_ENVIRONMENT_CHANNEL, payload: DesktopSshEnvironmentEnsureInputSchema, result: DesktopSshEnvironmentEnsureResultSchema, handler: ({ target, options }) => @@ -63,7 +54,7 @@ export const ensureSshEnvironment = makeIpcMethod({ }); export const disconnectSshEnvironment = makeIpcMethod({ - channel: DISCONNECT_SSH_ENVIRONMENT_CHANNEL, + channel: IpcChannels.DISCONNECT_SSH_ENVIRONMENT_CHANNEL, payload: DesktopSshEnvironmentTargetSchema, result: Schema.Void, handler: (target) => @@ -74,7 +65,7 @@ export const disconnectSshEnvironment = makeIpcMethod({ }); export const fetchSshEnvironmentDescriptor = makeIpcMethod({ - channel: FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, + channel: IpcChannels.FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, payload: DesktopSshHttpBaseUrlInputSchema, result: ExecutionEnvironmentDescriptor, handler: ({ httpBaseUrl }) => @@ -85,7 +76,7 @@ export const fetchSshEnvironmentDescriptor = makeIpcMethod({ }); export const bootstrapSshBearerSession = makeIpcMethod({ - channel: BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, + channel: IpcChannels.BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, payload: DesktopSshBearerBootstrapInputSchema, result: AuthBearerBootstrapResult, handler: ({ httpBaseUrl, credential }) => @@ -96,7 +87,7 @@ export const bootstrapSshBearerSession = makeIpcMethod({ }); export const fetchSshSessionState = makeIpcMethod({ - channel: FETCH_SSH_SESSION_STATE_CHANNEL, + channel: IpcChannels.FETCH_SSH_SESSION_STATE_CHANNEL, payload: DesktopSshBearerRequestInputSchema, result: AuthSessionState, handler: ({ httpBaseUrl, bearerToken }) => @@ -107,7 +98,7 @@ export const fetchSshSessionState = makeIpcMethod({ }); export const issueSshWebSocketToken = makeIpcMethod({ - channel: ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, + channel: IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, payload: DesktopSshBearerRequestInputSchema, result: AuthWebSocketTokenResult, handler: ({ httpBaseUrl, bearerToken }) => @@ -118,7 +109,7 @@ export const issueSshWebSocketToken = makeIpcMethod({ }); export const resolveSshPasswordPrompt = makeIpcMethod({ - channel: RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, + channel: IpcChannels.RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, payload: DesktopSshPasswordPromptResolutionInputSchema, result: Schema.Void, handler: ({ requestId, password }) => diff --git a/apps/desktop/src/ipc/methods/updates.ts b/apps/desktop/src/ipc/methods/updates.ts index a7a44e1d1d6..7cb0a3746a2 100644 --- a/apps/desktop/src/ipc/methods/updates.ts +++ b/apps/desktop/src/ipc/methods/updates.ts @@ -8,17 +8,11 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as DesktopUpdates from "../../main/DesktopUpdates.ts"; -import { - UPDATE_CHECK_CHANNEL, - UPDATE_DOWNLOAD_CHANNEL, - UPDATE_GET_STATE_CHANNEL, - UPDATE_INSTALL_CHANNEL, - UPDATE_SET_CHANNEL_CHANNEL, -} from "../channels.ts"; +import * as IpcChannels from "../channels.ts"; import { makeIpcMethod } from "../DesktopIpc.ts"; export const getUpdateState = makeIpcMethod({ - channel: UPDATE_GET_STATE_CHANNEL, + channel: IpcChannels.UPDATE_GET_STATE_CHANNEL, payload: Schema.Void, result: DesktopUpdateStateSchema, handler: () => @@ -29,7 +23,7 @@ export const getUpdateState = makeIpcMethod({ }); export const setUpdateChannel = makeIpcMethod({ - channel: UPDATE_SET_CHANNEL_CHANNEL, + channel: IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, payload: DesktopUpdateChannelSchema, result: DesktopUpdateStateSchema, handler: (channel) => @@ -40,7 +34,7 @@ export const setUpdateChannel = makeIpcMethod({ }); export const downloadUpdate = makeIpcMethod({ - channel: UPDATE_DOWNLOAD_CHANNEL, + channel: IpcChannels.UPDATE_DOWNLOAD_CHANNEL, payload: Schema.Void, result: DesktopUpdateActionResultSchema, handler: () => @@ -51,7 +45,7 @@ export const downloadUpdate = makeIpcMethod({ }); export const installUpdate = makeIpcMethod({ - channel: UPDATE_INSTALL_CHANNEL, + channel: IpcChannels.UPDATE_INSTALL_CHANNEL, payload: Schema.Void, result: DesktopUpdateActionResultSchema, handler: () => @@ -62,7 +56,7 @@ export const installUpdate = makeIpcMethod({ }); export const checkForUpdate = makeIpcMethod({ - channel: UPDATE_CHECK_CHANNEL, + channel: IpcChannels.UPDATE_CHECK_CHANNEL, payload: Schema.Void, result: DesktopUpdateCheckResultSchema, handler: () => diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index f34b402e737..8377e4d3770 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -9,15 +9,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as DesktopWindowIpcActions from "../../main/DesktopWindowIpcActions.ts"; -import { - CONFIRM_CHANNEL, - CONTEXT_MENU_CHANNEL, - GET_APP_BRANDING_CHANNEL, - GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, - OPEN_EXTERNAL_CHANNEL, - PICK_FOLDER_CHANNEL, - SET_THEME_CHANNEL, -} from "../channels.ts"; +import * as IpcChannels from "../channels.ts"; import { makeIpcMethod, makeSyncIpcMethod } from "../DesktopIpc.ts"; const ContextMenuPosition = Schema.Struct({ @@ -31,7 +23,7 @@ const ContextMenuInput = Schema.Struct({ }); export const getAppBranding = makeSyncIpcMethod({ - channel: GET_APP_BRANDING_CHANNEL, + channel: IpcChannels.GET_APP_BRANDING_CHANNEL, result: Schema.NullOr(DesktopAppBrandingSchema), handler: () => Effect.gen(function* () { @@ -41,7 +33,7 @@ export const getAppBranding = makeSyncIpcMethod({ }); export const getLocalEnvironmentBootstrap = makeSyncIpcMethod({ - channel: GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, + channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, result: Schema.NullOr(DesktopEnvironmentBootstrapSchema), handler: () => Effect.gen(function* () { @@ -51,7 +43,7 @@ export const getLocalEnvironmentBootstrap = makeSyncIpcMethod({ }); export const pickFolder = makeIpcMethod({ - channel: PICK_FOLDER_CHANNEL, + channel: IpcChannels.PICK_FOLDER_CHANNEL, payload: Schema.UndefinedOr(PickFolderOptionsSchema), result: Schema.NullOr(Schema.String), handler: (options) => @@ -62,7 +54,7 @@ export const pickFolder = makeIpcMethod({ }); export const confirm = makeIpcMethod({ - channel: CONFIRM_CHANNEL, + channel: IpcChannels.CONFIRM_CHANNEL, payload: Schema.String, result: Schema.Boolean, handler: (message) => @@ -73,7 +65,7 @@ export const confirm = makeIpcMethod({ }); export const setTheme = makeIpcMethod({ - channel: SET_THEME_CHANNEL, + channel: IpcChannels.SET_THEME_CHANNEL, payload: DesktopThemeSchema, result: Schema.Void, handler: (theme) => @@ -84,7 +76,7 @@ export const setTheme = makeIpcMethod({ }); export const showContextMenu = makeIpcMethod({ - channel: CONTEXT_MENU_CHANNEL, + channel: IpcChannels.CONTEXT_MENU_CHANNEL, payload: ContextMenuInput, result: Schema.NullOr(Schema.String), handler: (input) => @@ -95,7 +87,7 @@ export const showContextMenu = makeIpcMethod({ }); export const openExternal = makeIpcMethod({ - channel: OPEN_EXTERNAL_CHANNEL, + channel: IpcChannels.OPEN_EXTERNAL_CHANNEL, payload: Schema.String, result: Schema.Boolean, handler: (url) => @@ -104,13 +96,3 @@ export const openExternal = makeIpcMethod({ return yield* window.openExternal(url); }), }); - -export const windowInvokeMethods = [ - pickFolder, - confirm, - setTheme, - showContextMenu, - openExternal, -] as const; - -export const windowSyncMethods = [getAppBranding, getLocalEnvironmentBootstrap] as const; From e4c0d7716a7dc837dd51fec23e256e7e31f35bfb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 21:06:54 -0700 Subject: [PATCH 21/43] Reorganize desktop app modules by domain - Move desktop code out of `main` into app, backend, settings, shell, ssh, updates, and window folders - Update imports and tests to match the new layout --- apps/desktop/src/{main => app}/DesktopApp.ts | 14 +++--- .../{main => app}/DesktopAppIdentity.test.ts | 0 .../src/{main => app}/DesktopAppIdentity.ts | 0 .../src/{main => app}/DesktopAssets.ts | 0 .../src/{main => app}/DesktopConfig.test.ts | 0 .../src/{main => app}/DesktopConfig.ts | 0 .../{main => app}/DesktopEnvironment.test.ts | 0 .../src/{main => app}/DesktopEnvironment.ts | 7 ++- .../src/{main => app}/DesktopLifecycle.ts | 2 +- .../src/{main => app}/DesktopLogging.ts | 0 apps/desktop/src/{main => app}/DesktopRun.ts | 0 .../desktop/src/{main => app}/DesktopState.ts | 0 .../DesktopBackendConfiguration.test.ts | 8 ++-- .../DesktopBackendConfiguration.ts | 6 +-- .../{main => backend}/DesktopBackendEvents.ts | 8 ++-- .../DesktopBackendManager.test.ts | 0 .../DesktopBackendManager.ts | 0 apps/desktop/src/electron/ElectronProtocol.ts | 2 +- .../src/electron/ElectronSafeStorage.ts | 8 ++-- .../desktop/src/ipc/methods/clientSettings.ts | 7 ++- .../src/ipc/methods/savedEnvironments.ts | 4 +- .../desktop/src/ipc/methods/serverExposure.ts | 4 +- .../desktop/src/ipc/methods/sshEnvironment.ts | 6 +-- apps/desktop/src/ipc/methods/updates.ts | 2 +- apps/desktop/src/ipc/methods/window.ts | 2 +- apps/desktop/src/main.ts | 46 +++++++++---------- .../DesktopServerExposure.test.ts | 14 ++++-- .../DesktopServerExposure.ts | 10 ++-- .../tailscaleEndpointProvider.test.ts | 0 .../tailscaleEndpointProvider.ts | 2 +- .../DesktopSettingsState.test.ts | 2 +- .../DesktopSettingsState.ts | 4 +- .../{ => settings}/clientPersistence.test.ts | 4 +- .../src/{ => settings}/clientPersistence.ts | 12 ++--- .../{ => settings}/desktopSettings.test.ts | 0 .../src/{ => settings}/desktopSettings.ts | 10 ++-- .../DesktopShellEnvironment.test.ts | 0 .../DesktopShellEnvironment.ts | 2 +- .../DesktopSshEnvironment.test.ts | 0 .../{main => ssh}/DesktopSshEnvironment.ts | 0 .../DesktopSshPasswordPrompts.test.ts | 0 .../DesktopSshPasswordPrompts.ts | 0 .../{main => ssh}/DesktopSshRemoteApi.test.ts | 0 .../src/{main => ssh}/DesktopSshRemoteApi.ts | 0 .../{main => updates}/DesktopUpdates.test.ts | 12 ++--- .../src/{main => updates}/DesktopUpdates.ts | 19 ++++---- .../src/{ => updates}/updateChannels.ts | 0 .../src/{ => updates}/updateMachine.test.ts | 0 .../src/{ => updates}/updateMachine.ts | 0 .../DesktopApplicationMenu.test.ts | 8 ++-- .../DesktopApplicationMenu.ts | 6 +-- .../src/{main => window}/DesktopWindow.ts | 8 ++-- .../DesktopWindowIpcActions.ts | 4 +- 53 files changed, 130 insertions(+), 113 deletions(-) rename apps/desktop/src/{main => app}/DesktopApp.ts (94%) rename apps/desktop/src/{main => app}/DesktopAppIdentity.test.ts (100%) rename apps/desktop/src/{main => app}/DesktopAppIdentity.ts (100%) rename apps/desktop/src/{main => app}/DesktopAssets.ts (100%) rename apps/desktop/src/{main => app}/DesktopConfig.test.ts (100%) rename apps/desktop/src/{main => app}/DesktopConfig.ts (100%) rename apps/desktop/src/{main => app}/DesktopEnvironment.test.ts (100%) rename apps/desktop/src/{main => app}/DesktopEnvironment.ts (98%) rename apps/desktop/src/{main => app}/DesktopLifecycle.ts (99%) rename apps/desktop/src/{main => app}/DesktopLogging.ts (100%) rename apps/desktop/src/{main => app}/DesktopRun.ts (100%) rename apps/desktop/src/{main => app}/DesktopState.ts (100%) rename apps/desktop/src/{main => backend}/DesktopBackendConfiguration.test.ts (95%) rename apps/desktop/src/{main => backend}/DesktopBackendConfiguration.ts (96%) rename apps/desktop/src/{main => backend}/DesktopBackendEvents.ts (92%) rename apps/desktop/src/{main => backend}/DesktopBackendManager.test.ts (100%) rename apps/desktop/src/{main => backend}/DesktopBackendManager.ts (100%) rename apps/desktop/src/{main => server-exposure}/DesktopServerExposure.test.ts (97%) rename apps/desktop/src/{main => server-exposure}/DesktopServerExposure.ts (98%) rename apps/desktop/src/{ => server-exposure}/tailscaleEndpointProvider.test.ts (100%) rename apps/desktop/src/{ => server-exposure}/tailscaleEndpointProvider.ts (98%) rename apps/desktop/src/{main => settings}/DesktopSettingsState.test.ts (94%) rename apps/desktop/src/{main => settings}/DesktopSettingsState.ts (97%) rename apps/desktop/src/{ => settings}/clientPersistence.test.ts (98%) rename apps/desktop/src/{ => settings}/clientPersistence.ts (97%) rename apps/desktop/src/{ => settings}/desktopSettings.test.ts (100%) rename apps/desktop/src/{ => settings}/desktopSettings.ts (95%) rename apps/desktop/src/{main => shell}/DesktopShellEnvironment.test.ts (100%) rename apps/desktop/src/{main => shell}/DesktopShellEnvironment.ts (99%) rename apps/desktop/src/{main => ssh}/DesktopSshEnvironment.test.ts (100%) rename apps/desktop/src/{main => ssh}/DesktopSshEnvironment.ts (100%) rename apps/desktop/src/{main => ssh}/DesktopSshPasswordPrompts.test.ts (100%) rename apps/desktop/src/{main => ssh}/DesktopSshPasswordPrompts.ts (100%) rename apps/desktop/src/{main => ssh}/DesktopSshRemoteApi.test.ts (100%) rename apps/desktop/src/{main => ssh}/DesktopSshRemoteApi.ts (100%) rename apps/desktop/src/{main => updates}/DesktopUpdates.test.ts (95%) rename apps/desktop/src/{main => updates}/DesktopUpdates.ts (97%) rename apps/desktop/src/{ => updates}/updateChannels.ts (100%) rename apps/desktop/src/{ => updates}/updateMachine.test.ts (100%) rename apps/desktop/src/{ => updates}/updateMachine.ts (100%) rename apps/desktop/src/{main => window}/DesktopApplicationMenu.test.ts (95%) rename apps/desktop/src/{main => window}/DesktopApplicationMenu.ts (97%) rename apps/desktop/src/{main => window}/DesktopWindow.ts (97%) rename apps/desktop/src/{main => window}/DesktopWindowIpcActions.ts (96%) diff --git a/apps/desktop/src/main/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts similarity index 94% rename from apps/desktop/src/main/DesktopApp.ts rename to apps/desktop/src/app/DesktopApp.ts index ad4d81f6463..e9f2679748e 100644 --- a/apps/desktop/src/main/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -11,17 +11,17 @@ import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; -import * as DesktopApplicationMenu from "./DesktopApplicationMenu.ts"; -import * as DesktopBackendManager from "./DesktopBackendManager.ts"; +import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; +import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopLifecycle from "./DesktopLifecycle.ts"; import * as DesktopRun from "./DesktopRun.ts"; -import * as DesktopServerExposure from "./DesktopServerExposure.ts"; -import * as DesktopSettingsState from "./DesktopSettingsState.ts"; -import * as DesktopShellEnvironment from "./DesktopShellEnvironment.ts"; +import * as DesktopServerExposure from "../server-exposure/DesktopServerExposure.ts"; +import * as DesktopSettingsState from "../settings/DesktopSettingsState.ts"; +import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; import * as DesktopState from "./DesktopState.ts"; -import * as DesktopUpdates from "./DesktopUpdates.ts"; -import * as DesktopWindow from "./DesktopWindow.ts"; +import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; const DEFAULT_DESKTOP_BACKEND_PORT = 3773; const MAX_TCP_PORT = 65_535; diff --git a/apps/desktop/src/main/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts similarity index 100% rename from apps/desktop/src/main/DesktopAppIdentity.test.ts rename to apps/desktop/src/app/DesktopAppIdentity.test.ts diff --git a/apps/desktop/src/main/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts similarity index 100% rename from apps/desktop/src/main/DesktopAppIdentity.ts rename to apps/desktop/src/app/DesktopAppIdentity.ts diff --git a/apps/desktop/src/main/DesktopAssets.ts b/apps/desktop/src/app/DesktopAssets.ts similarity index 100% rename from apps/desktop/src/main/DesktopAssets.ts rename to apps/desktop/src/app/DesktopAssets.ts diff --git a/apps/desktop/src/main/DesktopConfig.test.ts b/apps/desktop/src/app/DesktopConfig.test.ts similarity index 100% rename from apps/desktop/src/main/DesktopConfig.test.ts rename to apps/desktop/src/app/DesktopConfig.test.ts diff --git a/apps/desktop/src/main/DesktopConfig.ts b/apps/desktop/src/app/DesktopConfig.ts similarity index 100% rename from apps/desktop/src/main/DesktopConfig.ts rename to apps/desktop/src/app/DesktopConfig.ts diff --git a/apps/desktop/src/main/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts similarity index 100% rename from apps/desktop/src/main/DesktopEnvironment.test.ts rename to apps/desktop/src/app/DesktopEnvironment.test.ts diff --git a/apps/desktop/src/main/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts similarity index 98% rename from apps/desktop/src/main/DesktopEnvironment.ts rename to apps/desktop/src/app/DesktopEnvironment.ts index 58e27f13062..78bbafd4c7c 100644 --- a/apps/desktop/src/main/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -7,9 +7,12 @@ import type { import { Context, Effect, Layer, Option } from "effect"; import * as EffectPath from "effect/Path"; -import { type DesktopSettings, resolveDefaultDesktopSettings } from "../desktopSettings.ts"; +import { + type DesktopSettings, + resolveDefaultDesktopSettings, +} from "../settings/desktopSettings.ts"; import * as DesktopConfig from "./DesktopConfig.ts"; -import { isNightlyDesktopVersion } from "../updateChannels.ts"; +import { isNightlyDesktopVersion } from "../updates/updateChannels.ts"; export interface MakeDesktopEnvironmentInput { readonly dirname: string; diff --git a/apps/desktop/src/main/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts similarity index 99% rename from apps/desktop/src/main/DesktopLifecycle.ts rename to apps/desktop/src/app/DesktopLifecycle.ts index 78de85bd445..1411c3ef9cf 100644 --- a/apps/desktop/src/main/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -13,7 +13,7 @@ import * as DesktopRun from "./DesktopRun.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as DesktopState from "./DesktopState.ts"; -import * as DesktopWindow from "./DesktopWindow.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; export interface DesktopShutdownShape { readonly request: Effect.Effect; diff --git a/apps/desktop/src/main/DesktopLogging.ts b/apps/desktop/src/app/DesktopLogging.ts similarity index 100% rename from apps/desktop/src/main/DesktopLogging.ts rename to apps/desktop/src/app/DesktopLogging.ts diff --git a/apps/desktop/src/main/DesktopRun.ts b/apps/desktop/src/app/DesktopRun.ts similarity index 100% rename from apps/desktop/src/main/DesktopRun.ts rename to apps/desktop/src/app/DesktopRun.ts diff --git a/apps/desktop/src/main/DesktopState.ts b/apps/desktop/src/app/DesktopState.ts similarity index 100% rename from apps/desktop/src/main/DesktopState.ts rename to apps/desktop/src/app/DesktopState.ts diff --git a/apps/desktop/src/main/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts similarity index 95% rename from apps/desktop/src/main/DesktopBackendConfiguration.test.ts rename to apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 7134316ec59..6d8752ef553 100644 --- a/apps/desktop/src/main/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -5,11 +5,11 @@ import * as EffectPath from "effect/Path"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; -import * as DesktopConfig from "./DesktopConfig.ts"; -import * as DesktopRun from "./DesktopRun.ts"; -import * as DesktopServerExposure from "./DesktopServerExposure.ts"; +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopRun from "../app/DesktopRun.ts"; +import * as DesktopServerExposure from "../server-exposure/DesktopServerExposure.ts"; const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExposure, { getState: Effect.die("unexpected getState"), diff --git a/apps/desktop/src/main/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts similarity index 96% rename from apps/desktop/src/main/DesktopBackendConfiguration.ts rename to apps/desktop/src/backend/DesktopBackendConfiguration.ts index dd1a417ac0f..503c4659524 100644 --- a/apps/desktop/src/main/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -8,9 +8,9 @@ import * as Random from "effect/Random"; import * as Ref from "effect/Ref"; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; -import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -import * as DesktopServerExposure from "./DesktopServerExposure.ts"; -import * as DesktopRun from "./DesktopRun.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopServerExposure from "../server-exposure/DesktopServerExposure.ts"; +import * as DesktopRun from "../app/DesktopRun.ts"; export interface DesktopBackendConfigurationShape { readonly resolve: Effect.Effect; diff --git a/apps/desktop/src/main/DesktopBackendEvents.ts b/apps/desktop/src/backend/DesktopBackendEvents.ts similarity index 92% rename from apps/desktop/src/main/DesktopBackendEvents.ts rename to apps/desktop/src/backend/DesktopBackendEvents.ts index a1249e39dc9..5a3c1216922 100644 --- a/apps/desktop/src/main/DesktopBackendEvents.ts +++ b/apps/desktop/src/backend/DesktopBackendEvents.ts @@ -6,10 +6,10 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; -import { DesktopBackendOutputLog } from "./DesktopLogging.ts"; -import * as DesktopRun from "./DesktopRun.ts"; -import * as DesktopState from "./DesktopState.ts"; -import * as DesktopWindow from "./DesktopWindow.ts"; +import { DesktopBackendOutputLog } from "../app/DesktopLogging.ts"; +import * as DesktopRun from "../app/DesktopRun.ts"; +import * as DesktopState from "../app/DesktopState.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; export interface DesktopBackendEventsShape { readonly onStarting: Effect.Effect; diff --git a/apps/desktop/src/main/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts similarity index 100% rename from apps/desktop/src/main/DesktopBackendManager.test.ts rename to apps/desktop/src/backend/DesktopBackendManager.test.ts diff --git a/apps/desktop/src/main/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts similarity index 100% rename from apps/desktop/src/main/DesktopBackendManager.ts rename to apps/desktop/src/backend/DesktopBackendManager.ts diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index d62dbb36ec1..925886f4d07 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -9,7 +9,7 @@ import * as Scope from "effect/Scope"; import * as Electron from "electron"; -import { DesktopEnvironment, type DesktopEnvironmentShape } from "../main/DesktopEnvironment.ts"; +import { DesktopEnvironment, type DesktopEnvironmentShape } from "../app/DesktopEnvironment.ts"; export const DESKTOP_SCHEME = "t3"; diff --git a/apps/desktop/src/electron/ElectronSafeStorage.ts b/apps/desktop/src/electron/ElectronSafeStorage.ts index 087bc797a69..af09f99588a 100644 --- a/apps/desktop/src/electron/ElectronSafeStorage.ts +++ b/apps/desktop/src/electron/ElectronSafeStorage.ts @@ -3,9 +3,11 @@ import * as Layer from "effect/Layer"; import * as Electron from "electron"; -import type { DesktopSecretStorage as ClientPersistenceSecretStorage } from "../clientPersistence.ts"; - -export interface ElectronSafeStorageShape extends ClientPersistenceSecretStorage {} +export interface ElectronSafeStorageShape { + readonly isEncryptionAvailable: () => boolean; + readonly encryptString: (value: string) => Buffer; + readonly decryptString: (value: Buffer) => string; +} export class ElectronSafeStorage extends Context.Service< ElectronSafeStorage, diff --git a/apps/desktop/src/ipc/methods/clientSettings.ts b/apps/desktop/src/ipc/methods/clientSettings.ts index ebb93bf0f13..b7164ae15fb 100644 --- a/apps/desktop/src/ipc/methods/clientSettings.ts +++ b/apps/desktop/src/ipc/methods/clientSettings.ts @@ -2,8 +2,11 @@ import { ClientSettingsSchema } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import * as DesktopEnvironment from "../../main/DesktopEnvironment.ts"; -import { readClientSettingsEffect, writeClientSettingsEffect } from "../../clientPersistence.ts"; +import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; +import { + readClientSettingsEffect, + writeClientSettingsEffect, +} from "../../settings/clientPersistence.ts"; import * as IpcChannels from "../channels.ts"; import { makeIpcMethod } from "../DesktopIpc.ts"; diff --git a/apps/desktop/src/ipc/methods/savedEnvironments.ts b/apps/desktop/src/ipc/methods/savedEnvironments.ts index 9dbecadc1a3..b856ab7bcef 100644 --- a/apps/desktop/src/ipc/methods/savedEnvironments.ts +++ b/apps/desktop/src/ipc/methods/savedEnvironments.ts @@ -8,8 +8,8 @@ import { removeSavedEnvironmentSecretEffect, writeSavedEnvironmentRegistryEffect, writeSavedEnvironmentSecretEffect, -} from "../../clientPersistence.ts"; -import * as DesktopEnvironment from "../../main/DesktopEnvironment.ts"; +} from "../../settings/clientPersistence.ts"; +import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; import * as ElectronSafeStorage from "../../electron/ElectronSafeStorage.ts"; import * as IpcChannels from "../channels.ts"; import { makeIpcMethod } from "../DesktopIpc.ts"; diff --git a/apps/desktop/src/ipc/methods/serverExposure.ts b/apps/desktop/src/ipc/methods/serverExposure.ts index 56c7e5c3fd0..60350695bf1 100644 --- a/apps/desktop/src/ipc/methods/serverExposure.ts +++ b/apps/desktop/src/ipc/methods/serverExposure.ts @@ -6,8 +6,8 @@ import { import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import * as DesktopLifecycle from "../../main/DesktopLifecycle.ts"; -import * as DesktopServerExposure from "../../main/DesktopServerExposure.ts"; +import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; +import * as DesktopServerExposure from "../../server-exposure/DesktopServerExposure.ts"; import * as IpcChannels from "../channels.ts"; import { makeIpcMethod } from "../DesktopIpc.ts"; diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts index 2b9f03942b5..b67bc42e40c 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts @@ -18,9 +18,9 @@ import * as Schema from "effect/Schema"; import * as IpcChannels from "../channels.ts"; import { makeIpcMethod } from "../DesktopIpc.ts"; -import * as DesktopSshEnvironment from "../../main/DesktopSshEnvironment.ts"; -import * as DesktopSshPasswordPrompts from "../../main/DesktopSshPasswordPrompts.ts"; -import * as DesktopSshRemoteApi from "../../main/DesktopSshRemoteApi.ts"; +import * as DesktopSshEnvironment from "../../ssh/DesktopSshEnvironment.ts"; +import * as DesktopSshPasswordPrompts from "../../ssh/DesktopSshPasswordPrompts.ts"; +import * as DesktopSshRemoteApi from "../../ssh/DesktopSshRemoteApi.ts"; export const discoverSshHosts = makeIpcMethod({ channel: IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL, diff --git a/apps/desktop/src/ipc/methods/updates.ts b/apps/desktop/src/ipc/methods/updates.ts index 7cb0a3746a2..1e837933788 100644 --- a/apps/desktop/src/ipc/methods/updates.ts +++ b/apps/desktop/src/ipc/methods/updates.ts @@ -7,7 +7,7 @@ import { import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import * as DesktopUpdates from "../../main/DesktopUpdates.ts"; +import * as DesktopUpdates from "../../updates/DesktopUpdates.ts"; import * as IpcChannels from "../channels.ts"; import { makeIpcMethod } from "../DesktopIpc.ts"; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 8377e4d3770..94fe7d72ecf 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -8,7 +8,7 @@ import { import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import * as DesktopWindowIpcActions from "../../main/DesktopWindowIpcActions.ts"; +import * as DesktopWindowIpcActions from "../../window/DesktopWindowIpcActions.ts"; import * as IpcChannels from "../channels.ts"; import { makeIpcMethod, makeSyncIpcMethod } from "../DesktopIpc.ts"; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index b15ede1c2ab..4d7281c56dc 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -12,7 +12,7 @@ import * as NetService from "@t3tools/shared/Net"; import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; -import type { DesktopSettings } from "./desktopSettings.ts"; +import type { DesktopSettings } from "./settings/desktopSettings.ts"; import * as DesktopIpc from "./ipc/DesktopIpc.ts"; import * as ElectronApp from "./electron/ElectronApp.ts"; import * as ElectronDialog from "./electron/ElectronDialog.ts"; @@ -23,28 +23,28 @@ import * as ElectronShell from "./electron/ElectronShell.ts"; import * as ElectronTheme from "./electron/ElectronTheme.ts"; import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; import * as ElectronWindow from "./electron/ElectronWindow.ts"; -import * as DesktopApp from "./main/DesktopApp.ts"; -import * as DesktopAppIdentity from "./main/DesktopAppIdentity.ts"; -import * as DesktopApplicationMenu from "./main/DesktopApplicationMenu.ts"; -import * as DesktopAssets from "./main/DesktopAssets.ts"; -import * as DesktopBackendConfiguration from "./main/DesktopBackendConfiguration.ts"; -import * as DesktopBackendEvents from "./main/DesktopBackendEvents.ts"; -import * as DesktopBackendManager from "./main/DesktopBackendManager.ts"; -import * as DesktopConfig from "./main/DesktopConfig.ts"; -import * as DesktopEnvironment from "./main/DesktopEnvironment.ts"; -import * as DesktopLifecycle from "./main/DesktopLifecycle.ts"; -import { DesktopBackendOutputLogLive, DesktopLoggerLive } from "./main/DesktopLogging.ts"; -import * as DesktopRun from "./main/DesktopRun.ts"; -import * as DesktopServerExposure from "./main/DesktopServerExposure.ts"; -import * as DesktopSettingsState from "./main/DesktopSettingsState.ts"; -import * as DesktopShellEnvironment from "./main/DesktopShellEnvironment.ts"; -import * as DesktopSshEnvironment from "./main/DesktopSshEnvironment.ts"; -import * as DesktopSshPasswordPrompts from "./main/DesktopSshPasswordPrompts.ts"; -import * as DesktopSshRemoteApi from "./main/DesktopSshRemoteApi.ts"; -import * as DesktopState from "./main/DesktopState.ts"; -import * as DesktopUpdates from "./main/DesktopUpdates.ts"; -import * as DesktopWindow from "./main/DesktopWindow.ts"; -import * as DesktopWindowIpcActions from "./main/DesktopWindowIpcActions.ts"; +import * as DesktopApp from "./app/DesktopApp.ts"; +import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; +import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; +import * as DesktopAssets from "./app/DesktopAssets.ts"; +import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; +import * as DesktopBackendEvents from "./backend/DesktopBackendEvents.ts"; +import * as DesktopBackendManager from "./backend/DesktopBackendManager.ts"; +import * as DesktopConfig from "./app/DesktopConfig.ts"; +import * as DesktopEnvironment from "./app/DesktopEnvironment.ts"; +import * as DesktopLifecycle from "./app/DesktopLifecycle.ts"; +import { DesktopBackendOutputLogLive, DesktopLoggerLive } from "./app/DesktopLogging.ts"; +import * as DesktopRun from "./app/DesktopRun.ts"; +import * as DesktopServerExposure from "./server-exposure/DesktopServerExposure.ts"; +import * as DesktopSettingsState from "./settings/DesktopSettingsState.ts"; +import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; +import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; +import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; +import * as DesktopSshRemoteApi from "./ssh/DesktopSshRemoteApi.ts"; +import * as DesktopState from "./app/DesktopState.ts"; +import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; +import * as DesktopWindow from "./window/DesktopWindow.ts"; +import * as DesktopWindowIpcActions from "./window/DesktopWindowIpcActions.ts"; const desktopEnvironmentLayer = Layer.unwrap( Effect.gen(function* () { diff --git a/apps/desktop/src/main/DesktopServerExposure.test.ts b/apps/desktop/src/server-exposure/DesktopServerExposure.test.ts similarity index 97% rename from apps/desktop/src/main/DesktopServerExposure.test.ts rename to apps/desktop/src/server-exposure/DesktopServerExposure.test.ts index 056bf4d9143..a7ec089ab78 100644 --- a/apps/desktop/src/main/DesktopServerExposure.test.ts +++ b/apps/desktop/src/server-exposure/DesktopServerExposure.test.ts @@ -10,12 +10,18 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { DEFAULT_DESKTOP_SETTINGS, readDesktopSettingsEffect } from "../desktopSettings.ts"; -import { DesktopEnvironment, layer as makeDesktopEnvironmentLayer } from "./DesktopEnvironment.ts"; -import * as DesktopConfig from "./DesktopConfig.ts"; +import { + DEFAULT_DESKTOP_SETTINGS, + readDesktopSettingsEffect, +} from "../settings/desktopSettings.ts"; +import { + DesktopEnvironment, + layer as makeDesktopEnvironmentLayer, +} from "../app/DesktopEnvironment.ts"; +import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; import type { DesktopNetworkInterfaces } from "./DesktopServerExposure.ts"; -import * as DesktopSettingsState from "./DesktopSettingsState.ts"; +import * as DesktopSettingsState from "../settings/DesktopSettingsState.ts"; const encoder = new TextEncoder(); diff --git a/apps/desktop/src/main/DesktopServerExposure.ts b/apps/desktop/src/server-exposure/DesktopServerExposure.ts similarity index 98% rename from apps/desktop/src/main/DesktopServerExposure.ts rename to apps/desktop/src/server-exposure/DesktopServerExposure.ts index 7ac838f12f3..ab3aa45b6e4 100644 --- a/apps/desktop/src/main/DesktopServerExposure.ts +++ b/apps/desktop/src/server-exposure/DesktopServerExposure.ts @@ -26,11 +26,11 @@ import { type DesktopSettings, setDesktopServerExposurePreference, setDesktopTailscaleServePreference, -} from "../desktopSettings.ts"; -import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -import * as DesktopConfig from "./DesktopConfig.ts"; -import { resolveTailscaleAdvertisedEndpoints } from "../tailscaleEndpointProvider.ts"; -import * as DesktopSettingsState from "./DesktopSettingsState.ts"; +} from "../settings/desktopSettings.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; +import * as DesktopSettingsState from "../settings/DesktopSettingsState.ts"; export const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; diff --git a/apps/desktop/src/tailscaleEndpointProvider.test.ts b/apps/desktop/src/server-exposure/tailscaleEndpointProvider.test.ts similarity index 100% rename from apps/desktop/src/tailscaleEndpointProvider.test.ts rename to apps/desktop/src/server-exposure/tailscaleEndpointProvider.test.ts diff --git a/apps/desktop/src/tailscaleEndpointProvider.ts b/apps/desktop/src/server-exposure/tailscaleEndpointProvider.ts similarity index 98% rename from apps/desktop/src/tailscaleEndpointProvider.ts rename to apps/desktop/src/server-exposure/tailscaleEndpointProvider.ts index 8d4df2f91ab..a91fbd2edcd 100644 --- a/apps/desktop/src/tailscaleEndpointProvider.ts +++ b/apps/desktop/src/server-exposure/tailscaleEndpointProvider.ts @@ -11,7 +11,7 @@ import { Effect, Option } from "effect"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; -import type { DesktopNetworkInterfaces } from "./main/DesktopServerExposure.ts"; +import type { DesktopNetworkInterfaces } from "./DesktopServerExposure.ts"; export { isTailscaleIpv4Address, parseTailscaleMagicDnsName } from "@t3tools/tailscale"; diff --git a/apps/desktop/src/main/DesktopSettingsState.test.ts b/apps/desktop/src/settings/DesktopSettingsState.test.ts similarity index 94% rename from apps/desktop/src/main/DesktopSettingsState.test.ts rename to apps/desktop/src/settings/DesktopSettingsState.test.ts index ac12492219e..4f957ab7f0a 100644 --- a/apps/desktop/src/main/DesktopSettingsState.test.ts +++ b/apps/desktop/src/settings/DesktopSettingsState.test.ts @@ -1,7 +1,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import { DEFAULT_DESKTOP_SETTINGS } from "../desktopSettings.ts"; +import { DEFAULT_DESKTOP_SETTINGS } from "./desktopSettings.ts"; import * as DesktopSettingsState from "./DesktopSettingsState.ts"; describe("DesktopSettingsState", () => { diff --git a/apps/desktop/src/main/DesktopSettingsState.ts b/apps/desktop/src/settings/DesktopSettingsState.ts similarity index 97% rename from apps/desktop/src/main/DesktopSettingsState.ts rename to apps/desktop/src/settings/DesktopSettingsState.ts index 0194975cbc3..bfc1b74030e 100644 --- a/apps/desktop/src/main/DesktopSettingsState.ts +++ b/apps/desktop/src/settings/DesktopSettingsState.ts @@ -12,8 +12,8 @@ import { DEFAULT_DESKTOP_SETTINGS, readDesktopSettingsEffect, writeDesktopSettingsEffect, -} from "../desktopSettings.ts"; -import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +} from "./desktopSettings.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; export type DesktopSettingsPersistenceError = PlatformError.PlatformError | Schema.SchemaError; diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/settings/clientPersistence.test.ts similarity index 98% rename from apps/desktop/src/clientPersistence.test.ts rename to apps/desktop/src/settings/clientPersistence.test.ts index a327641f1b7..a7df60cb243 100644 --- a/apps/desktop/src/clientPersistence.test.ts +++ b/apps/desktop/src/settings/clientPersistence.test.ts @@ -15,8 +15,8 @@ import { writeClientSettingsEffect, writeSavedEnvironmentRegistryEffect, writeSavedEnvironmentSecretEffect, - type DesktopSecretStorage, } from "./clientPersistence.ts"; +import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; const DesktopSshTargetSchema = Schema.Struct({ alias: Schema.String, @@ -63,7 +63,7 @@ function readRegistryDocument(filePath: string) { }); } -function makeSecretStorage(available: boolean): DesktopSecretStorage { +function makeSecretStorage(available: boolean): ElectronSafeStorage.ElectronSafeStorageShape { return { isEncryptionAvailable: () => available, encryptString: (value) => Buffer.from(`enc:${value}`, "utf8"), diff --git a/apps/desktop/src/clientPersistence.ts b/apps/desktop/src/settings/clientPersistence.ts similarity index 97% rename from apps/desktop/src/clientPersistence.ts rename to apps/desktop/src/settings/clientPersistence.ts index 5a682bb3f98..7ed153df50e 100644 --- a/apps/desktop/src/clientPersistence.ts +++ b/apps/desktop/src/settings/clientPersistence.ts @@ -11,6 +11,8 @@ import * as Path from "effect/Path"; import * as Random from "effect/Random"; import * as Schema from "effect/Schema"; +import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; + interface ClientSettingsDocument { readonly settings: ClientSettings; } @@ -27,12 +29,6 @@ interface SavedEnvironmentRegistryDocument { readonly records: readonly PersistedSavedEnvironmentStorageRecord[]; } -export interface DesktopSecretStorage { - readonly isEncryptionAvailable: () => boolean; - readonly encryptString: (value: string) => Buffer; - readonly decryptString: (value: Buffer) => string; -} - const ClientSettingsDocumentSchema = Schema.Struct({ settings: ClientSettingsSchema, }); @@ -199,7 +195,7 @@ export function writeSavedEnvironmentRegistryEffect( export function readSavedEnvironmentSecretEffect(input: { readonly registryPath: string; readonly environmentId: string; - readonly secretStorage: DesktopSecretStorage; + readonly secretStorage: ElectronSafeStorage.ElectronSafeStorageShape; }): Effect.Effect { return Effect.gen(function* () { const document = yield* readSavedEnvironmentRegistryDocumentEffect(input.registryPath); @@ -224,7 +220,7 @@ export function writeSavedEnvironmentSecretEffect(input: { readonly registryPath: string; readonly environmentId: string; readonly secret: string; - readonly secretStorage: DesktopSecretStorage; + readonly secretStorage: ElectronSafeStorage.ElectronSafeStorageShape; }): Effect.Effect { return Effect.gen(function* () { const document = yield* readSavedEnvironmentRegistryDocumentEffect(input.registryPath); diff --git a/apps/desktop/src/desktopSettings.test.ts b/apps/desktop/src/settings/desktopSettings.test.ts similarity index 100% rename from apps/desktop/src/desktopSettings.test.ts rename to apps/desktop/src/settings/desktopSettings.test.ts diff --git a/apps/desktop/src/desktopSettings.ts b/apps/desktop/src/settings/desktopSettings.ts similarity index 95% rename from apps/desktop/src/desktopSettings.ts rename to apps/desktop/src/settings/desktopSettings.ts index deb7689d2e5..761860cbfe7 100644 --- a/apps/desktop/src/desktopSettings.ts +++ b/apps/desktop/src/settings/desktopSettings.ts @@ -3,11 +3,11 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import type { PlatformError } from "effect/PlatformError"; +import * as PlatformError from "effect/PlatformError"; import * as Random from "effect/Random"; import * as Schema from "effect/Schema"; -import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; +import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts"; export interface DesktopSettings { readonly serverExposureMode: DesktopServerExposureMode; @@ -143,7 +143,11 @@ export function readDesktopSettingsEffect( export function writeDesktopSettingsEffect( settingsPath: string, settings: DesktopSettings, -): Effect.Effect { +): Effect.Effect< + void, + PlatformError.PlatformError | Schema.SchemaError, + FileSystem.FileSystem | Path.Path +> { return Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/apps/desktop/src/main/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts similarity index 100% rename from apps/desktop/src/main/DesktopShellEnvironment.test.ts rename to apps/desktop/src/shell/DesktopShellEnvironment.test.ts diff --git a/apps/desktop/src/main/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts similarity index 99% rename from apps/desktop/src/main/DesktopShellEnvironment.ts rename to apps/desktop/src/shell/DesktopShellEnvironment.ts index 381321d0123..9d726c5df9d 100644 --- a/apps/desktop/src/main/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; type EnvironmentPatch = Record; diff --git a/apps/desktop/src/main/DesktopSshEnvironment.test.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts similarity index 100% rename from apps/desktop/src/main/DesktopSshEnvironment.test.ts rename to apps/desktop/src/ssh/DesktopSshEnvironment.test.ts diff --git a/apps/desktop/src/main/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts similarity index 100% rename from apps/desktop/src/main/DesktopSshEnvironment.ts rename to apps/desktop/src/ssh/DesktopSshEnvironment.ts diff --git a/apps/desktop/src/main/DesktopSshPasswordPrompts.test.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts similarity index 100% rename from apps/desktop/src/main/DesktopSshPasswordPrompts.test.ts rename to apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts diff --git a/apps/desktop/src/main/DesktopSshPasswordPrompts.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts similarity index 100% rename from apps/desktop/src/main/DesktopSshPasswordPrompts.ts rename to apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts diff --git a/apps/desktop/src/main/DesktopSshRemoteApi.test.ts b/apps/desktop/src/ssh/DesktopSshRemoteApi.test.ts similarity index 100% rename from apps/desktop/src/main/DesktopSshRemoteApi.test.ts rename to apps/desktop/src/ssh/DesktopSshRemoteApi.test.ts diff --git a/apps/desktop/src/main/DesktopSshRemoteApi.ts b/apps/desktop/src/ssh/DesktopSshRemoteApi.ts similarity index 100% rename from apps/desktop/src/main/DesktopSshRemoteApi.ts rename to apps/desktop/src/ssh/DesktopSshRemoteApi.ts diff --git a/apps/desktop/src/main/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts similarity index 95% rename from apps/desktop/src/main/DesktopUpdates.test.ts rename to apps/desktop/src/updates/DesktopUpdates.test.ts index 8e472105cf5..b08527ce769 100644 --- a/apps/desktop/src/main/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -11,14 +11,14 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { TestClock } from "effect/testing"; -import * as DesktopBackendManager from "./DesktopBackendManager.ts"; -import * as DesktopConfig from "./DesktopConfig.ts"; -import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { DEFAULT_DESKTOP_SETTINGS } from "../desktopSettings.ts"; -import * as DesktopSettingsState from "./DesktopSettingsState.ts"; -import * as DesktopState from "./DesktopState.ts"; +import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktopSettings.ts"; +import * as DesktopSettingsState from "../settings/DesktopSettingsState.ts"; +import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; interface UpdatesHarnessOptions { diff --git a/apps/desktop/src/main/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts similarity index 97% rename from apps/desktop/src/main/DesktopUpdates.ts rename to apps/desktop/src/updates/DesktopUpdates.ts index ae7c2dd0164..0bb3f24925b 100644 --- a/apps/desktop/src/main/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -22,14 +22,17 @@ import * as Scope from "effect/Scope"; import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import * as DesktopBackendManager from "./DesktopBackendManager.ts"; -import * as DesktopConfig from "./DesktopConfig.ts"; -import * as DesktopSettingsState from "./DesktopSettingsState.ts"; -import * as DesktopState from "./DesktopState.ts"; -import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -import { type DesktopSettings, setDesktopUpdateChannelPreference } from "../desktopSettings.ts"; +import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopSettingsState from "../settings/DesktopSettingsState.ts"; +import * as DesktopState from "../app/DesktopState.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { + type DesktopSettings, + setDesktopUpdateChannelPreference, +} from "../settings/desktopSettings.ts"; import * as IpcChannels from "../ipc/channels.ts"; -import { resolveDefaultDesktopUpdateChannel } from "../updateChannels.ts"; +import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; import { createInitialDesktopUpdateState, reduceDesktopUpdateStateOnCheckFailure, @@ -41,7 +44,7 @@ import { reduceDesktopUpdateStateOnInstallFailure, reduceDesktopUpdateStateOnNoUpdate, reduceDesktopUpdateStateOnUpdateAvailable, -} from "../updateMachine.ts"; +} from "./updateMachine.ts"; const AUTO_UPDATE_STARTUP_DELAY = "15 seconds"; const AUTO_UPDATE_POLL_INTERVAL = "4 minutes"; diff --git a/apps/desktop/src/updateChannels.ts b/apps/desktop/src/updates/updateChannels.ts similarity index 100% rename from apps/desktop/src/updateChannels.ts rename to apps/desktop/src/updates/updateChannels.ts diff --git a/apps/desktop/src/updateMachine.test.ts b/apps/desktop/src/updates/updateMachine.test.ts similarity index 100% rename from apps/desktop/src/updateMachine.test.ts rename to apps/desktop/src/updates/updateMachine.test.ts diff --git a/apps/desktop/src/updateMachine.ts b/apps/desktop/src/updates/updateMachine.ts similarity index 100% rename from apps/desktop/src/updateMachine.ts rename to apps/desktop/src/updates/updateMachine.ts diff --git a/apps/desktop/src/main/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts similarity index 95% rename from apps/desktop/src/main/DesktopApplicationMenu.test.ts rename to apps/desktop/src/window/DesktopApplicationMenu.test.ts index 3791d03d4c8..9a896b7c9a8 100644 --- a/apps/desktop/src/main/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -11,10 +11,10 @@ import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as DesktopApplicationMenu from "./DesktopApplicationMenu.ts"; -import * as DesktopConfig from "./DesktopConfig.ts"; -import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -import * as DesktopRun from "./DesktopRun.ts"; -import * as DesktopUpdates from "./DesktopUpdates.ts"; +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopRun from "../app/DesktopRun.ts"; +import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; const environmentInput = { diff --git a/apps/desktop/src/main/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts similarity index 97% rename from apps/desktop/src/main/DesktopApplicationMenu.ts rename to apps/desktop/src/window/DesktopApplicationMenu.ts index 6b7afd6f935..165ec17a87e 100644 --- a/apps/desktop/src/main/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -9,9 +9,9 @@ import type * as Electron from "electron"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; -import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -import * as DesktopRun from "./DesktopRun.ts"; -import * as DesktopUpdates from "./DesktopUpdates.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopRun from "../app/DesktopRun.ts"; +import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; type DesktopApplicationMenuRuntimeServices = diff --git a/apps/desktop/src/main/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts similarity index 97% rename from apps/desktop/src/main/DesktopWindow.ts rename to apps/desktop/src/window/DesktopWindow.ts index 93aba082e27..6319b74d544 100644 --- a/apps/desktop/src/main/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -7,15 +7,15 @@ import * as Ref from "effect/Ref"; import type * as Electron from "electron"; -import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; -import * as DesktopServerExposure from "./DesktopServerExposure.ts"; -import * as DesktopState from "./DesktopState.ts"; -import * as DesktopAssets from "./DesktopAssets.ts"; +import * as DesktopServerExposure from "../server-exposure/DesktopServerExposure.ts"; +import * as DesktopState from "../app/DesktopState.ts"; +import * as DesktopAssets from "../app/DesktopAssets.ts"; const TITLEBAR_HEIGHT = 40; const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux diff --git a/apps/desktop/src/main/DesktopWindowIpcActions.ts b/apps/desktop/src/window/DesktopWindowIpcActions.ts similarity index 96% rename from apps/desktop/src/main/DesktopWindowIpcActions.ts rename to apps/desktop/src/window/DesktopWindowIpcActions.ts index c1e61aa7510..6d2f7064898 100644 --- a/apps/desktop/src/main/DesktopWindowIpcActions.ts +++ b/apps/desktop/src/window/DesktopWindowIpcActions.ts @@ -15,8 +15,8 @@ import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import * as DesktopBackendManager from "./DesktopBackendManager.ts"; -import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; export interface DesktopWindowIpcContextMenuInput { readonly items: readonly ContextMenuItem[]; From 9fb7747a72bbb70830d04fbaaae0e5db707a4b75 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 21:11:10 -0700 Subject: [PATCH 22/43] Use desktop server exposure domain folder Co-authored-by: codex --- apps/desktop/src/app/DesktopApp.ts | 2 +- apps/desktop/src/backend/DesktopBackendConfiguration.test.ts | 2 +- apps/desktop/src/backend/DesktopBackendConfiguration.ts | 2 +- apps/desktop/src/ipc/methods/serverExposure.ts | 2 +- apps/desktop/src/main.ts | 2 +- .../DesktopServerExposure.test.ts | 0 .../DesktopServerExposure.ts | 0 .../tailscaleEndpointProvider.test.ts | 0 .../tailscaleEndpointProvider.ts | 0 apps/desktop/src/window/DesktopWindow.ts | 2 +- 10 files changed, 6 insertions(+), 6 deletions(-) rename apps/desktop/src/{server-exposure => serverExposure}/DesktopServerExposure.test.ts (100%) rename apps/desktop/src/{server-exposure => serverExposure}/DesktopServerExposure.ts (100%) rename apps/desktop/src/{server-exposure => serverExposure}/tailscaleEndpointProvider.test.ts (100%) rename apps/desktop/src/{server-exposure => serverExposure}/tailscaleEndpointProvider.ts (100%) diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index e9f2679748e..c9961e34058 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -16,7 +16,7 @@ import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopLifecycle from "./DesktopLifecycle.ts"; import * as DesktopRun from "./DesktopRun.ts"; -import * as DesktopServerExposure from "../server-exposure/DesktopServerExposure.ts"; +import * as DesktopServerExposure from "../serverExposure/DesktopServerExposure.ts"; import * as DesktopSettingsState from "../settings/DesktopSettingsState.ts"; import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; import * as DesktopState from "./DesktopState.ts"; diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 6d8752ef553..1d8e838f1f2 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -9,7 +9,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopRun from "../app/DesktopRun.ts"; -import * as DesktopServerExposure from "../server-exposure/DesktopServerExposure.ts"; +import * as DesktopServerExposure from "../serverExposure/DesktopServerExposure.ts"; const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExposure, { getState: Effect.die("unexpected getState"), diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 503c4659524..bfbf9161bd6 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -9,7 +9,7 @@ import * as Ref from "effect/Ref"; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; -import * as DesktopServerExposure from "../server-exposure/DesktopServerExposure.ts"; +import * as DesktopServerExposure from "../serverExposure/DesktopServerExposure.ts"; import * as DesktopRun from "../app/DesktopRun.ts"; export interface DesktopBackendConfigurationShape { diff --git a/apps/desktop/src/ipc/methods/serverExposure.ts b/apps/desktop/src/ipc/methods/serverExposure.ts index 60350695bf1..280b482c828 100644 --- a/apps/desktop/src/ipc/methods/serverExposure.ts +++ b/apps/desktop/src/ipc/methods/serverExposure.ts @@ -7,7 +7,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; -import * as DesktopServerExposure from "../../server-exposure/DesktopServerExposure.ts"; +import * as DesktopServerExposure from "../../serverExposure/DesktopServerExposure.ts"; import * as IpcChannels from "../channels.ts"; import { makeIpcMethod } from "../DesktopIpc.ts"; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 4d7281c56dc..71f0173b4a2 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -35,7 +35,7 @@ import * as DesktopEnvironment from "./app/DesktopEnvironment.ts"; import * as DesktopLifecycle from "./app/DesktopLifecycle.ts"; import { DesktopBackendOutputLogLive, DesktopLoggerLive } from "./app/DesktopLogging.ts"; import * as DesktopRun from "./app/DesktopRun.ts"; -import * as DesktopServerExposure from "./server-exposure/DesktopServerExposure.ts"; +import * as DesktopServerExposure from "./serverExposure/DesktopServerExposure.ts"; import * as DesktopSettingsState from "./settings/DesktopSettingsState.ts"; import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; diff --git a/apps/desktop/src/server-exposure/DesktopServerExposure.test.ts b/apps/desktop/src/serverExposure/DesktopServerExposure.test.ts similarity index 100% rename from apps/desktop/src/server-exposure/DesktopServerExposure.test.ts rename to apps/desktop/src/serverExposure/DesktopServerExposure.test.ts diff --git a/apps/desktop/src/server-exposure/DesktopServerExposure.ts b/apps/desktop/src/serverExposure/DesktopServerExposure.ts similarity index 100% rename from apps/desktop/src/server-exposure/DesktopServerExposure.ts rename to apps/desktop/src/serverExposure/DesktopServerExposure.ts diff --git a/apps/desktop/src/server-exposure/tailscaleEndpointProvider.test.ts b/apps/desktop/src/serverExposure/tailscaleEndpointProvider.test.ts similarity index 100% rename from apps/desktop/src/server-exposure/tailscaleEndpointProvider.test.ts rename to apps/desktop/src/serverExposure/tailscaleEndpointProvider.test.ts diff --git a/apps/desktop/src/server-exposure/tailscaleEndpointProvider.ts b/apps/desktop/src/serverExposure/tailscaleEndpointProvider.ts similarity index 100% rename from apps/desktop/src/server-exposure/tailscaleEndpointProvider.ts rename to apps/desktop/src/serverExposure/tailscaleEndpointProvider.ts diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 6319b74d544..1aa8c714933 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -13,7 +13,7 @@ import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; -import * as DesktopServerExposure from "../server-exposure/DesktopServerExposure.ts"; +import * as DesktopServerExposure from "../serverExposure/DesktopServerExposure.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopAssets from "../app/DesktopAssets.ts"; From 5a3e6b1eda6e6cb08ca5e19ce406bf100e5cc7fa Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 21:34:35 -0700 Subject: [PATCH 23/43] Refine desktop error handling and Effect imports - Replace ad hoc failures with typed desktop errors - Switch desktop tests and runtime layers to NodeServices - Tighten Effect typings across desktop, server, and shared code --- apps/desktop/package.json | 1 + apps/desktop/src/app/DesktopApp.ts | 28 ++++---- .../src/app/DesktopAppIdentity.test.ts | 4 +- .../src/app/DesktopEnvironment.test.ts | 8 ++- apps/desktop/src/app/DesktopEnvironment.ts | 13 ++-- apps/desktop/src/app/DesktopLogging.ts | 69 ++++++++++++------- .../DesktopBackendConfiguration.test.ts | 17 ++++- .../src/electron/ElectronProtocol.test.ts | 11 ++- apps/desktop/src/electron/ElectronProtocol.ts | 54 +++++++++++---- apps/desktop/src/main.ts | 2 - .../DesktopServerExposure.test.ts | 3 +- .../serverExposure/DesktopServerExposure.ts | 4 +- .../tailscaleEndpointProvider.test.ts | 3 +- .../tailscaleEndpointProvider.ts | 16 ++--- .../src/settings/clientPersistence.test.ts | 5 +- .../desktop/src/settings/clientPersistence.ts | 35 ++++++++-- .../src/settings/desktopSettings.test.ts | 5 +- .../src/shell/DesktopShellEnvironment.test.ts | 10 ++- .../src/shell/DesktopShellEnvironment.ts | 37 ++++++---- .../src/ssh/DesktopSshEnvironment.test.ts | 5 +- .../src/updates/DesktopUpdates.test.ts | 59 ++++++++-------- apps/desktop/src/updates/DesktopUpdates.ts | 4 +- .../src/window/DesktopApplicationMenu.test.ts | 4 +- .../src/window/DesktopWindowIpcActions.ts | 9 +-- apps/desktop/tsconfig.json | 14 +++- .../src/provider/Layers/CursorAdapter.test.ts | 1 + .../provider/Layers/OpenCodeAdapter.test.ts | 34 +++------ .../SourceControlRepositoryService.ts | 36 ++++------ apps/web/tsconfig.json | 2 +- bun.lock | 1 + 30 files changed, 294 insertions(+), 200 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 478688182b9..80f43e2b687 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -21,6 +21,7 @@ "electron-updater": "^6.6.2" }, "devDependencies": { + "@effect/language-service": "catalog:", "@effect/vitest": "catalog:", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index c9961e34058..d681fef1df0 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -39,6 +39,14 @@ class DesktopBackendPortUnavailableError extends Data.TaggedError( } } +class DesktopDevelopmentBackendPortRequiredError extends Data.TaggedError( + "DesktopDevelopmentBackendPortRequiredError", +)<{}> { + override get message() { + return "T3CODE_PORT is required in desktop development."; + } +} + const resolveDesktopBackendPort = Effect.fn("resolveDesktopBackendPort")(function* ( configuredPort: Option.Option, ) { @@ -68,13 +76,11 @@ const resolveDesktopBackendPort = Effect.fn("resolveDesktopBackendPort")(functio } } - return yield* Effect.fail( - new DesktopBackendPortUnavailableError({ - startPort: DEFAULT_DESKTOP_BACKEND_PORT, - maxPort: MAX_TCP_PORT, - hosts: DESKTOP_BACKEND_PORT_PROBE_HOSTS, - }), - ); + return yield* new DesktopBackendPortUnavailableError({ + startPort: DEFAULT_DESKTOP_BACKEND_PORT, + maxPort: MAX_TCP_PORT, + hosts: DESKTOP_BACKEND_PORT_PROBE_HOSTS, + }); }); const handleFatalStartupError = ( @@ -114,10 +120,8 @@ const handleFatalStartupError = ( yield* electronApp.quit; }); -const fatalStartupCause = (stage: string, cause: Cause.Cause) => - handleFatalStartupError(stage, new Error(Cause.pretty(cause))).pipe( - Effect.andThen(Effect.failCause(cause)), - ); +const fatalStartupCause = (stage: string, cause: Cause.Cause) => + handleFatalStartupError(stage, Cause.pretty(cause)).pipe(Effect.andThen(Effect.failCause(cause))); const bootstrap = Effect.gen(function* () { const backendManager = yield* DesktopBackendManager.DesktopBackendManager; @@ -130,7 +134,7 @@ const bootstrap = Effect.gen(function* () { yield* run.logInfo("bootstrap start"); if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) { - return yield* Effect.fail(new Error("T3CODE_PORT is required in desktop development.")); + return yield* new DesktopDevelopmentBackendPortRequiredError(); } const backendPortSelection = yield* resolveDesktopBackendPort(environment.configuredBackendPort); diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 0da6d97e77b..833c8c1d373 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -1,6 +1,6 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import * as EffectPath from "effect/Path"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -80,7 +80,7 @@ const makeEnvironmentLayer = (overrides: TestEnvironmentInput = {}) => { }).pipe( Layer.provide( Layer.mergeAll( - EffectPath.layer, + NodeServices.layer, DesktopConfig.layerTest({ HOME: "/Users/alice", ...env, diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 07efd187411..1a2be23f5f1 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -1,6 +1,8 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; -import { Effect, Layer, Option } from "effect"; -import * as EffectPath from "effect/Path"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopConfig from "./DesktopConfig.ts"; @@ -24,7 +26,7 @@ const makeEnvironmentLayer = ( DesktopEnvironment.layer({ ...defaultInput, ...overrides, - }).pipe(Layer.provide(Layer.mergeAll(EffectPath.layer, DesktopConfig.layerTest(env)))); + }).pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest(env)))); const makeEnvironment = ( overrides: Partial = {}, diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 78bbafd4c7c..ed17d377078 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -4,8 +4,11 @@ import type { DesktopRuntimeArch, DesktopRuntimeInfo, } from "@t3tools/contracts"; -import { Context, Effect, Layer, Option } from "effect"; -import * as EffectPath from "effect/Path"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import { type DesktopSettings, @@ -27,7 +30,7 @@ export interface MakeDesktopEnvironmentInput { } export interface DesktopEnvironmentShape { - readonly path: EffectPath.Path; + readonly path: Path.Path; readonly dirname: string; readonly platform: NodeJS.Platform; readonly processArch: string; @@ -146,9 +149,9 @@ function resolveDesktopRuntimeInfo(input: { const makeDesktopEnvironment = ( input: MakeDesktopEnvironmentInput, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { - const path = yield* EffectPath.Path; + const path = yield* Path.Path; const config = yield* DesktopConfig.DesktopConfig; const homeDirectory = resolveDesktopHomeDirectory({ config, diff --git a/apps/desktop/src/app/DesktopLogging.ts b/apps/desktop/src/app/DesktopLogging.ts index da47296e355..ed888fca35d 100644 --- a/apps/desktop/src/app/DesktopLogging.ts +++ b/apps/desktop/src/app/DesktopLogging.ts @@ -1,17 +1,17 @@ -import { - Context, - DateTime, - Duration, - Effect, - FileSystem, - Layer, - Logger, - Option, - Path, - References, - Ref, - Semaphore, -} from "effect"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as References from "effect/References"; +import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; @@ -43,6 +43,21 @@ export class DesktopBackendOutputLog extends Context.Service< const textEncoder = new TextEncoder(); +class DesktopLogFileWriterConfigurationError extends Data.TaggedError( + "DesktopLogFileWriterConfigurationError", +)<{ + readonly option: "maxBytes" | "maxFiles"; + readonly value: number; +}> { + override get message() { + return `${this.option} must be >= 1 (received ${this.value})`; + } +} + +type DesktopLogFileWriterError = + | DesktopLogFileWriterConfigurationError + | PlatformError.PlatformError; + const sanitizeLogValue = (value: string): string => value.replace(/\s+/g, " ").trim(); const DesktopBackendOutputLogNoop: DesktopBackendOutputLogShape = { @@ -54,18 +69,20 @@ const refreshFileSize = ( fileSystem: FileSystem.FileSystem, filePath: string, ): Effect.Effect => - Effect.gen(function* () { - return yield* fileSystem.stat(filePath).pipe( - Effect.map((stat) => Number(stat.size)), - Effect.orElseSucceed(() => 0), - ); - }); + fileSystem.stat(filePath).pipe( + Effect.map((stat) => Number(stat.size)), + Effect.orElseSucceed(() => 0), + ); const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(function* (input: { readonly filePath: string; readonly maxBytes?: number; readonly maxFiles?: number; -}): Effect.fn.Return { +}): Effect.fn.Return< + RotatingLogFileWriter, + DesktopLogFileWriterError, + FileSystem.FileSystem | Path.Path +> { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const maxBytes = input.maxBytes ?? DESKTOP_LOG_FILE_MAX_BYTES; @@ -74,10 +91,16 @@ const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(functio const baseName = path.basename(input.filePath); if (maxBytes < 1) { - return yield* Effect.fail(new Error(`maxBytes must be >= 1 (received ${maxBytes})`)); + return yield* new DesktopLogFileWriterConfigurationError({ + option: "maxBytes", + value: maxBytes, + }); } if (maxFiles < 1) { - return yield* Effect.fail(new Error(`maxFiles must be >= 1 (received ${maxFiles})`)); + return yield* new DesktopLogFileWriterConfigurationError({ + option: "maxFiles", + value: maxFiles, + }); } yield* fileSystem.makeDirectory(directory, { recursive: true }); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 1d8e838f1f2..b68cc818142 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -1,9 +1,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import * as EffectPath from "effect/Path"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; @@ -11,6 +11,17 @@ import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopRun from "../app/DesktopRun.ts"; import * as DesktopServerExposure from "../serverExposure/DesktopServerExposure.ts"; +const PersistedServerObservabilitySettingsDocument = Schema.Struct({ + observability: Schema.Struct({ + otlpTracesUrl: Schema.String, + otlpMetricsUrl: Schema.String, + }), +}); + +const encodePersistedServerObservabilitySettingsDocument = Schema.encodeEffect( + Schema.fromJsonString(PersistedServerObservabilitySettingsDocument), +); + const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExposure, { getState: Effect.die("unexpected getState"), backendConfig: Effect.succeed({ @@ -40,7 +51,7 @@ function makeEnvironmentLayer(baseDir: string) { }).pipe( Layer.provide( Layer.mergeAll( - EffectPath.layer, + NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir, T3CODE_PORT: "9999", @@ -123,7 +134,7 @@ describe("DesktopBackendConfiguration", () => { }); yield* fileSystem.writeFileString( environment.serverSettingsPath, - JSON.stringify({ + yield* encodePersistedServerObservabilitySettingsDocument({ observability: { otlpTracesUrl: " http://127.0.0.1:4318/v1/traces ", otlpMetricsUrl: " http://127.0.0.1:4318/v1/metrics ", diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 327053ddd60..409cf3e67cc 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -83,12 +83,11 @@ describe("ElectronProtocol", () => { }); assert.isDefined(capturedHandler); - return yield* Effect.promise( - () => - new Promise((resolve) => { - capturedHandler?.({ url: "t3://app/" } as Electron.ProtocolRequest, resolve); - }), - ); + return yield* Effect.callback((resume) => { + capturedHandler?.({ url: "t3://app/" } as Electron.ProtocolRequest, (response) => + resume(Effect.succeed(response)), + ); + }); }), ); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 925886f4d07..ef62755b6a6 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -1,5 +1,6 @@ import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; +import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -13,21 +14,40 @@ import { DesktopEnvironment, type DesktopEnvironmentShape } from "../app/Desktop export const DESKTOP_SCHEME = "t3"; +export class ElectronProtocolRegistrationError extends Data.TaggedError( + "ElectronProtocolRegistrationError", +)<{ + readonly scheme: string; + readonly cause: unknown; +}> { + override get message() { + return `Failed to register ${this.scheme}: file protocol.`; + } +} + +export class ElectronProtocolStaticBundleMissingError extends Data.TaggedError( + "ElectronProtocolStaticBundleMissingError", +)<{}> { + override get message() { + return "Desktop static bundle missing. Build apps/server (with bundled client) first."; + } +} + export interface ElectronProtocolShape { - readonly registerDesktopSchemePrivileges: Effect.Effect; - readonly registerFileProtocol: (input: { + readonly registerDesktopSchemePrivileges: Effect.Effect; + readonly registerFileProtocol: (input: { readonly scheme: string; readonly handler: ( request: Electron.ProtocolRequest, - ) => Effect.Effect; + ) => Effect.Effect; readonly onFailure?: ( request: Electron.ProtocolRequest, - cause: Cause.Cause, + cause: Cause.Cause, ) => Electron.ProtocolResponse; - }) => Effect.Effect; + }) => Effect.Effect; readonly registerDesktopFileProtocol: Effect.Effect< void, - unknown, + ElectronProtocolRegistrationError | ElectronProtocolStaticBundleMissingError, FileSystem.FileSystem | DesktopEnvironment | Scope.Scope >; } @@ -131,7 +151,7 @@ function isStaticAssetRequest(requestUrl: string, environment: DesktopEnvironmen const make = Effect.gen(function* () { const registeredProtocols = yield* Ref.make>(new Set()); - const registerFileProtocol = ({ + const registerFileProtocol = ({ scheme, handler, onFailure, @@ -139,12 +159,12 @@ const make = Effect.gen(function* () { readonly scheme: string; readonly handler: ( request: Electron.ProtocolRequest, - ) => Effect.Effect; + ) => Effect.Effect; readonly onFailure?: ( request: Electron.ProtocolRequest, - cause: Cause.Cause, + cause: Cause.Cause, ) => Electron.ProtocolResponse; - }): Effect.Effect => + }): Effect.Effect => Effect.gen(function* () { const alreadyRegistered = yield* Ref.get(registeredProtocols).pipe( Effect.map((protocols) => protocols.has(scheme)), @@ -172,10 +192,16 @@ const make = Effect.gen(function* () { }, ); if (!registered) { - throw new Error(`Failed to register ${scheme}: file protocol.`); + throw new ElectronProtocolRegistrationError({ + scheme, + cause: "registerFileProtocol returned false", + }); } }, - catch: (error) => error, + catch: (cause) => + cause instanceof ElectronProtocolRegistrationError + ? cause + : new ElectronProtocolRegistrationError({ scheme, cause }), }).pipe( Effect.andThen( Ref.update(registeredProtocols, (protocols) => new Set(protocols).add(scheme)), @@ -202,9 +228,7 @@ const make = Effect.gen(function* () { const staticRoot = yield* resolveDesktopStaticDir; if (Option.isNone(staticRoot)) { - return yield* Effect.fail( - new Error("Desktop static bundle missing. Build apps/server (with bundled client) first."), - ); + return yield* new ElectronProtocolStaticBundleMissingError(); } const staticRootResolved = environment.path.resolve(staticRoot.value); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 71f0173b4a2..b1b23f64933 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -2,7 +2,6 @@ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; -import * as EffectPath from "effect/Path"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -140,7 +139,6 @@ const desktopApplicationLayer = Layer.mergeAll( ).pipe(Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopBackendLayer)); const desktopRuntimeLayer = desktopApplicationLayer.pipe( - Layer.provideMerge(EffectPath.layer), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(NodeHttpClient.layerUndici), Layer.provideMerge(NetService.layer), diff --git a/apps/desktop/src/serverExposure/DesktopServerExposure.test.ts b/apps/desktop/src/serverExposure/DesktopServerExposure.test.ts index a7ec089ab78..696f80a33bc 100644 --- a/apps/desktop/src/serverExposure/DesktopServerExposure.test.ts +++ b/apps/desktop/src/serverExposure/DesktopServerExposure.test.ts @@ -3,7 +3,6 @@ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import * as EffectPath from "effect/Path"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Sink from "effect/Sink"; @@ -82,7 +81,7 @@ function makeEnvironmentLayer(baseDir: string, env: Record Effect.Effect; + readonly probe?: (baseUrl: string) => Effect.Effect; }): Effect.fn.Return, never, HttpClient.HttpClient> { if (!input.dnsName) { return Option.none(); @@ -73,13 +74,12 @@ const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn( magicDnsName: input.dnsName, ...(input.servePort === undefined ? {} : { servePort: input.servePort }), }); - const probe = (input.probe?.(httpBaseUrl) ?? + const probe = + input.probe?.(httpBaseUrl) ?? probeTailscaleHttpsEndpoint({ baseUrl: httpBaseUrl, - })) as Effect.Effect; - const isReachable = input.serveEnabled - ? yield* probe.pipe(Effect.catch(() => Effect.succeed(false))) - : false; + }); + const isReachable = input.serveEnabled ? yield* probe : false; return Option.some( createAdvertisedEndpoint({ @@ -105,7 +105,7 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd readonly servePort?: number; readonly networkInterfaces: DesktopNetworkInterfaces; readonly statusJson?: string | null; - readonly probe?: (baseUrl: string) => Effect.Effect; + readonly probe?: (baseUrl: string) => Effect.Effect; }): Effect.fn.Return< readonly AdvertisedEndpoint[], never, diff --git a/apps/desktop/src/settings/clientPersistence.test.ts b/apps/desktop/src/settings/clientPersistence.test.ts index a7df60cb243..31bbd7c09e3 100644 --- a/apps/desktop/src/settings/clientPersistence.test.ts +++ b/apps/desktop/src/settings/clientPersistence.test.ts @@ -5,7 +5,10 @@ import { type ClientSettings, type PersistedSavedEnvironmentRecord, } from "@t3tools/contracts"; -import { Effect, FileSystem, Path, Schema } from "effect"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import { readClientSettingsEffect, diff --git a/apps/desktop/src/settings/clientPersistence.ts b/apps/desktop/src/settings/clientPersistence.ts index 7ed153df50e..f1bf21bde5e 100644 --- a/apps/desktop/src/settings/clientPersistence.ts +++ b/apps/desktop/src/settings/clientPersistence.ts @@ -8,6 +8,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as Random from "effect/Random"; import * as Schema from "effect/Schema"; @@ -70,7 +71,7 @@ const encodeSavedEnvironmentRegistryDocumentJson = Schema.encodeEffect( function readJsonFileEffect( filePath: string, - decode: (raw: string) => Effect.Effect, + decode: (raw: string) => Effect.Effect, ): Effect.Effect, never, FileSystem.FileSystem> { return Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -89,8 +90,12 @@ function readJsonFileEffect( function writeJsonFileEffect( filePath: string, value: T, - encode: (value: T) => Effect.Effect, -): Effect.Effect { + encode: (value: T) => Effect.Effect, +): Effect.Effect< + void, + PlatformError.PlatformError | Schema.SchemaError, + FileSystem.FileSystem | Path.Path +> { return Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -137,7 +142,11 @@ export function readClientSettingsEffect( export function writeClientSettingsEffect( settingsPath: string, settings: ClientSettings, -): Effect.Effect { +): Effect.Effect< + void, + PlatformError.PlatformError | Schema.SchemaError, + FileSystem.FileSystem | Path.Path +> { return writeJsonFileEffect( settingsPath, { settings } satisfies ClientSettingsDocument, @@ -158,7 +167,11 @@ export function readSavedEnvironmentRegistryEffect( export function writeSavedEnvironmentRegistryEffect( registryPath: string, records: readonly PersistedSavedEnvironmentRecord[], -): Effect.Effect { +): Effect.Effect< + void, + PlatformError.PlatformError | Schema.SchemaError, + FileSystem.FileSystem | Path.Path +> { return Effect.gen(function* () { const currentDocument = yield* readSavedEnvironmentRegistryDocumentEffect(registryPath); const encryptedBearerTokenById = new Map( @@ -221,7 +234,11 @@ export function writeSavedEnvironmentSecretEffect(input: { readonly environmentId: string; readonly secret: string; readonly secretStorage: ElectronSafeStorage.ElectronSafeStorageShape; -}): Effect.Effect { +}): Effect.Effect< + boolean, + PlatformError.PlatformError | Schema.SchemaError, + FileSystem.FileSystem | Path.Path +> { return Effect.gen(function* () { const document = yield* readSavedEnvironmentRegistryDocumentEffect(input.registryPath); @@ -275,7 +292,11 @@ export function writeSavedEnvironmentSecretEffect(input: { export function removeSavedEnvironmentSecretEffect(input: { readonly registryPath: string; readonly environmentId: string; -}): Effect.Effect { +}): Effect.Effect< + void, + PlatformError.PlatformError | Schema.SchemaError, + FileSystem.FileSystem | Path.Path +> { return Effect.gen(function* () { const document = yield* readSavedEnvironmentRegistryDocumentEffect(input.registryPath); if ( diff --git a/apps/desktop/src/settings/desktopSettings.test.ts b/apps/desktop/src/settings/desktopSettings.test.ts index 98481b4f783..26f3c3d6b02 100644 --- a/apps/desktop/src/settings/desktopSettings.test.ts +++ b/apps/desktop/src/settings/desktopSettings.test.ts @@ -1,6 +1,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; -import { Effect, FileSystem, Path, Schema } from "effect"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import { DEFAULT_DESKTOP_SETTINGS, diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index efe04eb383f..6d77c052696 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -153,7 +153,9 @@ describe("DesktopShellEnvironment", () => { Effect.gen(function* () { calls.push({ shell, names }); if (calls.length === 1) { - return yield* Effect.fail(new Error("unknown flag")); + return yield* new DesktopShellEnvironment.DesktopShellEnvironmentProbeError({ + message: "unknown flag", + }); } return {}; }), @@ -310,7 +312,11 @@ describe("DesktopShellEnvironment", () => { probe: { readWindowsEnvironment: (_names, options) => options.loadProfile - ? Effect.fail(new Error("profile load failed")) + ? Effect.fail( + new DesktopShellEnvironment.DesktopShellEnvironmentProbeError({ + message: "profile load failed", + }), + ) : Effect.succeed({ PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }), }, }); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 9d726c5df9d..4419e0fed5a 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -1,4 +1,5 @@ import * as Context from "effect/Context"; +import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -19,20 +20,29 @@ interface WindowsProbeOptions { readonly loadProfile: boolean; } +export class DesktopShellEnvironmentProbeError extends Data.TaggedError( + "DesktopShellEnvironmentProbeError", +)<{ + readonly message: string; +}> {} + interface ShellProbe { readonly readLoginShellEnvironment: ( shell: string, names: ReadonlyArray, - ) => Effect.Effect; - readonly readLaunchctlPath: Effect.Effect, unknown>; + ) => Effect.Effect; + readonly readLaunchctlPath: Effect.Effect< + Option.Option, + DesktopShellEnvironmentProbeError + >; readonly readWindowsEnvironment: ( names: ReadonlyArray, options: WindowsProbeOptions, - ) => Effect.Effect; + ) => Effect.Effect; } export interface DesktopShellEnvironmentShape { - readonly installIntoProcess: Effect.Effect; + readonly installIntoProcess: Effect.Effect; } export class DesktopShellEnvironment extends Context.Service< @@ -201,7 +211,7 @@ const runCommandOutput = ( readonly timeout: Duration.Duration; readonly shell?: boolean; }, -): Effect.Effect => +): Effect.Effect => spawner .string( ChildProcess.make(input.command, input.args, { @@ -269,13 +279,13 @@ const readWindowsEnvironment = ( probe: ShellProbe, names: ReadonlyArray, options: WindowsProbeOptions, -): Effect.Effect => +): Effect.Effect => probe.readWindowsEnvironment(names, options).pipe(Effect.catch(() => Effect.succeed({}))); const installWindowsEnvironment = ( config: ShellEnvironmentConfig, probe: ShellProbe, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const noProfile = yield* readWindowsEnvironment(probe, ["PATH"], { loadProfile: false }); const profile = yield* readWindowsEnvironment(probe, WINDOWS_PROFILE_ENV_NAMES, { @@ -302,7 +312,7 @@ const installWindowsEnvironment = ( const installPosixEnvironment = ( config: ShellEnvironmentConfig, probe: ShellProbe, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const shellEnvironment: EnvironmentPatch = {}; @@ -356,7 +366,7 @@ const installPosixEnvironment = ( const installShellEnvironment = ( config: ShellEnvironmentConfig, probe: ShellProbe, -): Effect.Effect => { +): Effect.Effect => { if (config.platform === "win32") { return installWindowsEnvironment(config, probe); } @@ -392,12 +402,15 @@ export const layerTest = (input: { readonly readLoginShellEnvironment?: ( shell: string, names: ReadonlyArray, - ) => Effect.Effect; - readonly readLaunchctlPath?: Effect.Effect, unknown>; + ) => Effect.Effect; + readonly readLaunchctlPath?: Effect.Effect< + Option.Option, + DesktopShellEnvironmentProbeError + >; readonly readWindowsEnvironment?: ( names: ReadonlyArray, options: WindowsProbeOptions, - ) => Effect.Effect; + ) => Effect.Effect; }; }) => { const config: ShellEnvironmentConfig = { diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts index 1dfb32b4ce2..77c86be39d2 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts @@ -3,7 +3,10 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as NetService from "@t3tools/shared/Net"; import { SshPasswordPromptError } from "@t3tools/ssh/errors"; -import { Effect, FileSystem, Layer, Path } from "effect"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as DesktopSshEnvironment from "./DesktopSshEnvironment.ts"; import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index b08527ce769..a5289f650b7 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -5,7 +5,6 @@ import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; -import * as EffectPath from "effect/Path"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -131,7 +130,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { }).pipe( Layer.provide( Layer.mergeAll( - EffectPath.layer, + NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`, T3CODE_DESKTOP_MOCK_UPDATES: "true", @@ -209,45 +208,41 @@ describe("DesktopUpdates", () => { it.effect("updates and broadcasts state from updater events", () => { const harness = makeHarness(); - return Effect.gen(function* () { - yield* Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; - harness.emit("update-available", { version: "1.2.4" }); - yield* flushCallbacks; + harness.emit("update-available", { version: "1.2.4" }); + yield* flushCallbacks; - const state = yield* updates.getState; - assert.equal(state.status, "available"); - assert.equal(state.availableVersion, "1.2.4"); - assert.isNotNull(state.checkedAt); - assert.equal(harness.sentStates.at(-1)?.status, "available"); - }), - ); - }).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + const state = yield* updates.getState; + assert.equal(state.status, "available"); + assert.equal(state.availableVersion, "1.2.4"); + assert.isNotNull(state.checkedAt); + assert.equal(harness.sentStates.at(-1)?.status, "available"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); it.effect("persists channel changes through the settings service", () => { const harness = makeHarness(); - return Effect.gen(function* () { - yield* Effect.scoped( - Effect.gen(function* () { - const settingsState = yield* DesktopSettingsState.DesktopSettingsState; - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* settingsState.set(DEFAULT_DESKTOP_SETTINGS); - yield* updates.configure; + return Effect.scoped( + Effect.gen(function* () { + const settingsState = yield* DesktopSettingsState.DesktopSettingsState; + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* settingsState.set(DEFAULT_DESKTOP_SETTINGS); + yield* updates.configure; - const state = yield* updates.setChannel("nightly"); - const settings = yield* settingsState.get; + const state = yield* updates.setChannel("nightly"); + const settings = yield* settingsState.get; - assert.equal(state.channel, "nightly"); - assert.equal(settings.updateChannel, "nightly"); - assert.equal(settings.updateChannelConfiguredByUser, true); - }), - ); - }).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + assert.equal(state.channel, "nightly"); + assert.equal(settings.updateChannel, "nightly"); + assert.equal(settings.updateChannelConfiguredByUser, true); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); it.effect("fails channel changes with a typed error while a check is in progress", () => diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 0bb3f24925b..da52d4894b8 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -644,9 +644,7 @@ const make = Effect.gen(function* () { Effect.gen(function* () { const activeAction = yield* activeUpdateAction; if (Option.isSome(activeAction)) { - return yield* Effect.fail( - new DesktopUpdateActionInProgressError({ action: activeAction.value }), - ); + return yield* new DesktopUpdateActionInProgressError({ action: activeAction.value }); } yield* updatePersistedSettings((settings) => diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 9a896b7c9a8..74a60388430 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -1,7 +1,7 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; -import * as EffectPath from "effect/Path"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -118,7 +118,7 @@ describe("DesktopApplicationMenu", () => { DesktopEnvironment.layer(environmentInput).pipe( Layer.provide( Layer.mergeAll( - EffectPath.layer, + NodeServices.layer, DesktopConfig.layerTest({ HOME: "/Users/alice" }), ), ), diff --git a/apps/desktop/src/window/DesktopWindowIpcActions.ts b/apps/desktop/src/window/DesktopWindowIpcActions.ts index 6d2f7064898..b5532da5e24 100644 --- a/apps/desktop/src/window/DesktopWindowIpcActions.ts +++ b/apps/desktop/src/window/DesktopWindowIpcActions.ts @@ -85,12 +85,9 @@ const make = Effect.gen(function* () { return Option.getOrNull(selectedPath); }), confirm: (message) => - Effect.gen(function* () { - return yield* electronDialog.confirm({ - owner: yield* electronWindow.focusedMainOrFirst, - message, - }); - }), + electronWindow.focusedMainOrFirst.pipe( + Effect.flatMap((owner) => electronDialog.confirm({ owner, message })), + ), setTheme: (theme) => electronTheme.setSource(theme), showContextMenu: ({ items, position }) => Effect.gen(function* () { diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index ff3e4cd0f38..951889c1135 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -3,7 +3,19 @@ "compilerOptions": { "composite": true, "types": ["node", "electron"], - "lib": ["ESNext", "DOM", "esnext.disposable"] + "lib": ["ESNext", "DOM", "esnext.disposable"], + "plugins": [ + { + "name": "@effect/language-service", + "namespaceImportPackages": ["effect"], + "diagnosticSeverity": { + "importFromBarrel": "error", + "anyUnknownInErrorContext": "error", + "instanceOfSchema": "warning", + "deterministicKeys": "warning" + } + } + ] }, "include": ["src", "tsdown.config.ts"] } diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 375eec049a2..3c676b22ee5 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -113,6 +113,7 @@ async function waitForFileContent(filePath: string, attempts = 40) { // tests assumed. const makeResolveCursorSettings = Effect.gen(function* () { const serverSettings = yield* ServerSettingsService; + // @effect-diagnostics effect/returnEffectInGen:off return serverSettings.getSettings.pipe( Effect.map((snapshot) => snapshot.providers.cursor), Effect.orDie, diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 370e5c028ff..76b9da7b32c 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -193,9 +193,7 @@ const openCodeAdapterTestSettings = Schema.decodeSync(OpenCodeSettings)({ const OpenCodeAdapterTestLayer = Layer.effect( OpenCodeAdapter, - Effect.gen(function* () { - return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings); - }), + makeOpenCodeAdapter(openCodeAdapterTestSettings), ).pipe( Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), @@ -390,14 +388,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { ); it.effect("passes agent and variant options for the adapter's bound custom instance id", () => { - const customInstanceId = ProviderInstanceId.make("opencode_zen"); + const instanceId = ProviderInstanceId.make("opencode_zen"); const adapterLayer = Layer.effect( OpenCodeAdapter, - Effect.gen(function* () { - return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings, { - instanceId: customInstanceId, - }); - }), + makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }), ).pipe( Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), @@ -441,14 +435,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }); it.effect("uses the bound custom instance id for fallback sendTurn model selection", () => { - const customInstanceId = ProviderInstanceId.make("opencode_zen"); + const instanceId = ProviderInstanceId.make("opencode_zen"); const adapterLayer = Layer.effect( OpenCodeAdapter, - Effect.gen(function* () { - return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings, { - instanceId: customInstanceId, - }); - }), + makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }), ).pipe( Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), @@ -487,14 +477,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }); it.effect("rejects sendTurn model selections for another instance id", () => { - const customInstanceId = ProviderInstanceId.make("opencode_zen"); + const instanceId = ProviderInstanceId.make("opencode_zen"); const adapterLayer = Layer.effect( OpenCodeAdapter, - Effect.gen(function* () { - return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings, { - instanceId: customInstanceId, - }); - }), + makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }), ).pipe( Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), @@ -634,10 +620,8 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { const adapterLayer = Layer.effect( OpenCodeAdapter, - Effect.gen(function* () { - return yield* makeOpenCodeAdapter(openCodeAdapterTestSettings, { - nativeEventLogger, - }); + makeOpenCodeAdapter(openCodeAdapterTestSettings, { + nativeEventLogger, }), ).pipe( Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 1bf71ac12dc..e7d488e317f 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -153,13 +153,11 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* () function* (destinationPath: string) { const trimmed = destinationPath.trim(); if (trimmed.length === 0) { - return yield* Effect.fail( - repositoryError({ - operation: "cloneRepository", - provider: "unknown", - detail: "Choose a destination path before cloning.", - }), - ); + return yield* repositoryError({ + operation: "cloneRepository", + provider: "unknown", + detail: "Choose a destination path before cloning.", + }); } return path.resolve(expandHomePath(trimmed, path)); @@ -183,13 +181,11 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* () ), ); if (entries.length > 0) { - return yield* Effect.fail( - repositoryError({ - operation: "cloneRepository", - provider: "unknown", - detail: "Destination path already exists and is not empty.", - }), - ); + return yield* repositoryError({ + operation: "cloneRepository", + provider: "unknown", + detail: "Destination path already exists and is not empty.", + }); } } else { yield* fileSystem.makeDirectory(path.dirname(normalizedDestination), { recursive: true }); @@ -222,13 +218,11 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* () } if (!remoteUrl) { - return yield* Effect.fail( - repositoryError({ - operation: "cloneRepository", - provider, - detail: "Enter a repository path or clone URL before cloning.", - }), - ); + return yield* repositoryError({ + operation: "cloneRepository", + provider, + detail: "Enter a repository path or clone URL before cloning.", + }); } yield* git.execute({ diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 4dd68d7213f..c721aa85f99 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -18,7 +18,7 @@ "namespaceImportPackages": ["@effect/platform-node"], "diagnosticSeverity": { "importFromBarrel": "error", - "anyUnknownInErrorContext": "warning", + "anyUnknownInErrorContext": "error", "instanceOfSchema": "warning", "deterministicKeys": "warning" } diff --git a/bun.lock b/bun.lock index c621a8f140f..38c21ec04ee 100644 --- a/bun.lock +++ b/bun.lock @@ -24,6 +24,7 @@ "electron-updater": "^6.6.2", }, "devDependencies": { + "@effect/language-service": "catalog:", "@effect/vitest": "catalog:", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", From ffc7c776947a52d0a618fe0b5e2ba29f59905ae3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 21:41:31 -0700 Subject: [PATCH 24/43] Refactor contracts to use Effect subpath imports - Switch schemas and helpers to `effect/Schema`, `Effect`, and related subpaths - Update contract tests to match the new import style --- packages/contracts/src/auth.ts | 2 +- packages/contracts/src/baseSchemas.ts | 2 +- packages/contracts/src/desktopBootstrap.ts | 2 +- packages/contracts/src/editor.ts | 2 +- packages/contracts/src/environment.ts | 3 ++- packages/contracts/src/filesystem.ts | 2 +- packages/contracts/src/git.test.ts | 2 +- packages/contracts/src/git.ts | 2 +- packages/contracts/src/keybindings.test.ts | 4 ++-- packages/contracts/src/keybindings.ts | 2 +- packages/contracts/src/model.ts | 4 +++- packages/contracts/src/orchestration.test.ts | 3 ++- packages/contracts/src/orchestration.ts | 7 ++++++- packages/contracts/src/project.ts | 2 +- packages/contracts/src/provider.test.ts | 2 +- packages/contracts/src/provider.ts | 2 +- packages/contracts/src/providerInstance.test.ts | 2 +- packages/contracts/src/providerInstance.ts | 3 ++- packages/contracts/src/providerRuntime.test.ts | 2 +- packages/contracts/src/providerRuntime.ts | 3 ++- packages/contracts/src/remoteAccess.ts | 2 +- packages/contracts/src/rpc.ts | 2 +- packages/contracts/src/server.test.ts | 2 +- packages/contracts/src/server.ts | 3 ++- packages/contracts/src/settings.test.ts | 2 +- packages/contracts/src/settings.ts | 2 +- packages/contracts/src/sourceControl.ts | 2 +- packages/contracts/src/terminal.test.ts | 2 +- packages/contracts/src/terminal.ts | 3 ++- packages/contracts/src/vcs.ts | 2 +- 30 files changed, 44 insertions(+), 31 deletions(-) diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 8110104e198..8439d12b069 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { AuthSessionId, TrimmedNonEmptyString } from "./baseSchemas.ts"; diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index 10ee6851be2..9234903d40f 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; export const TrimmedString = Schema.Trim; export const TrimmedNonEmptyString = TrimmedString.check(Schema.isNonEmpty()); diff --git a/packages/contracts/src/desktopBootstrap.ts b/packages/contracts/src/desktopBootstrap.ts index eed5625c65a..5982bdcf9f1 100644 --- a/packages/contracts/src/desktopBootstrap.ts +++ b/packages/contracts/src/desktopBootstrap.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; export const DesktopBackendBootstrap = Schema.Struct({ mode: Schema.Literal("desktop"), diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index f3e6926aa38..4bf84d8294e 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; export const EditorLaunchStyle = Schema.Literals(["direct-path", "goto", "line-column"]); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index aa34c339a39..fb52972d5aa 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -1,4 +1,5 @@ -import { Effect, Schema } from "effect"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { EnvironmentId, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; diff --git a/packages/contracts/src/filesystem.ts b/packages/contracts/src/filesystem.ts index a518e2e9acd..37da5863429 100644 --- a/packages/contracts/src/filesystem.ts +++ b/packages/contracts/src/filesystem.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; const FILESYSTEM_PATH_MAX_LENGTH = 512; diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index b4f7bc213ce..33eb0a8339f 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { VcsCreateWorktreeInput, diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index b5fa114d789..0b155bf49b7 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { SourceControlProviderError, SourceControlProviderInfo } from "./sourceControl.ts"; import { VcsDriverKind } from "./vcs.ts"; diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index f040697013b..2165597ac30 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -1,6 +1,6 @@ -import { Schema } from "effect"; import { assert, it } from "@effect/vitest"; -import { Effect } from "effect"; +import * as Schema from "effect/Schema"; +import * as Effect from "effect/Effect"; import { KeybindingsConfig, diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 01587c2a644..502e564fb82 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { TrimmedString } from "./baseSchemas.ts"; export const MAX_KEYBINDING_VALUE_LENGTH = 64; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index cd8ab45a4bd..85ee815818e 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -1,4 +1,6 @@ -import { Effect, Schema, SchemaTransformation } from "effect"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SchemaTransformation from "effect/SchemaTransformation"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; import { ProviderDriverKind } from "./providerInstance.ts"; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 605d0375342..73758de0724 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { it } from "@effect/vitest"; -import { Effect, Schema } from "effect"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { DEFAULT_PROVIDER_INTERACTION_MODE, diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 047ef9e5cd9..44d840d1499 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1,4 +1,9 @@ -import { Effect, Option, Schema, SchemaIssue, SchemaTransformation, Struct } from "effect"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SchemaIssue from "effect/SchemaIssue"; +import * as SchemaTransformation from "effect/SchemaTransformation"; +import * as Struct from "effect/Struct"; import { ProviderOptionSelections } from "./model.ts"; import { RepositoryIdentity } from "./environment.ts"; import { diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index d089951bc07..c99cff3133e 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index 67318d0e33f..d3df41ba9f4 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { ProviderEvent, diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index e8ad017d7b0..94fb007a7bc 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; import { ApprovalRequestId, diff --git a/packages/contracts/src/providerInstance.test.ts b/packages/contracts/src/providerInstance.test.ts index 9139001cf3b..1a2ffbb9954 100644 --- a/packages/contracts/src/providerInstance.test.ts +++ b/packages/contracts/src/providerInstance.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { ProviderDriverKind, diff --git a/packages/contracts/src/providerInstance.ts b/packages/contracts/src/providerInstance.ts index d5bb25f7729..2a9fc9ed0d1 100644 --- a/packages/contracts/src/providerInstance.ts +++ b/packages/contracts/src/providerInstance.ts @@ -33,7 +33,8 @@ * * @module providerInstance */ -import { Effect, Schema } from "effect"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; const PROVIDER_SLUG_MAX_CHARS = 64; diff --git a/packages/contracts/src/providerRuntime.test.ts b/packages/contracts/src/providerRuntime.test.ts index 42c0117935b..587c1879454 100644 --- a/packages/contracts/src/providerRuntime.test.ts +++ b/packages/contracts/src/providerRuntime.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { ProviderRuntimeEvent } from "./providerRuntime.ts"; diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index f5e4e7452a8..5032dc4eb41 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -1,4 +1,5 @@ -import { Effect, Schema } from "effect"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { EventId, IsoDateTime, diff --git a/packages/contracts/src/remoteAccess.ts b/packages/contracts/src/remoteAccess.ts index 70a20d7aeb4..e3de3c29e12 100644 --- a/packages/contracts/src/remoteAccess.ts +++ b/packages/contracts/src/remoteAccess.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 1c3ddc93739..eb72b14f9e5 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index cae68e1f64b..a2ad0cbb383 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { describe, expect, it } from "vitest"; import { ServerProvider } from "./server.ts"; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 15afea93ad9..0081c00ac7e 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,4 +1,5 @@ -import { Effect, Schema } from "effect"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; import { ServerAuthDescriptor } from "./auth.ts"; import { diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index d2b73f567a7..8c292827927 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { ProviderInstanceId } from "./providerInstance.ts"; import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "./settings.ts"; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index a4805494dfb..748e6ab7cb4 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 0384b5d18da..a8764a68763 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { VcsDriverKind } from "./vcs.ts"; diff --git a/packages/contracts/src/terminal.test.ts b/packages/contracts/src/terminal.test.ts index 514320f6d4d..2401e38d4e0 100644 --- a/packages/contracts/src/terminal.test.ts +++ b/packages/contracts/src/terminal.test.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { describe, expect, it } from "vitest"; import { diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index c4d9972d8c3..b91e4cb89e0 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -1,4 +1,5 @@ -import { Effect, Schema } from "effect"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; export const DEFAULT_TERMINAL_ID = "default"; diff --git a/packages/contracts/src/vcs.ts b/packages/contracts/src/vcs.ts index 2dd09cf04c6..25f6c04c6dc 100644 --- a/packages/contracts/src/vcs.ts +++ b/packages/contracts/src/vcs.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; export const VcsDriverKind = Schema.Literals(["git", "jj", "unknown"]); From 244e066e8bee8a648779f04391388c0c89e83137 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 22:25:50 -0700 Subject: [PATCH 25/43] Normalize Effect imports across shared packages - Switch Effect usage to submodule imports for language-service compliance - Update package tsconfigs and narrow generated-script inclusion - Adjust related tests and helpers for the new import style --- apps/desktop/tsconfig.json | 2 +- packages/client-runtime/tsconfig.json | 14 +++++++++ packages/contracts/tsconfig.json | 2 +- packages/effect-acp/scripts/generate.ts | 7 ++++- packages/effect-acp/tsconfig.json | 4 +-- .../scripts/generate.ts | 7 ++++- .../effect-codex-app-server/tsconfig.json | 4 +-- packages/shared/src/DrainableWorker.test.ts | 3 +- packages/shared/src/DrainableWorker.ts | 6 ++-- .../shared/src/KeyedCoalescingWorker.test.ts | 5 ++-- packages/shared/src/KeyedCoalescingWorker.ts | 6 ++-- packages/shared/src/Net.test.ts | 30 +++++++++---------- packages/shared/src/Net.ts | 15 ++++++---- packages/shared/src/schemaJson.ts | 20 ++++++------- packages/shared/src/serverSettings.ts | 2 +- packages/shared/src/shell.ts | 4 +-- packages/shared/tsconfig.json | 14 +++++++++ packages/ssh/src/auth.test.ts | 5 +++- packages/ssh/src/auth.ts | 7 ++++- packages/ssh/src/command.test.ts | 8 ++++- packages/ssh/src/command.ts | 8 ++++- packages/ssh/src/config.test.ts | 4 ++- packages/ssh/src/config.ts | 5 +++- packages/ssh/src/errors.ts | 2 +- packages/ssh/src/tunnel.test.ts | 8 ++++- packages/ssh/src/tunnel.ts | 30 +++++++++---------- packages/ssh/tsconfig.json | 1 + packages/tailscale/src/tailscale.test.ts | 5 +++- packages/tailscale/src/tailscale.ts | 6 +++- 29 files changed, 158 insertions(+), 76 deletions(-) diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 951889c1135..6fee28207ab 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -7,7 +7,7 @@ "plugins": [ { "name": "@effect/language-service", - "namespaceImportPackages": ["effect"], + "namespaceImportPackages": ["@effect/platform-node", "effect"], "diagnosticSeverity": { "importFromBarrel": "error", "anyUnknownInErrorContext": "error", diff --git a/packages/client-runtime/tsconfig.json b/packages/client-runtime/tsconfig.json index 564a5990051..668012f744b 100644 --- a/packages/client-runtime/tsconfig.json +++ b/packages/client-runtime/tsconfig.json @@ -1,4 +1,18 @@ { "extends": "../../tsconfig.base.json", + "compilerOptions": { + "plugins": [ + { + "name": "@effect/language-service", + "namespaceImportPackages": ["@effect/platform-node", "effect"], + "diagnosticSeverity": { + "importFromBarrel": "error", + "anyUnknownInErrorContext": "warning", + "instanceOfSchema": "warning", + "deterministicKeys": "warning" + } + } + ] + }, "include": ["src"] } diff --git a/packages/contracts/tsconfig.json b/packages/contracts/tsconfig.json index 92d639f99bd..668012f744b 100644 --- a/packages/contracts/tsconfig.json +++ b/packages/contracts/tsconfig.json @@ -4,7 +4,7 @@ "plugins": [ { "name": "@effect/language-service", - "namespaceImportPackages": ["@effect/platform-node"], + "namespaceImportPackages": ["@effect/platform-node", "effect"], "diagnosticSeverity": { "importFromBarrel": "error", "anyUnknownInErrorContext": "warning", diff --git a/packages/effect-acp/scripts/generate.ts b/packages/effect-acp/scripts/generate.ts index f2837c4f859..0e345b2349b 100644 --- a/packages/effect-acp/scripts/generate.ts +++ b/packages/effect-acp/scripts/generate.ts @@ -3,7 +3,12 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { make as makeJsonSchemaGenerator } from "@effect/openapi-generator/JsonSchemaGenerator"; -import { Effect, FileSystem, Layer, Logger, Path, Schema } from "effect"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import { Command, Flag } from "effect/unstable/cli"; import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; diff --git a/packages/effect-acp/tsconfig.json b/packages/effect-acp/tsconfig.json index 61162f9454a..668012f744b 100644 --- a/packages/effect-acp/tsconfig.json +++ b/packages/effect-acp/tsconfig.json @@ -4,7 +4,7 @@ "plugins": [ { "name": "@effect/language-service", - "namespaceImportPackages": ["@effect/platform-node"], + "namespaceImportPackages": ["@effect/platform-node", "effect"], "diagnosticSeverity": { "importFromBarrel": "error", "anyUnknownInErrorContext": "warning", @@ -14,5 +14,5 @@ } ] }, - "include": ["src", "scripts", "test"] + "include": ["src"] } diff --git a/packages/effect-codex-app-server/scripts/generate.ts b/packages/effect-codex-app-server/scripts/generate.ts index 0b2eaf19b04..cce77b41a9f 100644 --- a/packages/effect-codex-app-server/scripts/generate.ts +++ b/packages/effect-codex-app-server/scripts/generate.ts @@ -3,7 +3,12 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { make as makeJsonSchemaGenerator } from "@effect/openapi-generator/JsonSchemaGenerator"; -import { Effect, FileSystem, Layer, Logger, Path, Schema } from "effect"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import { FetchHttpClient, HttpClient, diff --git a/packages/effect-codex-app-server/tsconfig.json b/packages/effect-codex-app-server/tsconfig.json index 61162f9454a..668012f744b 100644 --- a/packages/effect-codex-app-server/tsconfig.json +++ b/packages/effect-codex-app-server/tsconfig.json @@ -4,7 +4,7 @@ "plugins": [ { "name": "@effect/language-service", - "namespaceImportPackages": ["@effect/platform-node"], + "namespaceImportPackages": ["@effect/platform-node", "effect"], "diagnosticSeverity": { "importFromBarrel": "error", "anyUnknownInErrorContext": "warning", @@ -14,5 +14,5 @@ } ] }, - "include": ["src", "scripts", "test"] + "include": ["src"] } diff --git a/packages/shared/src/DrainableWorker.test.ts b/packages/shared/src/DrainableWorker.test.ts index 0033038d0c5..7fc1a5fc23d 100644 --- a/packages/shared/src/DrainableWorker.test.ts +++ b/packages/shared/src/DrainableWorker.test.ts @@ -1,6 +1,7 @@ import { it } from "@effect/vitest"; import { describe, expect } from "vitest"; -import { Deferred, Effect } from "effect"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; import { makeDrainableWorker } from "./DrainableWorker.ts"; diff --git a/packages/shared/src/DrainableWorker.ts b/packages/shared/src/DrainableWorker.ts index 55483f33e8c..de40ec5e36b 100644 --- a/packages/shared/src/DrainableWorker.ts +++ b/packages/shared/src/DrainableWorker.ts @@ -8,8 +8,10 @@ * * @module DrainableWorker */ -import type { Scope } from "effect"; -import { Effect, TxQueue, TxRef } from "effect"; +import * as Scope from "effect/Scope"; +import * as Effect from "effect/Effect"; +import * as TxQueue from "effect/TxQueue"; +import * as TxRef from "effect/TxRef"; export interface DrainableWorker { /** diff --git a/packages/shared/src/KeyedCoalescingWorker.test.ts b/packages/shared/src/KeyedCoalescingWorker.test.ts index 78c3a6b9102..a010d732310 100644 --- a/packages/shared/src/KeyedCoalescingWorker.test.ts +++ b/packages/shared/src/KeyedCoalescingWorker.test.ts @@ -1,6 +1,7 @@ import { it } from "@effect/vitest"; import { describe, expect } from "vitest"; -import { Deferred, Effect } from "effect"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; import { makeKeyedCoalescingWorker } from "./KeyedCoalescingWorker.ts"; @@ -73,7 +74,7 @@ describe("makeKeyedCoalescingWorker", () => { if (value === "first") { yield* Deferred.succeed(firstStarted, undefined).pipe(Effect.orDie); yield* Deferred.await(releaseFailure); - yield* Effect.fail("boom"); + return yield* Effect.fail("boom"); } if (value === "second") { diff --git a/packages/shared/src/KeyedCoalescingWorker.ts b/packages/shared/src/KeyedCoalescingWorker.ts index 567c1dac173..f4edebfafb3 100644 --- a/packages/shared/src/KeyedCoalescingWorker.ts +++ b/packages/shared/src/KeyedCoalescingWorker.ts @@ -7,8 +7,10 @@ * * @module KeyedCoalescingWorker */ -import type { Scope } from "effect"; -import { Effect, TxQueue, TxRef } from "effect"; +import * as Scope from "effect/Scope"; +import * as Effect from "effect/Effect"; +import * as TxQueue from "effect/TxQueue"; +import * as TxRef from "effect/TxRef"; export interface KeyedCoalescingWorker { readonly enqueue: (key: K, value: V) => Effect.Effect; diff --git a/packages/shared/src/Net.test.ts b/packages/shared/src/Net.test.ts index 19033a082b4..b8e74f1f1cc 100644 --- a/packages/shared/src/Net.test.ts +++ b/packages/shared/src/Net.test.ts @@ -1,11 +1,11 @@ -import * as Net from "node:net"; +import * as NodeNet from "node:net"; import { assert, describe, it } from "@effect/vitest"; -import { Effect } from "effect"; +import * as Effect from "effect/Effect"; -import { NetError, NetService } from "./Net.ts"; +import * as Net from "./Net.ts"; -const closeServer = (server: Net.Server) => +const closeServer = (server: NodeNet.Server) => Effect.sync(() => { try { server.close(); @@ -14,24 +14,24 @@ const closeServer = (server: Net.Server) => } }); -const getPort = (server: Net.Server): number => { +const getPort = (server: NodeNet.Server): number => { const address = server.address(); return typeof address === "object" && address !== null ? address.port : 0; }; -const openServer = (host?: string): Effect.Effect => - Effect.callback((resume) => { - const server = Net.createServer(); +const openServer = (host?: string): Effect.Effect => + Effect.callback((resume) => { + const server = NodeNet.createServer(); let settled = false; - const settle = (effect: Effect.Effect) => { + const settle = (effect: Effect.Effect) => { if (settled) return; settled = true; resume(effect); }; server.once("error", (cause) => { - settle(Effect.fail(new NetError({ message: "Failed to open test server", cause }))); + settle(Effect.fail(new Net.NetError({ message: "Failed to open test server", cause }))); }); if (host) { @@ -43,11 +43,11 @@ const openServer = (host?: string): Effect.Effect => return closeServer(server); }); -it.layer(NetService.layer)("NetService", (it) => { +it.layer(Net.NetService.layer)("NetService", (it) => { describe("Net helpers", () => { it.effect("reserveLoopbackPort returns a positive loopback port", () => Effect.gen(function* () { - const net = yield* NetService; + const net = yield* Net.NetService; const port = yield* net.reserveLoopbackPort(); assert.ok(port > 0); @@ -59,7 +59,7 @@ it.layer(NetService.layer)("NetService", (it) => { openServer("127.0.0.1"), (server) => Effect.gen(function* () { - const net = yield* NetService; + const net = yield* Net.NetService; const port = getPort(server); const available = yield* net.isPortAvailableOnLoopback(port); @@ -71,7 +71,7 @@ it.layer(NetService.layer)("NetService", (it) => { it.effect("findAvailablePort returns preferred when it is free", () => Effect.gen(function* () { - const net = yield* NetService; + const net = yield* Net.NetService; const preferred = yield* net.reserveLoopbackPort(); const resolved = yield* net.findAvailablePort(preferred); @@ -84,7 +84,7 @@ it.layer(NetService.layer)("NetService", (it) => { openServer(), (server) => Effect.gen(function* () { - const net = yield* NetService; + const net = yield* Net.NetService; const preferred = getPort(server); const resolved = yield* net.findAvailablePort(preferred); diff --git a/packages/shared/src/Net.ts b/packages/shared/src/Net.ts index c6f01f9234c..22e80dffb3d 100644 --- a/packages/shared/src/Net.ts +++ b/packages/shared/src/Net.ts @@ -1,6 +1,9 @@ -import * as Net from "node:net"; +import * as NodeNet from "node:net"; -import { Data, Effect, Layer, Context } from "effect"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Context from "effect/Context"; export class NetError extends Data.TaggedError("NetError")<{ readonly message: string; @@ -18,7 +21,7 @@ function isErrnoExceptionWithCode(cause: unknown): cause is { ); } -const closeServer = (server: Net.Server) => { +const closeServer = (server: NodeNet.Server) => { try { server.close(); } catch { @@ -28,7 +31,7 @@ const closeServer = (server: Net.Server) => { const tryReservePort = (port: number): Effect.Effect => Effect.callback((resume) => { - const server = Net.createServer(); + const server = NodeNet.createServer(); let settled = false; const settle = (effect: Effect.Effect) => { @@ -90,7 +93,7 @@ export const make = () => { */ const canListenOnHost = (port: number, host: string): Effect.Effect => Effect.callback((resume) => { - const server = Net.createServer(); + const server = NodeNet.createServer(); let settled = false; const settle = (value: boolean) => { @@ -128,7 +131,7 @@ export const make = () => { */ const reserveLoopbackPort = (host = "127.0.0.1"): Effect.Effect => Effect.callback((resume) => { - const probe = Net.createServer(); + const probe = NodeNet.createServer(); let settled = false; const settle = (effect: Effect.Effect) => { diff --git a/packages/shared/src/schemaJson.ts b/packages/shared/src/schemaJson.ts index 9e38b1f8b85..ada6b790656 100644 --- a/packages/shared/src/schemaJson.ts +++ b/packages/shared/src/schemaJson.ts @@ -1,14 +1,12 @@ -import { - Cause, - Effect, - Exit, - Option, - Result, - Schema, - SchemaGetter, - SchemaIssue, - SchemaTransformation, -} from "effect"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as SchemaGetter from "effect/SchemaGetter"; +import * as SchemaIssue from "effect/SchemaIssue"; +import * as SchemaTransformation from "effect/SchemaTransformation"; export const decodeJsonResult = >( schema: S, diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 655de4d92ad..672c09687fc 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -1,5 +1,5 @@ import { ServerSettings, type ServerSettingsPatch } from "@t3tools/contracts"; -import { Schema } from "effect"; +import * as Schema from "effect/Schema"; import { deepMerge } from "./Struct.ts"; import { fromLenientJson } from "./schemaJson.ts"; import { createModelSelection } from "./model.ts"; diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index ecb88f837c1..9b833feac64 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -1,4 +1,4 @@ -import * as OS from "node:os"; +import * as NodeOS from "node:os"; import { execFileSync } from "node:child_process"; import { accessSync, constants, statSync } from "node:fs"; import { extname, join } from "node:path"; @@ -32,7 +32,7 @@ function trimNonEmpty(value: string | null | undefined): string | undefined { function readUserLoginShell(): string | undefined { try { - return trimNonEmpty(OS.userInfo().shell); + return trimNonEmpty(NodeOS.userInfo().shell); } catch { return undefined; } diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index 564a5990051..668012f744b 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -1,4 +1,18 @@ { "extends": "../../tsconfig.base.json", + "compilerOptions": { + "plugins": [ + { + "name": "@effect/language-service", + "namespaceImportPackages": ["@effect/platform-node", "effect"], + "diagnosticSeverity": { + "importFromBarrel": "error", + "anyUnknownInErrorContext": "warning", + "instanceOfSchema": "warning", + "deterministicKeys": "warning" + } + } + ] + }, "include": ["src"] } diff --git a/packages/ssh/src/auth.test.ts b/packages/ssh/src/auth.test.ts index f9fe311450d..e59707207a2 100644 --- a/packages/ssh/src/auth.test.ts +++ b/packages/ssh/src/auth.test.ts @@ -1,6 +1,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; -import { Effect, FileSystem, Path } from "effect"; + +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import { buildSshAskpassHelperDescriptor, diff --git a/packages/ssh/src/auth.ts b/packages/ssh/src/auth.ts index 7cb2de11ef5..13ddee9ca2d 100644 --- a/packages/ssh/src/auth.ts +++ b/packages/ssh/src/auth.ts @@ -1,4 +1,9 @@ -import { Context, Effect, FileSystem, Layer, Path, PlatformError } from "effect"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import { SshPasswordPromptError } from "./errors.ts"; diff --git a/packages/ssh/src/command.test.ts b/packages/ssh/src/command.test.ts index 22e93b69c43..ef7636fa89f 100644 --- a/packages/ssh/src/command.test.ts +++ b/packages/ssh/src/command.test.ts @@ -1,6 +1,12 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { Duration, Effect, Fiber, Layer, Result, Sink, Stream } from "effect"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; import { ChildProcessSpawner } from "effect/unstable/process"; diff --git a/packages/ssh/src/command.ts b/packages/ssh/src/command.ts index cadba077602..dc8839378cd 100644 --- a/packages/ssh/src/command.ts +++ b/packages/ssh/src/command.ts @@ -1,7 +1,13 @@ import * as Crypto from "node:crypto"; import type { DesktopSshEnvironmentTarget, DesktopUpdateChannel } from "@t3tools/contracts"; -import { Duration, Effect, FileSystem, Option, Path, Scope, Stream } from "effect"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { buildSshChildEnvironment, type SshAuthOptions } from "./auth.ts"; diff --git a/packages/ssh/src/config.test.ts b/packages/ssh/src/config.test.ts index 0e2f5472ddf..0446370337a 100644 --- a/packages/ssh/src/config.test.ts +++ b/packages/ssh/src/config.test.ts @@ -1,6 +1,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; -import { Effect, FileSystem, Path } from "effect"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import { discoverSshHosts, diff --git a/packages/ssh/src/config.ts b/packages/ssh/src/config.ts index b326f66a468..a8d17946129 100644 --- a/packages/ssh/src/config.ts +++ b/packages/ssh/src/config.ts @@ -1,6 +1,9 @@ import type { DesktopDiscoveredSshHost } from "@t3tools/contracts"; -import { Effect, FileSystem, Path, PlatformError } from "effect"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import { SshHostDiscoveryError } from "./errors.ts"; diff --git a/packages/ssh/src/errors.ts b/packages/ssh/src/errors.ts index b902ef82721..357aaef091d 100644 --- a/packages/ssh/src/errors.ts +++ b/packages/ssh/src/errors.ts @@ -1,4 +1,4 @@ -import { Data } from "effect"; +import * as Data from "effect/Data"; export class SshHostDiscoveryError extends Data.TaggedError("SshHostDiscoveryError")<{ readonly message: string; diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index f6c7598ef75..98908ac89e2 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -1,7 +1,13 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { NetService } from "@t3tools/shared/Net"; -import { Duration, Effect, Fiber, Layer, Result, Sink, Stream } from "effect"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index a4b110c1947..f4b26df0acd 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -4,22 +4,20 @@ import type { } from "@t3tools/contracts"; import { type NetError, NetService } from "@t3tools/shared/Net"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; -import { - Deferred, - Context, - Duration, - Effect, - Exit, - FileSystem, - Layer, - Option, - Path, - Ref, - Schema, - Scope, - Schedule, - Stream, -} from "effect"; +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; diff --git a/packages/ssh/tsconfig.json b/packages/ssh/tsconfig.json index 2f8e6d4df6d..668012f744b 100644 --- a/packages/ssh/tsconfig.json +++ b/packages/ssh/tsconfig.json @@ -4,6 +4,7 @@ "plugins": [ { "name": "@effect/language-service", + "namespaceImportPackages": ["@effect/platform-node", "effect"], "diagnosticSeverity": { "importFromBarrel": "error", "anyUnknownInErrorContext": "warning", diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index 149525a03f4..dd2b1772fd6 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -1,5 +1,8 @@ import { assert, describe, it } from "@effect/vitest"; -import { Effect, Layer, Sink, Stream } from "effect"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; import { diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index e90299d3ac1..c40cd54fc44 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -1,4 +1,8 @@ -import { Data, Effect, Option, Schema, Stream } from "effect"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; From f854da1cb873cda2e1f42307f1dd831c86421e19 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 22:27:18 -0700 Subject: [PATCH 26/43] Reuse effect context for SSH password prompt cleanup - Capture the current effect context before registering the window-close handler - Run pending prompt cleanup through the captured fork to preserve context --- apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts index 2404999adf4..e87439f8e18 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts @@ -284,8 +284,11 @@ const make = (options: LayerOptions = {}) => }; yield* Ref.update(pendingRef, (entries) => new Map(entries).set(requestId, pending)); + const context = yield* Effect.context(); + const runFork = Effect.runForkWith(context); + const cancelOnWindowClosed = () => { - Effect.runFork( + runFork( removePending(pendingRef, requestId).pipe( Effect.flatMap((entry) => Option.match(entry, { From b07e6269c719b0d0caced9152b26576d047fa0a4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 22:30:17 -0700 Subject: [PATCH 27/43] Tighten Effect tsconfig diagnostics - Promote anyUnknownInErrorContext to error across packages - Include scripts and test files in effect package typechecks --- packages/client-runtime/tsconfig.json | 2 +- packages/contracts/tsconfig.json | 2 +- packages/effect-acp/tsconfig.json | 4 ++-- packages/effect-codex-app-server/tsconfig.json | 4 ++-- packages/shared/tsconfig.json | 2 +- packages/ssh/tsconfig.json | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/client-runtime/tsconfig.json b/packages/client-runtime/tsconfig.json index 668012f744b..bd559329ef3 100644 --- a/packages/client-runtime/tsconfig.json +++ b/packages/client-runtime/tsconfig.json @@ -7,7 +7,7 @@ "namespaceImportPackages": ["@effect/platform-node", "effect"], "diagnosticSeverity": { "importFromBarrel": "error", - "anyUnknownInErrorContext": "warning", + "anyUnknownInErrorContext": "error", "instanceOfSchema": "warning", "deterministicKeys": "warning" } diff --git a/packages/contracts/tsconfig.json b/packages/contracts/tsconfig.json index 668012f744b..bd559329ef3 100644 --- a/packages/contracts/tsconfig.json +++ b/packages/contracts/tsconfig.json @@ -7,7 +7,7 @@ "namespaceImportPackages": ["@effect/platform-node", "effect"], "diagnosticSeverity": { "importFromBarrel": "error", - "anyUnknownInErrorContext": "warning", + "anyUnknownInErrorContext": "error", "instanceOfSchema": "warning", "deterministicKeys": "warning" } diff --git a/packages/effect-acp/tsconfig.json b/packages/effect-acp/tsconfig.json index 668012f744b..b66569ba110 100644 --- a/packages/effect-acp/tsconfig.json +++ b/packages/effect-acp/tsconfig.json @@ -7,12 +7,12 @@ "namespaceImportPackages": ["@effect/platform-node", "effect"], "diagnosticSeverity": { "importFromBarrel": "error", - "anyUnknownInErrorContext": "warning", + "anyUnknownInErrorContext": "error", "instanceOfSchema": "warning", "deterministicKeys": "warning" } } ] }, - "include": ["src"] + "include": ["src", "scripts", "test"] } diff --git a/packages/effect-codex-app-server/tsconfig.json b/packages/effect-codex-app-server/tsconfig.json index 668012f744b..b66569ba110 100644 --- a/packages/effect-codex-app-server/tsconfig.json +++ b/packages/effect-codex-app-server/tsconfig.json @@ -7,12 +7,12 @@ "namespaceImportPackages": ["@effect/platform-node", "effect"], "diagnosticSeverity": { "importFromBarrel": "error", - "anyUnknownInErrorContext": "warning", + "anyUnknownInErrorContext": "error", "instanceOfSchema": "warning", "deterministicKeys": "warning" } } ] }, - "include": ["src"] + "include": ["src", "scripts", "test"] } diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index 668012f744b..bd559329ef3 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -7,7 +7,7 @@ "namespaceImportPackages": ["@effect/platform-node", "effect"], "diagnosticSeverity": { "importFromBarrel": "error", - "anyUnknownInErrorContext": "warning", + "anyUnknownInErrorContext": "error", "instanceOfSchema": "warning", "deterministicKeys": "warning" } diff --git a/packages/ssh/tsconfig.json b/packages/ssh/tsconfig.json index 668012f744b..bd559329ef3 100644 --- a/packages/ssh/tsconfig.json +++ b/packages/ssh/tsconfig.json @@ -7,7 +7,7 @@ "namespaceImportPackages": ["@effect/platform-node", "effect"], "diagnosticSeverity": { "importFromBarrel": "error", - "anyUnknownInErrorContext": "warning", + "anyUnknownInErrorContext": "error", "instanceOfSchema": "warning", "deterministicKeys": "warning" } From efa529d3d737da6d829d3ab964cb12b0867dc9bd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 23:11:38 -0700 Subject: [PATCH 28/43] Refactor desktop bootstrap around direct environment inputs - Derive desktop home from the injected environment instead of config fallbacks - Move backend lifecycle hooks into shared app services and simplify tests - Consolidate desktop runtime ignore rules at the repo root --- .gitignore | 2 + apps/desktop/.gitignore | 2 - .../src/app/DesktopAppIdentity.test.ts | 4 +- apps/desktop/src/app/DesktopConfig.test.ts | 59 ---- apps/desktop/src/app/DesktopConfig.ts | 73 +--- .../src/app/DesktopEnvironment.test.ts | 15 +- apps/desktop/src/app/DesktopEnvironment.ts | 25 +- .../DesktopBackendConfiguration.test.ts | 2 +- .../src/backend/DesktopBackendEvents.ts | 94 ------ .../src/backend/DesktopBackendManager.test.ts | 150 ++++----- .../src/backend/DesktopBackendManager.ts | 63 +++- apps/desktop/src/electron/ElectronApp.test.ts | 16 - .../desktop/src/electron/ElectronMenu.test.ts | 30 -- .../src/electron/ElectronProtocol.test.ts | 24 -- .../src/electron/ElectronShell.test.ts | 19 -- .../src/electron/ElectronTheme.test.ts | 11 - .../src/electron/ElectronUpdater.test.ts | 30 -- apps/desktop/src/ipc/methods/window.ts | 74 +++- apps/desktop/src/main.ts | 10 +- .../DesktopServerExposure.test.ts | 2 +- .../src/shell/DesktopShellEnvironment.test.ts | 317 ++++++------------ .../src/shell/DesktopShellEnvironment.ts | 257 +++++--------- .../src/updates/DesktopUpdates.test.ts | 2 +- apps/desktop/src/updates/DesktopUpdates.ts | 12 - .../src/window/DesktopApplicationMenu.test.ts | 9 +- .../src/window/DesktopWindowIpcActions.ts | 110 ------ 26 files changed, 390 insertions(+), 1022 deletions(-) delete mode 100644 apps/desktop/.gitignore delete mode 100644 apps/desktop/src/app/DesktopConfig.test.ts delete mode 100644 apps/desktop/src/backend/DesktopBackendEvents.ts delete mode 100644 apps/desktop/src/window/DesktopWindowIpcActions.ts diff --git a/.gitignore b/.gitignore index 9e14e917910..e16286c0736 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,5 @@ __screenshots__/ .tanstack squashfs-root/ .vercel +dist-electron/ +.electron-runtime/ diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore deleted file mode 100644 index 45b848af652..00000000000 --- a/apps/desktop/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -dist-electron/ -.electron-runtime/ diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 833c8c1d373..f95fd1bef71 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -15,7 +15,7 @@ import * as DesktopEnvironment from "./DesktopEnvironment.ts"; const defaultEnvironmentInput = { dirname: "/repo/apps/desktop/dist-electron", - cwd: "/repo", + homeDirectory: "/Users/alice", platform: "darwin", processArch: "arm64", appVersion: "1.2.3", @@ -82,7 +82,6 @@ const makeEnvironmentLayer = (overrides: TestEnvironmentInput = {}) => { Layer.mergeAll( NodeServices.layer, DesktopConfig.layerTest({ - HOME: "/Users/alice", ...env, }), ), @@ -167,7 +166,6 @@ describe("DesktopAppIdentity", () => { calls, environment: { env: { - HOME: "/Users/alice", T3CODE_COMMIT_HASH: "0123456789abcdef", }, }, diff --git a/apps/desktop/src/app/DesktopConfig.test.ts b/apps/desktop/src/app/DesktopConfig.test.ts deleted file mode 100644 index f45473ebb94..00000000000 --- a/apps/desktop/src/app/DesktopConfig.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as ConfigProvider from "effect/ConfigProvider"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; - -import * as DesktopConfig from "./DesktopConfig.ts"; - -describe("DesktopConfig", () => { - it.effect("loads typed desktop config from the effect ConfigProvider", () => - Effect.gen(function* () { - const config = yield* DesktopConfig.DesktopConfig; - - assert.deepEqual(config.home, Option.some("/Users/alice")); - assert.deepEqual(config.t3Home, Option.some("/tmp/t3")); - assert.deepEqual( - Option.map(config.devServerUrl, (url) => url.href), - Option.some("http://localhost:5173/"), - ); - assert.deepEqual(config.configuredBackendPort, Option.some(4949)); - assert.deepEqual(config.commitHashOverride, Option.some("0123456789abcdef")); - assert.deepEqual(config.desktopLanHostOverride, Option.some("192.168.1.50")); - assert.deepEqual(config.desktopHttpsEndpointUrls, [ - "https://t3.example.test", - "https://tailnet.example.test", - ]); - assert.equal(config.disableAutoUpdate, true); - assert.deepEqual(config.desktopUpdateGithubToken, Option.some("desktop-token")); - assert.equal(config.mockUpdates, true); - assert.equal(config.mockUpdateServerPort, 4141); - }).pipe( - Effect.provide( - DesktopConfig.layer.pipe( - Layer.provide( - ConfigProvider.layer( - ConfigProvider.fromEnv({ - env: { - HOME: " /Users/alice ", - T3CODE_HOME: " /tmp/t3 ", - VITE_DEV_SERVER_URL: "http://localhost:5173", - T3CODE_PORT: "4949", - T3CODE_COMMIT_HASH: " 0123456789abcdef ", - T3CODE_DESKTOP_LAN_HOST: " 192.168.1.50 ", - T3CODE_DESKTOP_HTTPS_ENDPOINTS: - " https://t3.example.test, https://tailnet.example.test ", - T3CODE_DISABLE_AUTO_UPDATE: "1", - T3CODE_DESKTOP_UPDATE_GITHUB_TOKEN: " desktop-token ", - GH_TOKEN: "ignored-token", - T3CODE_DESKTOP_MOCK_UPDATES: "true", - T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141", - }, - }), - ), - ), - ), - ), - ), - ); -}); diff --git a/apps/desktop/src/app/DesktopConfig.ts b/apps/desktop/src/app/DesktopConfig.ts index 64f8bd537b3..7c0664913d1 100644 --- a/apps/desktop/src/app/DesktopConfig.ts +++ b/apps/desktop/src/app/DesktopConfig.ts @@ -1,35 +1,7 @@ import * as Config from "effect/Config"; import * as ConfigProvider from "effect/ConfigProvider"; -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -export interface DesktopConfigShape { - readonly home: Option.Option; - readonly userProfile: Option.Option; - readonly homeDrive: Option.Option; - readonly homePath: Option.Option; - readonly appDataDirectory: Option.Option; - readonly xdgConfigHome: Option.Option; - readonly t3Home: Option.Option; - readonly devServerUrl: Option.Option; - readonly devRemoteT3ServerEntryPath: Option.Option; - readonly configuredBackendPort: Option.Option; - readonly commitHashOverride: Option.Option; - readonly desktopLanHostOverride: Option.Option; - readonly desktopHttpsEndpointUrls: readonly string[]; - readonly appImagePath: Option.Option; - readonly disableAutoUpdate: boolean; - readonly desktopUpdateGithubToken: Option.Option; - readonly mockUpdates: boolean; - readonly mockUpdateServerPort: number; -} - -export class DesktopConfig extends Context.Service()( - "t3/desktop/Config", -) {} - const trimNonEmptyOption = (value: string): Option.Option => { const trimmed = value.trim(); return trimmed.length > 0 ? Option.some(trimmed) : Option.none(); @@ -55,19 +27,12 @@ const commaSeparatedStrings = (name: string) => ), ); -const firstSomeOf = (values: ReadonlyArray>): Option.Option => - Option.firstSomeOf(values); - const compactEnv = (env: Readonly>): Record => Object.fromEntries( Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined), ); -const EnvDesktopConfig = Config.all({ - home: trimmedString("HOME"), - userProfile: trimmedString("USERPROFILE"), - homeDrive: trimmedString("HOMEDRIVE"), - homePath: trimmedString("HOMEPATH"), +export const DesktopConfig = Config.all({ appDataDirectory: trimmedString("APPDATA"), xdgConfigHome: trimmedString("XDG_CONFIG_HOME"), t3Home: trimmedString("T3CODE_HOME"), @@ -79,43 +44,11 @@ const EnvDesktopConfig = Config.all({ desktopHttpsEndpointUrls: commaSeparatedStrings("T3CODE_DESKTOP_HTTPS_ENDPOINTS"), appImagePath: trimmedString("APPIMAGE"), disableAutoUpdate: optionalBoolean("T3CODE_DISABLE_AUTO_UPDATE"), - desktopUpdateGithubToken: trimmedString("T3CODE_DESKTOP_UPDATE_GITHUB_TOKEN"), - ghToken: trimmedString("GH_TOKEN"), mockUpdates: optionalBoolean("T3CODE_DESKTOP_MOCK_UPDATES"), mockUpdateServerPort: Config.port("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe( Config.withDefault(3000), ), -}).pipe( - Config.map( - (config): DesktopConfigShape => ({ - home: config.home, - userProfile: config.userProfile, - homeDrive: config.homeDrive, - homePath: config.homePath, - appDataDirectory: config.appDataDirectory, - xdgConfigHome: config.xdgConfigHome, - t3Home: config.t3Home, - devServerUrl: config.devServerUrl, - devRemoteT3ServerEntryPath: config.devRemoteT3ServerEntryPath, - configuredBackendPort: config.configuredBackendPort, - commitHashOverride: config.commitHashOverride, - desktopLanHostOverride: config.desktopLanHostOverride, - desktopHttpsEndpointUrls: config.desktopHttpsEndpointUrls, - appImagePath: config.appImagePath, - disableAutoUpdate: config.disableAutoUpdate, - desktopUpdateGithubToken: firstSomeOf([config.desktopUpdateGithubToken, config.ghToken]), - mockUpdates: config.mockUpdates, - mockUpdateServerPort: config.mockUpdateServerPort, - }), - ), -); - -export const layer = Layer.effect( - DesktopConfig, - Effect.gen(function* () { - return yield* EnvDesktopConfig; - }), -); +}); export const layerTest = (env: Readonly>) => - layer.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: compactEnv(env) })))); + ConfigProvider.layer(ConfigProvider.fromEnv({ env: compactEnv(env) })); diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 1a2be23f5f1..c7bb5f6547f 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -9,7 +9,7 @@ import * as DesktopConfig from "./DesktopConfig.ts"; const defaultInput = { dirname: "/repo/apps/desktop/dist-electron", - cwd: "/cwd", + homeDirectory: "/Users/alice", platform: "darwin", processArch: "arm64", appVersion: "0.0.22", @@ -37,22 +37,11 @@ const makeEnvironment = ( }).pipe(Effect.provide(makeEnvironmentLayer(overrides, env))); describe("DesktopEnvironment", () => { - it.effect("resolves home directory from platform env with cwd fallback", () => - Effect.gen(function* () { - assert.equal( - (yield* makeEnvironment({}, { HOME: " /Users/alice " })).homeDirectory, - "/Users/alice", - ); - assert.equal((yield* makeEnvironment({ cwd: "/cwd" })).homeDirectory, "/cwd"); - }), - ); - it.effect("derives state paths and development identity inside Effect", () => Effect.gen(function* () { const environment = yield* makeEnvironment( {}, { - HOME: "/Users/alice", T3CODE_HOME: " /tmp/t3 ", T3CODE_COMMIT_HASH: " 0123456789abcdef ", T3CODE_PORT: "4949", @@ -91,7 +80,7 @@ describe("DesktopEnvironment", () => { it.effect("resolves picker defaults without nullish sentinels", () => Effect.gen(function* () { - const environment = yield* makeEnvironment({}, { HOME: "/Users/alice" }); + const environment = yield* makeEnvironment(); assert.deepEqual(environment.resolvePickFolderDefaultPath(null), Option.none()); assert.deepEqual( diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index ed17d377078..646aa1c57cc 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -4,6 +4,7 @@ import type { DesktopRuntimeArch, DesktopRuntimeInfo, } from "@t3tools/contracts"; +import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -19,7 +20,7 @@ import { isNightlyDesktopVersion } from "../updates/updateChannels.ts"; export interface MakeDesktopEnvironmentInput { readonly dirname: string; - readonly cwd: string; + readonly homeDirectory: string; readonly platform: NodeJS.Platform; readonly processArch: string; readonly appVersion: string; @@ -77,21 +78,6 @@ export class DesktopEnvironment extends Context.Service< DesktopEnvironmentShape >()("t3/desktop/Environment") {} -function resolveDesktopHomeDirectory(input: { - readonly config: DesktopConfig.DesktopConfigShape; - readonly cwd: string; -}): string { - const driveHome = Option.zipWith( - input.config.homeDrive, - input.config.homePath, - (drive, homePath) => `${drive}${homePath}`, - ); - return Option.getOrElse( - Option.firstSomeOf([input.config.home, input.config.userProfile, driveHome]), - () => input.cwd, - ); -} - const APP_BASE_NAME = "T3 Code"; function resolveDesktopAppStageLabel(input: { @@ -149,14 +135,11 @@ function resolveDesktopRuntimeInfo(input: { const makeDesktopEnvironment = ( input: MakeDesktopEnvironmentInput, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const path = yield* Path.Path; const config = yield* DesktopConfig.DesktopConfig; - const homeDirectory = resolveDesktopHomeDirectory({ - config, - cwd: input.cwd, - }); + const homeDirectory = input.homeDirectory; const devServerUrl = config.devServerUrl; const isDevelopment = Option.isSome(devServerUrl); const appDataDirectory = diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index b68cc818142..2e7069a5350 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -40,7 +40,7 @@ const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExp function makeEnvironmentLayer(baseDir: string) { return DesktopEnvironment.layer({ dirname: "/repo/apps/desktop/src", - cwd: "/repo", + homeDirectory: baseDir, platform: "darwin", processArch: "x64", appVersion: "1.2.3", diff --git a/apps/desktop/src/backend/DesktopBackendEvents.ts b/apps/desktop/src/backend/DesktopBackendEvents.ts deleted file mode 100644 index 5a3c1216922..00000000000 --- a/apps/desktop/src/backend/DesktopBackendEvents.ts +++ /dev/null @@ -1,94 +0,0 @@ -import * as Context from "effect/Context"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Ref from "effect/Ref"; - -import * as DesktopBackendManager from "./DesktopBackendManager.ts"; -import { DesktopBackendOutputLog } from "../app/DesktopLogging.ts"; -import * as DesktopRun from "../app/DesktopRun.ts"; -import * as DesktopState from "../app/DesktopState.ts"; -import * as DesktopWindow from "../window/DesktopWindow.ts"; - -export interface DesktopBackendEventsShape { - readonly onStarting: Effect.Effect; - readonly onStarted: (input: { - readonly pid: number; - readonly config: DesktopBackendManager.DesktopBackendStartConfig; - }) => Effect.Effect; - readonly onReady: Effect.Effect; - readonly onReadinessFailure: ( - error: DesktopBackendManager.BackendTimeoutError, - ) => Effect.Effect; - readonly onOutput: ( - streamName: DesktopBackendManager.BackendProcessOutputStream, - chunk: Uint8Array, - ) => Effect.Effect; - readonly onExit: (input: { - readonly pid: Option.Option; - readonly reason: string; - }) => Effect.Effect; - readonly onRestartScheduled: (input: { - readonly reason: string; - readonly delay: Duration.Duration; - }) => Effect.Effect; -} - -export class DesktopBackendEvents extends Context.Service< - DesktopBackendEvents, - DesktopBackendEventsShape ->()("t3/desktop/BackendEvents") {} - -const make = Effect.gen(function* () { - const backendOutputLog = yield* DesktopBackendOutputLog; - const desktopWindow = yield* DesktopWindow.DesktopWindow; - const run = yield* DesktopRun.DesktopRun; - const state = yield* DesktopState.DesktopState; - - return DesktopBackendEvents.of({ - onStarting: Ref.set(state.backendReady, false), - onStarted: ({ pid, config }) => - Effect.gen(function* () { - const runId = yield* run.id; - yield* backendOutputLog.writeSessionBoundary({ - phase: "START", - runId, - details: `pid=${pid} port=${config.bootstrap.port} cwd=${config.cwd}`, - }); - }), - onReady: desktopWindow.handleBackendReady.pipe( - Effect.catch((error) => - run.logError("failed to open main window after backend readiness", { - message: error.message, - }), - ), - ), - onReadinessFailure: (error) => - run.logWarning("backend readiness check failed during bootstrap", { error: error.message }), - onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), - onExit: ({ pid, reason }) => - Effect.gen(function* () { - yield* Option.match(pid, { - onNone: () => Effect.void, - onSome: (value) => - Effect.gen(function* () { - const runId = yield* run.id; - yield* backendOutputLog.writeSessionBoundary({ - phase: "END", - runId, - details: `pid=${value} ${reason}`, - }); - }), - }); - yield* Ref.set(state.backendReady, false); - }), - onRestartScheduled: ({ reason, delay }) => - run.logError("backend exited unexpectedly; restart scheduled", { - reason, - delayMs: Duration.toMillis(delay), - }), - }); -}); - -export const layer = Layer.effect(DesktopBackendEvents, make); diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index f425ec48104..7e930750c9c 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -20,7 +20,13 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; -import * as DesktopBackendEvents from "./DesktopBackendEvents.ts"; +import { + DesktopBackendOutputLog, + type DesktopBackendOutputLogShape, +} from "../app/DesktopLogging.ts"; +import * as DesktopRun from "../app/DesktopRun.ts"; +import * as DesktopState from "../app/DesktopState.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { executablePath: "/electron", @@ -97,7 +103,9 @@ function decodeBootstrap(raw: string) { function makeManagerLayer(input: { readonly spawnerLayer: Layer.Layer; readonly httpClientLayer?: Layer.Layer; - readonly events?: Partial; + readonly backendOutputLog?: Partial; + readonly desktopRun?: Partial; + readonly desktopWindow?: Partial; readonly config?: DesktopBackendManager.DesktopBackendStartConfig; }) { return DesktopBackendManager.layer.pipe( @@ -111,16 +119,31 @@ function makeManagerLayer(input: { }), input.spawnerLayer, input.httpClientLayer ?? healthyHttpClientLayer, - Layer.succeed(DesktopBackendEvents.DesktopBackendEvents, { - onStarting: Effect.void, - onStarted: () => Effect.void, - onReady: Effect.void, - onReadinessFailure: () => Effect.void, - onOutput: () => Effect.void, - onExit: () => Effect.void, - onRestartScheduled: () => Effect.void, - ...input.events, - } satisfies DesktopBackendEvents.DesktopBackendEventsShape), + DesktopState.layer, + Layer.succeed(DesktopBackendOutputLog, { + writeSessionBoundary: () => Effect.void, + writeOutputChunk: () => Effect.void, + ...input.backendOutputLog, + } satisfies DesktopBackendOutputLogShape), + Layer.succeed(DesktopRun.DesktopRun, { + id: Effect.succeed("test-run"), + refreshId: Effect.succeed("test-run"), + logInfo: () => Effect.void, + logWarning: () => Effect.void, + logError: () => Effect.void, + ...input.desktopRun, + } satisfies DesktopRun.DesktopRunShape), + Layer.succeed(DesktopWindow.DesktopWindow, { + createMain: Effect.die("unexpected createMain"), + ensureMain: Effect.die("unexpected ensureMain"), + revealOrCreateMain: Effect.die("unexpected revealOrCreateMain"), + activate: Effect.void, + createMainIfBackendReady: Effect.void, + handleBackendReady: Effect.void, + dispatchMenuAction: () => Effect.void, + syncAppearance: Effect.void, + ...input.desktopWindow, + } satisfies DesktopWindow.DesktopWindowShape), ), ), ); @@ -160,11 +183,14 @@ describe("DesktopBackendManager", () => { bootstrap: configWithObservability, }, spawnerLayer, - events: { - onReady: Effect.sync(() => { + desktopWindow: { + handleBackendReady: Effect.sync(() => { readyCount += 1; }).pipe(Effect.andThen(Deferred.succeed(ready, void 0))), - onExit: () => Queue.offer(exited, void 0).pipe(Effect.asVoid), + }, + backendOutputLog: { + writeSessionBoundary: ({ phase }) => + phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void, }, }); @@ -175,20 +201,23 @@ describe("DesktopBackendManager", () => { assert.equal(readyCount, 1); assert.isDefined(spawnedCommand); - if (spawnedCommand?._tag === "StandardCommand") { - assert.equal(spawnedCommand.command, "/electron"); - assert.deepEqual(spawnedCommand.args, ["/server/bin.mjs", "--bootstrap-fd", "3"]); - assert.equal(spawnedCommand.options.cwd, "/server"); - assert.equal(spawnedCommand.options.extendEnv, true); - assert.equal(spawnedCommand.options.stdout, "pipe"); - assert.equal(spawnedCommand.options.stderr, "pipe"); - assert.equal(spawnedCommand.options.killSignal, "SIGTERM"); - assert.isDefined(spawnedCommand.options.forceKillAfter); - assert.equal( - Duration.toMillis(Duration.fromInputUnsafe(spawnedCommand.options.forceKillAfter)), - 2_000, - ); + if (spawnedCommand._tag !== "StandardCommand") { + throw new Error("Expected backend to spawn a standard command."); } + + assert.equal(spawnedCommand.command, "/electron"); + assert.deepEqual(spawnedCommand.args, ["/server/bin.mjs", "--bootstrap-fd", "3"]); + assert.equal(spawnedCommand.options.cwd, "/server"); + assert.equal(spawnedCommand.options.extendEnv, true); + assert.equal(spawnedCommand.options.stdout, "pipe"); + assert.equal(spawnedCommand.options.stderr, "pipe"); + assert.equal(spawnedCommand.options.killSignal, "SIGTERM"); + assert.isDefined(spawnedCommand.options.forceKillAfter); + assert.equal( + Duration.toMillis(Duration.fromInputUnsafe(spawnedCommand.options.forceKillAfter)), + 2_000, + ); + assert.deepEqual(yield* decodeBootstrap(bootstrapJson), configWithObservability); }).pipe(Effect.provide(managerLayer)); }), @@ -225,11 +254,14 @@ describe("DesktopBackendManager", () => { return responseForRequest(request, status); }), ), - events: { - onReady: Effect.sync(() => { + desktopWindow: { + handleBackendReady: Effect.sync(() => { readyCount += 1; }).pipe(Effect.andThen(Deferred.succeed(ready, void 0))), - onExit: () => Queue.offer(exited, void 0).pipe(Effect.asVoid), + }, + backendOutputLog: { + writeSessionBoundary: ({ phase }) => + phase === "END" ? Queue.offer(exited, void 0).pipe(Effect.asVoid) : Effect.void, }, }); @@ -250,50 +282,6 @@ describe("DesktopBackendManager", () => { }), ); - it.effect("inherits child output when captureOutput is false", () => - Effect.gen(function* () { - let spawnedCommand: ChildProcess.Command | undefined; - const ready = yield* Deferred.make(); - const exited = yield* Queue.unbounded(); - - const spawnerLayer = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => - Effect.sync(() => { - spawnedCommand = command; - return makeProcess({ - exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - }); - }), - ), - ); - - const managerLayer = makeManagerLayer({ - config: { - ...baseConfig, - captureOutput: false, - }, - spawnerLayer, - events: { - onReady: Deferred.succeed(ready, void 0).pipe(Effect.asVoid), - onExit: () => Queue.offer(exited, void 0).pipe(Effect.asVoid), - }, - }); - - yield* Effect.gen(function* () { - const manager = yield* DesktopBackendManager.DesktopBackendManager; - yield* manager.start; - yield* Queue.take(exited); - - assert.isDefined(spawnedCommand); - if (spawnedCommand?._tag === "StandardCommand") { - assert.equal(spawnedCommand.options.stdout, "inherit"); - assert.equal(spawnedCommand.options.stderr, "inherit"); - } - }).pipe(Effect.provide(managerLayer)); - }), - ); - it.effect("starts the configured backend and closes the scoped process on stop", () => Effect.gen(function* () { let startCount = 0; @@ -308,6 +296,7 @@ describe("DesktopBackendManager", () => { Effect.gen(function* () { const scope = yield* Scope.Scope; startCount += 1; + yield* Queue.offer(startedPids, 123); const close = Effect.sync(() => { closedCount += 1; }).pipe(Effect.andThen(Deferred.succeed(closed, void 0)), Effect.asVoid); @@ -324,9 +313,8 @@ describe("DesktopBackendManager", () => { const managerLayer = makeManagerLayer({ spawnerLayer, - events: { - onStarted: ({ pid }) => Queue.offer(startedPids, pid).pipe(Effect.asVoid), - onReady: Deferred.succeed(ready, void 0).pipe(Effect.asVoid), + desktopWindow: { + handleBackendReady: Deferred.succeed(ready, void 0).pipe(Effect.asVoid), }, }); @@ -377,9 +365,9 @@ describe("DesktopBackendManager", () => { const managerLayer = makeManagerLayer({ spawnerLayer, - events: { - onRestartScheduled: ({ delay }) => - Queue.offer(restartDelays, Duration.toMillis(delay)).pipe(Effect.asVoid), + desktopRun: { + logError: (_message, annotations) => + Queue.offer(restartDelays, Number(annotations?.delayMs ?? 0)).pipe(Effect.asVoid), }, }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 3116fd89e02..03bac019dc7 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -24,7 +24,10 @@ import { } from "@t3tools/contracts"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; -import * as DesktopBackendEvents from "./DesktopBackendEvents.ts"; +import { DesktopBackendOutputLog } from "../app/DesktopLogging.ts"; +import * as DesktopRun from "../app/DesktopRun.ts"; +import * as DesktopState from "../app/DesktopState.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; const INITIAL_RESTART_DELAY = Duration.millis(500); const MAX_RESTART_DELAY = Duration.seconds(10); @@ -173,8 +176,6 @@ const closeRun = ( ).pipe(Effect.ignore); }; -const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); - const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(function* ( baseUrl: URL, timeout: Duration.Duration, @@ -196,10 +197,9 @@ function describeProcessExit( result: Result.Result, ): BackendProcessExit { if (Result.isSuccess(result)) { - const code = Number(result.success); return { - code: Option.some(code), - reason: `code=${code}`, + code: Option.some(result.success), + reason: `code=${result.success}`, result, }; } @@ -222,6 +222,8 @@ function drainBackendOutput( ); } +const encodeBootstrapJson = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); + const runBackendProcess = Effect.fn("runBackendProcess")(function* ( options: RunBackendProcessOptions, ): Effect.fn.Return { @@ -257,7 +259,7 @@ const runBackendProcess = Effect.fn("runBackendProcess")(function* ( .spawn(command) .pipe(Effect.mapError((cause) => new BackendProcessSpawnError({ cause }))); - yield* options.onStarted?.(Number(handle.pid)) ?? Effect.void; + yield* options.onStarted?.(handle.pid) ?? Effect.void; if (options.captureOutput) { yield* drainBackendOutput("stdout", handle.stdout, onOutput).pipe(Effect.forkScoped); yield* drainBackendOutput("stderr", handle.stderr, onOutput).pipe(Effect.forkScoped); @@ -278,7 +280,10 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio const parentScope = yield* Scope.Scope; const fileSystem = yield* FileSystem.FileSystem; const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const events = yield* DesktopBackendEvents.DesktopBackendEvents; + const backendOutputLog = yield* DesktopBackendOutputLog; + const desktopState = yield* DesktopState.DesktopState; + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const run = yield* DesktopRun.DesktopRun; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const state = yield* Ref.make(initialState); @@ -324,7 +329,7 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio return; } - yield* events.onStarting; + yield* Ref.set(desktopState.backendReady, false); const config = yield* configuration.resolve; const entryExists = yield* fileSystem .exists(config.entryPath) @@ -402,10 +407,15 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio ); if (isCurrentRun) { - yield* events.onExit({ - pid, - reason, - }); + if (Option.isSome(pid)) { + const runId = yield* run.id; + yield* backendOutputLog.writeSessionBoundary({ + phase: "END", + runId, + details: `pid=${pid.value} ${reason}`, + }); + } + yield* Ref.set(desktopState.backendReady, false); } if (isCurrentRun && nextState.desiredRunning && !nextState.shuttingDown) { @@ -426,7 +436,12 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio ...latest, restartAttempt: 0, })); - yield* events.onStarted({ pid, config }); + const desktopRunId = yield* run.id; + yield* backendOutputLog.writeSessionBoundary({ + phase: "START", + runId: desktopRunId, + details: `pid=${pid} port=${config.bootstrap.port} cwd=${config.cwd}`, + }); }), onReady: () => Effect.gen(function* () { @@ -437,10 +452,19 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio onSome: (run) => (run.id === runId ? true : latest.ready), }), })); - yield* events.onReady; + yield* desktopWindow.handleBackendReady.pipe( + Effect.catch((error) => + run.logError("failed to open main window after backend readiness", { + message: error.message, + }), + ), + ); + }), + onReadinessFailure: (error) => + run.logWarning("backend readiness check failed during bootstrap", { + error: error.message, }), - onReadinessFailure: events.onReadinessFailure, - onOutput: events.onOutput, + onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(HttpClient.HttpClient, httpClient), @@ -482,7 +506,10 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio onNone: () => Effect.void, onSome: (delay) => Effect.gen(function* () { - yield* events.onRestartScheduled({ reason, delay }); + yield* run.logError("backend exited unexpectedly; restart scheduled", { + reason, + delayMs: Duration.toMillis(delay), + }); const restartFiber = yield* Effect.forkIn( Effect.sleep(delay).pipe( Effect.andThen( diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts index 962a3fbdc1b..c51157a2364 100644 --- a/apps/desktop/src/electron/ElectronApp.test.ts +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -106,20 +106,4 @@ describe("ElectronApp", () => { assert.deepEqual(removeListenerMock.mock.calls, [["activate", listener]]); }).pipe(Effect.provide(ElectronApp.layer)), ); - - it.effect("wraps app lifecycle operations", () => - Effect.gen(function* () { - const electronApp = yield* ElectronApp.ElectronApp; - - yield* electronApp.setPath("userData", "/tmp/t3code"); - yield* electronApp.appendCommandLineSwitch("class", "t3code"); - yield* electronApp.relaunch({ execPath: "/electron", args: ["main.js"] }); - yield* electronApp.exit(0); - - assert.deepEqual(setPathMock.mock.calls, [["userData", "/tmp/t3code"]]); - assert.deepEqual(appendSwitchMock.mock.calls, [["class", "t3code"]]); - assert.deepEqual(relaunchMock.mock.calls, [[{ execPath: "/electron", args: ["main.js"] }]]); - assert.deepEqual(exitMock.mock.calls, [[0]]); - }).pipe(Effect.provide(ElectronApp.layer)), - ); }); diff --git a/apps/desktop/src/electron/ElectronMenu.test.ts b/apps/desktop/src/electron/ElectronMenu.test.ts index de2f014e2e5..59c14659bf5 100644 --- a/apps/desktop/src/electron/ElectronMenu.test.ts +++ b/apps/desktop/src/electron/ElectronMenu.test.ts @@ -31,19 +31,6 @@ describe("ElectronMenu", () => { setApplicationMenuMock.mockReset(); }); - it.effect("sets the native application menu from a template", () => - Effect.gen(function* () { - const menu = { id: "application-menu" }; - buildFromTemplateMock.mockReturnValue(menu); - - const electronMenu = yield* ElectronMenu.ElectronMenu; - yield* electronMenu.setApplicationMenu([{ role: "about" }]); - - assert.deepEqual(buildFromTemplateMock.mock.calls, [[[{ role: "about" }]]]); - assert.deepEqual(setApplicationMenuMock.mock.calls, [[menu]]); - }).pipe(Effect.provide(ElectronMenu.layer)), - ); - it.effect("returns none without building a menu when there are no valid items", () => Effect.gen(function* () { const electronMenu = yield* ElectronMenu.ElectronMenu; @@ -85,23 +72,6 @@ describe("ElectronMenu", () => { }).pipe(Effect.provide(ElectronMenu.layer)), ); - it.effect("pops up a native menu template", () => - Effect.gen(function* () { - const popupMock = vi.fn(); - const window = {} as Electron.BrowserWindow; - buildFromTemplateMock.mockReturnValue({ popup: popupMock }); - - const electronMenu = yield* ElectronMenu.ElectronMenu; - yield* electronMenu.popupTemplate({ - window, - template: [{ role: "copy" }], - }); - - assert.deepEqual(buildFromTemplateMock.mock.calls[0]?.[0], [{ role: "copy" }]); - assert.deepEqual(popupMock.mock.calls, [[{ window }]]); - }).pipe(Effect.provide(ElectronMenu.layer)), - ); - it.effect("resolves with none when the menu closes without a click", () => Effect.gen(function* () { buildFromTemplateMock.mockImplementation(() => ({ diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 409cf3e67cc..3fc3e0c6a1a 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -36,30 +36,6 @@ describe("ElectronProtocol", () => { assert.isTrue(Option.isNone(ElectronProtocol.normalizeDesktopProtocolPathname("/../secret"))); }); - it.effect("registers the desktop scheme privileges", () => - Effect.gen(function* () { - const electronProtocol = yield* ElectronProtocol.ElectronProtocol; - - yield* electronProtocol.registerDesktopSchemePrivileges; - - assert.deepEqual(registerSchemesAsPrivilegedMock.mock.calls, [ - [ - [ - { - scheme: "t3", - privileges: { - standard: true, - secure: true, - supportFetchAPI: true, - corsEnabled: true, - }, - }, - ], - ], - ]); - }).pipe(Effect.provide(ElectronProtocol.layer)), - ); - it.effect("scopes registered file protocols", () => Effect.gen(function* () { let capturedHandler: diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index f34643c3873..42d1bb33add 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -1,6 +1,5 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; import { beforeEach, vi } from "vitest"; const { openExternalMock, writeTextMock } = vi.hoisted(() => ({ @@ -25,15 +24,6 @@ describe("ElectronShell", () => { writeTextMock.mockReset(); }); - it("parses only safe external URLs", () => { - assert.equal( - Option.getOrNull(ElectronShell.parseSafeExternalUrl("https://example.com/path")), - "https://example.com/path", - ); - assert.isTrue(Option.isNone(ElectronShell.parseSafeExternalUrl("javascript:alert(1)"))); - assert.isTrue(Option.isNone(ElectronShell.parseSafeExternalUrl(42))); - }); - it.effect("opens safe external URLs", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); @@ -66,13 +56,4 @@ describe("ElectronShell", () => { assert.equal(result, false); }).pipe(Effect.provide(ElectronShell.layer)), ); - - it.effect("copies text through Electron clipboard", () => - Effect.gen(function* () { - const electronShell = yield* ElectronShell.ElectronShell; - yield* electronShell.copyText("https://example.com/path"); - - assert.deepEqual(writeTextMock.mock.calls, [["https://example.com/path"]]); - }).pipe(Effect.provide(ElectronShell.layer)), - ); }); diff --git a/apps/desktop/src/electron/ElectronTheme.test.ts b/apps/desktop/src/electron/ElectronTheme.test.ts index 73682669765..c52882852ff 100644 --- a/apps/desktop/src/electron/ElectronTheme.test.ts +++ b/apps/desktop/src/electron/ElectronTheme.test.ts @@ -34,17 +34,6 @@ describe("ElectronTheme", () => { themeState.themeSource = "system"; }); - it.effect("reads and writes native theme state", () => - Effect.gen(function* () { - const electronTheme = yield* ElectronTheme.ElectronTheme; - - assert.isTrue(yield* electronTheme.shouldUseDarkColors); - yield* electronTheme.setSource("dark"); - - assert.equal(themeState.themeSource, "dark"); - }).pipe(Effect.provide(ElectronTheme.layer)), - ); - it.effect("scopes native theme update listeners", () => Effect.gen(function* () { const listener = vi.fn(); diff --git a/apps/desktop/src/electron/ElectronUpdater.test.ts b/apps/desktop/src/electron/ElectronUpdater.test.ts index f45d6fd3eab..eec21a9ae56 100644 --- a/apps/desktop/src/electron/ElectronUpdater.test.ts +++ b/apps/desktop/src/electron/ElectronUpdater.test.ts @@ -44,36 +44,6 @@ describe("ElectronUpdater", () => { autoUpdaterMock.setFeedURL.mockClear(); }); - it.effect("wraps updater configuration and actions", () => - Effect.gen(function* () { - const updater = yield* ElectronUpdater.ElectronUpdater; - - yield* updater.setFeedURL({ provider: "generic", url: "http://127.0.0.1:3000" }); - yield* updater.setAutoDownload(false); - yield* updater.setAutoInstallOnAppQuit(false); - yield* updater.setChannel("nightly"); - yield* updater.setAllowPrerelease(true); - yield* updater.setAllowDowngrade(true); - yield* updater.setDisableDifferentialDownload(true); - yield* updater.checkForUpdates; - yield* updater.downloadUpdate; - yield* updater.quitAndInstall({ isSilent: true, isForceRunAfter: true }); - - assert.deepEqual(autoUpdaterMock.setFeedURL.mock.calls, [ - [{ provider: "generic", url: "http://127.0.0.1:3000" }], - ]); - assert.equal(autoUpdaterMock.autoDownload, false); - assert.equal(autoUpdaterMock.autoInstallOnAppQuit, false); - assert.equal(autoUpdaterMock.channel, "nightly"); - assert.equal(autoUpdaterMock.allowPrerelease, true); - assert.equal(autoUpdaterMock.allowDowngrade, true); - assert.equal(autoUpdaterMock.disableDifferentialDownload, true); - assert.equal(autoUpdaterMock.checkForUpdates.mock.calls.length, 1); - assert.equal(autoUpdaterMock.downloadUpdate.mock.calls.length, 1); - assert.deepEqual(autoUpdaterMock.quitAndInstall.mock.calls, [[true, true]]); - }).pipe(Effect.provide(ElectronUpdater.layer)), - ); - it.effect("scopes updater event listeners", () => Effect.gen(function* () { const listener = vi.fn(); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 94fe7d72ecf..1557343340c 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -6,9 +6,16 @@ import { PickFolderOptionsSchema, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import * as DesktopWindowIpcActions from "../../window/DesktopWindowIpcActions.ts"; +import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; +import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; +import * as ElectronDialog from "../../electron/ElectronDialog.ts"; +import * as ElectronMenu from "../../electron/ElectronMenu.ts"; +import * as ElectronShell from "../../electron/ElectronShell.ts"; +import * as ElectronTheme from "../../electron/ElectronTheme.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as IpcChannels from "../channels.ts"; import { makeIpcMethod, makeSyncIpcMethod } from "../DesktopIpc.ts"; @@ -22,13 +29,19 @@ const ContextMenuInput = Schema.Struct({ position: Schema.optionalKey(ContextMenuPosition), }); +function toWebSocketBaseUrl(httpBaseUrl: URL): string { + const url = new URL(httpBaseUrl.href); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.href; +} + export const getAppBranding = makeSyncIpcMethod({ channel: IpcChannels.GET_APP_BRANDING_CHANNEL, result: Schema.NullOr(DesktopAppBrandingSchema), handler: () => Effect.gen(function* () { - const window = yield* DesktopWindowIpcActions.DesktopWindowIpcActions; - return yield* window.getAppBranding; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + return environment.branding; }), }); @@ -37,8 +50,19 @@ export const getLocalEnvironmentBootstrap = makeSyncIpcMethod({ result: Schema.NullOr(DesktopEnvironmentBootstrapSchema), handler: () => Effect.gen(function* () { - const window = yield* DesktopWindowIpcActions.DesktopWindowIpcActions; - return yield* window.getLocalEnvironmentBootstrap; + const backendManager = yield* DesktopBackendManager.DesktopBackendManager; + const config = yield* backendManager.currentConfig; + return Option.match(config, { + onNone: () => null, + onSome: ({ bootstrap, httpBaseUrl }) => ({ + label: "Local environment", + httpBaseUrl: httpBaseUrl.href, + wsBaseUrl: toWebSocketBaseUrl(httpBaseUrl), + ...(bootstrap.desktopBootstrapToken + ? { bootstrapToken: bootstrap.desktopBootstrapToken } + : {}), + }), + }); }), }); @@ -48,8 +72,14 @@ export const pickFolder = makeIpcMethod({ result: Schema.NullOr(Schema.String), handler: (options) => Effect.gen(function* () { - const window = yield* DesktopWindowIpcActions.DesktopWindowIpcActions; - return yield* window.pickFolder(options); + const dialog = yield* ElectronDialog.ElectronDialog; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const selectedPath = yield* dialog.pickFolder({ + owner: yield* electronWindow.focusedMainOrFirst, + defaultPath: environment.resolvePickFolderDefaultPath(options), + }); + return Option.getOrNull(selectedPath); }), }); @@ -59,8 +89,11 @@ export const confirm = makeIpcMethod({ result: Schema.Boolean, handler: (message) => Effect.gen(function* () { - const window = yield* DesktopWindowIpcActions.DesktopWindowIpcActions; - return yield* window.confirm(message); + const dialog = yield* ElectronDialog.ElectronDialog; + const electronWindow = yield* ElectronWindow.ElectronWindow; + return yield* electronWindow.focusedMainOrFirst.pipe( + Effect.flatMap((owner) => dialog.confirm({ owner, message })), + ); }), }); @@ -70,8 +103,8 @@ export const setTheme = makeIpcMethod({ result: Schema.Void, handler: (theme) => Effect.gen(function* () { - const window = yield* DesktopWindowIpcActions.DesktopWindowIpcActions; - yield* window.setTheme(theme); + const electronTheme = yield* ElectronTheme.ElectronTheme; + yield* electronTheme.setSource(theme); }), }); @@ -81,8 +114,19 @@ export const showContextMenu = makeIpcMethod({ result: Schema.NullOr(Schema.String), handler: (input) => Effect.gen(function* () { - const window = yield* DesktopWindowIpcActions.DesktopWindowIpcActions; - return yield* window.showContextMenu(input); + const electronMenu = yield* ElectronMenu.ElectronMenu; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const window = yield* electronWindow.focusedMainOrFirst; + if (Option.isNone(window)) { + return null; + } + + const selectedItemId = yield* electronMenu.showContextMenu({ + window: window.value, + items: input.items, + position: Option.fromNullishOr(input.position), + }); + return Option.getOrNull(selectedItemId); }), }); @@ -92,7 +136,7 @@ export const openExternal = makeIpcMethod({ result: Schema.Boolean, handler: (url) => Effect.gen(function* () { - const window = yield* DesktopWindowIpcActions.DesktopWindowIpcActions; - return yield* window.openExternal(url); + const shell = yield* ElectronShell.ElectronShell; + return yield* shell.openExternal(url); }), }); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index b1b23f64933..07ef8392d44 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,6 +1,7 @@ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeOS from "node:os"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -27,9 +28,7 @@ import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; import * as DesktopAssets from "./app/DesktopAssets.ts"; import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; -import * as DesktopBackendEvents from "./backend/DesktopBackendEvents.ts"; import * as DesktopBackendManager from "./backend/DesktopBackendManager.ts"; -import * as DesktopConfig from "./app/DesktopConfig.ts"; import * as DesktopEnvironment from "./app/DesktopEnvironment.ts"; import * as DesktopLifecycle from "./app/DesktopLifecycle.ts"; import { DesktopBackendOutputLogLive, DesktopLoggerLive } from "./app/DesktopLogging.ts"; @@ -43,7 +42,6 @@ import * as DesktopSshRemoteApi from "./ssh/DesktopSshRemoteApi.ts"; import * as DesktopState from "./app/DesktopState.ts"; import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; import * as DesktopWindow from "./window/DesktopWindow.ts"; -import * as DesktopWindowIpcActions from "./window/DesktopWindowIpcActions.ts"; const desktopEnvironmentLayer = Layer.unwrap( Effect.gen(function* () { @@ -52,13 +50,13 @@ const desktopEnvironmentLayer = Layer.unwrap( ); return DesktopEnvironment.layer({ dirname: __dirname, - cwd: process.cwd(), + homeDirectory: NodeOS.homedir(), platform: process.platform, processArch: process.arch, ...metadata, }); }), -).pipe(Layer.provideMerge(DesktopConfig.layer)); +); const resolveDesktopSshCliRunner = ( environment: DesktopEnvironment.DesktopEnvironmentShape, @@ -126,7 +124,6 @@ const desktopWindowLayer = DesktopWindow.layer.pipe(Layer.provideMerge(desktopSe const desktopBackendLayer = DesktopBackendManager.layer.pipe( Layer.provideMerge(DesktopAppIdentity.layer), Layer.provideMerge(DesktopBackendConfiguration.layer), - Layer.provideMerge(DesktopBackendEvents.layer), Layer.provideMerge(desktopWindowLayer), ); @@ -135,7 +132,6 @@ const desktopApplicationLayer = Layer.mergeAll( DesktopApplicationMenu.layer, DesktopShellEnvironment.layer, desktopSshLayer, - DesktopWindowIpcActions.layer, ).pipe(Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopBackendLayer)); const desktopRuntimeLayer = desktopApplicationLayer.pipe( diff --git a/apps/desktop/src/serverExposure/DesktopServerExposure.test.ts b/apps/desktop/src/serverExposure/DesktopServerExposure.test.ts index 696f80a33bc..4ad7158544f 100644 --- a/apps/desktop/src/serverExposure/DesktopServerExposure.test.ts +++ b/apps/desktop/src/serverExposure/DesktopServerExposure.test.ts @@ -71,7 +71,7 @@ function mockSpawnerLayer(statusJson = "{}") { function makeEnvironmentLayer(baseDir: string, env: Record = {}) { return makeDesktopEnvironmentLayer({ dirname: "/repo/apps/desktop/src", - cwd: "/repo", + homeDirectory: baseDir, platform: "darwin", processArch: "x64", appVersion: "1.2.3", diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 6d77c052696..897e7336a24 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -1,48 +1,88 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Logger from "effect/Logger"; -import * as Option from "effect/Option"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopShellEnvironment from "./DesktopShellEnvironment.ts"; -const LOGIN_SHELL_ENV_NAMES = [ - "PATH", - "SSH_AUTH_SOCK", - "HOMEBREW_PREFIX", - "HOMEBREW_CELLAR", - "HOMEBREW_REPOSITORY", - "XDG_CONFIG_HOME", - "XDG_DATA_HOME", -] as const; +const textEncoder = new TextEncoder(); -type ProbeOverrides = NonNullable[0]["probe"]>; +function envOutput(values: Readonly>): string { + return Object.entries(values) + .flatMap(([name, value]) => [ + `__T3CODE_ENV_${name}_START__`, + value, + `__T3CODE_ENV_${name}_END__`, + ]) + .join("\n"); +} + +function makeProcess(output: string): ChildProcessSpawner.ChildProcessHandle { + const stdout = output.length === 0 ? Stream.empty : Stream.make(textEncoder.encode(output)); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + stdout, + stderr: Stream.empty, + all: stdout, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }); +} + +function withProcessEnv( + env: NodeJS.ProcessEnv, + effect: Effect.Effect, +): Effect.Effect { + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env; + process.env = env; + return previous; + }), + () => effect, + (previous) => + Effect.sync(() => { + process.env = previous; + }), + ); +} function runShellEnvironment(input: { readonly env: NodeJS.ProcessEnv; readonly platform: NodeJS.Platform; - readonly userShell?: string; - readonly probe: ProbeOverrides; - readonly logger?: Logger.Logger; + readonly handler: (command: ChildProcess.Command) => string; }) { - const shellEnvironmentLayer = DesktopShellEnvironment.layerTest({ - env: input.env, - platform: input.platform, - ...(input.userShell === undefined ? {} : { userShell: input.userShell }), - probe: input.probe, - }); - const layer = - input.logger === undefined - ? shellEnvironmentLayer - : Layer.mergeAll( - shellEnvironmentLayer, - Logger.layer([input.logger], { mergeWithExisting: false }), - ); + const environmentLayer = Layer.succeed( + DesktopEnvironment.DesktopEnvironment, + DesktopEnvironment.DesktopEnvironment.of({ + platform: input.platform, + } as DesktopEnvironment.DesktopEnvironmentShape), + ); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => Effect.succeed(makeProcess(input.handler(command)))), + ); - return Effect.gen(function* () { + const program = Effect.gen(function* () { const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; yield* shellEnvironment.installIntoProcess; - }).pipe(Effect.provide(layer)); + }).pipe( + Effect.provide( + DesktopShellEnvironment.layer.pipe( + Layer.provide(Layer.mergeAll(environmentLayer, spawnerLayer)), + ), + ), + ); + + return withProcessEnv(input.env, program); } describe("DesktopShellEnvironment", () => { @@ -52,25 +92,23 @@ describe("DesktopShellEnvironment", () => { SHELL: "/bin/zsh", PATH: "/Users/test/.local/bin:/usr/bin", }; - const calls: Array<{ readonly shell: string; readonly names: ReadonlyArray }> = []; + const commands: ChildProcess.Command[] = []; yield* runShellEnvironment({ env, platform: "darwin", - probe: { - readLoginShellEnvironment: (shell, names) => - Effect.sync(() => { - calls.push({ shell, names }); - return { - PATH: "/opt/homebrew/bin:/usr/bin", - SSH_AUTH_SOCK: "/tmp/secretive.sock", - HOMEBREW_PREFIX: "/opt/homebrew", - }; - }), + handler: (command) => { + commands.push(command); + return envOutput({ + PATH: "/opt/homebrew/bin:/usr/bin", + SSH_AUTH_SOCK: "/tmp/secretive.sock", + HOMEBREW_PREFIX: "/opt/homebrew", + }); }, }); - assert.deepEqual(calls, [{ shell: "/bin/zsh", names: LOGIN_SHELL_ENV_NAMES }]); + assert.equal(commands.length, 1); + assert.equal(commands[0]?._tag === "StandardCommand" ? commands[0].command : "", "/bin/zsh"); assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin"); assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock"); assert.equal(env.HOMEBREW_PREFIX, "/opt/homebrew"); @@ -88,13 +126,11 @@ describe("DesktopShellEnvironment", () => { yield* runShellEnvironment({ env, platform: "darwin", - probe: { - readLoginShellEnvironment: () => - Effect.succeed({ - PATH: "/opt/homebrew/bin:/usr/bin", - SSH_AUTH_SOCK: "/tmp/login-shell.sock", - }), - }, + handler: () => + envOutput({ + PATH: "/opt/homebrew/bin:/usr/bin", + SSH_AUTH_SOCK: "/tmp/login-shell.sock", + }), }); assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin"); @@ -108,24 +144,17 @@ describe("DesktopShellEnvironment", () => { SHELL: "/bin/zsh", PATH: "/usr/bin", }; - const calls: Array<{ readonly shell: string; readonly names: ReadonlyArray }> = []; yield* runShellEnvironment({ env, platform: "linux", - probe: { - readLoginShellEnvironment: (shell, names) => - Effect.sync(() => { - calls.push({ shell, names }); - return { - PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin", - SSH_AUTH_SOCK: "/tmp/secretive.sock", - }; - }), - }, + handler: () => + envOutput({ + PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin", + SSH_AUTH_SOCK: "/tmp/secretive.sock", + }), }); - assert.deepEqual(calls, [{ shell: "/bin/zsh", names: LOGIN_SHELL_ENV_NAMES }]); assert.equal(env.PATH, "/home/linuxbrew/.linuxbrew/bin:/usr/bin"); assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock"); }), @@ -137,118 +166,23 @@ describe("DesktopShellEnvironment", () => { SHELL: "/opt/homebrew/bin/nu", PATH: "/usr/bin", }; - const calls: Array<{ readonly shell: string; readonly names: ReadonlyArray }> = []; - const messages: string[] = []; - const logger = Logger.make(({ message }) => { - messages.push(String(message)); - }); + const commands: string[] = []; yield* runShellEnvironment({ env, platform: "darwin", - userShell: "/bin/zsh", - logger, - probe: { - readLoginShellEnvironment: (shell, names) => - Effect.gen(function* () { - calls.push({ shell, names }); - if (calls.length === 1) { - return yield* new DesktopShellEnvironment.DesktopShellEnvironmentProbeError({ - message: "unknown flag", - }); - } - return {}; - }), - readLaunchctlPath: Effect.succeed(Option.some("/opt/homebrew/bin:/usr/bin")), + handler: (command) => { + if (command._tag !== "StandardCommand") return ""; + commands.push(command.command); + return command.command === "/bin/launchctl" ? "/opt/homebrew/bin:/usr/bin" : ""; }, }); - assert.deepEqual(calls, [ - { shell: "/opt/homebrew/bin/nu", names: LOGIN_SHELL_ENV_NAMES }, - { shell: "/bin/zsh", names: LOGIN_SHELL_ENV_NAMES }, - ]); - assert.isTrue( - messages.some((message) => message.includes("failed to read login shell environment")), - ); + assert.deepEqual(commands, ["/opt/homebrew/bin/nu", "/bin/zsh", "/bin/launchctl"]); assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin"); }), ); - it.effect("does nothing on unsupported platforms", () => - Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { - SHELL: "C:/Program Files/Git/bin/bash.exe", - PATH: "C:\\Windows\\System32", - SSH_AUTH_SOCK: "/tmp/inherited.sock", - }; - let readCount = 0; - - yield* runShellEnvironment({ - env, - platform: "freebsd", - probe: { - readLoginShellEnvironment: () => - Effect.sync(() => { - readCount += 1; - return { - PATH: "/usr/local/bin:/usr/bin", - SSH_AUTH_SOCK: "/tmp/secretive.sock", - }; - }), - }, - }); - - assert.equal(readCount, 0); - assert.equal(env.PATH, "C:\\Windows\\System32"); - assert.equal(env.SSH_AUTH_SOCK, "/tmp/inherited.sock"); - }), - ); - - it.effect("hydrates PATH on Windows from PowerShell and common CLI directories", () => - Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { - PATH: "C:\\Windows\\System32", - APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", - LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", - USERPROFILE: "C:\\Users\\testuser", - }; - const windowsReads: Array<{ - readonly names: ReadonlyArray; - readonly loadProfile: boolean; - }> = []; - - yield* runShellEnvironment({ - env, - platform: "win32", - probe: { - readWindowsEnvironment: (names, options) => - Effect.sync(() => { - windowsReads.push({ names, loadProfile: options.loadProfile }); - return options.loadProfile ? {} : { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }; - }), - }, - }); - - assert.deepEqual(windowsReads, [ - { names: ["PATH"], loadProfile: false }, - { names: ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], loadProfile: true }, - ]); - assert.equal( - env.PATH, - [ - "C:\\Users\\testuser\\AppData\\Roaming\\npm", - "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", - "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", - "C:\\Users\\testuser\\AppData\\Local\\pnpm", - "C:\\Users\\testuser\\.bun\\bin", - "C:\\Users\\testuser\\scoop\\shims", - "C:\\Custom\\Bin", - "C:\\Windows\\System32", - ].join(";"), - ); - }), - ); - it.effect("loads PowerShell profile environment on Windows", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { @@ -261,18 +195,16 @@ describe("DesktopShellEnvironment", () => { yield* runShellEnvironment({ env, platform: "win32", - probe: { - readWindowsEnvironment: (_names, options) => - Effect.succeed( - options.loadProfile - ? { - PATH: "C:\\Profile\\Node;C:\\Windows\\System32", - FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", - FNM_MULTISHELL_PATH: - "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", - } - : { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }, - ), + handler: (command) => { + if (command._tag !== "StandardCommand") return ""; + const loadProfile = !command.args.includes("-NoProfile"); + return loadProfile + ? envOutput({ + PATH: "C:\\Profile\\Node;C:\\Windows\\System32", + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", + FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", + }) + : envOutput({ PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }); }, }); @@ -297,41 +229,4 @@ describe("DesktopShellEnvironment", () => { ); }), ); - - it.effect("preserves baseline Windows env when the profile probe fails", () => - Effect.gen(function* () { - const env: NodeJS.ProcessEnv = { - PATH: "C:\\Windows\\System32", - APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", - USERPROFILE: "C:\\Users\\testuser", - }; - - yield* runShellEnvironment({ - env, - platform: "win32", - probe: { - readWindowsEnvironment: (_names, options) => - options.loadProfile - ? Effect.fail( - new DesktopShellEnvironment.DesktopShellEnvironmentProbeError({ - message: "profile load failed", - }), - ) - : Effect.succeed({ PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }), - }, - }); - - assert.equal( - env.PATH, - [ - "C:\\Users\\testuser\\AppData\\Roaming\\npm", - "C:\\Users\\testuser\\.bun\\bin", - "C:\\Users\\testuser\\scoop\\shims", - "C:\\Custom\\Bin", - "C:\\Windows\\System32", - ].join(";"), - ); - assert.isUndefined(env.SSH_AUTH_SOCK); - }), - ); }); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 4419e0fed5a..8588383a19f 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -1,5 +1,4 @@ import * as Context from "effect/Context"; -import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -20,27 +19,6 @@ interface WindowsProbeOptions { readonly loadProfile: boolean; } -export class DesktopShellEnvironmentProbeError extends Data.TaggedError( - "DesktopShellEnvironmentProbeError", -)<{ - readonly message: string; -}> {} - -interface ShellProbe { - readonly readLoginShellEnvironment: ( - shell: string, - names: ReadonlyArray, - ) => Effect.Effect; - readonly readLaunchctlPath: Effect.Effect< - Option.Option, - DesktopShellEnvironmentProbeError - >; - readonly readWindowsEnvironment: ( - names: ReadonlyArray, - options: WindowsProbeOptions, - ) => Effect.Effect; -} - export interface DesktopShellEnvironmentShape { readonly installIntoProcess: Effect.Effect; } @@ -65,12 +43,6 @@ const LOGIN_SHELL_TIMEOUT = Duration.seconds(5); const LAUNCHCTL_TIMEOUT = Duration.seconds(2); const PROCESS_TERMINATE_GRACE = Duration.seconds(1); -const noneProbe: ShellProbe = { - readLoginShellEnvironment: () => Effect.succeed({}), - readLaunchctlPath: Effect.succeed(Option.none()), - readWindowsEnvironment: () => Effect.succeed({}), -}; - const trimNonEmpty = (value: string | null | undefined): Option.Option => Option.fromNullishOr(value).pipe( Option.map((entry) => entry.trim()), @@ -203,92 +175,92 @@ const extractEnvironment = (output: string, names: ReadonlyArray): Envir return environment; }; -const runCommandOutput = ( - spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], - input: { - readonly command: string; - readonly args: ReadonlyArray; - readonly timeout: Duration.Duration; - readonly shell?: boolean; - }, -): Effect.Effect => - spawner - .string( - ChildProcess.make(input.command, input.args, { - shell: input.shell ?? false, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - killSignal: "SIGTERM", - forceKillAfter: PROCESS_TERMINATE_GRACE, - }), - ) - .pipe( - Effect.timeoutOption(input.timeout), - Effect.map(Option.getOrElse(() => "")), - Effect.catch(() => Effect.succeed("")), - ); - -const makeProcessProbe = ( - spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], -): ShellProbe => ({ - readLoginShellEnvironment: (shell, names) => - names.length === 0 - ? Effect.succeed({}) - : runCommandOutput(spawner, { - command: shell, - args: ["-ilc", capturePosixEnvironmentCommand(names)], - timeout: LOGIN_SHELL_TIMEOUT, - }).pipe(Effect.map((output) => extractEnvironment(output, names))), - readLaunchctlPath: runCommandOutput(spawner, { - command: "/bin/launchctl", - args: ["getenv", "PATH"], - timeout: LAUNCHCTL_TIMEOUT, - }).pipe(Effect.map(trimNonEmpty)), - readWindowsEnvironment: (names, options) => { - if (names.length === 0) return Effect.succeed({}); - - const args = [ - "-NoLogo", - ...(options.loadProfile ? ([] as const) : (["-NoProfile"] as const)), - "-NonInteractive", - "-Command", - captureWindowsEnvironmentCommand(names), - ]; - - return Effect.gen(function* () { - for (const command of WINDOWS_SHELL_CANDIDATES) { - const output = yield* runCommandOutput(spawner, { - command, - args, - shell: true, - timeout: LOGIN_SHELL_TIMEOUT, - }); - const environment = extractEnvironment(output, names); - if (Object.keys(environment).length > 0) { - return environment; - } - } +const runCommandOutput = (input: { + readonly command: string; + readonly args: ReadonlyArray; + readonly timeout: Duration.Duration; + readonly shell?: boolean; +}): Effect.Effect => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return yield* spawner + .string( + ChildProcess.make(input.command, input.args, { + shell: input.shell ?? false, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + killSignal: "SIGTERM", + forceKillAfter: PROCESS_TERMINATE_GRACE, + }), + ) + .pipe( + Effect.timeoutOption(input.timeout), + Effect.map(Option.getOrElse(() => "")), + Effect.catch(() => Effect.succeed("")), + ); + }); - return {}; - }); - }, -}); +const readLoginShellEnvironment = ( + shell: string, + names: ReadonlyArray, +): Effect.Effect => + names.length === 0 + ? Effect.succeed({}) + : runCommandOutput({ + command: shell, + args: ["-ilc", capturePosixEnvironmentCommand(names)], + timeout: LOGIN_SHELL_TIMEOUT, + }).pipe(Effect.map((output) => extractEnvironment(output, names))); + +const readLaunchctlPath: Effect.Effect< + Option.Option, + never, + ChildProcessSpawner.ChildProcessSpawner +> = runCommandOutput({ + command: "/bin/launchctl", + args: ["getenv", "PATH"], + timeout: LAUNCHCTL_TIMEOUT, +}).pipe(Effect.map(trimNonEmpty)); const readWindowsEnvironment = ( - probe: ShellProbe, names: ReadonlyArray, options: WindowsProbeOptions, -): Effect.Effect => - probe.readWindowsEnvironment(names, options).pipe(Effect.catch(() => Effect.succeed({}))); +): Effect.Effect => { + if (names.length === 0) return Effect.succeed({}); + + const args = [ + "-NoLogo", + ...(options.loadProfile ? ([] as const) : (["-NoProfile"] as const)), + "-NonInteractive", + "-Command", + captureWindowsEnvironmentCommand(names), + ]; + + return Effect.gen(function* () { + for (const command of WINDOWS_SHELL_CANDIDATES) { + const output = yield* runCommandOutput({ + command, + args, + shell: true, + timeout: LOGIN_SHELL_TIMEOUT, + }); + const environment = extractEnvironment(output, names); + if (Object.keys(environment).length > 0) { + return environment; + } + } + + return {}; + }); +}; const installWindowsEnvironment = ( config: ShellEnvironmentConfig, - probe: ShellProbe, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { - const noProfile = yield* readWindowsEnvironment(probe, ["PATH"], { loadProfile: false }); - const profile = yield* readWindowsEnvironment(probe, WINDOWS_PROFILE_ENV_NAMES, { + const noProfile = yield* readWindowsEnvironment(["PATH"], { loadProfile: false }); + const profile = yield* readWindowsEnvironment(WINDOWS_PROFILE_ENV_NAMES, { loadProfile: true, }); const mergedPath = mergePaths("win32", [ @@ -311,32 +283,21 @@ const installWindowsEnvironment = ( const installPosixEnvironment = ( config: ShellEnvironmentConfig, - probe: ShellProbe, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const shellEnvironment: EnvironmentPatch = {}; for (const shell of listLoginShellCandidates(config)) { - const result = yield* probe.readLoginShellEnvironment(shell, LOGIN_SHELL_ENV_NAMES).pipe( - Effect.option, - Effect.tap((environment) => - Option.isNone(environment) - ? Effect.logWarning("failed to read login shell environment", { shell }) - : Effect.void, - ), + Object.assign( + shellEnvironment, + yield* readLoginShellEnvironment(shell, LOGIN_SHELL_ENV_NAMES), ); - - if (Option.isSome(result)) { - Object.assign(shellEnvironment, result.value); - if (shellEnvironment.PATH) break; - } + if (shellEnvironment.PATH) break; } const launchctlPath = config.platform === "darwin" && !shellEnvironment.PATH - ? yield* probe.readLaunchctlPath.pipe( - Effect.catch(() => Effect.succeed(Option.none())), - ) + ? yield* readLaunchctlPath : Option.none(); const mergedPath = mergePaths(config.platform, [ trimNonEmpty(shellEnvironment.PATH).pipe(Option.orElse(() => launchctlPath)), @@ -365,13 +326,12 @@ const installPosixEnvironment = ( const installShellEnvironment = ( config: ShellEnvironmentConfig, - probe: ShellProbe, -): Effect.Effect => { +): Effect.Effect => { if (config.platform === "win32") { - return installWindowsEnvironment(config, probe); + return installWindowsEnvironment(config); } if (config.platform === "darwin" || config.platform === "linux") { - return installPosixEnvironment(config, probe); + return installPosixEnvironment(config); } return Effect.void; }; @@ -382,46 +342,11 @@ export const layer = Layer.effect( const environment = yield* DesktopEnvironment.DesktopEnvironment; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; return DesktopShellEnvironment.of({ - installIntoProcess: installShellEnvironment( - { - env: process.env, - platform: environment.platform, - userShell: Option.none(), - }, - makeProcessProbe(spawner), - ), + installIntoProcess: installShellEnvironment({ + env: process.env, + platform: environment.platform, + userShell: Option.none(), + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)), }); }), ); - -export const layerTest = (input: { - readonly env: NodeJS.ProcessEnv; - readonly platform: NodeJS.Platform; - readonly userShell?: string; - readonly probe?: { - readonly readLoginShellEnvironment?: ( - shell: string, - names: ReadonlyArray, - ) => Effect.Effect; - readonly readLaunchctlPath?: Effect.Effect< - Option.Option, - DesktopShellEnvironmentProbeError - >; - readonly readWindowsEnvironment?: ( - names: ReadonlyArray, - options: WindowsProbeOptions, - ) => Effect.Effect; - }; -}) => { - const config: ShellEnvironmentConfig = { - env: input.env, - platform: input.platform, - userShell: trimNonEmpty(input.userShell), - }; - return Layer.succeed( - DesktopShellEnvironment, - DesktopShellEnvironment.of({ - installIntoProcess: installShellEnvironment(config, { ...noneProbe, ...input.probe }), - }), - ); -}; diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index a5289f650b7..789d7c41d13 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -119,7 +119,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { const environmentLayer = DesktopEnvironment.layer({ dirname: "/repo/apps/desktop/src", - cwd: "/repo", + homeDirectory: `/tmp/t3-desktop-updates-home-${process.pid}`, platform: "darwin", processArch: "x64", appVersion: "1.2.3", diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index da52d4894b8..fc724b7384a 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -579,18 +579,6 @@ const make = Effect.gen(function* () { const appUpdateYmlConfig = yield* readAppUpdateYml; yield* Ref.set(appUpdateYmlConfigRef, appUpdateYmlConfig); - if (Option.isSome(config.desktopUpdateGithubToken)) { - const appUpdateConfig = Option.getOrUndefined(appUpdateYmlConfig); - if (appUpdateConfig?.provider === "github") { - yield* electronUpdater.setFeedURL({ - ...appUpdateConfig, - provider: "github", - private: true, - token: config.desktopUpdateGithubToken.value, - } as ElectronUpdater.ElectronUpdaterFeedUrl); - } - } - if (config.mockUpdates) { yield* electronUpdater.setFeedURL({ provider: "generic", diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 74a60388430..92b6f686875 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -19,7 +19,7 @@ import * as DesktopWindow from "./DesktopWindow.ts"; const environmentInput = { dirname: "/repo/apps/desktop/dist-electron", - cwd: "/repo", + homeDirectory: "/Users/alice", platform: "linux", processArch: "arm64", appVersion: "1.2.3", @@ -116,12 +116,7 @@ describe("DesktopApplicationMenu", () => { Layer.provideMerge(electronAppLayer), Layer.provideMerge( DesktopEnvironment.layer(environmentInput).pipe( - Layer.provide( - Layer.mergeAll( - NodeServices.layer, - DesktopConfig.layerTest({ HOME: "/Users/alice" }), - ), - ), + Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))), ), ), ), diff --git a/apps/desktop/src/window/DesktopWindowIpcActions.ts b/apps/desktop/src/window/DesktopWindowIpcActions.ts deleted file mode 100644 index b5532da5e24..00000000000 --- a/apps/desktop/src/window/DesktopWindowIpcActions.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { - ContextMenuItem, - DesktopAppBranding, - DesktopEnvironmentBootstrap, - DesktopTheme, - PickFolderOptions, -} from "@t3tools/contracts"; -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; - -import * as ElectronDialog from "../electron/ElectronDialog.ts"; -import * as ElectronMenu from "../electron/ElectronMenu.ts"; -import * as ElectronShell from "../electron/ElectronShell.ts"; -import * as ElectronTheme from "../electron/ElectronTheme.ts"; -import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; -import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; - -export interface DesktopWindowIpcContextMenuInput { - readonly items: readonly ContextMenuItem[]; - readonly position?: { - readonly x: number; - readonly y: number; - }; -} - -export interface DesktopWindowIpcActionsShape { - readonly getAppBranding: Effect.Effect; - readonly getLocalEnvironmentBootstrap: Effect.Effect; - readonly pickFolder: (options: PickFolderOptions | undefined) => Effect.Effect; - readonly confirm: (message: string) => Effect.Effect; - readonly setTheme: (theme: DesktopTheme) => Effect.Effect; - readonly showContextMenu: ( - input: DesktopWindowIpcContextMenuInput, - ) => Effect.Effect; - readonly openExternal: (url: string) => Effect.Effect; -} - -export class DesktopWindowIpcActions extends Context.Service< - DesktopWindowIpcActions, - DesktopWindowIpcActionsShape ->()("t3/desktop/WindowIpcActions") {} - -function toWebSocketBaseUrl(httpBaseUrl: URL): string { - const url = new URL(httpBaseUrl.href); - url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; - return url.href; -} - -const make = Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const backendManager = yield* DesktopBackendManager.DesktopBackendManager; - const electronDialog = yield* ElectronDialog.ElectronDialog; - const electronMenu = yield* ElectronMenu.ElectronMenu; - const electronShell = yield* ElectronShell.ElectronShell; - const electronTheme = yield* ElectronTheme.ElectronTheme; - const electronWindow = yield* ElectronWindow.ElectronWindow; - - return DesktopWindowIpcActions.of({ - getAppBranding: Effect.succeed(environment.branding), - getLocalEnvironmentBootstrap: backendManager.currentConfig.pipe( - Effect.map( - Option.map((config) => { - const bootstrap = config.bootstrap; - return { - label: "Local environment", - httpBaseUrl: config.httpBaseUrl.href, - wsBaseUrl: toWebSocketBaseUrl(config.httpBaseUrl), - ...(bootstrap.desktopBootstrapToken - ? { bootstrapToken: bootstrap.desktopBootstrapToken } - : {}), - }; - }), - ), - Effect.map(Option.getOrNull), - ), - pickFolder: (options) => - Effect.gen(function* () { - const selectedPath = yield* electronDialog.pickFolder({ - owner: yield* electronWindow.focusedMainOrFirst, - defaultPath: environment.resolvePickFolderDefaultPath(options), - }); - return Option.getOrNull(selectedPath); - }), - confirm: (message) => - electronWindow.focusedMainOrFirst.pipe( - Effect.flatMap((owner) => electronDialog.confirm({ owner, message })), - ), - setTheme: (theme) => electronTheme.setSource(theme), - showContextMenu: ({ items, position }) => - Effect.gen(function* () { - const window = yield* electronWindow.focusedMainOrFirst; - if (Option.isNone(window)) { - return null; - } - - const selectedItemId = yield* electronMenu.showContextMenu({ - window: window.value, - items, - position: Option.fromNullishOr(position), - }); - return Option.getOrNull(selectedItemId); - }), - openExternal: (url) => electronShell.openExternal(url), - }); -}); - -export const layer = Layer.effect(DesktopWindowIpcActions, make); From a59ee631ea5e3f630356a389ecfdbb2b071c6eda Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 23:13:32 -0700 Subject: [PATCH 29/43] Handle desktop secret decode and window load errors - Return null when secret decryption throws - Call Electron loadURL/devtools directly instead of wrapping sync effects --- apps/desktop/src/settings/clientPersistence.ts | 8 +++++--- apps/desktop/src/window/DesktopWindow.ts | 10 +++------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/settings/clientPersistence.ts b/apps/desktop/src/settings/clientPersistence.ts index f1bf21bde5e..9c78a2d3628 100644 --- a/apps/desktop/src/settings/clientPersistence.ts +++ b/apps/desktop/src/settings/clientPersistence.ts @@ -223,9 +223,11 @@ export function readSavedEnvironmentSecretEffect(input: { return null; } - return yield* Effect.sync(() => - input.secretStorage.decryptString(Buffer.from(encoded, "base64")), - ).pipe(Effect.catchDefect(() => Effect.succeed(null))); + try { + return input.secretStorage.decryptString(Buffer.from(encoded, "base64")); + } catch { + return null; + } }); } diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 1aa8c714933..4cc45aab4cc 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -262,14 +262,10 @@ const make = Effect.gen(function* () { if (environment.isDevelopment) { const devServerUrl = yield* resolveDesktopDevServerUrl(environment); - yield* Effect.sync(() => { - void window.loadURL(devServerUrl); - window.webContents.openDevTools({ mode: "detach" }); - }); + void window.loadURL(devServerUrl); + window.webContents.openDevTools({ mode: "detach" }); } else { - yield* Effect.sync(() => { - void window.loadURL(backendHttpUrl.href); - }); + void window.loadURL(backendHttpUrl.href); } window.on("closed", () => { From ec26ca7332b4b2d913a0479aa776d7a3b55e0d8a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 23:19:05 -0700 Subject: [PATCH 30/43] Inline SSH password cancel IPC result - Remove the contract import - Use the literal desktop SSH cancel result channel --- apps/desktop/src/ipc/channels.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index dc897d3846f..f346da54a81 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -1,5 +1,3 @@ -import { DesktopSshPasswordPromptCancelledType } from "@t3tools/contracts"; - export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; export const CONFIRM_CHANNEL = "desktop:confirm"; export const SET_THEME_CHANNEL = "desktop:set-theme"; @@ -34,4 +32,4 @@ export const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-st export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; -export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = DesktopSshPasswordPromptCancelledType; +export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "desktop:ssh-password-prompt-cancelled"; From 354c05fadc504a5bc06d3db82ee14e9ebdd009b6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 23:22:52 -0700 Subject: [PATCH 31/43] Normalize SSH password prompt cancelled IPC result --- apps/desktop/src/ipc/channels.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index f346da54a81..2715b20cb36 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -32,4 +32,4 @@ export const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-st export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; -export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "desktop:ssh-password-prompt-cancelled"; +export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; From 02fa7836339b296505dd7b4334b5cceb9404f482 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 6 May 2026 23:45:29 -0700 Subject: [PATCH 32/43] Harden desktop backend restart and update handling - Defer Electron menu popup effects until execution time - Avoid persisting unchanged update channels - Share port validation in contracts and bootstrap schemas - Tighten backend restart state and add coverage --- .../src/backend/DesktopBackendManager.test.ts | 80 ++++++++++++++++++- .../src/backend/DesktopBackendManager.ts | 40 +++++----- .../desktop/src/electron/ElectronMenu.test.ts | 21 +++++ apps/desktop/src/electron/ElectronMenu.ts | 14 ++-- .../src/updates/DesktopUpdates.test.ts | 20 +++++ apps/desktop/src/updates/DesktopUpdates.ts | 8 +- apps/server/src/cli/config.ts | 21 +---- packages/contracts/src/baseSchemas.ts | 1 + packages/contracts/src/desktopBootstrap.ts | 6 +- 9 files changed, 160 insertions(+), 51 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 7e930750c9c..38deed089a8 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -10,6 +10,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Scope from "effect/Scope"; @@ -105,6 +106,7 @@ function makeManagerLayer(input: { readonly httpClientLayer?: Layer.Layer; readonly backendOutputLog?: Partial; readonly desktopRun?: Partial; + readonly desktopState?: DesktopState.DesktopStateShape; readonly desktopWindow?: Partial; readonly config?: DesktopBackendManager.DesktopBackendStartConfig; }) { @@ -119,7 +121,9 @@ function makeManagerLayer(input: { }), input.spawnerLayer, input.httpClientLayer ?? healthyHttpClientLayer, - DesktopState.layer, + input.desktopState + ? Layer.succeed(DesktopState.DesktopState, input.desktopState) + : DesktopState.layer, Layer.succeed(DesktopBackendOutputLog, { writeSessionBoundary: () => Effect.void, writeOutputChunk: () => Effect.void, @@ -289,6 +293,8 @@ describe("DesktopBackendManager", () => { const closed = yield* Deferred.make(); const startedPids = yield* Queue.unbounded(); const ready = yield* Deferred.make(); + const backendReady = yield* Ref.make(false); + const quitting = yield* Ref.make(false); const spawnerLayer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, @@ -313,6 +319,10 @@ describe("DesktopBackendManager", () => { const managerLayer = makeManagerLayer({ spawnerLayer, + desktopState: { + backendReady, + quitting, + }, desktopWindow: { handleBackendReady: Deferred.succeed(ready, void 0).pipe(Effect.asVoid), }, @@ -325,6 +335,8 @@ describe("DesktopBackendManager", () => { yield* manager.start; assert.equal(yield* Queue.take(startedPids), 123); yield* Deferred.await(ready); + yield* Ref.set(backendReady, true); + assert.isTrue(yield* Ref.get(backendReady)); assert.deepEqual(yield* manager.currentConfig, Option.some(baseConfig)); const runningSnapshot = yield* manager.snapshot; @@ -336,6 +348,7 @@ describe("DesktopBackendManager", () => { assert.equal(closedCount, 1); const stoppedSnapshot = yield* manager.snapshot; + assert.isFalse(yield* Ref.get(backendReady)); assert.equal(stoppedSnapshot.desiredRunning, false); assert.equal(stoppedSnapshot.ready, false); assert.equal(Option.isNone(stoppedSnapshot.activePid), true); @@ -365,6 +378,7 @@ describe("DesktopBackendManager", () => { const managerLayer = makeManagerLayer({ spawnerLayer, + httpClientLayer: httpClientLayer(() => Effect.never), desktopRun: { logError: (_message, annotations) => Queue.offer(restartDelays, Number(annotations?.delayMs ?? 0)).pipe(Effect.asVoid), @@ -380,6 +394,70 @@ describe("DesktopBackendManager", () => { yield* TestClock.adjust(Duration.millis(500)); assert.equal(yield* Queue.take(starts), 2); + assert.equal(yield* Queue.take(restartDelays), 1_000); + + yield* TestClock.adjust(Duration.millis(1_000)); + assert.equal(yield* Queue.take(starts), 3); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); + }), + ); + + it.effect("cancels a scheduled restart when start is requested manually", () => + Effect.gen(function* () { + const starts = yield* Queue.unbounded(); + const restartDelays = yield* Queue.unbounded(); + const secondClosed = yield* Deferred.make(); + let startCount = 0; + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + startCount += 1; + yield* Queue.offer(starts, startCount); + + if (startCount === 1) { + return makeProcess({ + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), + }); + } + + const scope = yield* Scope.Scope; + const close = Deferred.succeed(secondClosed, void 0).pipe(Effect.asVoid); + yield* Scope.addFinalizer(scope, close); + return makeProcess({ + exitCode: Deferred.await(secondClosed).pipe( + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), + kill: () => close, + }); + }), + ), + ); + + const managerLayer = makeManagerLayer({ + spawnerLayer, + httpClientLayer: httpClientLayer(() => Effect.never), + desktopRun: { + logError: (_message, annotations) => + Queue.offer(restartDelays, Number(annotations?.delayMs ?? 0)).pipe(Effect.asVoid), + }, + }); + + yield* Effect.gen(function* () { + const manager = yield* DesktopBackendManager.DesktopBackendManager; + yield* manager.start; + + assert.equal(yield* Queue.take(starts), 1); + assert.equal(yield* Queue.take(restartDelays), 500); + + yield* manager.start; + assert.equal(yield* Queue.take(starts), 2); + + yield* manager.stop(); + yield* TestClock.adjust(Duration.millis(500)); + + assert.equal(yield* Queue.size(starts), 0); }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); }), ); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 03bac019dc7..26d7e8cf30c 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -335,12 +335,12 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio .exists(config.entryPath) .pipe(Effect.orElseSucceed(() => false)); + yield* cancelRestart; yield* Ref.update(state, (latest) => ({ ...latest, desiredRunning: true, ready: false, config: Option.some(config), - restartFiber: Option.none(), })); if (!entryExists) { @@ -432,10 +432,6 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio ...run, pid: Option.some(pid), })); - yield* Ref.update(state, (latest) => ({ - ...latest, - restartAttempt: 0, - })); const desktopRunId = yield* run.id; yield* backendOutputLog.writeSessionBoundary({ phase: "START", @@ -447,6 +443,10 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio Effect.gen(function* () { yield* Ref.update(state, (latest) => ({ ...latest, + restartAttempt: Option.match(latest.active, { + onNone: () => latest.restartAttempt, + onSome: (run) => (run.id === runId ? 0 : latest.restartAttempt), + }), ready: Option.match(latest.active, { onNone: () => latest.ready, onSome: (run) => (run.id === runId ? true : latest.ready), @@ -540,19 +540,23 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio const stop = (options?: { readonly timeout?: Duration.Duration }): Effect.Effect => Effect.gen(function* () { const { active, restartFiber } = yield* mutex.withPermits(1)( - Ref.modify(state, (latest) => [ - { - active: latest.active, - restartFiber: latest.restartFiber, - }, - { - ...latest, - desiredRunning: false, - ready: false, - active: Option.none(), - restartFiber: Option.none>(), - }, - ]), + Effect.gen(function* () { + const result = yield* Ref.modify(state, (latest) => [ + { + active: latest.active, + restartFiber: latest.restartFiber, + }, + { + ...latest, + desiredRunning: false, + ready: false, + active: Option.none(), + restartFiber: Option.none>(), + }, + ]); + yield* Ref.set(desktopState.backendReady, false); + return result; + }), ); yield* Option.match(restartFiber, { diff --git a/apps/desktop/src/electron/ElectronMenu.test.ts b/apps/desktop/src/electron/ElectronMenu.test.ts index 59c14659bf5..0e66c5a6f3f 100644 --- a/apps/desktop/src/electron/ElectronMenu.test.ts +++ b/apps/desktop/src/electron/ElectronMenu.test.ts @@ -95,4 +95,25 @@ describe("ElectronMenu", () => { }); }).pipe(Effect.provide(ElectronMenu.layer)), ); + + it.effect("defers popupTemplate side effects until the returned Effect runs", () => + Effect.gen(function* () { + const popupMock = vi.fn(); + buildFromTemplateMock.mockImplementation(() => ({ popup: popupMock })); + + const electronMenu = yield* ElectronMenu.ElectronMenu; + const popup = electronMenu.popupTemplate({ + window: {} as Electron.BrowserWindow, + template: [{ label: "Copy" }], + }); + + assert.equal(buildFromTemplateMock.mock.calls.length, 0); + assert.equal(popupMock.mock.calls.length, 0); + + yield* popup; + + assert.equal(buildFromTemplateMock.mock.calls.length, 1); + assert.equal(popupMock.mock.calls.length, 1); + }).pipe(Effect.provide(ElectronMenu.layer)), + ); }); diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 54ecd63cc40..a7bf8fb4ce7 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -137,13 +137,13 @@ export const layer = Layer.sync(ElectronMenu, () => { Effect.sync(() => { Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); }), - popupTemplate: (input) => { - if (input.template.length === 0) { - return Effect.void; - } - Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window }); - return Effect.void; - }, + popupTemplate: (input) => + Effect.sync(() => { + if (input.template.length === 0) { + return Effect.void; + } + Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window }); + }), showContextMenu: (input) => Effect.callback>((resume) => { const normalizedItems = normalizeContextMenuItems(input.items); diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 789d7c41d13..85fb92b5595 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -245,6 +245,26 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("does not persist an unchanged update channel as a user preference", () => { + const harness = makeHarness(); + + return Effect.scoped( + Effect.gen(function* () { + const settingsState = yield* DesktopSettingsState.DesktopSettingsState; + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* settingsState.set(DEFAULT_DESKTOP_SETTINGS); + yield* updates.configure; + + const state = yield* updates.setChannel("latest"); + const settings = yield* settingsState.get; + + assert.equal(state.channel, "latest"); + assert.equal(settings.updateChannel, "latest"); + assert.equal(settings.updateChannelConfiguredByUser, false); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + it.effect("fails channel changes with a typed error while a check is in progress", () => Effect.gen(function* () { const checkStarted = yield* Deferred.make(); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index fc724b7384a..13cd22a16e2 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -635,15 +635,15 @@ const make = Effect.gen(function* () { return yield* new DesktopUpdateActionInProgressError({ action: activeAction.value }); } - yield* updatePersistedSettings((settings) => - setDesktopUpdateChannelPreference(settings, nextChannel), - ); - const state = yield* Ref.get(updateStateRef); if (nextChannel === state.channel) { return state; } + yield* updatePersistedSettings((settings) => + setDesktopUpdateChannelPreference(settings, nextChannel), + ); + const enabled = yield* shouldEnableAutoUpdates; yield* setState(createBaseUpdateState(nextChannel, enabled, environment)); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index baac5c708b6..48ac5cff3b8 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -1,5 +1,6 @@ import { NetService } from "@t3tools/shared/Net"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; +import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts"; import { Config, Duration, @@ -26,24 +27,6 @@ import { } from "../config.ts"; import { expandHomePath, resolveBaseDir } from "../os-jank.ts"; -export const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); - -const BootstrapEnvelopeSchema = Schema.Struct({ - mode: Schema.optional(RuntimeMode), - port: Schema.optional(PortSchema), - host: Schema.optional(Schema.String), - t3Home: Schema.optional(Schema.String), - devUrl: Schema.optional(Schema.URLFromString), - noBrowser: Schema.optional(Schema.Boolean), - desktopBootstrapToken: Schema.optional(Schema.String), - autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean), - logWebSocketEvents: Schema.optional(Schema.Boolean), - tailscaleServeEnabled: Schema.optional(Schema.Boolean), - tailscaleServePort: Schema.optional(PortSchema), - otlpTracesUrl: Schema.optional(Schema.String), - otlpMetricsUrl: Schema.optional(Schema.String), -}); - export const modeFlag = Flag.choice("mode", RuntimeMode.literals).pipe( Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), Flag.optional, @@ -253,7 +236,7 @@ export const resolveServerConfig = ( const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd; const bootstrapEnvelope = bootstrapFd !== undefined - ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd) + ? yield* readBootstrapEnvelope(DesktopBackendBootstrap, bootstrapFd) : Option.none(); const bootstrap = Option.getOrUndefined(bootstrapEnvelope); diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index 9234903d40f..5baf426a8c0 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -5,6 +5,7 @@ export const TrimmedNonEmptyString = TrimmedString.check(Schema.isNonEmpty()); export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); export const PositiveInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)); +export const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); export const IsoDateTime = Schema.String; export type IsoDateTime = typeof IsoDateTime.Type; diff --git a/packages/contracts/src/desktopBootstrap.ts b/packages/contracts/src/desktopBootstrap.ts index 5982bdcf9f1..c23dbbb3960 100644 --- a/packages/contracts/src/desktopBootstrap.ts +++ b/packages/contracts/src/desktopBootstrap.ts @@ -1,14 +1,16 @@ import * as Schema from "effect/Schema"; +import { PortSchema } from "./baseSchemas.ts"; + export const DesktopBackendBootstrap = Schema.Struct({ mode: Schema.Literal("desktop"), noBrowser: Schema.Boolean, - port: Schema.Number, + port: PortSchema, t3Home: Schema.String, host: Schema.String, desktopBootstrapToken: Schema.String, tailscaleServeEnabled: Schema.Boolean, - tailscaleServePort: Schema.Number, + tailscaleServePort: PortSchema, otlpTracesUrl: Schema.optional(Schema.String), otlpMetricsUrl: Schema.optional(Schema.String), }); From 0ee84dca294faea619bbe6ede40db40f25643c2e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 7 May 2026 12:06:20 -0700 Subject: [PATCH 33/43] Refactor desktop settings into dedicated services - Split app, client, and saved-environment settings concerns - Wrap Electron safe storage and server exposure persistence with typed effects - Update desktop IPC and tests to use the new settings services --- apps/desktop/src/app/DesktopApp.ts | 10 +- apps/desktop/src/app/DesktopEnvironment.ts | 2 +- apps/desktop/src/electron/ElectronMenu.ts | 2 +- .../src/electron/ElectronSafeStorage.ts | 59 ++- .../desktop/src/ipc/methods/clientSettings.ts | 15 +- .../src/ipc/methods/savedEnvironments.ts | 42 +- apps/desktop/src/main.ts | 18 +- .../DesktopServerExposure.test.ts | 39 +- .../serverExposure/DesktopServerExposure.ts | 89 ++-- .../src/settings/DesktopAppSettings.test.ts | 284 +++++++++++++ .../src/settings/DesktopAppSettings.ts | 311 ++++++++++++++ .../settings/DesktopClientSettings.test.ts | 184 +++++++++ .../src/settings/DesktopClientSettings.ts | 113 +++++ .../settings/DesktopSavedEnvironments.test.ts | 344 ++++++++++++++++ .../src/settings/DesktopSavedEnvironments.ts | 389 ++++++++++++++++++ .../src/settings/DesktopSettingsState.test.ts | 32 -- .../src/settings/DesktopSettingsState.ts | 94 ----- .../src/settings/clientPersistence.test.ts | 292 ------------- .../desktop/src/settings/clientPersistence.ts | 327 --------------- .../src/settings/desktopSettings.test.ts | 273 ------------ apps/desktop/src/settings/desktopSettings.ts | 162 -------- .../src/updates/DesktopUpdates.test.ts | 23 +- apps/desktop/src/updates/DesktopUpdates.ts | 29 +- 23 files changed, 1777 insertions(+), 1356 deletions(-) create mode 100644 apps/desktop/src/settings/DesktopAppSettings.test.ts create mode 100644 apps/desktop/src/settings/DesktopAppSettings.ts create mode 100644 apps/desktop/src/settings/DesktopClientSettings.test.ts create mode 100644 apps/desktop/src/settings/DesktopClientSettings.ts create mode 100644 apps/desktop/src/settings/DesktopSavedEnvironments.test.ts create mode 100644 apps/desktop/src/settings/DesktopSavedEnvironments.ts delete mode 100644 apps/desktop/src/settings/DesktopSettingsState.test.ts delete mode 100644 apps/desktop/src/settings/DesktopSettingsState.ts delete mode 100644 apps/desktop/src/settings/clientPersistence.test.ts delete mode 100644 apps/desktop/src/settings/clientPersistence.ts delete mode 100644 apps/desktop/src/settings/desktopSettings.test.ts delete mode 100644 apps/desktop/src/settings/desktopSettings.ts diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index d681fef1df0..6fb6835a515 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -17,7 +17,7 @@ import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopLifecycle from "./DesktopLifecycle.ts"; import * as DesktopRun from "./DesktopRun.ts"; import * as DesktopServerExposure from "../serverExposure/DesktopServerExposure.ts"; -import * as DesktopSettingsState from "../settings/DesktopSettingsState.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; import * as DesktopState from "./DesktopState.ts"; import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; @@ -128,7 +128,7 @@ const bootstrap = Effect.gen(function* () { const state = yield* DesktopState.DesktopState; const desktopWindow = yield* DesktopWindow.DesktopWindow; const environment = yield* DesktopEnvironment.DesktopEnvironment; - const settingsState = yield* DesktopSettingsState.DesktopSettingsState; + const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; const run = yield* DesktopRun.DesktopRun; yield* run.logInfo("bootstrap start"); @@ -149,7 +149,7 @@ const bootstrap = Effect.gen(function* () { }, ); - const settings = yield* settingsState.get; + const settings = yield* desktopSettings.get; if (settings.serverExposureMode !== environment.defaultDesktopSettings.serverExposureMode) { yield* run.logInfo("bootstrap restoring persisted server exposure mode", { mode: settings.serverExposureMode, @@ -195,7 +195,7 @@ export const program = Effect.scoped( const electronProtocol = yield* ElectronProtocol.ElectronProtocol; const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; - const settingsState = yield* DesktopSettingsState.DesktopSettingsState; + const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const updates = yield* DesktopUpdates.DesktopUpdates; const environment = yield* DesktopEnvironment.DesktopEnvironment; const run = yield* DesktopRun.DesktopRun; @@ -213,7 +213,7 @@ export const program = Effect.scoped( const userDataPath = yield* appIdentity.resolveUserDataPath; yield* electronApp.setPath("userData", userDataPath); yield* run.logInfo("runtime logging configured", { logDir: environment.logDir }); - yield* settingsState.load; + yield* desktopSettings.load; if (environment.platform === "linux") { yield* electronApp.appendCommandLineSwitch("class", environment.linuxWmClass); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 646aa1c57cc..baed0340fdf 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -14,7 +14,7 @@ import * as Path from "effect/Path"; import { type DesktopSettings, resolveDefaultDesktopSettings, -} from "../settings/desktopSettings.ts"; +} from "../settings/DesktopAppSettings.ts"; import * as DesktopConfig from "./DesktopConfig.ts"; import { isNightlyDesktopVersion } from "../updates/updateChannels.ts"; diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index a7bf8fb4ce7..7164fdb54c1 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -140,7 +140,7 @@ export const layer = Layer.sync(ElectronMenu, () => { popupTemplate: (input) => Effect.sync(() => { if (input.template.length === 0) { - return Effect.void; + return; } Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window }); }), diff --git a/apps/desktop/src/electron/ElectronSafeStorage.ts b/apps/desktop/src/electron/ElectronSafeStorage.ts index af09f99588a..eebb3e2b2f8 100644 --- a/apps/desktop/src/electron/ElectronSafeStorage.ts +++ b/apps/desktop/src/electron/ElectronSafeStorage.ts @@ -1,12 +1,48 @@ import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Electron from "electron"; +export class ElectronSafeStorageAvailabilityError extends Data.TaggedError( + "ElectronSafeStorageAvailabilityError", +)<{ + readonly cause: unknown; +}> { + override get message() { + return "Electron safe storage failed to check encryption availability."; + } +} + +export class ElectronSafeStorageEncryptError extends Data.TaggedError( + "ElectronSafeStorageEncryptError", +)<{ + readonly cause: unknown; +}> { + override get message() { + return "Electron safe storage failed to encrypt a string."; + } +} + +export class ElectronSafeStorageDecryptError extends Data.TaggedError( + "ElectronSafeStorageDecryptError", +)<{ + readonly cause: unknown; +}> { + override get message() { + return "Electron safe storage failed to decrypt a string."; + } +} + export interface ElectronSafeStorageShape { - readonly isEncryptionAvailable: () => boolean; - readonly encryptString: (value: string) => Buffer; - readonly decryptString: (value: Buffer) => string; + readonly isEncryptionAvailable: Effect.Effect; + readonly encryptString: ( + value: string, + ) => Effect.Effect; + readonly decryptString: ( + value: Uint8Array, + ) => Effect.Effect; } export class ElectronSafeStorage extends Context.Service< @@ -15,9 +51,20 @@ export class ElectronSafeStorage extends Context.Service< >()("@t3tools/desktop/ElectronSafeStorage") {} const make = ElectronSafeStorage.of({ - isEncryptionAvailable: () => Electron.safeStorage.isEncryptionAvailable(), - encryptString: (value) => Electron.safeStorage.encryptString(value), - decryptString: (value) => Electron.safeStorage.decryptString(value), + isEncryptionAvailable: Effect.try({ + try: () => Electron.safeStorage.isEncryptionAvailable(), + catch: (cause) => new ElectronSafeStorageAvailabilityError({ cause }), + }), + encryptString: (value) => + Effect.try({ + try: () => Electron.safeStorage.encryptString(value), + catch: (cause) => new ElectronSafeStorageEncryptError({ cause }), + }), + decryptString: (value) => + Effect.try({ + try: () => Electron.safeStorage.decryptString(Buffer.from(value)), + catch: (cause) => new ElectronSafeStorageDecryptError({ cause }), + }), }); export const layer = Layer.succeed(ElectronSafeStorage, make); diff --git a/apps/desktop/src/ipc/methods/clientSettings.ts b/apps/desktop/src/ipc/methods/clientSettings.ts index b7164ae15fb..e014cbdf21a 100644 --- a/apps/desktop/src/ipc/methods/clientSettings.ts +++ b/apps/desktop/src/ipc/methods/clientSettings.ts @@ -1,12 +1,9 @@ import { ClientSettingsSchema } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; -import { - readClientSettingsEffect, - writeClientSettingsEffect, -} from "../../settings/clientPersistence.ts"; +import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts"; import * as IpcChannels from "../channels.ts"; import { makeIpcMethod } from "../DesktopIpc.ts"; @@ -16,8 +13,8 @@ export const getClientSettings = makeIpcMethod({ result: Schema.NullOr(ClientSettingsSchema), handler: () => Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - return yield* readClientSettingsEffect(environment.clientSettingsPath); + const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; + return Option.getOrNull(yield* clientSettings.get); }), }); @@ -27,7 +24,7 @@ export const setClientSettings = makeIpcMethod({ result: Schema.Void, handler: (settings) => Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - yield* writeClientSettingsEffect(environment.clientSettingsPath, settings); + const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; + yield* clientSettings.set(settings); }), }); diff --git a/apps/desktop/src/ipc/methods/savedEnvironments.ts b/apps/desktop/src/ipc/methods/savedEnvironments.ts index b856ab7bcef..569bb3b80bd 100644 --- a/apps/desktop/src/ipc/methods/savedEnvironments.ts +++ b/apps/desktop/src/ipc/methods/savedEnvironments.ts @@ -1,16 +1,9 @@ import { EnvironmentId, PersistedSavedEnvironmentRecordSchema } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { - readSavedEnvironmentRegistryEffect, - readSavedEnvironmentSecretEffect, - removeSavedEnvironmentSecretEffect, - writeSavedEnvironmentRegistryEffect, - writeSavedEnvironmentSecretEffect, -} from "../../settings/clientPersistence.ts"; -import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; -import * as ElectronSafeStorage from "../../electron/ElectronSafeStorage.ts"; +import * as DesktopSavedEnvironments from "../../settings/DesktopSavedEnvironments.ts"; import * as IpcChannels from "../channels.ts"; import { makeIpcMethod } from "../DesktopIpc.ts"; @@ -32,8 +25,8 @@ export const getSavedEnvironmentRegistry = makeIpcMethod({ result: SavedEnvironmentRegistryPayload, handler: () => Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - return yield* readSavedEnvironmentRegistryEffect(environment.savedEnvironmentRegistryPath); + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + return yield* savedEnvironments.getRegistry; }), }); @@ -43,8 +36,8 @@ export const setSavedEnvironmentRegistry = makeIpcMethod({ result: Schema.Void, handler: (records) => Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - yield* writeSavedEnvironmentRegistryEffect(environment.savedEnvironmentRegistryPath, records); + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* savedEnvironments.setRegistry(records); }), }); @@ -54,13 +47,8 @@ export const getSavedEnvironmentSecret = makeIpcMethod({ result: Schema.NullOr(Schema.String), handler: (environmentId) => Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const secretStorage = yield* ElectronSafeStorage.ElectronSafeStorage; - return yield* readSavedEnvironmentSecretEffect({ - registryPath: environment.savedEnvironmentRegistryPath, - environmentId, - secretStorage, - }); + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + return Option.getOrNull(yield* savedEnvironments.getSecret(environmentId)); }), }); @@ -70,13 +58,10 @@ export const setSavedEnvironmentSecret = makeIpcMethod({ result: Schema.Boolean, handler: ({ environmentId, secret }) => Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const secretStorage = yield* ElectronSafeStorage.ElectronSafeStorage; - return yield* writeSavedEnvironmentSecretEffect({ - registryPath: environment.savedEnvironmentRegistryPath, + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + return yield* savedEnvironments.setSecret({ environmentId, secret, - secretStorage, }); }), }); @@ -87,10 +72,7 @@ export const removeSavedEnvironmentSecret = makeIpcMethod({ result: Schema.Void, handler: (environmentId) => Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - yield* removeSavedEnvironmentSecretEffect({ - registryPath: environment.savedEnvironmentRegistryPath, - environmentId, - }); + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* savedEnvironments.removeSecret(environmentId); }), }); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 07ef8392d44..e42a714f4ea 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -12,7 +12,7 @@ import * as NetService from "@t3tools/shared/Net"; import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; -import type { DesktopSettings } from "./settings/desktopSettings.ts"; +import type { DesktopSettings as DesktopSettingsValue } from "./settings/DesktopAppSettings.ts"; import * as DesktopIpc from "./ipc/DesktopIpc.ts"; import * as ElectronApp from "./electron/ElectronApp.ts"; import * as ElectronDialog from "./electron/ElectronDialog.ts"; @@ -34,7 +34,9 @@ import * as DesktopLifecycle from "./app/DesktopLifecycle.ts"; import { DesktopBackendOutputLogLive, DesktopLoggerLive } from "./app/DesktopLogging.ts"; import * as DesktopRun from "./app/DesktopRun.ts"; import * as DesktopServerExposure from "./serverExposure/DesktopServerExposure.ts"; -import * as DesktopSettingsState from "./settings/DesktopSettingsState.ts"; +import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts"; +import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts"; +import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts"; import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; @@ -60,7 +62,7 @@ const desktopEnvironmentLayer = Layer.unwrap( const resolveDesktopSshCliRunner = ( environment: DesktopEnvironment.DesktopEnvironmentShape, - settings: DesktopSettings, + settings: DesktopSettingsValue, ): RemoteT3RunnerOptions => { const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath); if (environment.isDevelopment && devRemoteEntryPath !== undefined) { @@ -78,10 +80,10 @@ const resolveDesktopSshCliRunner = ( const desktopSshEnvironmentLayer = Layer.unwrap( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; - const settingsState = yield* DesktopSettingsState.DesktopSettingsState; + const settings = yield* DesktopAppSettings.DesktopAppSettings; return DesktopSshEnvironment.layer({ - resolveCliRunner: settingsState.get.pipe( - Effect.map((settings) => resolveDesktopSshCliRunner(environment, settings)), + resolveCliRunner: settings.get.pipe( + Effect.map((currentSettings) => resolveDesktopSshCliRunner(environment, currentSettings)), ), }); }), @@ -104,7 +106,9 @@ const desktopFoundationLayer = Layer.mergeAll( DesktopRun.layer, DesktopState.layer, DesktopLifecycle.layerShutdown, - DesktopSettingsState.layer, + DesktopAppSettings.layer, + DesktopClientSettings.layer, + DesktopSavedEnvironments.layer, DesktopAssets.layer, DesktopLoggerLive, DesktopBackendOutputLogLive, diff --git a/apps/desktop/src/serverExposure/DesktopServerExposure.test.ts b/apps/desktop/src/serverExposure/DesktopServerExposure.test.ts index 4ad7158544f..0f3e9eaeb45 100644 --- a/apps/desktop/src/serverExposure/DesktopServerExposure.test.ts +++ b/apps/desktop/src/serverExposure/DesktopServerExposure.test.ts @@ -9,10 +9,6 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { - DEFAULT_DESKTOP_SETTINGS, - readDesktopSettingsEffect, -} from "../settings/desktopSettings.ts"; import { DesktopEnvironment, layer as makeDesktopEnvironmentLayer, @@ -20,7 +16,7 @@ import { import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; import type { DesktopNetworkInterfaces } from "./DesktopServerExposure.ts"; -import * as DesktopSettingsState from "../settings/DesktopSettingsState.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; const encoder = new TextEncoder(); @@ -98,7 +94,7 @@ function makeLayer(input: { }); return DesktopServerExposure.layer.pipe( - Layer.provideMerge(DesktopSettingsState.layer), + Layer.provideMerge(DesktopAppSettings.layer), Layer.provideMerge(NodeFileSystem.layer), Layer.provideMerge(NodeHttpClient.layerUndici), Layer.provideMerge(mockSpawnerLayer()), @@ -117,7 +113,7 @@ const withHarness = ( | DesktopEnvironment | FileSystem.FileSystem | DesktopServerExposure.DesktopServerExposure - | DesktopSettingsState.DesktopSettingsState + | DesktopAppSettings.DesktopAppSettings >, env: Record = {}, ) => @@ -135,17 +131,14 @@ describe("DesktopServerExposure", () => { emptyNetworkInterfaces, Effect.gen(function* () { const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - const settingsState = yield* DesktopSettingsState.DesktopSettingsState; + const settings = yield* DesktopAppSettings.DesktopAppSettings; - yield* settingsState.set({ - ...DEFAULT_DESKTOP_SETTINGS, - serverExposureMode: "network-accessible", - }); + yield* settings.setServerExposureMode("network-accessible"); const state = yield* serverExposure.configureFromSettings({ port: 4173 }); assert.equal(state.mode, "local-only"); assert.equal(state.endpointUrl, null); - assert.equal((yield* settingsState.get).serverExposureMode, "network-accessible"); + assert.equal((yield* settings.get).serverExposureMode, "network-accessible"); const backendConfig = yield* serverExposure.backendConfig; assert.equal(backendConfig.bindHost, "127.0.0.1"); @@ -172,11 +165,10 @@ describe("DesktopServerExposure", () => { withHarness( lanNetworkInterfaces, Effect.gen(function* () { - const environment = yield* DesktopEnvironment; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - const settingsState = yield* DesktopSettingsState.DesktopSettingsState; + const settings = yield* DesktopAppSettings.DesktopAppSettings; - yield* settingsState.load; + yield* settings.load; yield* serverExposure.configureFromSettings({ port: 4173 }); const change = yield* serverExposure.setMode("network-accessible"); @@ -193,10 +185,7 @@ describe("DesktopServerExposure", () => { assert.equal(backendConfig.bindHost, "0.0.0.0"); assert.equal(backendConfig.httpBaseUrl.href, "http://127.0.0.1:4173/"); - const persisted = yield* readDesktopSettingsEffect( - environment.desktopSettingsPath, - environment.appVersion, - ); + const persisted = yield* settings.get; assert.equal(persisted.serverExposureMode, "network-accessible"); }), ), @@ -206,11 +195,10 @@ describe("DesktopServerExposure", () => { withHarness( emptyNetworkInterfaces, Effect.gen(function* () { - const environment = yield* DesktopEnvironment; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - const settingsState = yield* DesktopSettingsState.DesktopSettingsState; + const settings = yield* DesktopAppSettings.DesktopAppSettings; - yield* settingsState.load; + yield* settings.load; yield* serverExposure.configureFromSettings({ port: 4173 }); const changed = yield* serverExposure.setTailscaleServeEnabled({ @@ -227,10 +215,7 @@ describe("DesktopServerExposure", () => { }); assert.equal(unchanged.requiresRelaunch, false); - const persisted = yield* readDesktopSettingsEffect( - environment.desktopSettingsPath, - environment.appVersion, - ); + const persisted = yield* settings.get; assert.equal(persisted.tailscaleServeEnabled, true); assert.equal(persisted.tailscaleServePort, 8443); }), diff --git a/apps/desktop/src/serverExposure/DesktopServerExposure.ts b/apps/desktop/src/serverExposure/DesktopServerExposure.ts index e8c69392b75..ecb41c261ed 100644 --- a/apps/desktop/src/serverExposure/DesktopServerExposure.ts +++ b/apps/desktop/src/serverExposure/DesktopServerExposure.ts @@ -13,24 +13,16 @@ import type { import * as Context from "effect/Context"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { - DEFAULT_DESKTOP_SETTINGS, - type DesktopSettings, - setDesktopServerExposurePreference, - setDesktopTailscaleServePreference, -} from "../settings/desktopSettings.ts"; -import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { DEFAULT_DESKTOP_SETTINGS, type DesktopSettings } from "../settings/DesktopAppSettings.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; -import * as DesktopSettingsState from "../settings/DesktopSettingsState.ts"; +import * as DesktopAppSettingsService from "../settings/DesktopAppSettings.ts"; export const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; @@ -238,7 +230,7 @@ export class DesktopServerExposurePersistenceError extends Data.TaggedError( "DesktopServerExposurePersistenceError", )<{ readonly operation: DesktopServerExposurePersistenceOperation; - readonly cause: DesktopSettingsState.DesktopSettingsPersistenceError; + readonly cause: DesktopAppSettingsService.DesktopSettingsWriteError; }> { override get message() { return `Failed to persist desktop ${this.operation} settings.`; @@ -414,30 +406,12 @@ const requiresBackendRelaunch = (previous: RuntimeState, next: RuntimeState): bo const make = Effect.gen(function* () { const config = yield* DesktopConfig.DesktopConfig; - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; const networkInterfaces = yield* DesktopNetworkInterfacesService; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; - const settingsState = yield* DesktopSettingsState.DesktopSettingsState; + const desktopSettings = yield* DesktopAppSettingsService.DesktopAppSettings; const stateRef = yield* Ref.make(initialRuntimeState()); - const persistSettings = ( - operation: DesktopServerExposurePersistenceOperation, - effect: Effect.Effect< - A, - DesktopSettingsState.DesktopSettingsPersistenceError, - FileSystem.FileSystem | Path.Path | DesktopEnvironment.DesktopEnvironment - >, - ) => - effect.pipe( - Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.mapError((cause) => new DesktopServerExposurePersistenceError({ operation, cause })), - ); - const readNetworkInterfaces = networkInterfaces.read; const getState = Ref.get(stateRef).pipe(Effect.map(toContractState)); @@ -445,7 +419,7 @@ const make = Effect.gen(function* () { const configureFromSettings = ({ port }: { readonly port: number }) => Effect.gen(function* () { - const settings = yield* settingsState.get; + const settings = yield* desktopSettings.get; const currentNetworkInterfaces = yield* readNetworkInterfaces; const resolved = resolveRuntimeState({ requestedMode: settings.serverExposureMode, @@ -461,8 +435,11 @@ const make = Effect.gen(function* () { const setMode = (mode: DesktopServerExposureMode) => Effect.gen(function* () { const previous = yield* Ref.get(stateRef); - const currentSettings = yield* settingsState.get; - const nextSettings = setDesktopServerExposurePreference(currentSettings, mode); + const currentSettings = yield* desktopSettings.get; + const nextSettings = { + ...currentSettings, + serverExposureMode: mode, + }; const currentNetworkInterfaces = yield* readNetworkInterfaces; const resolved = resolveRuntimeState({ requestedMode: mode, @@ -476,37 +453,39 @@ const make = Effect.gen(function* () { return yield* new DesktopServerExposureNoNetworkAddressError({ port: previous.port }); } - if (nextSettings !== currentSettings) { - yield* persistSettings( - "server-exposure-mode", - settingsState.updatePersisted((settings) => - setDesktopServerExposurePreference(settings, mode), - ), - ); - } + const change = yield* desktopSettings.setServerExposureMode(mode).pipe( + Effect.mapError( + (cause) => + new DesktopServerExposurePersistenceError({ + operation: "server-exposure-mode", + cause, + }), + ), + ); yield* Ref.set(stateRef, resolved.state); return { state: toContractState(resolved.state), - requiresRelaunch: requiresBackendRelaunch(previous, resolved.state), + requiresRelaunch: change.changed || requiresBackendRelaunch(previous, resolved.state), }; }); const setTailscaleServeEnabled = (input: { readonly enabled: boolean; readonly port?: number }) => Effect.gen(function* () { - const result = yield* persistSettings( - "tailscale-serve", - settingsState.modifyPersisted((settings) => { - const nextSettings = setDesktopTailscaleServePreference(settings, input); - return [ - { - changed: nextSettings !== settings, - settings: nextSettings, - }, - nextSettings, - ] as const; - }), - ); + const result = yield* desktopSettings + .setTailscaleServe({ + enabled: input.enabled, + port: Option.fromNullishOr(input.port), + }) + .pipe( + Effect.mapError( + (cause) => + new DesktopServerExposurePersistenceError({ + operation: "tailscale-serve", + cause, + }), + ), + ); const nextState = yield* Ref.updateAndGet(stateRef, (current) => ({ ...current, diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts new file mode 100644 index 00000000000..db6194cf8f7 --- /dev/null +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -0,0 +1,284 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { + DEFAULT_DESKTOP_SETTINGS, + resolveDefaultDesktopSettings, + type DesktopSettings as DesktopSettingsValue, +} from "./DesktopAppSettings.ts"; +import * as DesktopAppSettings from "./DesktopAppSettings.ts"; + +const DesktopSettingsPatch = Schema.Struct({ + serverExposureMode: Schema.optionalKey(Schema.Literals(["local-only", "network-accessible"])), + tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), + tailscaleServePort: Schema.optionalKey(Schema.Number), + updateChannel: Schema.optionalKey(Schema.Literals(["latest", "nightly"])), + updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean), +}); + +const decodeDesktopSettingsPatch = Schema.decodeEffect(Schema.fromJsonString(DesktopSettingsPatch)); +const encodeDesktopSettingsPatch = Schema.encodeEffect(Schema.fromJsonString(DesktopSettingsPatch)); + +function makeEnvironmentLayer(baseDir: string, appVersion = "0.0.17") { + return DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: baseDir, + platform: "darwin", + processArch: "x64", + appVersion, + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), + ), + ); +} + +const withSettings = ( + effect: Effect.Effect< + A, + E, + R | DesktopAppSettings.DesktopAppSettings | DesktopEnvironment.DesktopEnvironment + >, + options?: { readonly appVersion?: string }, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-settings-test-", + }); + return yield* effect.pipe( + Effect.provide( + DesktopAppSettings.layer.pipe( + Layer.provideMerge(makeEnvironmentLayer(baseDir, options?.appVersion)), + Layer.provideMerge(NodeServices.layer), + ), + ), + ); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); + +function writeSettingsPatch(patch: typeof DesktopSettingsPatch.Type) { + return Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const encoded = yield* encodeDesktopSettingsPatch(patch); + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(environment.desktopSettingsPath, `${encoded}\n`); + }); +} + +describe("DesktopSettings", () => { + it.effect("loads defaults when no settings file exists", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + assert.deepEqual(yield* settings.load, DEFAULT_DESKTOP_SETTINGS); + assert.deepEqual(yield* settings.get, DEFAULT_DESKTOP_SETTINGS); + }), + ), + ); + + it("defaults packaged nightly builds to the nightly update channel", () => { + assert.deepEqual(resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), { + serverExposureMode: "local-only", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + updateChannel: "nightly", + updateChannelConfiguredByUser: false, + } satisfies DesktopSettingsValue); + }); + + it.effect("loads persisted settings and applies semantic updates", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* writeSettingsPatch({ + serverExposureMode: "network-accessible", + tailscaleServeEnabled: true, + tailscaleServePort: 8443, + updateChannel: "latest", + updateChannelConfiguredByUser: true, + }); + + assert.deepEqual(yield* settings.load, { + serverExposureMode: "network-accessible", + tailscaleServeEnabled: true, + tailscaleServePort: 8443, + updateChannel: "latest", + updateChannelConfiguredByUser: true, + } satisfies DesktopSettingsValue); + + const exposure = yield* settings.setServerExposureMode("local-only"); + assert.isTrue(exposure.changed); + assert.equal(exposure.settings.serverExposureMode, "local-only"); + + const tailscale = yield* settings.setTailscaleServe({ + enabled: true, + port: Option.some(9443), + }); + assert.isTrue(tailscale.changed); + assert.equal(tailscale.settings.tailscaleServePort, 9443); + + const updateChannel = yield* settings.setUpdateChannel("nightly"); + assert.isTrue(updateChannel.changed); + assert.equal(updateChannel.settings.updateChannel, "nightly"); + assert.equal(updateChannel.settings.updateChannelConfiguredByUser, true); + }), + ), + ); + + it.effect("does not persist no-op semantic updates", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + + const exposure = yield* settings.setServerExposureMode("local-only"); + assert.isFalse(exposure.changed); + + const tailscale = yield* settings.setTailscaleServe({ + enabled: false, + port: Option.none(), + }); + assert.isFalse(tailscale.changed); + + const updateChannel = yield* settings.setUpdateChannel("latest"); + assert.isFalse(updateChannel.changed); + assert.equal(updateChannel.settings.updateChannelConfiguredByUser, false); + }), + ), + ); + + it.effect("falls back to defaults when the settings file is malformed", () => + withSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(environment.desktopSettingsPath, "{not-json"); + + assert.deepEqual(yield* settings.load, DEFAULT_DESKTOP_SETTINGS); + }), + ), + ); + + it.effect("loads lenient persisted desktop settings JSON", () => + withSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString( + environment.desktopSettingsPath, + `{ + // JSONC-style comments and trailing commas match server settings parsing. + "serverExposureMode": "network-accessible", + "tailscaleServeEnabled": true, + "tailscaleServePort": 8443, + }\n`, + ); + + assert.deepEqual(yield* settings.load, { + serverExposureMode: "network-accessible", + tailscaleServeEnabled: true, + tailscaleServePort: 8443, + updateChannel: "latest", + updateChannelConfiguredByUser: false, + } satisfies DesktopSettingsValue); + }), + ), + ); + + it.effect("persists sparse desktop settings documents", () => + withSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + + yield* settings.setServerExposureMode("network-accessible"); + + const persisted = yield* decodeDesktopSettingsPatch( + yield* fileSystem.readFileString(environment.desktopSettingsPath), + ); + assert.deepEqual(persisted, { + serverExposureMode: "network-accessible", + } satisfies typeof DesktopSettingsPatch.Type); + }), + ), + ); + + it.effect("migrates legacy implicit update channels to the runtime default", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* writeSettingsPatch({ + serverExposureMode: "local-only", + updateChannel: "latest", + }); + + assert.deepEqual(yield* settings.load, { + serverExposureMode: "local-only", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + updateChannel: "nightly", + updateChannelConfiguredByUser: false, + } satisfies DesktopSettingsValue); + }), + { appVersion: "0.0.17-nightly.20260415.1" }, + ), + ); + + it.effect("preserves explicit stable update channel on nightly builds", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* writeSettingsPatch({ + serverExposureMode: "local-only", + updateChannel: "latest", + updateChannelConfiguredByUser: true, + }); + + assert.deepEqual(yield* settings.load, { + serverExposureMode: "local-only", + tailscaleServeEnabled: false, + tailscaleServePort: 443, + updateChannel: "latest", + updateChannelConfiguredByUser: true, + } satisfies DesktopSettingsValue); + }), + { appVersion: "0.0.17-nightly.20260415.1" }, + ), + ); + + it.effect("normalizes invalid persisted Tailscale Serve ports", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* writeSettingsPatch({ + tailscaleServeEnabled: true, + tailscaleServePort: 0, + }); + + assert.deepEqual(yield* settings.load, { + serverExposureMode: "local-only", + tailscaleServeEnabled: true, + tailscaleServePort: 443, + updateChannel: "latest", + updateChannelConfiguredByUser: false, + } satisfies DesktopSettingsValue); + }), + ), + ); +}); diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts new file mode 100644 index 00000000000..6659562258e --- /dev/null +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -0,0 +1,311 @@ +import { + DesktopServerExposureModeSchema, + DesktopUpdateChannelSchema, + type DesktopServerExposureMode, + type DesktopUpdateChannel, +} from "@t3tools/contracts"; +import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Random from "effect/Random"; +import * as Schema from "effect/Schema"; +import * as SynchronizedRef from "effect/SynchronizedRef"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts"; + +export interface DesktopSettings { + readonly serverExposureMode: DesktopServerExposureMode; + readonly tailscaleServeEnabled: boolean; + readonly tailscaleServePort: number; + readonly updateChannel: DesktopUpdateChannel; + readonly updateChannelConfiguredByUser: boolean; +} + +export interface DesktopSettingsChange { + readonly settings: DesktopSettings; + readonly changed: boolean; +} + +export const DEFAULT_TAILSCALE_SERVE_PORT = 443; + +export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { + serverExposureMode: "local-only", + tailscaleServeEnabled: false, + tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, + updateChannel: "latest", + updateChannelConfiguredByUser: false, +}; + +const DesktopSettingsDocument = Schema.Struct({ + serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema), + tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), + tailscaleServePort: Schema.optionalKey(Schema.Number), + updateChannel: Schema.optionalKey(DesktopUpdateChannelSchema), + updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean), +}); + +type DesktopSettingsDocument = typeof DesktopSettingsDocument.Type; +type Mutable = { -readonly [K in keyof T]: T[K] }; + +const DesktopSettingsJson = fromLenientJson(DesktopSettingsDocument); +const decodeDesktopSettingsJson = Schema.decodeEffect(DesktopSettingsJson); +const encodeDesktopSettingsJson = Schema.encodeEffect(DesktopSettingsJson); + +const settingsChange = (settings: DesktopSettings, changed: boolean): DesktopSettingsChange => ({ + settings, + changed, +}); + +export class DesktopSettingsWriteError extends Data.TaggedError("DesktopSettingsWriteError")<{ + readonly cause: PlatformError.PlatformError | Schema.SchemaError; +}> { + override get message() { + return `Failed to write desktop settings: ${this.cause.message}`; + } +} + +export interface DesktopAppSettingsShape { + readonly load: Effect.Effect; + readonly get: Effect.Effect; + readonly setServerExposureMode: ( + mode: DesktopServerExposureMode, + ) => Effect.Effect; + readonly setTailscaleServe: (input: { + readonly enabled: boolean; + readonly port: Option.Option; + }) => Effect.Effect; + readonly setUpdateChannel: ( + channel: DesktopUpdateChannel, + ) => Effect.Effect; +} + +export class DesktopAppSettings extends Context.Service< + DesktopAppSettings, + DesktopAppSettingsShape +>()("t3/desktop/AppSettings") {} + +export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings { + return { + ...DEFAULT_DESKTOP_SETTINGS, + updateChannel: resolveDefaultDesktopUpdateChannel(appVersion), + }; +} + +function normalizeTailscaleServePort(value: unknown): number { + return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65_535 + ? value + : DEFAULT_TAILSCALE_SERVE_PORT; +} + +function normalizeDesktopSettingsDocument( + parsed: DesktopSettingsDocument, + appVersion: string, +): DesktopSettings { + const defaultSettings = resolveDefaultDesktopSettings(appVersion); + const parsedUpdateChannel = Option.fromNullishOr(parsed.updateChannel); + const isLegacySettings = parsed.updateChannelConfiguredByUser === undefined; + const updateChannelConfiguredByUser = + parsed.updateChannelConfiguredByUser === true || + (isLegacySettings && Option.contains(parsedUpdateChannel, "nightly")); + + return { + serverExposureMode: + parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", + tailscaleServeEnabled: parsed.tailscaleServeEnabled === true, + tailscaleServePort: normalizeTailscaleServePort(parsed.tailscaleServePort), + updateChannel: updateChannelConfiguredByUser + ? Option.getOrElse(parsedUpdateChannel, () => defaultSettings.updateChannel) + : defaultSettings.updateChannel, + updateChannelConfiguredByUser, + }; +} + +function toDesktopSettingsDocument( + settings: DesktopSettings, + defaults: DesktopSettings, +): DesktopSettingsDocument { + const document: Mutable = {}; + + if (settings.serverExposureMode !== defaults.serverExposureMode) { + document.serverExposureMode = settings.serverExposureMode; + } + if (settings.tailscaleServeEnabled !== defaults.tailscaleServeEnabled) { + document.tailscaleServeEnabled = settings.tailscaleServeEnabled; + } + if (settings.tailscaleServePort !== defaults.tailscaleServePort) { + document.tailscaleServePort = settings.tailscaleServePort; + } + if (settings.updateChannel !== defaults.updateChannel) { + document.updateChannel = settings.updateChannel; + } + if (settings.updateChannelConfiguredByUser !== defaults.updateChannelConfiguredByUser) { + document.updateChannelConfiguredByUser = settings.updateChannelConfiguredByUser; + } + + return document; +} + +function setServerExposureMode( + settings: DesktopSettings, + requestedMode: DesktopServerExposureMode, +): DesktopSettings { + return settings.serverExposureMode === requestedMode + ? settings + : { + ...settings, + serverExposureMode: requestedMode, + }; +} + +function setTailscaleServe( + settings: DesktopSettings, + input: { readonly enabled: boolean; readonly port: Option.Option }, +): DesktopSettings { + const port = Option.match(input.port, { + onNone: () => settings.tailscaleServePort, + onSome: normalizeTailscaleServePort, + }); + return settings.tailscaleServeEnabled === input.enabled && settings.tailscaleServePort === port + ? settings + : { + ...settings, + tailscaleServeEnabled: input.enabled, + tailscaleServePort: port, + }; +} + +function setUpdateChannel( + settings: DesktopSettings, + requestedChannel: DesktopUpdateChannel, +): DesktopSettings { + return settings.updateChannel === requestedChannel + ? settings + : { + ...settings, + updateChannel: requestedChannel, + updateChannelConfiguredByUser: true, + }; +} + +function readSettings( + fileSystem: FileSystem.FileSystem, + settingsPath: string, + appVersion: string, +): Effect.Effect { + const defaultSettings = resolveDefaultDesktopSettings(appVersion); + + return fileSystem.readFileString(settingsPath).pipe( + Effect.option, + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(defaultSettings), + onSome: (raw) => + decodeDesktopSettingsJson(raw).pipe( + Effect.map((parsed) => normalizeDesktopSettingsDocument(parsed, appVersion)), + Effect.catch(() => Effect.succeed(defaultSettings)), + ), + }), + ), + ); +} + +function writeSettings(input: { + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly settingsPath: string; + readonly settings: DesktopSettings; + readonly defaultSettings: DesktopSettings; +}): Effect.Effect { + return Effect.gen(function* () { + const directory = input.path.dirname(input.settingsPath); + const suffix = (yield* Random.nextUUIDv4).replace(/-/g, ""); + const tempPath = `${input.settingsPath}.${process.pid}.${suffix}.tmp`; + const encoded = yield* encodeDesktopSettingsJson( + toDesktopSettingsDocument(input.settings, input.defaultSettings), + ); + yield* input.fileSystem.makeDirectory(directory, { recursive: true }); + yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`); + yield* input.fileSystem.rename(tempPath, input.settingsPath); + }); +} + +export const layer = Layer.effect( + DesktopAppSettings, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const settingsRef = yield* SynchronizedRef.make(environment.defaultDesktopSettings); + + const persist = ( + update: (settings: DesktopSettings) => DesktopSettings, + ): Effect.Effect => + SynchronizedRef.modifyEffect(settingsRef, (settings) => { + const nextSettings = update(settings); + if (nextSettings === settings) { + return Effect.succeed([settingsChange(settings, false), settings] as const); + } + + return writeSettings({ + fileSystem, + path, + settingsPath: environment.desktopSettingsPath, + settings: nextSettings, + defaultSettings: environment.defaultDesktopSettings, + }).pipe( + Effect.mapError((cause) => new DesktopSettingsWriteError({ cause })), + Effect.as([settingsChange(nextSettings, true), nextSettings] as const), + ); + }); + + return DesktopAppSettings.of({ + get: SynchronizedRef.get(settingsRef), + load: Effect.gen(function* () { + const settings = yield* readSettings( + fileSystem, + environment.desktopSettingsPath, + environment.appVersion, + ); + return yield* SynchronizedRef.setAndGet(settingsRef, settings); + }), + setServerExposureMode: (mode) => persist((settings) => setServerExposureMode(settings, mode)), + setTailscaleServe: (input) => persist((settings) => setTailscaleServe(settings, input)), + setUpdateChannel: (channel) => persist((settings) => setUpdateChannel(settings, channel)), + }); + }), +); + +export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SETTINGS) => + Layer.effect( + DesktopAppSettings, + Effect.gen(function* () { + const settingsRef = yield* SynchronizedRef.make(initialSettings); + const update = (f: (settings: DesktopSettings) => DesktopSettings) => + SynchronizedRef.modify(settingsRef, (settings) => { + const nextSettings = f(settings); + return [ + { + settings: nextSettings, + changed: nextSettings !== settings, + }, + nextSettings, + ] as const; + }); + + return DesktopAppSettings.of({ + get: SynchronizedRef.get(settingsRef), + load: SynchronizedRef.get(settingsRef), + setServerExposureMode: (mode) => + update((settings) => setServerExposureMode(settings, mode)), + setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), + setUpdateChannel: (channel) => update((settings) => setUpdateChannel(settings, channel)), + }); + }), + ); diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts new file mode 100644 index 00000000000..435fa58cb45 --- /dev/null +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -0,0 +1,184 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopClientSettings from "./DesktopClientSettings.ts"; + +const clientSettings: ClientSettings = { + autoOpenPlanSidebar: false, + confirmThreadArchive: true, + confirmThreadDelete: false, + dismissedProviderUpdateNotificationKeys: [], + diffIgnoreWhitespace: true, + diffWordWrap: true, + favorites: [], + providerModelPreferences: {}, + sidebarProjectGroupingMode: "repository_path", + sidebarProjectGroupingOverrides: { + "environment-1:/tmp/project-a": "separate", + }, + sidebarProjectSortOrder: "manual", + sidebarThreadSortOrder: "created_at", + timestampFormat: "24-hour", +}; + +const decodeClientSettingsJson = Schema.decodeEffect(Schema.fromJsonString(ClientSettingsSchema)); +const decodeRecordJson = Schema.decodeEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); + +function makeLayer(baseDir: string) { + const environmentLayer = DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: baseDir, + platform: "darwin", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), + ), + ); + + return DesktopClientSettings.layer.pipe( + Layer.provideMerge(environmentLayer), + Layer.provideMerge(NodeServices.layer), + ); +} + +const withClientSettings = ( + effect: Effect.Effect, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-client-settings-test-", + }); + return yield* effect.pipe(Effect.provide(makeLayer(baseDir))); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); + +describe("DesktopClientSettings", () => { + it.effect("returns none when no client settings file exists", () => + withClientSettings( + Effect.gen(function* () { + const settings = yield* DesktopClientSettings.DesktopClientSettings; + assert.isTrue(Option.isNone(yield* settings.get)); + }), + ), + ); + + it.effect("persists and reloads client settings", () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + yield* settings.set(clientSettings); + + assert.deepEqual(yield* settings.get, Option.some(clientSettings)); + assert.deepEqual( + yield* decodeClientSettingsJson( + yield* fileSystem.readFileString(environment.clientSettingsPath), + ), + clientSettings, + ); + assert.isFalse( + Object.hasOwn( + yield* decodeRecordJson( + yield* fileSystem.readFileString(environment.clientSettingsPath), + ), + "settings", + ), + ); + }), + ), + ); + + it.effect("loads lenient direct client settings documents", () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString( + environment.clientSettingsPath, + `{ + // Matches server settings parsing. + "timestampFormat": "24-hour", + }\n`, + ); + + const persisted = yield* settings.get; + assert.isTrue(Option.isSome(persisted)); + if (Option.isSome(persisted)) { + assert.equal(persisted.value.timestampFormat, "24-hour"); + } + }), + ), + ); + + it.effect("loads legacy wrapped client settings documents", () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString( + environment.clientSettingsPath, + `{ + "settings": { + "timestampFormat": "12-hour" + } + }\n`, + ); + + const persisted = yield* settings.get; + assert.isTrue(Option.isSome(persisted)); + if (Option.isSome(persisted)) { + assert.equal(persisted.value.timestampFormat, "12-hour"); + } + }), + ), + ); + + it.effect("loads defaults from empty client settings documents", () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(environment.clientSettingsPath, "{}\n"); + + assert.deepEqual(yield* settings.get, Option.some(yield* decodeClientSettingsJson("{}"))); + }), + ), + ); + + it.effect("treats malformed client settings documents as absent", () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(environment.clientSettingsPath, "{not-json"); + + assert.isTrue(Option.isNone(yield* settings.get)); + }), + ), + ); +}); diff --git a/apps/desktop/src/settings/DesktopClientSettings.ts b/apps/desktop/src/settings/DesktopClientSettings.ts new file mode 100644 index 00000000000..4da260684ef --- /dev/null +++ b/apps/desktop/src/settings/DesktopClientSettings.ts @@ -0,0 +1,113 @@ +import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts"; +import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Random from "effect/Random"; +import * as Schema from "effect/Schema"; +import * as Ref from "effect/Ref"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; + +const ClientSettingsDocumentSchema = Schema.Struct({ + settings: ClientSettingsSchema, +}); + +const ClientSettingsJson = fromLenientJson(ClientSettingsSchema); +const LegacyClientSettingsDocumentJson = fromLenientJson(ClientSettingsDocumentSchema); +const decodeClientSettingsJson = (raw: string): Effect.Effect => + Schema.decodeEffect(LegacyClientSettingsDocumentJson)(raw).pipe( + Effect.map((document) => document.settings), + Effect.catch(() => Schema.decodeEffect(ClientSettingsJson)(raw)), + ); +const encodeClientSettingsJson = Schema.encodeEffect(ClientSettingsJson); + +export class DesktopClientSettingsWriteError extends Data.TaggedError( + "DesktopClientSettingsWriteError", +)<{ + readonly cause: PlatformError.PlatformError | Schema.SchemaError; +}> { + override get message() { + return `Failed to write desktop client settings: ${this.cause.message}`; + } +} + +export interface DesktopClientSettingsShape { + readonly get: Effect.Effect>; + readonly set: (settings: ClientSettings) => Effect.Effect; +} + +export class DesktopClientSettings extends Context.Service< + DesktopClientSettings, + DesktopClientSettingsShape +>()("t3/desktop/ClientSettings") {} + +const readClientSettings = ( + fileSystem: FileSystem.FileSystem, + settingsPath: string, +): Effect.Effect> => + fileSystem.readFileString(settingsPath).pipe( + Effect.option, + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(Option.none()), + onSome: (raw) => + decodeClientSettingsJson(raw).pipe( + Effect.map((settings) => Option.some(settings)), + Effect.catch(() => Effect.succeed(Option.none())), + ), + }), + ), + ); + +const writeClientSettings = Effect.fnUntraced(function* (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly settingsPath: string; + readonly settings: ClientSettings; +}): Effect.fn.Return { + const directory = input.path.dirname(input.settingsPath); + const suffix = (yield* Random.nextUUIDv4).replace(/-/g, ""); + const tempPath = `${input.settingsPath}.${process.pid}.${suffix}.tmp`; + const encoded = yield* encodeClientSettingsJson(input.settings); + yield* input.fileSystem.makeDirectory(directory, { recursive: true }); + yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`); + yield* input.fileSystem.rename(tempPath, input.settingsPath); +}); + +export const layer = Layer.effect( + DesktopClientSettings, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + return DesktopClientSettings.of({ + get: readClientSettings(fileSystem, environment.clientSettingsPath), + set: (settings) => + writeClientSettings({ + fileSystem, + path, + settingsPath: environment.clientSettingsPath, + settings, + }).pipe(Effect.mapError((cause) => new DesktopClientSettingsWriteError({ cause }))), + }); + }), +); + +export const layerTest = (initialSettings: Option.Option = Option.none()) => + Layer.effect( + DesktopClientSettings, + Effect.gen(function* () { + const settingsRef = yield* Ref.make(initialSettings); + return DesktopClientSettings.of({ + get: Ref.get(settingsRef), + set: (settings) => Ref.set(settingsRef, Option.some(settings)), + }); + }), + ); diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts new file mode 100644 index 00000000000..d1d37b96e11 --- /dev/null +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -0,0 +1,344 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; +import * as DesktopSavedEnvironments from "./DesktopSavedEnvironments.ts"; + +const textDecoder = new TextDecoder(); +const textEncoder = new TextEncoder(); + +const savedRegistryRecord: PersistedSavedEnvironmentRecord = { + environmentId: EnvironmentId.make("environment-1"), + label: "Remote environment", + httpBaseUrl: "https://remote.example.com/", + wsBaseUrl: "wss://remote.example.com/", + createdAt: "2026-04-09T00:00:00.000Z", + lastConnectedAt: "2026-04-09T01:00:00.000Z", + desktopSsh: { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 22, + }, +}; + +const SavedEnvironmentRegistryDocumentProbe = Schema.Struct({ + version: Schema.Number, + records: Schema.Array(Schema.Unknown), +}); +const decodeSavedEnvironmentRegistryDocumentProbe = Schema.decodeEffect( + Schema.fromJsonString(SavedEnvironmentRegistryDocumentProbe), +); + +function makeSafeStorageLayer(input: { + readonly available: boolean; + readonly availabilityError?: unknown; + readonly encryptError?: unknown; + readonly decryptError?: unknown; +}) { + return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, { + isEncryptionAvailable: + input.availabilityError === undefined + ? Effect.succeed(input.available) + : Effect.fail( + new ElectronSafeStorage.ElectronSafeStorageAvailabilityError({ + cause: input.availabilityError, + }), + ), + encryptString: (value) => + input.encryptError === undefined + ? Effect.succeed(textEncoder.encode(`enc:${value}`)) + : Effect.fail( + new ElectronSafeStorage.ElectronSafeStorageEncryptError({ + cause: input.encryptError, + }), + ), + decryptString: (value) => { + if (input.decryptError !== undefined) { + return Effect.fail( + new ElectronSafeStorage.ElectronSafeStorageDecryptError({ + cause: input.decryptError, + }), + ); + } + + const decoded = textDecoder.decode(value); + if (!decoded.startsWith("enc:")) { + return Effect.fail( + new ElectronSafeStorage.ElectronSafeStorageDecryptError({ + cause: new Error("invalid secret"), + }), + ); + } + return Effect.succeed(decoded.slice("enc:".length)); + }, + } satisfies ElectronSafeStorage.ElectronSafeStorageShape); +} + +function makeLayer( + baseDir: string, + options?: { + readonly availableSecretStorage?: boolean; + readonly availabilityError?: unknown; + readonly encryptError?: unknown; + readonly decryptError?: unknown; + }, +) { + const environmentLayer = DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: baseDir, + platform: "darwin", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), + ), + ); + + return DesktopSavedEnvironments.layer.pipe( + Layer.provideMerge(environmentLayer), + Layer.provideMerge( + makeSafeStorageLayer({ + available: options?.availableSecretStorage ?? true, + availabilityError: options?.availabilityError, + encryptError: options?.encryptError, + decryptError: options?.decryptError, + }), + ), + Layer.provideMerge(NodeServices.layer), + ); +} + +const withSavedEnvironments = ( + effect: Effect.Effect, + options?: { + readonly availableSecretStorage?: boolean; + readonly availabilityError?: unknown; + readonly encryptError?: unknown; + readonly decryptError?: unknown; + }, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-saved-environments-test-", + }); + return yield* effect.pipe(Effect.provide(makeLayer(baseDir, options))); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); + +describe("DesktopSavedEnvironments", () => { + it.effect("persists and reloads saved environment metadata", () => + withSavedEnvironments( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* savedEnvironments.setRegistry([savedRegistryRecord]); + + assert.deepEqual(yield* savedEnvironments.getRegistry, [savedRegistryRecord]); + const persisted = yield* decodeSavedEnvironmentRegistryDocumentProbe( + yield* fileSystem.readFileString(environment.savedEnvironmentRegistryPath), + ); + assert.equal(persisted.version, 1); + assert.lengthOf(persisted.records, 1); + }), + ), + ); + + it.effect("loads lenient saved environment registry documents", () => + withSavedEnvironments( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString( + environment.savedEnvironmentRegistryPath, + `{ + // Same optional envelope shape as browser saved environments. + "version": 1, + "records": [ + { + "environmentId": "${savedRegistryRecord.environmentId}", + "label": "Remote environment", + "httpBaseUrl": "https://remote.example.com/", + "wsBaseUrl": "wss://remote.example.com/", + "createdAt": "2026-04-09T00:00:00.000Z", + "lastConnectedAt": "2026-04-09T01:00:00.000Z", + "desktopSsh": { + "alias": "devbox", + "hostname": "devbox.example.com", + "username": "julius", + "port": 22, + }, + }, + ], + }\n`, + ); + + assert.deepEqual(yield* savedEnvironments.getRegistry, [savedRegistryRecord]); + }), + ), + ); + + it.effect("persists encrypted saved environment secrets when encryption is available", () => + withSavedEnvironments( + Effect.gen(function* () { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* savedEnvironments.setRegistry([savedRegistryRecord]); + + assert.isTrue( + yield* savedEnvironments.setSecret({ + environmentId: savedRegistryRecord.environmentId, + secret: "bearer-token", + }), + ); + + assert.deepEqual( + yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId), + Option.some("bearer-token"), + ); + }), + ), + ); + + it.effect("returns false when writing secrets while encryption is unavailable", () => + withSavedEnvironments( + Effect.gen(function* () { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* savedEnvironments.setRegistry([savedRegistryRecord]); + + assert.isFalse( + yield* savedEnvironments.setSecret({ + environmentId: savedRegistryRecord.environmentId, + secret: "next-token", + }), + ); + }), + { availableSecretStorage: false }, + ), + ); + + it.effect("surfaces typed safe storage availability failures", () => { + const cause = new Error("safe storage unavailable"); + return withSavedEnvironments( + Effect.gen(function* () { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* savedEnvironments.setRegistry([savedRegistryRecord]); + + const error = yield* savedEnvironments + .setSecret({ + environmentId: savedRegistryRecord.environmentId, + secret: "next-token", + }) + .pipe(Effect.flip); + + assert.instanceOf(error, ElectronSafeStorage.ElectronSafeStorageAvailabilityError); + assert.equal(error.cause, cause); + }), + { availabilityError: cause }, + ); + }); + + it.effect("removes saved environment secrets", () => + withSavedEnvironments( + Effect.gen(function* () { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* savedEnvironments.setRegistry([savedRegistryRecord]); + yield* savedEnvironments.setSecret({ + environmentId: savedRegistryRecord.environmentId, + secret: "bearer-token", + }); + + yield* savedEnvironments.removeSecret(savedRegistryRecord.environmentId); + + assert.isTrue( + Option.isNone(yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId)), + ); + }), + ), + ); + + it.effect("treats empty saved environment documents as empty", () => + withSavedEnvironments( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, "{}\n"); + + assert.deepEqual(yield* savedEnvironments.getRegistry, []); + assert.isTrue( + Option.isNone(yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId)), + ); + }), + ), + ); + + it.effect("treats malformed saved environment documents as empty", () => + withSavedEnvironments( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, "{not-json"); + + assert.deepEqual(yield* savedEnvironments.getRegistry, []); + assert.isTrue( + Option.isNone(yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId)), + ); + }), + ), + ); + + it.effect("returns false when writing a secret without metadata", () => + withSavedEnvironments( + Effect.gen(function* () { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + + assert.isFalse( + yield* savedEnvironments.setSecret({ + environmentId: savedRegistryRecord.environmentId, + secret: "bearer-token", + }), + ); + }), + ), + ); + + it.effect("preserves encrypted secrets when metadata is rewritten", () => + withSavedEnvironments( + Effect.gen(function* () { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* savedEnvironments.setRegistry([savedRegistryRecord]); + yield* savedEnvironments.setSecret({ + environmentId: savedRegistryRecord.environmentId, + secret: "bearer-token", + }); + + yield* savedEnvironments.setRegistry([savedRegistryRecord]); + + assert.deepEqual(yield* savedEnvironments.getRegistry, [savedRegistryRecord]); + assert.deepEqual( + yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId), + Option.some("bearer-token"), + ); + }), + ), + ); +}); diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.ts new file mode 100644 index 00000000000..0d86f27db7a --- /dev/null +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.ts @@ -0,0 +1,389 @@ +import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; +import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Random from "effect/Random"; +import * as Schema from "effect/Schema"; +import * as Ref from "effect/Ref"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; + +type PersistedSavedEnvironmentDesktopSsh = NonNullable< + PersistedSavedEnvironmentRecord["desktopSsh"] +>; + +interface PersistedSavedEnvironmentStorageRecord extends Omit< + PersistedSavedEnvironmentRecord, + "desktopSsh" +> { + readonly desktopSsh?: PersistedSavedEnvironmentDesktopSsh; + readonly encryptedBearerToken?: string; +} + +interface SavedEnvironmentRegistryDocument { + readonly version: number; + readonly records: readonly PersistedSavedEnvironmentStorageRecord[]; +} + +interface SavedEnvironmentRegistryStorageDocument { + readonly version?: number; + readonly records?: readonly PersistedSavedEnvironmentStorageRecord[]; +} + +const DesktopSshTargetSchema = Schema.Struct({ + alias: Schema.String, + hostname: Schema.String, + username: Schema.NullOr(Schema.String), + port: Schema.NullOr(Schema.Number), +}); + +const PersistedSavedEnvironmentStorageRecordSchema = Schema.Struct({ + environmentId: EnvironmentId, + label: Schema.String, + httpBaseUrl: Schema.String, + wsBaseUrl: Schema.String, + createdAt: Schema.String, + lastConnectedAt: Schema.NullOr(Schema.String), + desktopSsh: Schema.optionalKey(DesktopSshTargetSchema), + encryptedBearerToken: Schema.optionalKey(Schema.String), +}); + +const SavedEnvironmentRegistryDocumentSchema = Schema.Struct({ + version: Schema.optionalKey(Schema.Number), + records: Schema.optionalKey(Schema.Array(PersistedSavedEnvironmentStorageRecordSchema)), +}); + +const SavedEnvironmentRegistryDocumentJson = fromLenientJson( + SavedEnvironmentRegistryDocumentSchema, +); +const decodeSavedEnvironmentRegistryDocumentJson = Schema.decodeEffect( + SavedEnvironmentRegistryDocumentJson, +); +const encodeSavedEnvironmentRegistryDocumentJson = Schema.encodeEffect( + SavedEnvironmentRegistryDocumentJson, +); + +export class DesktopSavedEnvironmentsWriteError extends Data.TaggedError( + "DesktopSavedEnvironmentsWriteError", +)<{ + readonly cause: PlatformError.PlatformError | Schema.SchemaError; +}> { + override get message() { + return `Failed to write desktop saved environments: ${this.cause.message}`; + } +} + +export class DesktopSavedEnvironmentSecretDecodeError extends Data.TaggedError( + "DesktopSavedEnvironmentSecretDecodeError", +)<{ + readonly cause: Encoding.EncodingError; +}> { + override get message() { + return "Failed to decode desktop saved environment secret."; + } +} + +export type DesktopSavedEnvironmentsGetSecretError = + | DesktopSavedEnvironmentSecretDecodeError + | ElectronSafeStorage.ElectronSafeStorageAvailabilityError + | ElectronSafeStorage.ElectronSafeStorageDecryptError; + +export type DesktopSavedEnvironmentsSetSecretError = + | DesktopSavedEnvironmentsWriteError + | ElectronSafeStorage.ElectronSafeStorageAvailabilityError + | ElectronSafeStorage.ElectronSafeStorageEncryptError; + +export interface DesktopSavedEnvironmentsShape { + readonly getRegistry: Effect.Effect; + readonly setRegistry: ( + records: readonly PersistedSavedEnvironmentRecord[], + ) => Effect.Effect; + readonly getSecret: ( + environmentId: string, + ) => Effect.Effect, DesktopSavedEnvironmentsGetSecretError>; + readonly setSecret: (input: { + readonly environmentId: string; + readonly secret: string; + }) => Effect.Effect; + readonly removeSecret: ( + environmentId: string, + ) => Effect.Effect; +} + +export class DesktopSavedEnvironments extends Context.Service< + DesktopSavedEnvironments, + DesktopSavedEnvironmentsShape +>()("t3/desktop/SavedEnvironments") {} + +function toPersistedSavedEnvironmentRecord( + record: PersistedSavedEnvironmentStorageRecord, +): PersistedSavedEnvironmentRecord { + const nextRecord = { + environmentId: record.environmentId, + label: record.label, + httpBaseUrl: record.httpBaseUrl, + wsBaseUrl: record.wsBaseUrl, + createdAt: record.createdAt, + lastConnectedAt: record.lastConnectedAt, + }; + return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; +} + +function toSavedEnvironmentStorageRecord( + record: PersistedSavedEnvironmentRecord | PersistedSavedEnvironmentStorageRecord, + encryptedBearerToken: Option.Option, +): PersistedSavedEnvironmentStorageRecord { + const nextRecord = { + environmentId: record.environmentId, + label: record.label, + httpBaseUrl: record.httpBaseUrl, + wsBaseUrl: record.wsBaseUrl, + createdAt: record.createdAt, + lastConnectedAt: record.lastConnectedAt, + }; + const desktopSsh = record.desktopSsh; + if (desktopSsh) { + return Option.match(encryptedBearerToken, { + onNone: () => ({ ...nextRecord, desktopSsh }), + onSome: (value) => ({ + ...nextRecord, + desktopSsh, + encryptedBearerToken: value, + }), + }); + } + return Option.match(encryptedBearerToken, { + onNone: () => nextRecord, + onSome: (value) => ({ ...nextRecord, encryptedBearerToken: value }), + }); +} + +function normalizeSavedEnvironmentRegistryDocument( + document: SavedEnvironmentRegistryStorageDocument, +): SavedEnvironmentRegistryDocument { + return { + version: document.version ?? 1, + records: document.records ?? [], + }; +} + +function readRegistryDocument( + fileSystem: FileSystem.FileSystem, + registryPath: string, +): Effect.Effect { + return fileSystem.readFileString(registryPath).pipe( + Effect.option, + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed({ version: 1, records: [] }), + onSome: (raw) => + decodeSavedEnvironmentRegistryDocumentJson(raw).pipe( + Effect.map(normalizeSavedEnvironmentRegistryDocument), + Effect.catch(() => Effect.succeed({ version: 1, records: [] })), + ), + }), + ), + ); +} + +function writeRegistryDocument(input: { + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly registryPath: string; + readonly document: SavedEnvironmentRegistryDocument; +}): Effect.Effect { + return Effect.gen(function* () { + const directory = input.path.dirname(input.registryPath); + const suffix = (yield* Random.nextUUIDv4).replace(/-/g, ""); + const tempPath = `${input.registryPath}.${process.pid}.${suffix}.tmp`; + const encoded = yield* encodeSavedEnvironmentRegistryDocumentJson(input.document); + yield* input.fileSystem.makeDirectory(directory, { recursive: true }); + yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`); + yield* input.fileSystem.rename(tempPath, input.registryPath); + }); +} + +function preserveExistingSecrets( + currentDocument: SavedEnvironmentRegistryDocument, + records: readonly PersistedSavedEnvironmentRecord[], +): SavedEnvironmentRegistryDocument { + const encryptedBearerTokenById = new Map( + currentDocument.records.flatMap((record) => + record.encryptedBearerToken + ? [[record.environmentId, record.encryptedBearerToken] as const] + : [], + ), + ); + + return { + version: currentDocument.version, + records: records.map((record) => { + const encryptedBearerToken = encryptedBearerTokenById.get(record.environmentId); + return toSavedEnvironmentStorageRecord(record, Option.fromNullishOr(encryptedBearerToken)); + }), + }; +} + +function decodeSecretBytes( + encoded: string, +): Effect.Effect { + return Effect.fromResult(Encoding.decodeBase64(encoded)).pipe( + Effect.mapError((cause) => new DesktopSavedEnvironmentSecretDecodeError({ cause })), + ); +} + +export const layer = Layer.effect( + DesktopSavedEnvironments, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; + + const writeDocument = (document: SavedEnvironmentRegistryDocument) => + writeRegistryDocument({ + fileSystem, + path, + registryPath: environment.savedEnvironmentRegistryPath, + document, + }).pipe(Effect.mapError((cause) => new DesktopSavedEnvironmentsWriteError({ cause }))); + + return DesktopSavedEnvironments.of({ + getRegistry: readRegistryDocument(fileSystem, environment.savedEnvironmentRegistryPath).pipe( + Effect.map((document) => + document.records.map((record) => toPersistedSavedEnvironmentRecord(record)), + ), + ), + setRegistry: (records) => + Effect.gen(function* () { + const currentDocument = yield* readRegistryDocument( + fileSystem, + environment.savedEnvironmentRegistryPath, + ); + yield* writeDocument(preserveExistingSecrets(currentDocument, records)); + }), + getSecret: (environmentId) => + Effect.gen(function* () { + const document = yield* readRegistryDocument( + fileSystem, + environment.savedEnvironmentRegistryPath, + ); + const encoded = Option.fromNullishOr( + document.records.find((record) => record.environmentId === environmentId) + ?.encryptedBearerToken, + ); + if (Option.isNone(encoded) || !(yield* safeStorage.isEncryptionAvailable)) { + return Option.none(); + } + + const secretBytes = yield* decodeSecretBytes(encoded.value); + return Option.some(yield* safeStorage.decryptString(secretBytes)); + }), + setSecret: ({ environmentId, secret }) => + Effect.gen(function* () { + const document = yield* readRegistryDocument( + fileSystem, + environment.savedEnvironmentRegistryPath, + ); + + if (!(yield* safeStorage.isEncryptionAvailable)) { + return false; + } + + const encryptedBearerToken = Encoding.encodeBase64( + yield* safeStorage.encryptString(secret), + ); + let found = false; + const nextDocument: SavedEnvironmentRegistryDocument = { + version: document.version, + records: document.records.map((record) => { + if (record.environmentId !== environmentId) { + return record; + } + + found = true; + return toSavedEnvironmentStorageRecord(record, Option.some(encryptedBearerToken)); + }), + }; + + if (found) { + yield* writeDocument(nextDocument); + } + return found; + }), + removeSecret: (environmentId) => + Effect.gen(function* () { + const document = yield* readRegistryDocument( + fileSystem, + environment.savedEnvironmentRegistryPath, + ); + if ( + !document.records.some( + (record) => + record.environmentId === environmentId && record.encryptedBearerToken !== undefined, + ) + ) { + return; + } + + yield* writeDocument({ + version: document.version, + records: document.records.map((record) => { + if (record.environmentId !== environmentId) { + return record; + } + return toPersistedSavedEnvironmentRecord(record); + }), + }); + }), + }); + }), +); + +export const layerTest = (input?: { + readonly records?: readonly PersistedSavedEnvironmentRecord[]; + readonly secrets?: ReadonlyMap; +}) => + Layer.effect( + DesktopSavedEnvironments, + Effect.gen(function* () { + const recordsRef = yield* Ref.make(input?.records ?? []); + const secretsRef = yield* Ref.make(new Map(input?.secrets ?? [])); + + return DesktopSavedEnvironments.of({ + getRegistry: Ref.get(recordsRef), + setRegistry: (records) => Ref.set(recordsRef, records), + getSecret: (environmentId) => + Ref.get(secretsRef).pipe( + Effect.map((secrets) => Option.fromNullishOr(secrets.get(environmentId))), + ), + setSecret: ({ environmentId, secret }) => + Ref.get(recordsRef).pipe( + Effect.flatMap((records) => { + if (!records.some((record) => record.environmentId === environmentId)) { + return Effect.succeed(false); + } + return Ref.update(secretsRef, (secrets) => { + const nextSecrets = new Map(secrets); + nextSecrets.set(environmentId, secret); + return nextSecrets; + }).pipe(Effect.as(true)); + }), + ), + removeSecret: (environmentId) => + Ref.update(secretsRef, (secrets) => { + const nextSecrets = new Map(secrets); + nextSecrets.delete(environmentId); + return nextSecrets; + }), + }); + }), + ); diff --git a/apps/desktop/src/settings/DesktopSettingsState.test.ts b/apps/desktop/src/settings/DesktopSettingsState.test.ts deleted file mode 100644 index 4f957ab7f0a..00000000000 --- a/apps/desktop/src/settings/DesktopSettingsState.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; - -import { DEFAULT_DESKTOP_SETTINGS } from "./desktopSettings.ts"; -import * as DesktopSettingsState from "./DesktopSettingsState.ts"; - -describe("DesktopSettingsState", () => { - it.effect("updates settings through effectful ref operations", () => - Effect.gen(function* () { - const settingsState = yield* DesktopSettingsState.DesktopSettingsState; - - assert.deepEqual(yield* settingsState.get, DEFAULT_DESKTOP_SETTINGS); - - const settings = { - ...DEFAULT_DESKTOP_SETTINGS, - updateChannel: "nightly" as const, - updateChannelConfiguredByUser: true, - }; - yield* settingsState.set(settings); - - assert.deepEqual(yield* settingsState.get, settings); - - const updated = yield* settingsState.update((current) => ({ - ...current, - updateChannel: "latest", - })); - - assert.equal(updated.updateChannel, "latest"); - assert.deepEqual(yield* settingsState.get, updated); - }).pipe(Effect.provide(DesktopSettingsState.layer)), - ); -}); diff --git a/apps/desktop/src/settings/DesktopSettingsState.ts b/apps/desktop/src/settings/DesktopSettingsState.ts deleted file mode 100644 index bfc1b74030e..00000000000 --- a/apps/desktop/src/settings/DesktopSettingsState.ts +++ /dev/null @@ -1,94 +0,0 @@ -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; -import * as Schema from "effect/Schema"; -import * as SynchronizedRef from "effect/SynchronizedRef"; - -import { - type DesktopSettings, - DEFAULT_DESKTOP_SETTINGS, - readDesktopSettingsEffect, - writeDesktopSettingsEffect, -} from "./desktopSettings.ts"; -import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; - -export type DesktopSettingsPersistenceError = PlatformError.PlatformError | Schema.SchemaError; - -export interface DesktopSettingsStateShape { - readonly get: Effect.Effect; - readonly set: (settings: DesktopSettings) => Effect.Effect; - readonly load: Effect.Effect< - DesktopSettings, - never, - FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment - >; - readonly update: ( - f: (settings: DesktopSettings) => DesktopSettings, - ) => Effect.Effect; - readonly updatePersisted: ( - f: (settings: DesktopSettings) => DesktopSettings, - ) => Effect.Effect< - DesktopSettings, - DesktopSettingsPersistenceError, - FileSystem.FileSystem | Path.Path | DesktopEnvironment.DesktopEnvironment - >; - readonly modifyPersisted: ( - f: (settings: DesktopSettings) => readonly [A, DesktopSettings], - ) => Effect.Effect< - A, - DesktopSettingsPersistenceError, - FileSystem.FileSystem | Path.Path | DesktopEnvironment.DesktopEnvironment - >; -} - -export class DesktopSettingsState extends Context.Service< - DesktopSettingsState, - DesktopSettingsStateShape ->()("t3/desktop/SettingsState") {} - -export const layer = Layer.effect( - DesktopSettingsState, - Effect.gen(function* () { - const settingsRef = yield* SynchronizedRef.make(DEFAULT_DESKTOP_SETTINGS); - - const update = (f: (settings: DesktopSettings) => DesktopSettings) => - SynchronizedRef.updateAndGet(settingsRef, f); - const modifyPersisted = (f: (settings: DesktopSettings) => readonly [A, DesktopSettings]) => - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - return yield* SynchronizedRef.modifyEffect(settingsRef, (settings) => { - const [result, nextSettings] = f(settings); - if (nextSettings === settings) { - return Effect.succeed([result, settings] as const); - } - - return writeDesktopSettingsEffect(environment.desktopSettingsPath, nextSettings).pipe( - Effect.as([result, nextSettings] as const), - ); - }); - }); - - return DesktopSettingsState.of({ - get: SynchronizedRef.get(settingsRef), - set: (settings) => SynchronizedRef.set(settingsRef, settings), - load: Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const settings = yield* readDesktopSettingsEffect( - environment.desktopSettingsPath, - environment.appVersion, - ); - return yield* SynchronizedRef.setAndGet(settingsRef, settings); - }), - update, - updatePersisted: (f) => - modifyPersisted((settings) => { - const nextSettings = f(settings); - return [nextSettings, nextSettings] as const; - }), - modifyPersisted, - }); - }), -); diff --git a/apps/desktop/src/settings/clientPersistence.test.ts b/apps/desktop/src/settings/clientPersistence.test.ts deleted file mode 100644 index 31bbd7c09e3..00000000000 --- a/apps/desktop/src/settings/clientPersistence.test.ts +++ /dev/null @@ -1,292 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, describe, it } from "@effect/vitest"; -import { - EnvironmentId, - type ClientSettings, - type PersistedSavedEnvironmentRecord, -} from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; -import * as Schema from "effect/Schema"; - -import { - readClientSettingsEffect, - readSavedEnvironmentRegistryEffect, - readSavedEnvironmentSecretEffect, - removeSavedEnvironmentSecretEffect, - writeClientSettingsEffect, - writeSavedEnvironmentRegistryEffect, - writeSavedEnvironmentSecretEffect, -} from "./clientPersistence.ts"; -import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; - -const DesktopSshTargetSchema = Schema.Struct({ - alias: Schema.String, - hostname: Schema.String, - username: Schema.NullOr(Schema.String), - port: Schema.NullOr(Schema.Number), -}); - -const PersistedSavedEnvironmentStorageRecordSchema = Schema.Struct({ - environmentId: EnvironmentId, - label: Schema.String, - httpBaseUrl: Schema.String, - wsBaseUrl: Schema.String, - createdAt: Schema.String, - lastConnectedAt: Schema.NullOr(Schema.String), - desktopSsh: Schema.optional(DesktopSshTargetSchema), - encryptedBearerToken: Schema.optional(Schema.String), -}); - -const SavedEnvironmentRegistryDocumentSchema = Schema.Struct({ - records: Schema.Array(PersistedSavedEnvironmentStorageRecordSchema), -}); - -const decodeSavedEnvironmentRegistryDocument = Schema.decodeEffect( - Schema.fromJsonString(SavedEnvironmentRegistryDocumentSchema), -); - -function makeTempPath(fileName: string) { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const directory = yield* fs.makeTempDirectoryScoped({ - prefix: "t3-client-persistence-test-", - }); - return path.join(directory, fileName); - }); -} - -function readRegistryDocument(filePath: string) { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const raw = yield* fs.readFileString(filePath); - return yield* decodeSavedEnvironmentRegistryDocument(raw); - }); -} - -function makeSecretStorage(available: boolean): ElectronSafeStorage.ElectronSafeStorageShape { - return { - isEncryptionAvailable: () => available, - encryptString: (value) => Buffer.from(`enc:${value}`, "utf8"), - decryptString: (value) => { - const decoded = value.toString("utf8"); - if (!decoded.startsWith("enc:")) { - throw new Error("invalid secret"); - } - return decoded.slice("enc:".length); - }, - }; -} - -const clientSettings: ClientSettings = { - autoOpenPlanSidebar: false, - confirmThreadArchive: true, - confirmThreadDelete: false, - dismissedProviderUpdateNotificationKeys: [], - diffIgnoreWhitespace: true, - diffWordWrap: true, - favorites: [], - providerModelPreferences: {}, - sidebarProjectGroupingMode: "repository_path", - sidebarProjectGroupingOverrides: { - "environment-1:/tmp/project-a": "separate", - }, - sidebarProjectSortOrder: "manual", - sidebarThreadSortOrder: "created_at", - timestampFormat: "24-hour", -}; - -const savedRegistryRecord: PersistedSavedEnvironmentRecord = { - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - httpBaseUrl: "https://remote.example.com/", - wsBaseUrl: "wss://remote.example.com/", - createdAt: "2026-04-09T00:00:00.000Z", - lastConnectedAt: "2026-04-09T01:00:00.000Z", - desktopSsh: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, -}; - -describe("clientPersistence", () => { - it.effect("persists and reloads client settings", () => - Effect.gen(function* () { - const settingsPath = yield* makeTempPath("client-settings.json"); - - yield* writeClientSettingsEffect(settingsPath, clientSettings); - - const settings = yield* readClientSettingsEffect(settingsPath); - assert.deepEqual(settings, clientSettings); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - - it.effect("persists and reloads saved environment metadata", () => - Effect.gen(function* () { - const registryPath = yield* makeTempPath("saved-environments.json"); - - yield* writeSavedEnvironmentRegistryEffect(registryPath, [savedRegistryRecord]); - - const records = yield* readSavedEnvironmentRegistryEffect(registryPath); - assert.deepEqual(records, [savedRegistryRecord]); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - - it.effect("persists encrypted saved environment secrets when encryption is available", () => - Effect.gen(function* () { - const registryPath = yield* makeTempPath("saved-environments.json"); - const secretStorage = makeSecretStorage(true); - - yield* writeSavedEnvironmentRegistryEffect(registryPath, [savedRegistryRecord]); - - const written = yield* writeSavedEnvironmentSecretEffect({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secret: "bearer-token", - secretStorage, - }); - assert.equal(written, true); - - const secret = yield* readSavedEnvironmentSecretEffect({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secretStorage, - }); - assert.equal(secret, "bearer-token"); - - const document = yield* readRegistryDocument(registryPath); - assert.deepEqual(document, { - records: [ - { - ...savedRegistryRecord, - encryptedBearerToken: Buffer.from("enc:bearer-token", "utf8").toString("base64"), - }, - ], - }); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - - it.effect("preserves existing secrets when encryption is unavailable", () => - Effect.gen(function* () { - const registryPath = yield* makeTempPath("saved-environments.json"); - const availableSecretStorage = makeSecretStorage(true); - - yield* writeSavedEnvironmentRegistryEffect(registryPath, [savedRegistryRecord]); - - yield* writeSavedEnvironmentSecretEffect({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secret: "bearer-token", - secretStorage: availableSecretStorage, - }); - - const written = yield* writeSavedEnvironmentSecretEffect({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secret: "next-token", - secretStorage: makeSecretStorage(false), - }); - assert.equal(written, false); - - const secret = yield* readSavedEnvironmentSecretEffect({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secretStorage: availableSecretStorage, - }); - assert.equal(secret, "bearer-token"); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - - it.effect("removes saved environment secrets", () => - Effect.gen(function* () { - const registryPath = yield* makeTempPath("saved-environments.json"); - const secretStorage = makeSecretStorage(true); - - yield* writeSavedEnvironmentRegistryEffect(registryPath, [savedRegistryRecord]); - - yield* writeSavedEnvironmentSecretEffect({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secret: "bearer-token", - secretStorage, - }); - - yield* removeSavedEnvironmentSecretEffect({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - }); - - const secret = yield* readSavedEnvironmentSecretEffect({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secretStorage, - }); - assert.equal(secret, null); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - - it.effect("treats malformed secrets documents as empty", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const registryPath = yield* makeTempPath("saved-environments.json"); - yield* fs.writeFileString(registryPath, "{}\n"); - - const secret = yield* readSavedEnvironmentSecretEffect({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secretStorage: makeSecretStorage(true), - }); - assert.equal(secret, null); - - yield* removeSavedEnvironmentSecretEffect({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - }); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - - it.effect("returns false when writing a secret without metadata", () => - Effect.gen(function* () { - const registryPath = yield* makeTempPath("saved-environments.json"); - - const written = yield* writeSavedEnvironmentSecretEffect({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secret: "bearer-token", - secretStorage: makeSecretStorage(true), - }); - assert.equal(written, false); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - - it.effect("preserves encrypted secrets when metadata is rewritten", () => - Effect.gen(function* () { - const registryPath = yield* makeTempPath("saved-environments.json"); - const secretStorage = makeSecretStorage(true); - - yield* writeSavedEnvironmentRegistryEffect(registryPath, [savedRegistryRecord]); - - yield* writeSavedEnvironmentSecretEffect({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secret: "bearer-token", - secretStorage, - }); - - yield* writeSavedEnvironmentRegistryEffect(registryPath, [savedRegistryRecord]); - - const records = yield* readSavedEnvironmentRegistryEffect(registryPath); - assert.deepEqual(records, [savedRegistryRecord]); - const secret = yield* readSavedEnvironmentSecretEffect({ - registryPath, - environmentId: savedRegistryRecord.environmentId, - secretStorage, - }); - assert.equal(secret, "bearer-token"); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); -}); diff --git a/apps/desktop/src/settings/clientPersistence.ts b/apps/desktop/src/settings/clientPersistence.ts deleted file mode 100644 index 9c78a2d3628..00000000000 --- a/apps/desktop/src/settings/clientPersistence.ts +++ /dev/null @@ -1,327 +0,0 @@ -import { - ClientSettingsSchema, - EnvironmentId, - type ClientSettings, - type PersistedSavedEnvironmentRecord, -} from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Option from "effect/Option"; -import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; -import * as Random from "effect/Random"; -import * as Schema from "effect/Schema"; - -import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; - -interface ClientSettingsDocument { - readonly settings: ClientSettings; -} - -interface PersistedSavedEnvironmentStorageRecord extends Omit< - PersistedSavedEnvironmentRecord, - "desktopSsh" -> { - readonly desktopSsh?: PersistedSavedEnvironmentRecord["desktopSsh"] | undefined; - readonly encryptedBearerToken?: string | undefined; -} - -interface SavedEnvironmentRegistryDocument { - readonly records: readonly PersistedSavedEnvironmentStorageRecord[]; -} - -const ClientSettingsDocumentSchema = Schema.Struct({ - settings: ClientSettingsSchema, -}); - -const DesktopSshTargetSchema = Schema.Struct({ - alias: Schema.String, - hostname: Schema.String, - username: Schema.NullOr(Schema.String), - port: Schema.NullOr(Schema.Number), -}); - -const PersistedSavedEnvironmentStorageRecordSchema = Schema.Struct({ - environmentId: EnvironmentId, - label: Schema.String, - httpBaseUrl: Schema.String, - wsBaseUrl: Schema.String, - createdAt: Schema.String, - lastConnectedAt: Schema.NullOr(Schema.String), - desktopSsh: Schema.optional(DesktopSshTargetSchema), - encryptedBearerToken: Schema.optional(Schema.String), -}); - -const SavedEnvironmentRegistryDocumentSchema = Schema.Struct({ - records: Schema.Array(PersistedSavedEnvironmentStorageRecordSchema), -}); - -const decodeClientSettingsDocumentJson = Schema.decodeEffect( - Schema.fromJsonString(ClientSettingsDocumentSchema), -); -const encodeClientSettingsDocumentJson = Schema.encodeEffect( - Schema.fromJsonString(ClientSettingsDocumentSchema), -); -const decodeSavedEnvironmentRegistryDocumentJson = Schema.decodeEffect( - Schema.fromJsonString(SavedEnvironmentRegistryDocumentSchema), -); -const encodeSavedEnvironmentRegistryDocumentJson = Schema.encodeEffect( - Schema.fromJsonString(SavedEnvironmentRegistryDocumentSchema), -); - -function readJsonFileEffect( - filePath: string, - decode: (raw: string) => Effect.Effect, -): Effect.Effect, never, FileSystem.FileSystem> { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const raw = yield* fileSystem.readFileString(filePath).pipe(Effect.option); - return yield* Option.match(raw, { - onNone: () => Effect.succeed(Option.none()), - onSome: (value) => - decode(value).pipe( - Effect.option, - Effect.map((decoded) => decoded), - ), - }); - }); -} - -function writeJsonFileEffect( - filePath: string, - value: T, - encode: (value: T) => Effect.Effect, -): Effect.Effect< - void, - PlatformError.PlatformError | Schema.SchemaError, - FileSystem.FileSystem | Path.Path -> { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const directory = path.dirname(filePath); - const suffix = (yield* Random.nextUUIDv4).replace(/-/g, ""); - const tempPath = `${filePath}.${process.pid}.${suffix}.tmp`; - const encoded = yield* encode(value); - yield* fileSystem.makeDirectory(directory, { recursive: true }); - yield* fileSystem.writeFileString(tempPath, `${encoded}\n`); - yield* fileSystem.rename(tempPath, filePath); - }); -} - -function readSavedEnvironmentRegistryDocumentEffect( - filePath: string, -): Effect.Effect { - return readJsonFileEffect(filePath, decodeSavedEnvironmentRegistryDocumentJson).pipe( - Effect.map(Option.getOrElse((): SavedEnvironmentRegistryDocument => ({ records: [] }))), - ); -} - -function toPersistedSavedEnvironmentRecord( - record: PersistedSavedEnvironmentStorageRecord, -): PersistedSavedEnvironmentRecord { - const nextRecord = { - environmentId: record.environmentId, - label: record.label, - httpBaseUrl: record.httpBaseUrl, - wsBaseUrl: record.wsBaseUrl, - createdAt: record.createdAt, - lastConnectedAt: record.lastConnectedAt, - }; - return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; -} - -export function readClientSettingsEffect( - settingsPath: string, -): Effect.Effect { - return readJsonFileEffect(settingsPath, decodeClientSettingsDocumentJson).pipe( - Effect.map(Option.match({ onNone: () => null, onSome: (document) => document.settings })), - ); -} - -export function writeClientSettingsEffect( - settingsPath: string, - settings: ClientSettings, -): Effect.Effect< - void, - PlatformError.PlatformError | Schema.SchemaError, - FileSystem.FileSystem | Path.Path -> { - return writeJsonFileEffect( - settingsPath, - { settings } satisfies ClientSettingsDocument, - encodeClientSettingsDocumentJson, - ); -} - -export function readSavedEnvironmentRegistryEffect( - registryPath: string, -): Effect.Effect { - return readSavedEnvironmentRegistryDocumentEffect(registryPath).pipe( - Effect.map((document) => - document.records.map((record) => toPersistedSavedEnvironmentRecord(record)), - ), - ); -} - -export function writeSavedEnvironmentRegistryEffect( - registryPath: string, - records: readonly PersistedSavedEnvironmentRecord[], -): Effect.Effect< - void, - PlatformError.PlatformError | Schema.SchemaError, - FileSystem.FileSystem | Path.Path -> { - return Effect.gen(function* () { - const currentDocument = yield* readSavedEnvironmentRegistryDocumentEffect(registryPath); - const encryptedBearerTokenById = new Map( - currentDocument.records.flatMap((record) => - record.encryptedBearerToken - ? [[record.environmentId, record.encryptedBearerToken] as const] - : [], - ), - ); - yield* writeJsonFileEffect( - registryPath, - { - records: records.map((record) => { - const encryptedBearerToken = encryptedBearerTokenById.get(record.environmentId); - return encryptedBearerToken - ? { - environmentId: record.environmentId, - label: record.label, - httpBaseUrl: record.httpBaseUrl, - wsBaseUrl: record.wsBaseUrl, - createdAt: record.createdAt, - lastConnectedAt: record.lastConnectedAt, - ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), - encryptedBearerToken, - } - : record; - }), - } satisfies SavedEnvironmentRegistryDocument, - encodeSavedEnvironmentRegistryDocumentJson, - ); - }); -} - -export function readSavedEnvironmentSecretEffect(input: { - readonly registryPath: string; - readonly environmentId: string; - readonly secretStorage: ElectronSafeStorage.ElectronSafeStorageShape; -}): Effect.Effect { - return Effect.gen(function* () { - const document = yield* readSavedEnvironmentRegistryDocumentEffect(input.registryPath); - const encoded = document.records.find( - (record) => record.environmentId === input.environmentId, - )?.encryptedBearerToken; - if (!encoded) { - return null; - } - - if (!input.secretStorage.isEncryptionAvailable()) { - return null; - } - - try { - return input.secretStorage.decryptString(Buffer.from(encoded, "base64")); - } catch { - return null; - } - }); -} - -export function writeSavedEnvironmentSecretEffect(input: { - readonly registryPath: string; - readonly environmentId: string; - readonly secret: string; - readonly secretStorage: ElectronSafeStorage.ElectronSafeStorageShape; -}): Effect.Effect< - boolean, - PlatformError.PlatformError | Schema.SchemaError, - FileSystem.FileSystem | Path.Path -> { - return Effect.gen(function* () { - const document = yield* readSavedEnvironmentRegistryDocumentEffect(input.registryPath); - - if (!input.secretStorage.isEncryptionAvailable()) { - return false; - } - - let found = false; - - yield* writeJsonFileEffect( - input.registryPath, - { - records: document.records.map((record) => { - if (record.environmentId !== input.environmentId) { - return record; - } - - found = true; - const encryptedBearerToken = input.secretStorage - .encryptString(input.secret) - .toString("base64"); - const nextRecord = { - environmentId: record.environmentId, - label: record.label, - httpBaseUrl: record.httpBaseUrl, - wsBaseUrl: record.wsBaseUrl, - createdAt: record.createdAt, - lastConnectedAt: record.lastConnectedAt, - encryptedBearerToken, - }; - return record.desktopSsh - ? { - environmentId: nextRecord.environmentId, - label: nextRecord.label, - httpBaseUrl: nextRecord.httpBaseUrl, - wsBaseUrl: nextRecord.wsBaseUrl, - createdAt: nextRecord.createdAt, - lastConnectedAt: nextRecord.lastConnectedAt, - encryptedBearerToken: nextRecord.encryptedBearerToken, - desktopSsh: record.desktopSsh, - } - : nextRecord; - }), - } satisfies SavedEnvironmentRegistryDocument, - encodeSavedEnvironmentRegistryDocumentJson, - ); - return found; - }); -} - -export function removeSavedEnvironmentSecretEffect(input: { - readonly registryPath: string; - readonly environmentId: string; -}): Effect.Effect< - void, - PlatformError.PlatformError | Schema.SchemaError, - FileSystem.FileSystem | Path.Path -> { - return Effect.gen(function* () { - const document = yield* readSavedEnvironmentRegistryDocumentEffect(input.registryPath); - if ( - !document.records.some( - (record) => - record.environmentId === input.environmentId && record.encryptedBearerToken !== undefined, - ) - ) { - return; - } - - yield* writeJsonFileEffect( - input.registryPath, - { - records: document.records.map((record) => { - if (record.environmentId !== input.environmentId) { - return record; - } - - return toPersistedSavedEnvironmentRecord(record); - }), - } satisfies SavedEnvironmentRegistryDocument, - encodeSavedEnvironmentRegistryDocumentJson, - ); - }); -} diff --git a/apps/desktop/src/settings/desktopSettings.test.ts b/apps/desktop/src/settings/desktopSettings.test.ts deleted file mode 100644 index 26f3c3d6b02..00000000000 --- a/apps/desktop/src/settings/desktopSettings.test.ts +++ /dev/null @@ -1,273 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, describe, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; -import * as Schema from "effect/Schema"; - -import { - DEFAULT_DESKTOP_SETTINGS, - readDesktopSettingsEffect, - resolveDefaultDesktopSettings, - setDesktopServerExposurePreference, - setDesktopTailscaleServePreference, - setDesktopUpdateChannelPreference, - writeDesktopSettingsEffect, -} from "./desktopSettings.ts"; - -const DesktopSettingsPatch = Schema.Struct({ - serverExposureMode: Schema.optional(Schema.Literals(["local-only", "network-accessible"])), - tailscaleServeEnabled: Schema.optional(Schema.Boolean), - tailscaleServePort: Schema.optional(Schema.Number), - updateChannel: Schema.optional(Schema.Literals(["latest", "nightly"])), - updateChannelConfiguredByUser: Schema.optional(Schema.Boolean), -}); - -const encodeDesktopSettingsPatch = Schema.encodeEffect(Schema.fromJsonString(DesktopSettingsPatch)); - -function makeSettingsPath() { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const directory = yield* fs.makeTempDirectoryScoped({ - prefix: "t3-desktop-settings-test-", - }); - return path.join(directory, "desktop-settings.json"); - }); -} - -function writeSettingsPatch(filePath: string, patch: typeof DesktopSettingsPatch.Type) { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const encoded = yield* encodeDesktopSettingsPatch(patch); - yield* fs.writeFileString(filePath, `${encoded}\n`); - }); -} - -describe("desktopSettings", () => { - it.effect("returns defaults when no settings file exists", () => - Effect.gen(function* () { - const settingsPath = yield* makeSettingsPath(); - const settings = yield* readDesktopSettingsEffect(settingsPath, "0.0.17"); - assert.deepEqual(settings, DEFAULT_DESKTOP_SETTINGS); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - - it("defaults packaged nightly builds to the nightly update channel", () => { - assert.deepEqual(resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), { - serverExposureMode: "local-only", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - updateChannel: "nightly", - updateChannelConfiguredByUser: false, - }); - }); - - it.effect("persists and reloads the configured server exposure mode", () => - Effect.gen(function* () { - const settingsPath = yield* makeSettingsPath(); - - yield* writeDesktopSettingsEffect(settingsPath, { - serverExposureMode: "network-accessible", - tailscaleServeEnabled: true, - tailscaleServePort: 8443, - updateChannel: "latest", - updateChannelConfiguredByUser: true, - }); - - const settings = yield* readDesktopSettingsEffect(settingsPath, "0.0.17"); - assert.deepEqual(settings, { - serverExposureMode: "network-accessible", - tailscaleServeEnabled: true, - tailscaleServePort: 8443, - updateChannel: "latest", - updateChannelConfiguredByUser: true, - }); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - - it("preserves the requested network-accessible preference across temporary fallback", () => { - assert.deepEqual( - setDesktopServerExposurePreference( - { - serverExposureMode: "local-only", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - updateChannel: "latest", - updateChannelConfiguredByUser: false, - }, - "network-accessible", - ), - { - serverExposureMode: "network-accessible", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - updateChannel: "latest", - updateChannelConfiguredByUser: false, - }, - ); - }); - - it("persists the requested Tailscale Serve preference", () => { - assert.deepEqual( - setDesktopTailscaleServePreference( - { - serverExposureMode: "local-only", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - updateChannel: "latest", - updateChannelConfiguredByUser: false, - }, - { enabled: true, port: 8443 }, - ), - { - serverExposureMode: "local-only", - tailscaleServeEnabled: true, - tailscaleServePort: 8443, - updateChannel: "latest", - updateChannelConfiguredByUser: false, - }, - ); - }); - - it("preserves the configured Tailscale Serve port when no new port is requested", () => { - assert.deepEqual( - setDesktopTailscaleServePreference( - { - serverExposureMode: "local-only", - tailscaleServeEnabled: false, - tailscaleServePort: 8443, - updateChannel: "latest", - updateChannelConfiguredByUser: false, - }, - { enabled: true }, - ), - { - serverExposureMode: "local-only", - tailscaleServeEnabled: true, - tailscaleServePort: 8443, - updateChannel: "latest", - updateChannelConfiguredByUser: false, - }, - ); - }); - - it("persists the requested nightly update channel", () => { - assert.deepEqual( - setDesktopUpdateChannelPreference( - { - serverExposureMode: "local-only", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - updateChannel: "latest", - updateChannelConfiguredByUser: false, - }, - "nightly", - ), - { - serverExposureMode: "local-only", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - updateChannel: "nightly", - updateChannelConfiguredByUser: true, - }, - ); - }); - - it.effect("falls back to defaults when the settings file is malformed", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const settingsPath = yield* makeSettingsPath(); - yield* fs.writeFileString(settingsPath, "{not-json"); - - const settings = yield* readDesktopSettingsEffect(settingsPath, "0.0.17"); - assert.deepEqual(settings, DEFAULT_DESKTOP_SETTINGS); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - - it.effect( - "falls back to the nightly channel for legacy nightly settings without an update track", - () => - Effect.gen(function* () { - const settingsPath = yield* makeSettingsPath(); - yield* writeSettingsPatch(settingsPath, { serverExposureMode: "local-only" }); - - const settings = yield* readDesktopSettingsEffect( - settingsPath, - "0.0.17-nightly.20260415.1", - ); - assert.deepEqual(settings, { - serverExposureMode: "local-only", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - updateChannel: "nightly", - updateChannelConfiguredByUser: false, - }); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - - it.effect( - "migrates legacy implicit stable settings to nightly when running a nightly build", - () => - Effect.gen(function* () { - const settingsPath = yield* makeSettingsPath(); - yield* writeSettingsPatch(settingsPath, { - serverExposureMode: "local-only", - updateChannel: "latest", - }); - - const settings = yield* readDesktopSettingsEffect( - settingsPath, - "0.0.17-nightly.20260415.1", - ); - assert.deepEqual(settings, { - serverExposureMode: "local-only", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - updateChannel: "nightly", - updateChannelConfiguredByUser: false, - }); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - - it.effect("preserves an explicit stable choice on nightly builds", () => - Effect.gen(function* () { - const settingsPath = yield* makeSettingsPath(); - yield* writeSettingsPatch(settingsPath, { - serverExposureMode: "local-only", - updateChannel: "latest", - updateChannelConfiguredByUser: true, - }); - - const settings = yield* readDesktopSettingsEffect(settingsPath, "0.0.17-nightly.20260415.1"); - assert.deepEqual(settings, { - serverExposureMode: "local-only", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - updateChannel: "latest", - updateChannelConfiguredByUser: true, - }); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - - it.effect( - "falls back to the default Tailscale Serve port when the persisted port is invalid", - () => - Effect.gen(function* () { - const settingsPath = yield* makeSettingsPath(); - yield* writeSettingsPatch(settingsPath, { - tailscaleServeEnabled: true, - tailscaleServePort: 0, - }); - - const settings = yield* readDesktopSettingsEffect(settingsPath, "0.0.17"); - assert.deepEqual(settings, { - serverExposureMode: "local-only", - tailscaleServeEnabled: true, - tailscaleServePort: 443, - updateChannel: "latest", - updateChannelConfiguredByUser: false, - }); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); -}); diff --git a/apps/desktop/src/settings/desktopSettings.ts b/apps/desktop/src/settings/desktopSettings.ts deleted file mode 100644 index 761860cbfe7..00000000000 --- a/apps/desktop/src/settings/desktopSettings.ts +++ /dev/null @@ -1,162 +0,0 @@ -import type { DesktopServerExposureMode, DesktopUpdateChannel } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Option from "effect/Option"; -import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; -import * as Random from "effect/Random"; -import * as Schema from "effect/Schema"; - -import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts"; - -export interface DesktopSettings { - readonly serverExposureMode: DesktopServerExposureMode; - readonly tailscaleServeEnabled: boolean; - readonly tailscaleServePort: number; - readonly updateChannel: DesktopUpdateChannel; - readonly updateChannelConfiguredByUser: boolean; -} - -export const DEFAULT_TAILSCALE_SERVE_PORT = 443; - -export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { - serverExposureMode: "local-only", - tailscaleServeEnabled: false, - tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, - updateChannel: "latest", - updateChannelConfiguredByUser: false, -}; - -const DesktopSettingsDocument = Schema.Struct({ - serverExposureMode: Schema.optional(Schema.Literals(["local-only", "network-accessible"])), - tailscaleServeEnabled: Schema.optional(Schema.Boolean), - tailscaleServePort: Schema.optional(Schema.Number), - updateChannel: Schema.optional(Schema.Literals(["latest", "nightly"])), - updateChannelConfiguredByUser: Schema.optional(Schema.Boolean), -}); - -type DesktopSettingsDocument = typeof DesktopSettingsDocument.Type; - -const decodeDesktopSettingsJson = Schema.decodeEffect( - Schema.fromJsonString(DesktopSettingsDocument), -); -const encodeDesktopSettingsJson = Schema.encodeEffect( - Schema.fromJsonString(DesktopSettingsDocument), -); - -export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings { - return { - ...DEFAULT_DESKTOP_SETTINGS, - updateChannel: resolveDefaultDesktopUpdateChannel(appVersion), - }; -} - -export function setDesktopServerExposurePreference( - settings: DesktopSettings, - requestedMode: DesktopServerExposureMode, -): DesktopSettings { - return settings.serverExposureMode === requestedMode - ? settings - : { - ...settings, - serverExposureMode: requestedMode, - }; -} - -export function setDesktopTailscaleServePreference( - settings: DesktopSettings, - input: { readonly enabled: boolean; readonly port?: number }, -): DesktopSettings { - const port = - input.port === undefined - ? settings.tailscaleServePort - : normalizeTailscaleServePort(input.port); - return settings.tailscaleServeEnabled === input.enabled && settings.tailscaleServePort === port - ? settings - : { - ...settings, - tailscaleServeEnabled: input.enabled, - tailscaleServePort: port, - }; -} - -export function normalizeTailscaleServePort(value: unknown): number { - return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65_535 - ? value - : DEFAULT_TAILSCALE_SERVE_PORT; -} - -export function setDesktopUpdateChannelPreference( - settings: DesktopSettings, - requestedChannel: DesktopUpdateChannel, -): DesktopSettings { - return { - ...settings, - updateChannel: requestedChannel, - updateChannelConfiguredByUser: true, - }; -} - -function normalizeDesktopSettingsDocument( - parsed: DesktopSettingsDocument, - appVersion: string, -): DesktopSettings { - const defaultSettings = resolveDefaultDesktopSettings(appVersion); - const parsedUpdateChannel = Option.fromNullishOr(parsed.updateChannel); - const isLegacySettings = parsed.updateChannelConfiguredByUser === undefined; - const updateChannelConfiguredByUser = - parsed.updateChannelConfiguredByUser === true || - (isLegacySettings && Option.contains(parsedUpdateChannel, "nightly")); - - return { - serverExposureMode: - parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", - tailscaleServeEnabled: parsed.tailscaleServeEnabled === true, - tailscaleServePort: normalizeTailscaleServePort(parsed.tailscaleServePort), - updateChannel: updateChannelConfiguredByUser - ? Option.getOrElse(parsedUpdateChannel, () => defaultSettings.updateChannel) - : defaultSettings.updateChannel, - updateChannelConfiguredByUser, - }; -} - -export function readDesktopSettingsEffect( - settingsPath: string, - appVersion: string, -): Effect.Effect { - const defaultSettings = resolveDefaultDesktopSettings(appVersion); - - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const raw = yield* fileSystem.readFileString(settingsPath).pipe(Effect.option); - return yield* Option.match(raw, { - onNone: () => Effect.succeed(defaultSettings), - onSome: (value) => - decodeDesktopSettingsJson(value).pipe( - Effect.map((parsed) => normalizeDesktopSettingsDocument(parsed, appVersion)), - Effect.catch(() => Effect.succeed(defaultSettings)), - ), - }); - }); -} - -export function writeDesktopSettingsEffect( - settingsPath: string, - settings: DesktopSettings, -): Effect.Effect< - void, - PlatformError.PlatformError | Schema.SchemaError, - FileSystem.FileSystem | Path.Path -> { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const directory = path.dirname(settingsPath); - const suffix = (yield* Random.nextUUIDv4).replace(/-/g, ""); - const tempPath = `${settingsPath}.${process.pid}.${suffix}.tmp`; - const encoded = yield* encodeDesktopSettingsJson(settings); - yield* fileSystem.makeDirectory(directory, { recursive: true }); - yield* fileSystem.writeFileString(tempPath, `${encoded}\n`); - yield* fileSystem.rename(tempPath, settingsPath); - }); -} diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 85fb92b5595..ed56ed3d3fe 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -15,8 +15,7 @@ import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktopSettings.ts"; -import * as DesktopSettingsState from "../settings/DesktopSettingsState.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; @@ -146,7 +145,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { Layer.provideMerge(windowLayer), Layer.provideMerge(backendLayer), Layer.provideMerge(DesktopState.layer), - Layer.provideMerge(DesktopSettingsState.layer), + Layer.provideMerge(DesktopAppSettings.layer), Layer.provideMerge( DesktopConfig.layerTest({ T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`, @@ -230,17 +229,16 @@ describe("DesktopUpdates", () => { return Effect.scoped( Effect.gen(function* () { - const settingsState = yield* DesktopSettingsState.DesktopSettingsState; + const settings = yield* DesktopAppSettings.DesktopAppSettings; const updates = yield* DesktopUpdates.DesktopUpdates; - yield* settingsState.set(DEFAULT_DESKTOP_SETTINGS); yield* updates.configure; const state = yield* updates.setChannel("nightly"); - const settings = yield* settingsState.get; + const persistedSettings = yield* settings.get; assert.equal(state.channel, "nightly"); - assert.equal(settings.updateChannel, "nightly"); - assert.equal(settings.updateChannelConfiguredByUser, true); + assert.equal(persistedSettings.updateChannel, "nightly"); + assert.equal(persistedSettings.updateChannelConfiguredByUser, true); }), ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); @@ -250,17 +248,16 @@ describe("DesktopUpdates", () => { return Effect.scoped( Effect.gen(function* () { - const settingsState = yield* DesktopSettingsState.DesktopSettingsState; + const settings = yield* DesktopAppSettings.DesktopAppSettings; const updates = yield* DesktopUpdates.DesktopUpdates; - yield* settingsState.set(DEFAULT_DESKTOP_SETTINGS); yield* updates.configure; const state = yield* updates.setChannel("latest"); - const settings = yield* settingsState.get; + const persistedSettings = yield* settings.get; assert.equal(state.channel, "latest"); - assert.equal(settings.updateChannel, "latest"); - assert.equal(settings.updateChannelConfiguredByUser, false); + assert.equal(persistedSettings.updateChannel, "latest"); + assert.equal(persistedSettings.updateChannelConfiguredByUser, false); }), ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 13cd22a16e2..4f4a4f6a69b 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -15,7 +15,6 @@ import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -24,13 +23,9 @@ import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; -import * as DesktopSettingsState from "../settings/DesktopSettingsState.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; -import { - type DesktopSettings, - setDesktopUpdateChannelPreference, -} from "../settings/desktopSettings.ts"; import * as IpcChannels from "../ipc/channels.ts"; import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; import { @@ -75,7 +70,7 @@ export class DesktopUpdateActionInProgressError extends Data.TaggedError( export class DesktopUpdatePersistenceError extends Data.TaggedError( "DesktopUpdatePersistenceError", )<{ - readonly cause: DesktopSettingsState.DesktopSettingsPersistenceError; + readonly cause: DesktopAppSettings.DesktopSettingsWriteError; }> { override get message() { return "Failed to persist desktop update settings."; @@ -217,17 +212,7 @@ const make = Effect.gen(function* () { const electronWindow = yield* ElectronWindow.ElectronWindow; const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const settingsState = yield* DesktopSettingsState.DesktopSettingsState; - const updatePersistedSettings = ( - f: Parameters[0], - ): Effect.Effect => - settingsState.updatePersisted(f).pipe( - Effect.mapError((cause) => new DesktopUpdatePersistenceError({ cause })), - Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ); + const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const appUpdateYmlConfigRef = yield* Ref.make>(Option.none()); const updatePollerScopeRef = yield* Ref.make>(Option.none()); @@ -586,7 +571,7 @@ const make = Effect.gen(function* () { } as ElectronUpdater.ElectronUpdaterFeedUrl); } - const settings = yield* settingsState.get; + const settings = yield* desktopSettings.get; const enabled = yield* shouldEnableAutoUpdates; yield* setState(createBaseUpdateState(settings.updateChannel, enabled, environment)); if (!enabled) { @@ -640,9 +625,9 @@ const make = Effect.gen(function* () { return state; } - yield* updatePersistedSettings((settings) => - setDesktopUpdateChannelPreference(settings, nextChannel), - ); + yield* desktopSettings + .setUpdateChannel(nextChannel) + .pipe(Effect.mapError((cause) => new DesktopUpdatePersistenceError({ cause }))); const enabled = yield* shouldEnableAutoUpdates; yield* setState(createBaseUpdateState(nextChannel, enabled, environment)); From 7bdfc8c35da2aa306ed60e55a5cd0b23eef6fda6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 7 May 2026 12:25:39 -0700 Subject: [PATCH 34/43] Fix rebased desktop bootstrap config Co-authored-by: codex --- apps/server/src/cli/config.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 48ac5cff3b8..0af25ab45c8 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -266,11 +266,7 @@ export const resolveServerConfig = ( }, ); const devUrl = Option.getOrElse( - resolveOptionPrecedence( - normalizedFlags.devUrl, - Option.fromUndefinedOr(env.devUrl), - Option.fromUndefinedOr(bootstrap?.devUrl), - ), + resolveOptionPrecedence(normalizedFlags.devUrl, Option.fromUndefinedOr(env.devUrl)), () => undefined, ); const baseDir = yield* resolveBaseDir( @@ -310,7 +306,6 @@ export const resolveServerConfig = ( isHeadlessStartup ? Option.some(false) : Option.none(), normalizedFlags.autoBootstrapProjectFromCwd, Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd), - Option.fromUndefinedOr(bootstrap?.autoBootstrapProjectFromCwd), ), () => mode === "web", ); @@ -318,7 +313,6 @@ export const resolveServerConfig = ( resolveOptionPrecedence( normalizedFlags.logWebSocketEvents, Option.fromUndefinedOr(env.logWebSocketEvents), - Option.fromUndefinedOr(bootstrap?.logWebSocketEvents), ), () => Boolean(devUrl), ); From ccee7cecd56505518b51df28e9d41f51942c4810 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 7 May 2026 15:00:14 -0700 Subject: [PATCH 35/43] Simplify desktop shutdown and timeout handling - Remove explicit shutdown paths - Rely on scoped finalizers for backend, updates, and SSH prompts - Co-authored-by: codex --- apps/desktop/src/app/DesktopApp.ts | 79 +++++++++---------- .../src/backend/DesktopBackendManager.ts | 24 +----- .../src/ssh/DesktopSshPasswordPrompts.ts | 55 +++++-------- .../src/updates/DesktopUpdates.test.ts | 2 - apps/desktop/src/updates/DesktopUpdates.ts | 23 +----- .../src/window/DesktopApplicationMenu.test.ts | 1 - 6 files changed, 60 insertions(+), 124 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 6fb6835a515..7dcd477f1a0 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -3,7 +3,6 @@ import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; -import * as Scope from "effect/Scope"; import * as NetService from "@t3tools/shared/Net"; import * as ElectronApp from "../electron/ElectronApp.ts"; @@ -186,52 +185,46 @@ const bootstrap = Effect.gen(function* () { export const program = Effect.scoped( Effect.gen(function* () { const shutdown = yield* DesktopLifecycle.DesktopShutdown; + const appIdentity = yield* DesktopAppIdentity.DesktopAppIdentity; + const applicationMenu = yield* DesktopApplicationMenu.DesktopApplicationMenu; + const backendManager = yield* DesktopBackendManager.DesktopBackendManager; + const electronApp = yield* ElectronApp.ElectronApp; + const electronProtocol = yield* ElectronProtocol.ElectronProtocol; + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; + const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; + const updates = yield* DesktopUpdates.DesktopUpdates; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const run = yield* DesktopRun.DesktopRun; - yield* Effect.gen(function* () { - const appIdentity = yield* DesktopAppIdentity.DesktopAppIdentity; - const applicationMenu = yield* DesktopApplicationMenu.DesktopApplicationMenu; - const backendManager = yield* DesktopBackendManager.DesktopBackendManager; - const electronApp = yield* ElectronApp.ElectronApp; - const electronProtocol = yield* ElectronProtocol.ElectronProtocol; - const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; - const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; - const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; - const updates = yield* DesktopUpdates.DesktopUpdates; - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const run = yield* DesktopRun.DesktopRun; - - yield* electronProtocol.registerDesktopSchemePrivileges; - yield* run.refreshId; - yield* Scope.addFinalizer( - yield* Scope.Scope, - Effect.zip(backendManager.shutdown, updates.shutdown).pipe( - Effect.ensuring(shutdown.markComplete), - ), - ); + yield* electronProtocol.registerDesktopSchemePrivileges; + yield* run.refreshId; + yield* Effect.addFinalizer(() => + backendManager.stop().pipe(Effect.ensuring(shutdown.markComplete)), + ); - yield* shellEnvironment.installIntoProcess; - const userDataPath = yield* appIdentity.resolveUserDataPath; - yield* electronApp.setPath("userData", userDataPath); - yield* run.logInfo("runtime logging configured", { logDir: environment.logDir }); - yield* desktopSettings.load; + yield* shellEnvironment.installIntoProcess; + const userDataPath = yield* appIdentity.resolveUserDataPath; + yield* electronApp.setPath("userData", userDataPath); + yield* run.logInfo("runtime logging configured", { logDir: environment.logDir }); + yield* desktopSettings.load; - if (environment.platform === "linux") { - yield* electronApp.appendCommandLineSwitch("class", environment.linuxWmClass); - } + if (environment.platform === "linux") { + yield* electronApp.appendCommandLineSwitch("class", environment.linuxWmClass); + } - yield* appIdentity.configure; - yield* lifecycle.register; + yield* appIdentity.configure; + yield* lifecycle.register; - yield* electronApp.whenReady.pipe( - Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)), - ); - yield* run.logInfo("app ready"); - yield* appIdentity.configure; - yield* applicationMenu.configure; - yield* electronProtocol.registerDesktopFileProtocol; - yield* updates.configure; - yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); - yield* shutdown.awaitRequest; - }); + yield* electronApp.whenReady.pipe( + Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)), + ); + yield* run.logInfo("app ready"); + yield* appIdentity.configure; + yield* applicationMenu.configure; + yield* electronProtocol.registerDesktopFileProtocol; + yield* updates.configure; + yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); + yield* shutdown.awaitRequest; }), ); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 26d7e8cf30c..042da3ff84d 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -103,13 +103,11 @@ export interface DesktopBackendSnapshot { readonly activePid: Option.Option; readonly restartAttempt: number; readonly restartScheduled: boolean; - readonly shuttingDown: boolean; } export interface DesktopBackendManagerShape { readonly start: Effect.Effect; readonly stop: (options?: { readonly timeout?: Duration.Duration }) => Effect.Effect; - readonly shutdown: Effect.Effect; readonly currentConfig: Effect.Effect>; readonly snapshot: Effect.Effect; } @@ -134,7 +132,6 @@ interface BackendManagerState { readonly restartAttempt: number; readonly restartFiber: Option.Option>; readonly nextRunId: number; - readonly shuttingDown: boolean; } const initialState: BackendManagerState = { @@ -145,7 +142,6 @@ const initialState: BackendManagerState = { restartAttempt: 0, restartFiber: Option.none(), nextRunId: 1, - shuttingDown: false, }; const activePid = (active: Option.Option): Option.Option => @@ -300,7 +296,6 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio activePid: activePid(current.active), restartAttempt: current.restartAttempt, restartScheduled: Option.isSome(current.restartFiber), - shuttingDown: current.shuttingDown, }), ), ); @@ -325,7 +320,7 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio mutex.withPermits(1)( Effect.gen(function* () { const current = yield* Ref.get(state); - if (current.shuttingDown || Option.isSome(current.active)) { + if (Option.isSome(current.active)) { return; } @@ -418,7 +413,7 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio yield* Ref.set(desktopState.backendReady, false); } - if (isCurrentRun && nextState.desiredRunning && !nextState.shuttingDown) { + if (isCurrentRun && nextState.desiredRunning) { yield* scheduleRestart(reason); } }), @@ -488,7 +483,7 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio const scheduleRestart = (reason: string): Effect.Effect => Effect.gen(function* () { const scheduled = yield* Ref.modify(state, (latest) => { - if (latest.shuttingDown || !latest.desiredRunning || Option.isSome(latest.restartFiber)) { + if (!latest.desiredRunning || Option.isSome(latest.restartFiber)) { return [Option.none(), latest] as const; } @@ -569,22 +564,11 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio }); }); - const shutdown = Effect.gen(function* () { - yield* Ref.update(state, (latest) => ({ - ...latest, - shuttingDown: true, - desiredRunning: false, - })); - yield* cancelRestart; - yield* stop(); - }); - - yield* Scope.addFinalizer(parentScope, shutdown); + yield* Effect.addFinalizer(() => stop()); return DesktopBackendManager.of({ start, stop, - shutdown, currentConfig, snapshot, }); diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts index e87439f8e18..0e0db3b8e83 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts @@ -7,12 +7,10 @@ import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Random from "effect/Random"; import * as Ref from "effect/Ref"; -import * as Scope from "effect/Scope"; import * as IpcChannels from "../ipc/channels.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; @@ -137,7 +135,6 @@ interface PendingSshPasswordPrompt { readonly requestId: string; readonly destination: string; readonly deferred: Deferred.Deferred; - readonly timeoutFiber: Fiber.Fiber; } interface LayerOptions { @@ -159,21 +156,13 @@ const removePending = ( return [Option.some(entry), nextPending] as const; }); -const interruptTimeout = (pending: PendingSshPasswordPrompt) => - Fiber.interrupt(pending.timeoutFiber).pipe(Effect.ignore); - const failPending = ( pending: PendingSshPasswordPrompt, error: DesktopSshPasswordPromptRequestError, -) => - interruptTimeout(pending).pipe( - Effect.andThen(Deferred.fail(pending.deferred, error)), - Effect.asVoid, - ); +) => Deferred.fail(pending.deferred, error).pipe(Effect.asVoid); const make = (options: LayerOptions = {}) => Effect.gen(function* () { - const serviceScope = yield* Scope.Scope; const electronWindow = yield* ElectronWindow.ElectronWindow; const pendingRef = yield* Ref.make(new Map()); const passwordPromptTimeoutMs = @@ -199,8 +188,7 @@ const make = (options: LayerOptions = {}) => Effect.asVoid, ); - yield* Scope.addFinalizer( - serviceScope, + yield* Effect.addFinalizer(() => cancelPending("SSH password prompt service stopped.").pipe(Effect.ignore), ); @@ -231,7 +219,6 @@ const make = (options: LayerOptions = {}) => return; } - yield* interruptTimeout(entry); yield* Deferred.succeed(entry.deferred, input.password).pipe(Effect.asVoid); }); @@ -259,28 +246,10 @@ const make = (options: LayerOptions = {}) => expiresAt, }; const deferred = yield* Deferred.make(); - const timeoutFiber = yield* Effect.sleep(Duration.millis(passwordPromptTimeoutMs)).pipe( - Effect.andThen(removePending(pendingRef, requestId)), - Effect.flatMap((pending) => - Option.match(pending, { - onNone: () => Effect.void, - onSome: (entry) => - Deferred.fail( - entry.deferred, - new DesktopSshPromptTimedOutError({ - requestId, - destination: input.destination, - }), - ).pipe(Effect.asVoid), - }), - ), - Effect.forkIn(serviceScope), - ); const pending: PendingSshPasswordPrompt = { requestId, destination: input.destination, deferred, - timeoutFiber, }; yield* Ref.update(pendingRef, (entries) => new Map(entries).set(requestId, pending)); @@ -311,9 +280,21 @@ const make = (options: LayerOptions = {}) => if (!window.value.isDestroyed()) { window.value.removeListener("closed", cancelOnWindowClosed); } - }).pipe( - Effect.andThen(removePending(pendingRef, requestId)), - Effect.andThen(interruptTimeout(pending)), + }).pipe(Effect.andThen(removePending(pendingRef, requestId)), Effect.asVoid); + const waitForPassword = Deferred.await(deferred).pipe( + Effect.timeoutOption(Duration.millis(passwordPromptTimeoutMs)), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new DesktopSshPromptTimedOutError({ + requestId, + destination: input.destination, + }), + ), + onSome: Effect.succeed, + }), + ), ); return yield* Effect.try({ @@ -340,7 +321,7 @@ const make = (options: LayerOptions = {}) => destination: input.destination, cause, }), - }).pipe(Effect.andThen(Deferred.await(deferred)), Effect.ensuring(cleanup)); + }).pipe(Effect.andThen(waitForPassword), Effect.ensuring(cleanup)); }); return DesktopSshPasswordPrompts.of({ diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index ed56ed3d3fe..9838fe10747 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -104,7 +104,6 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { const backendLayer = Layer.succeed(DesktopBackendManager.DesktopBackendManager, { start: Effect.void, stop: () => Effect.void, - shutdown: Effect.void, currentConfig: Effect.succeed(Option.none()), snapshot: Effect.succeed({ desiredRunning: false, @@ -112,7 +111,6 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { activePid: Option.none(), restartAttempt: 0, restartScheduled: false, - shuttingDown: false, }), }); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 4f4a4f6a69b..c19954f7b2d 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -11,7 +11,6 @@ import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -94,7 +93,6 @@ export interface DesktopUpdatesShape { readonly check: (reason: string) => Effect.Effect; readonly download: Effect.Effect; readonly install: Effect.Effect; - readonly shutdown: Effect.Effect; } export class DesktopUpdates extends Context.Service()( @@ -215,7 +213,6 @@ const make = Effect.gen(function* () { const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const appUpdateYmlConfigRef = yield* Ref.make>(Option.none()); - const updatePollerScopeRef = yield* Ref.make>(Option.none()); const updateCheckInFlightRef = yield* Ref.make(false); const updateDownloadInFlightRef = yield* Ref.make(false); const updateInstallInFlightRef = yield* Ref.make(false); @@ -288,14 +285,6 @@ const make = Effect.gen(function* () { return Option.none<"check" | "download" | "install">(); }); - const clearUpdatePollTimer = Effect.gen(function* () { - const scope = yield* Ref.getAndSet(updatePollerScopeRef, Option.none()); - yield* Option.match(scope, { - onNone: () => Effect.void, - onSome: (value) => Scope.close(value, Exit.void).pipe(Effect.ignore), - }); - }); - const applyAutoUpdaterChannel = (channel: DesktopUpdateChannel): Effect.Effect => Effect.gen(function* () { const allowsPrerelease = channel === "nightly"; @@ -392,7 +381,6 @@ const make = Effect.gen(function* () { yield* Ref.set(desktopState.quitting, true); yield* Ref.set(updateInstallInFlightRef, true); - yield* clearUpdatePollTimer; return yield* Effect.gen(function* () { yield* backendManager.stop({ timeout: Duration.seconds(5) }); @@ -418,18 +406,12 @@ const make = Effect.gen(function* () { }); const startUpdatePollers: Effect.Effect = Effect.gen(function* () { - yield* clearUpdatePollTimer; - const parentScope = yield* Scope.Scope; - const scope = yield* Scope.make("sequential"); - yield* Ref.set(updatePollerScopeRef, Option.some(scope)); - yield* Scope.addFinalizer(parentScope, Scope.close(scope, Exit.void)); - yield* Effect.sleep(AUTO_UPDATE_STARTUP_DELAY).pipe( Effect.andThen(checkForUpdates("startup")), Effect.catchCause((cause) => logUpdaterError("startup update check failed", { cause: Cause.pretty(cause) }), ), - Effect.forkIn(scope), + Effect.forkScoped, ); yield* Effect.sleep(AUTO_UPDATE_POLL_INTERVAL).pipe( Effect.andThen(checkForUpdates("poll")), @@ -437,7 +419,7 @@ const make = Effect.gen(function* () { Effect.catchCause((cause) => logUpdaterError("poll update check failed", { cause: Cause.pretty(cause) }), ), - Effect.forkIn(scope), + Effect.forkScoped, ); }); @@ -681,7 +663,6 @@ const make = Effect.gen(function* () { state: yield* Ref.get(updateStateRef), }; }), - shutdown: Ref.set(updateInstallInFlightRef, false).pipe(Effect.andThen(clearUpdatePollTimer)), }); }); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 92b6f686875..9b3a43ffad2 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -70,7 +70,6 @@ const desktopUpdatesLayer = Layer.succeed(DesktopUpdates.DesktopUpdates, { check: () => Effect.die("unexpected check"), download: Effect.die("unexpected download"), install: Effect.die("unexpected install"), - shutdown: Effect.void, } satisfies DesktopUpdates.DesktopUpdatesShape); const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => From daf956bf6dc59d8a37d7cafb8c2405f0aad5ca91 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 7 May 2026 15:18:44 -0700 Subject: [PATCH 36/43] Refactor desktop logging around run annotations - Remove the DesktopRun service and derive run IDs from scoped log annotations - Update backend lifecycle and menu logging to use Effect logging helpers - Adjust tests for backend restarts and menu behavior --- apps/desktop/src/app/DesktopApp.ts | 65 +++++++++-------- apps/desktop/src/app/DesktopLifecycle.ts | 15 ++-- apps/desktop/src/app/DesktopLogging.ts | 10 ++- apps/desktop/src/app/DesktopRun.ts | 65 ----------------- .../DesktopBackendConfiguration.test.ts | 2 - .../backend/DesktopBackendConfiguration.ts | 8 +-- .../src/backend/DesktopBackendManager.test.ts | 31 ++------ .../src/backend/DesktopBackendManager.ts | 28 ++++---- apps/desktop/src/main.ts | 2 - .../src/window/DesktopApplicationMenu.test.ts | 10 --- .../src/window/DesktopApplicationMenu.ts | 70 ++++++++----------- 11 files changed, 99 insertions(+), 207 deletions(-) delete mode 100644 apps/desktop/src/app/DesktopRun.ts diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 7dcd477f1a0..71a07d45522 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -2,6 +2,7 @@ import * as Cause from "effect/Cause"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as Random from "effect/Random"; import * as Ref from "effect/Ref"; import * as NetService from "@t3tools/shared/Net"; @@ -14,7 +15,6 @@ import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopLifecycle from "./DesktopLifecycle.ts"; -import * as DesktopRun from "./DesktopRun.ts"; import * as DesktopServerExposure from "../serverExposure/DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; @@ -26,6 +26,10 @@ const DEFAULT_DESKTOP_BACKEND_PORT = 3773; const MAX_TCP_PORT = 65_535; const DESKTOP_BACKEND_PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::"] as const; +const makeDesktopRunId = Random.nextUUIDv4.pipe( + Effect.map((value) => value.replaceAll("-", "").slice(0, 12)), +); + class DesktopBackendPortUnavailableError extends Data.TaggedError( "DesktopBackendPortUnavailableError", )<{ @@ -89,7 +93,6 @@ const handleFatalStartupError = ( void, never, | DesktopLifecycle.DesktopShutdown - | DesktopRun.DesktopRun | DesktopState.DesktopState | ElectronApp.ElectronApp | ElectronDialog.ElectronDialog @@ -99,15 +102,16 @@ const handleFatalStartupError = ( const state = yield* DesktopState.DesktopState; const electronApp = yield* ElectronApp.ElectronApp; const electronDialog = yield* ElectronDialog.ElectronDialog; - const run = yield* DesktopRun.DesktopRun; const message = error instanceof Error ? error.message : String(error); const detail = error instanceof Error && typeof error.stack === "string" ? `\n${error.stack}` : ""; - yield* run.logError("fatal startup error", { - stage, - message, - ...(detail.length > 0 ? { detail } : {}), - }); + yield* Effect.logError("fatal startup error").pipe( + Effect.annotateLogs({ + stage, + message, + ...(detail.length > 0 ? { detail } : {}), + }), + ); const wasQuitting = yield* Ref.getAndSet(state.quitting, true); if (!wasQuitting) { yield* electronDialog.showErrorBox( @@ -129,8 +133,7 @@ const bootstrap = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - const run = yield* DesktopRun.DesktopRun; - yield* run.logInfo("bootstrap start"); + yield* Effect.logInfo("bootstrap start"); if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) { return yield* new DesktopDevelopmentBackendPortRequiredError(); @@ -138,44 +141,45 @@ const bootstrap = Effect.gen(function* () { const backendPortSelection = yield* resolveDesktopBackendPort(environment.configuredBackendPort); const backendPort = backendPortSelection.port; - yield* run.logInfo( + yield* Effect.logInfo( backendPortSelection.selectedByScan ? "selected backend port via sequential scan" : "using configured backend port", - { + ).pipe( + Effect.annotateLogs({ port: backendPort, ...(backendPortSelection.selectedByScan ? { startPort: DEFAULT_DESKTOP_BACKEND_PORT } : {}), - }, + }), ); const settings = yield* desktopSettings.get; if (settings.serverExposureMode !== environment.defaultDesktopSettings.serverExposureMode) { - yield* run.logInfo("bootstrap restoring persisted server exposure mode", { - mode: settings.serverExposureMode, - }); + yield* Effect.logInfo("bootstrap restoring persisted server exposure mode").pipe( + Effect.annotateLogs({ mode: settings.serverExposureMode }), + ); } const serverExposureState = yield* serverExposure.configureFromSettings({ port: backendPort }); const backendConfig = yield* serverExposure.backendConfig; - yield* run.logInfo("bootstrap resolved backend endpoint", { - baseUrl: backendConfig.httpBaseUrl.href, - }); + yield* Effect.logInfo("bootstrap resolved backend endpoint").pipe( + Effect.annotateLogs({ baseUrl: backendConfig.httpBaseUrl.href }), + ); if (serverExposureState.endpointUrl) { - yield* run.logInfo("bootstrap enabled network access", { - endpointUrl: serverExposureState.endpointUrl, - }); + yield* Effect.logInfo("bootstrap enabled network access").pipe( + Effect.annotateLogs({ endpointUrl: serverExposureState.endpointUrl }), + ); } else if (settings.serverExposureMode === "network-accessible") { - yield* run.logWarning( + yield* Effect.logWarning( "bootstrap fell back to local-only because no advertised network host was available", ); } yield* installDesktopIpcHandlers; - yield* run.logInfo("bootstrap ipc handlers registered"); + yield* Effect.logInfo("bootstrap ipc handlers registered"); if (!(yield* Ref.get(state.quitting))) { yield* backendManager.start; } - yield* run.logInfo("bootstrap backend start requested"); + yield* Effect.logInfo("bootstrap backend start requested"); if (environment.isDevelopment) { yield* desktopWindow.ensureMain; @@ -184,6 +188,9 @@ const bootstrap = Effect.gen(function* () { export const program = Effect.scoped( Effect.gen(function* () { + const runId = yield* makeDesktopRunId; + yield* Effect.annotateLogsScoped({ scope: "desktop", runId }); + const shutdown = yield* DesktopLifecycle.DesktopShutdown; const appIdentity = yield* DesktopAppIdentity.DesktopAppIdentity; const applicationMenu = yield* DesktopApplicationMenu.DesktopApplicationMenu; @@ -195,10 +202,8 @@ export const program = Effect.scoped( const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const updates = yield* DesktopUpdates.DesktopUpdates; const environment = yield* DesktopEnvironment.DesktopEnvironment; - const run = yield* DesktopRun.DesktopRun; yield* electronProtocol.registerDesktopSchemePrivileges; - yield* run.refreshId; yield* Effect.addFinalizer(() => backendManager.stop().pipe(Effect.ensuring(shutdown.markComplete)), ); @@ -206,7 +211,9 @@ export const program = Effect.scoped( yield* shellEnvironment.installIntoProcess; const userDataPath = yield* appIdentity.resolveUserDataPath; yield* electronApp.setPath("userData", userDataPath); - yield* run.logInfo("runtime logging configured", { logDir: environment.logDir }); + yield* Effect.logInfo("runtime logging configured").pipe( + Effect.annotateLogs({ logDir: environment.logDir }), + ); yield* desktopSettings.load; if (environment.platform === "linux") { @@ -219,7 +226,7 @@ export const program = Effect.scoped( yield* electronApp.whenReady.pipe( Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)), ); - yield* run.logInfo("app ready"); + yield* Effect.logInfo("app ready"); yield* appIdentity.configure; yield* applicationMenu.configure; yield* electronProtocol.registerDesktopFileProtocol; diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index 1411c3ef9cf..244b2f31efa 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -9,7 +9,6 @@ import * as Scope from "effect/Scope"; import type * as Electron from "electron"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -import * as DesktopRun from "./DesktopRun.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as DesktopState from "./DesktopState.ts"; @@ -48,7 +47,6 @@ export const layerShutdown = Layer.effect(DesktopShutdown, makeShutdown); export type DesktopLifecycleRuntimeServices = | DesktopEnvironment.DesktopEnvironment - | DesktopRun.DesktopRun | DesktopShutdown | DesktopState.DesktopState | DesktopWindow.DesktopWindow @@ -69,7 +67,6 @@ export class DesktopLifecycle extends Context.Service) => Effect.logInfo(message).pipe( Effect.annotateLogs({ - scope: "desktop", component: "desktop-lifecycle", ...annotations, }), @@ -169,9 +166,8 @@ export const layer = Layer.succeed( Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; const environment = yield* DesktopEnvironment.DesktopEnvironment; - const run = yield* DesktopRun.DesktopRun; const state = yield* DesktopState.DesktopState; - yield* run.logInfo("desktop relaunch requested", { reason }); + yield* logLifecycleInfo("desktop relaunch requested", { reason }); yield* Effect.gen(function* () { yield* Effect.yieldNow; yield* Ref.set(state.quitting, true); @@ -187,9 +183,12 @@ export const layer = Layer.succeed( yield* electronApp.exit(0); }).pipe( Effect.catchCause((cause) => - run.logError("desktop relaunch failed", { - cause: Cause.pretty(cause), - }), + Effect.logError("desktop relaunch failed").pipe( + Effect.annotateLogs({ + component: "desktop-lifecycle", + cause: Cause.pretty(cause), + }), + ), ), Effect.forkDetach, Effect.asVoid, diff --git a/apps/desktop/src/app/DesktopLogging.ts b/apps/desktop/src/app/DesktopLogging.ts index ed888fca35d..12858e40d10 100644 --- a/apps/desktop/src/app/DesktopLogging.ts +++ b/apps/desktop/src/app/DesktopLogging.ts @@ -27,7 +27,6 @@ export interface RotatingLogFileWriter { export interface DesktopBackendOutputLogShape { readonly writeSessionBoundary: (input: { readonly phase: "START" | "END"; - readonly runId: string; readonly details: string; }) => Effect.Effect; readonly writeOutputChunk: ( @@ -65,6 +64,12 @@ const DesktopBackendOutputLogNoop: DesktopBackendOutputLogShape = { writeOutputChunk: () => Effect.void, }; +const currentDesktopRunId = Effect.gen(function* () { + const annotations = yield* References.CurrentLogAnnotations; + const runId = annotations.runId; + return typeof runId === "string" && runId.length > 0 ? runId : "unknown"; +}); + const refreshFileSize = ( fileSystem: FileSystem.FileSystem, filePath: string, @@ -229,8 +234,9 @@ export const DesktopBackendOutputLogLive = Layer.effect( onNone: () => DesktopBackendOutputLogNoop, onSome: (logFile) => ({ - writeSessionBoundary: ({ phase, runId, details }) => + writeSessionBoundary: ({ phase, details }) => Effect.gen(function* () { + const runId = yield* currentDesktopRunId; const timestamp = DateTime.formatIso(yield* DateTime.now); yield* logFile.writeText( `[${timestamp}] ---- APP SESSION ${phase} run=${runId} ${sanitizeLogValue(details)} ----\n`, diff --git a/apps/desktop/src/app/DesktopRun.ts b/apps/desktop/src/app/DesktopRun.ts deleted file mode 100644 index e752bfa2193..00000000000 --- a/apps/desktop/src/app/DesktopRun.ts +++ /dev/null @@ -1,65 +0,0 @@ -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Random from "effect/Random"; -import * as Ref from "effect/Ref"; - -const INITIAL_RUN_ID = "startup"; - -const randomHexString = (length: number): Effect.Effect => - Effect.gen(function* () { - let value = ""; - while (value.length < length) { - value += (yield* Random.nextUUIDv4).replace(/-/g, ""); - } - return value.slice(0, length); - }); - -export interface DesktopRunShape { - readonly id: Effect.Effect; - readonly refreshId: Effect.Effect; - readonly logInfo: (message: string, annotations?: Record) => Effect.Effect; - readonly logWarning: ( - message: string, - annotations?: Record, - ) => Effect.Effect; - readonly logError: ( - message: string, - annotations?: Record, - ) => Effect.Effect; -} - -export class DesktopRun extends Context.Service()("t3/desktop/Run") {} - -const make = Effect.gen(function* () { - const idRef = yield* Ref.make(INITIAL_RUN_ID); - - const annotate = ( - effect: Effect.Effect, - annotations?: Record, - ): Effect.Effect => - Effect.gen(function* () { - const runId = yield* Ref.get(idRef); - return yield* effect.pipe( - Effect.annotateLogs({ - scope: "desktop", - runId, - ...annotations, - }), - ); - }); - - return DesktopRun.of({ - id: Ref.get(idRef), - refreshId: Effect.gen(function* () { - const runId = yield* randomHexString(12); - yield* Ref.set(idRef, runId); - return runId; - }), - logInfo: (message, annotations) => annotate(Effect.logInfo(message), annotations), - logWarning: (message, annotations) => annotate(Effect.logWarning(message), annotations), - logError: (message, annotations) => annotate(Effect.logError(message), annotations), - }); -}); - -export const layer = Layer.effect(DesktopRun, make); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 2e7069a5350..73a47df16d5 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -8,7 +8,6 @@ import * as Schema from "effect/Schema"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; -import * as DesktopRun from "../app/DesktopRun.ts"; import * as DesktopServerExposure from "../serverExposure/DesktopServerExposure.ts"; const PersistedServerObservabilitySettingsDocument = Schema.Struct({ @@ -83,7 +82,6 @@ const withHarness = ( Effect.provide( DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), - Layer.provideMerge(DesktopRun.layer), Layer.provideMerge(makeEnvironmentLayer(baseDir)), ), ), diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index bfbf9161bd6..910ff272562 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -10,7 +10,6 @@ import * as Ref from "effect/Ref"; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopServerExposure from "../serverExposure/DesktopServerExposure.ts"; -import * as DesktopRun from "../app/DesktopRun.ts"; export interface DesktopBackendConfigurationShape { readonly resolve: Effect.Effect; @@ -50,11 +49,10 @@ const backendChildEnvPatch = (): Record => const readPersistedBackendObservabilitySettings: Effect.Effect< BackendObservabilitySettings, never, - FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment | DesktopRun.DesktopRun + FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment > = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const environment = yield* DesktopEnvironment.DesktopEnvironment; - const run = yield* DesktopRun.DesktopRun; const exists = yield* fileSystem .exists(environment.serverSettingsPath) .pipe(Effect.orElseSucceed(() => false)); @@ -64,7 +62,7 @@ const readPersistedBackendObservabilitySettings: Effect.Effect< const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(Effect.option); if (Option.isNone(raw)) { - yield* run.logWarning("failed to read persisted backend observability settings"); + yield* Effect.logWarning("failed to read persisted backend observability settings"); return emptyBackendObservabilitySettings; } @@ -142,7 +140,6 @@ export const layer = Layer.effect( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; - const run = yield* DesktopRun.DesktopRun; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; const tokenRef = yield* Ref.make(Option.none()); @@ -152,7 +149,6 @@ export const layer = Layer.effect( const observabilitySettings = yield* readPersistedBackendObservabilitySettings.pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), - Effect.provideService(DesktopRun.DesktopRun, run), ); return yield* resolveBackendStartConfig({ bootstrapToken, diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 38deed089a8..a90bfe971e4 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -25,7 +25,6 @@ import { DesktopBackendOutputLog, type DesktopBackendOutputLogShape, } from "../app/DesktopLogging.ts"; -import * as DesktopRun from "../app/DesktopRun.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; @@ -105,7 +104,6 @@ function makeManagerLayer(input: { readonly spawnerLayer: Layer.Layer; readonly httpClientLayer?: Layer.Layer; readonly backendOutputLog?: Partial; - readonly desktopRun?: Partial; readonly desktopState?: DesktopState.DesktopStateShape; readonly desktopWindow?: Partial; readonly config?: DesktopBackendManager.DesktopBackendStartConfig; @@ -129,14 +127,6 @@ function makeManagerLayer(input: { writeOutputChunk: () => Effect.void, ...input.backendOutputLog, } satisfies DesktopBackendOutputLogShape), - Layer.succeed(DesktopRun.DesktopRun, { - id: Effect.succeed("test-run"), - refreshId: Effect.succeed("test-run"), - logInfo: () => Effect.void, - logWarning: () => Effect.void, - logError: () => Effect.void, - ...input.desktopRun, - } satisfies DesktopRun.DesktopRunShape), Layer.succeed(DesktopWindow.DesktopWindow, { createMain: Effect.die("unexpected createMain"), ensureMain: Effect.die("unexpected ensureMain"), @@ -359,7 +349,6 @@ describe("DesktopBackendManager", () => { it.effect("restarts an unexpectedly exited backend with the Effect clock", () => Effect.gen(function* () { const starts = yield* Queue.unbounded(); - const restartDelays = yield* Queue.unbounded(); let startCount = 0; const spawnerLayer = Layer.succeed( @@ -379,10 +368,6 @@ describe("DesktopBackendManager", () => { const managerLayer = makeManagerLayer({ spawnerLayer, httpClientLayer: httpClientLayer(() => Effect.never), - desktopRun: { - logError: (_message, annotations) => - Queue.offer(restartDelays, Number(annotations?.delayMs ?? 0)).pipe(Effect.asVoid), - }, }); yield* Effect.gen(function* () { @@ -390,13 +375,15 @@ describe("DesktopBackendManager", () => { yield* manager.start; assert.equal(yield* Queue.take(starts), 1); - assert.equal(yield* Queue.take(restartDelays), 500); - yield* TestClock.adjust(Duration.millis(500)); + yield* TestClock.adjust(Duration.millis(499)); + assert.equal(yield* Queue.size(starts), 0); + yield* TestClock.adjust(Duration.millis(1)); assert.equal(yield* Queue.take(starts), 2); - assert.equal(yield* Queue.take(restartDelays), 1_000); - yield* TestClock.adjust(Duration.millis(1_000)); + yield* TestClock.adjust(Duration.millis(999)); + assert.equal(yield* Queue.size(starts), 0); + yield* TestClock.adjust(Duration.millis(1)); assert.equal(yield* Queue.take(starts), 3); }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); }), @@ -405,7 +392,6 @@ describe("DesktopBackendManager", () => { it.effect("cancels a scheduled restart when start is requested manually", () => Effect.gen(function* () { const starts = yield* Queue.unbounded(); - const restartDelays = yield* Queue.unbounded(); const secondClosed = yield* Deferred.make(); let startCount = 0; @@ -438,10 +424,6 @@ describe("DesktopBackendManager", () => { const managerLayer = makeManagerLayer({ spawnerLayer, httpClientLayer: httpClientLayer(() => Effect.never), - desktopRun: { - logError: (_message, annotations) => - Queue.offer(restartDelays, Number(annotations?.delayMs ?? 0)).pipe(Effect.asVoid), - }, }); yield* Effect.gen(function* () { @@ -449,7 +431,6 @@ describe("DesktopBackendManager", () => { yield* manager.start; assert.equal(yield* Queue.take(starts), 1); - assert.equal(yield* Queue.take(restartDelays), 500); yield* manager.start; assert.equal(yield* Queue.take(starts), 2); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 042da3ff84d..d588ac4886d 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -25,7 +25,6 @@ import { import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; import { DesktopBackendOutputLog } from "../app/DesktopLogging.ts"; -import * as DesktopRun from "../app/DesktopRun.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; @@ -279,7 +278,6 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio const backendOutputLog = yield* DesktopBackendOutputLog; const desktopState = yield* DesktopState.DesktopState; const desktopWindow = yield* DesktopWindow.DesktopWindow; - const run = yield* DesktopRun.DesktopRun; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const state = yield* Ref.make(initialState); @@ -403,10 +401,8 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio if (isCurrentRun) { if (Option.isSome(pid)) { - const runId = yield* run.id; yield* backendOutputLog.writeSessionBoundary({ phase: "END", - runId, details: `pid=${pid.value} ${reason}`, }); } @@ -427,10 +423,8 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio ...run, pid: Option.some(pid), })); - const desktopRunId = yield* run.id; yield* backendOutputLog.writeSessionBoundary({ phase: "START", - runId: desktopRunId, details: `pid=${pid} port=${config.bootstrap.port} cwd=${config.cwd}`, }); }), @@ -449,16 +443,16 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio })); yield* desktopWindow.handleBackendReady.pipe( Effect.catch((error) => - run.logError("failed to open main window after backend readiness", { - message: error.message, - }), + Effect.logError("failed to open main window after backend readiness").pipe( + Effect.annotateLogs({ message: error.message }), + ), ), ); }), onReadinessFailure: (error) => - run.logWarning("backend readiness check failed during bootstrap", { - error: error.message, - }), + Effect.logWarning("backend readiness check failed during bootstrap").pipe( + Effect.annotateLogs({ error: error.message }), + ), onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), @@ -501,10 +495,12 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio onNone: () => Effect.void, onSome: (delay) => Effect.gen(function* () { - yield* run.logError("backend exited unexpectedly; restart scheduled", { - reason, - delayMs: Duration.toMillis(delay), - }); + yield* Effect.logError("backend exited unexpectedly; restart scheduled").pipe( + Effect.annotateLogs({ + reason, + delayMs: Duration.toMillis(delay), + }), + ); const restartFiber = yield* Effect.forkIn( Effect.sleep(delay).pipe( Effect.andThen( diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index e42a714f4ea..4aeaeb30d18 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -32,7 +32,6 @@ import * as DesktopBackendManager from "./backend/DesktopBackendManager.ts"; import * as DesktopEnvironment from "./app/DesktopEnvironment.ts"; import * as DesktopLifecycle from "./app/DesktopLifecycle.ts"; import { DesktopBackendOutputLogLive, DesktopLoggerLive } from "./app/DesktopLogging.ts"; -import * as DesktopRun from "./app/DesktopRun.ts"; import * as DesktopServerExposure from "./serverExposure/DesktopServerExposure.ts"; import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts"; import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts"; @@ -103,7 +102,6 @@ const electronLayer = Layer.mergeAll( ); const desktopFoundationLayer = Layer.mergeAll( - DesktopRun.layer, DesktopState.layer, DesktopLifecycle.layerShutdown, DesktopAppSettings.layer, diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 9b3a43ffad2..fc589b3e39b 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -13,7 +13,6 @@ import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as DesktopApplicationMenu from "./DesktopApplicationMenu.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; -import * as DesktopRun from "../app/DesktopRun.ts"; import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; @@ -53,14 +52,6 @@ const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, { showErrorBox: () => Effect.void, } satisfies ElectronDialog.ElectronDialogShape); -const desktopRunLayer = Layer.succeed(DesktopRun.DesktopRun, { - id: Effect.succeed("test-run"), - refreshId: Effect.succeed("test-run"), - logInfo: () => Effect.void, - logWarning: () => Effect.void, - logError: () => Effect.void, -} satisfies DesktopRun.DesktopRunShape); - const desktopUpdatesLayer = Layer.succeed(DesktopUpdates.DesktopUpdates, { getState: Effect.die("unexpected getState"), emitState: Effect.void, @@ -110,7 +101,6 @@ describe("DesktopApplicationMenu", () => { Layer.provideMerge(makeElectronMenuLayer(applicationMenuTemplate)), Layer.provideMerge(makeDesktopWindowLayer(selectedAction)), Layer.provideMerge(desktopUpdatesLayer), - Layer.provideMerge(desktopRunLayer), Layer.provideMerge(electronDialogLayer), Layer.provideMerge(electronAppLayer), Layer.provideMerge( diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index 165ec17a87e..c0957c3c634 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -10,19 +10,9 @@ import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; -import * as DesktopRun from "../app/DesktopRun.ts"; import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; -type DesktopApplicationMenuRuntimeServices = - | DesktopEnvironment.DesktopEnvironment - | DesktopRun.DesktopRun - | DesktopUpdates.DesktopUpdates - | DesktopWindow.DesktopWindow - | ElectronApp.ElectronApp - | ElectronDialog.ElectronDialog - | ElectronMenu.ElectronMenu; - export interface DesktopApplicationMenuShape { readonly configure: Effect.Effect; } @@ -71,20 +61,18 @@ const checkForUpdatesFromMenu: Effect.Effect< const handleCheckForUpdatesMenuClick: Effect.Effect< void, DesktopWindow.DesktopWindowError, - | DesktopRun.DesktopRun - | DesktopUpdates.DesktopUpdates - | ElectronDialog.ElectronDialog - | DesktopWindow.DesktopWindow + DesktopUpdates.DesktopUpdates | ElectronDialog.ElectronDialog | DesktopWindow.DesktopWindow > = Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; const electronDialog = yield* ElectronDialog.ElectronDialog; - const run = yield* DesktopRun.DesktopRun; const disabledReason = yield* updates.disabledReason; if (Option.isSome(disabledReason)) { - yield* run.logInfo("manual update check requested, but updates are disabled", { - component: "desktop-updater", - disabledReason: disabledReason.value, - }); + yield* Effect.logInfo("manual update check requested, but updates are disabled").pipe( + Effect.annotateLogs({ + component: "desktop-updater", + disabledReason: disabledReason.value, + }), + ); yield* electronDialog.showMessageBox({ type: "info", title: "Updates unavailable", @@ -104,32 +92,30 @@ const make = Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; const electronMenu = yield* ElectronMenu.ElectronMenu; const environment = yield* DesktopEnvironment.DesktopEnvironment; - const run = yield* DesktopRun.DesktopRun; const appName = yield* electronApp.name; - const context = yield* Effect.context(); - - const runMenuEffect = (action: string, effect: Effect.Effect) => { - void Effect.runPromiseWith(context as unknown as Context.Context)( - effect.pipe( - Effect.catchCause((cause) => - run.logError("desktop menu action failed", { - action, - cause: Cause.pretty(cause), - }), - ), - ), - ); - }; - - const checkForUpdatesClick = () => { - runMenuEffect("check-for-updates", handleCheckForUpdatesMenuClick); - }; - - const settingsClick = () => { - runMenuEffect("open-settings", dispatchMenuAction("open-settings")); - }; const configure = Effect.gen(function* () { + const context = yield* Effect.context(); + const runMenuEffect = (action: string, effect: Effect.Effect) => { + void Effect.runPromiseWith(context as unknown as Context.Context)( + effect.pipe( + Effect.catchCause((cause) => + Effect.logError("desktop menu action failed").pipe( + Effect.annotateLogs({ + action, + cause: Cause.pretty(cause), + }), + ), + ), + ), + ); + }; + const checkForUpdatesClick = () => { + runMenuEffect("check-for-updates", handleCheckForUpdatesMenuClick); + }; + const settingsClick = () => { + runMenuEffect("open-settings", dispatchMenuAction("open-settings")); + }; const template: Electron.MenuItemConstructorOptions[] = []; if (environment.platform === "darwin") { From d008e094dfbb003d53880669c2fdb124929312d3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 7 May 2026 15:35:29 -0700 Subject: [PATCH 37/43] Probe backend readiness on environment endpoint - Switch desktop backend readiness checks to `/.well-known/t3/environment` - Update tests to expect the new probe URL --- apps/desktop/src/backend/DesktopBackendManager.test.ts | 7 +++++-- apps/desktop/src/backend/DesktopBackendManager.ts | 6 ++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index a90bfe971e4..751ee79edcd 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -265,13 +265,16 @@ describe("DesktopBackendManager", () => { yield* Deferred.await(firstRequest); assert.equal(readyCount, 0); - assert.deepEqual(requestUrls, ["http://127.0.0.1:3773/"]); + assert.deepEqual(requestUrls, ["http://127.0.0.1:3773/.well-known/t3/environment"]); yield* TestClock.adjust(Duration.millis(100)); yield* Queue.take(exited); assert.equal(readyCount, 1); - assert.deepEqual(requestUrls, ["http://127.0.0.1:3773/", "http://127.0.0.1:3773/"]); + assert.deepEqual(requestUrls, [ + "http://127.0.0.1:3773/.well-known/t3/environment", + "http://127.0.0.1:3773/.well-known/t3/environment", + ]); }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); }), ); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index d588ac4886d..2ac126cb5f0 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -34,6 +34,7 @@ const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1); const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100); const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1); const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(2); +const BACKEND_READINESS_PATH = "/.well-known/t3/environment"; type BackendProcessLayerServices = ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient; @@ -175,16 +176,17 @@ const waitForHttpReady = Effect.fn("desktop.backendManager.waitForHttpReady")(fu baseUrl: URL, timeout: Duration.Duration, ): Effect.fn.Return { + const readinessUrl = new URL(BACKEND_READINESS_PATH, baseUrl); const client = (yield* HttpClient.HttpClient).pipe( HttpClient.filterStatusOk, HttpClient.transformResponse(Effect.timeout(DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT)), HttpClient.retry(Schedule.spaced(DEFAULT_BACKEND_READINESS_INTERVAL)), ); - yield* client.get(new URL("/", baseUrl)).pipe( + yield* client.get(readinessUrl).pipe( Effect.asVoid, Effect.timeout(timeout), - Effect.mapError(() => new BackendTimeoutError({ url: baseUrl })), + Effect.mapError(() => new BackendTimeoutError({ url: readinessUrl })), ); }); From 413d2f9dedf6ecf724e494543cf43515c5af2257 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 7 May 2026 16:31:21 -0700 Subject: [PATCH 38/43] Improve desktop startup logging and window readiness - Persist backend output and desktop logs in development - Delay dev window creation until backend is ready - Move Electron scheme privilege registration into startup layer --- apps/desktop/package.json | 2 +- apps/desktop/src/app/DesktopApp.ts | 9 +- .../src/app/DesktopEnvironment.test.ts | 31 ++- apps/desktop/src/app/DesktopEnvironment.ts | 2 +- apps/desktop/src/app/DesktopLogging.test.ts | 96 ++++++++++ apps/desktop/src/app/DesktopLogging.ts | 28 ++- .../DesktopBackendConfiguration.test.ts | 38 +++- .../backend/DesktopBackendConfiguration.ts | 2 +- .../src/electron/ElectronProtocol.test.ts | 27 +++ apps/desktop/src/electron/ElectronProtocol.ts | 4 +- apps/desktop/src/main.ts | 14 +- apps/desktop/src/window/DesktopWindow.test.ts | 180 ++++++++++++++++++ apps/desktop/src/window/DesktopWindow.ts | 43 ++++- bun.lock | 12 +- 14 files changed, 434 insertions(+), 54 deletions(-) create mode 100644 apps/desktop/src/app/DesktopLogging.test.ts create mode 100644 apps/desktop/src/window/DesktopWindow.test.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 80f43e2b687..843209363f3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -17,7 +17,7 @@ "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", - "electron": "40.9.3", + "electron": "41.5.0", "electron-updater": "^6.6.2" }, "devDependencies": { diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 71a07d45522..1acd6ffc10b 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -20,7 +20,6 @@ import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; import * as DesktopState from "./DesktopState.ts"; import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; -import * as DesktopWindow from "../window/DesktopWindow.ts"; const DEFAULT_DESKTOP_BACKEND_PORT = 3773; const MAX_TCP_PORT = 65_535; @@ -129,7 +128,6 @@ const fatalStartupCause = (stage: string, cause: Cause.Cause) => const bootstrap = Effect.gen(function* () { const backendManager = yield* DesktopBackendManager.DesktopBackendManager; const state = yield* DesktopState.DesktopState; - const desktopWindow = yield* DesktopWindow.DesktopWindow; const environment = yield* DesktopEnvironment.DesktopEnvironment; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; @@ -178,11 +176,7 @@ const bootstrap = Effect.gen(function* () { if (!(yield* Ref.get(state.quitting))) { yield* backendManager.start; - } - yield* Effect.logInfo("bootstrap backend start requested"); - - if (environment.isDevelopment) { - yield* desktopWindow.ensureMain; + yield* Effect.logInfo("bootstrap backend start requested"); } }); @@ -203,7 +197,6 @@ export const program = Effect.scoped( const updates = yield* DesktopUpdates.DesktopUpdates; const environment = yield* DesktopEnvironment.DesktopEnvironment; - yield* electronProtocol.registerDesktopSchemePrivileges; yield* Effect.addFinalizer(() => backendManager.stop().pipe(Effect.ensuring(shutdown.markComplete)), ); diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index c7bb5f6547f..04bd8ea3cb9 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -53,15 +53,12 @@ describe("DesktopEnvironment", () => { assert.equal(environment.isDevelopment, true); assert.equal(environment.appDataDirectory, "/Users/alice/Library/Application Support"); assert.equal(environment.baseDir, "/tmp/t3"); - assert.equal(environment.stateDir, "/tmp/t3/userdata"); - assert.equal(environment.desktopSettingsPath, "/tmp/t3/userdata/desktop-settings.json"); - assert.equal(environment.clientSettingsPath, "/tmp/t3/userdata/client-settings.json"); - assert.equal( - environment.savedEnvironmentRegistryPath, - "/tmp/t3/userdata/saved-environments.json", - ); - assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json"); - assert.equal(environment.logDir, "/tmp/t3/userdata/logs"); + assert.equal(environment.stateDir, "/tmp/t3/dev"); + assert.equal(environment.desktopSettingsPath, "/tmp/t3/dev/desktop-settings.json"); + assert.equal(environment.clientSettingsPath, "/tmp/t3/dev/client-settings.json"); + assert.equal(environment.savedEnvironmentRegistryPath, "/tmp/t3/dev/saved-environments.json"); + assert.equal(environment.serverSettingsPath, "/tmp/t3/dev/settings.json"); + assert.equal(environment.logDir, "/tmp/t3/dev/logs"); assert.equal(environment.rootDir, "/repo"); assert.equal(environment.appRoot, "/repo"); assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs"); @@ -78,6 +75,22 @@ describe("DesktopEnvironment", () => { }), ); + it.effect("derives production state paths under userdata", () => + Effect.gen(function* () { + const environment = yield* makeEnvironment( + {}, + { + T3CODE_HOME: "/tmp/t3", + }, + ); + + assert.equal(environment.isDevelopment, false); + assert.equal(environment.stateDir, "/tmp/t3/userdata"); + assert.equal(environment.logDir, "/tmp/t3/userdata/logs"); + assert.equal(environment.serverSettingsPath, "/tmp/t3/userdata/settings.json"); + }), + ); + it.effect("resolves picker defaults without nullish sentinels", () => Effect.gen(function* () { const environment = yield* makeEnvironment(); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index baed0340fdf..8db53750557 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -151,7 +151,6 @@ const makeDesktopEnvironment = ( ? path.join(homeDirectory, "Library", "Application Support") : Option.getOrElse(config.xdgConfigHome, () => path.join(homeDirectory, ".config")); const baseDir = Option.getOrElse(config.t3Home, () => path.join(homeDirectory, ".t3")); - const stateDir = path.join(baseDir, "userdata"); const rootDir = path.resolve(input.dirname, "../../.."); const appRoot = input.isPackaged ? input.appPath : rootDir; const branding = resolveDesktopAppBranding({ @@ -159,6 +158,7 @@ const makeDesktopEnvironment = ( appVersion: input.appVersion, }); const displayName = branding.displayName; + const stateDir = path.join(baseDir, isDevelopment ? "dev" : "userdata"); const userDataDirName = isDevelopment ? "t3code-dev" : "t3code"; const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; const resourcesPath = input.resourcesPath; diff --git a/apps/desktop/src/app/DesktopLogging.test.ts b/apps/desktop/src/app/DesktopLogging.test.ts new file mode 100644 index 00000000000..7b0c473c9bf --- /dev/null +++ b/apps/desktop/src/app/DesktopLogging.test.ts @@ -0,0 +1,96 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; + +import * as DesktopConfig from "./DesktopConfig.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import { + DesktopBackendOutputLog, + DesktopBackendOutputLogLive, + DesktopLoggerLive, +} from "./DesktopLogging.ts"; + +const environmentInput = (baseDir: string) => + ({ + dirname: "/repo/apps/desktop/dist-electron", + homeDirectory: baseDir, + platform: "darwin", + processArch: "arm64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: false, + resourcesPath: "/repo/resources", + runningUnderArm64Translation: false, + }) satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; + +const makeEnvironmentLayer = (baseDir: string) => + DesktopEnvironment.layer(environmentInput(baseDir)).pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + DesktopConfig.layerTest({ + T3CODE_HOME: baseDir, + VITE_DEV_SERVER_URL: "http://127.0.0.1:5733", + }), + ), + ), + ); + +describe("DesktopLogging", () => { + it.effect("persists desktop main logs in development", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-logging-test-", + }); + const environmentLayer = makeEnvironmentLayer(baseDir); + const logPath = yield* Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + return environment.path.join(environment.logDir, "desktop-main.log"); + }).pipe(Effect.provide(environmentLayer)); + + yield* Effect.scoped( + Effect.logInfo("desktop file logger test").pipe( + Effect.annotateLogs({ testCase: "desktop-main-dev" }), + Effect.provide(DesktopLoggerLive.pipe(Layer.provideMerge(environmentLayer))), + ), + ); + + const log = yield* fileSystem.readFileString(logPath); + assert.include(log, "desktop file logger test"); + assert.include(log, "desktop-main-dev"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("persists backend child session boundaries in development", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-output-log-test-", + }); + const environmentLayer = makeEnvironmentLayer(baseDir); + const logPath = yield* Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + return environment.path.join(environment.logDir, "server-child.log"); + }).pipe(Effect.provide(environmentLayer)); + + yield* Effect.gen(function* () { + const outputLog = yield* DesktopBackendOutputLog; + yield* outputLog.writeSessionBoundary({ + phase: "START", + details: "pid=123 port=3773 cwd=/repo", + }); + }).pipe( + Effect.annotateLogs({ runId: "test-run" }), + Effect.provide(DesktopBackendOutputLogLive.pipe(Layer.provideMerge(environmentLayer))), + ); + + const log = yield* fileSystem.readFileString(logPath); + assert.include(log, "APP SESSION START"); + assert.include(log, "run=test-run"); + assert.include(log, "pid=123 port=3773 cwd=/repo"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/desktop/src/app/DesktopLogging.ts b/apps/desktop/src/app/DesktopLogging.ts index 12858e40d10..60cc8be4283 100644 --- a/apps/desktop/src/app/DesktopLogging.ts +++ b/apps/desktop/src/app/DesktopLogging.ts @@ -196,19 +196,25 @@ const makeDesktopFileLogger = Effect.gen(function* () { }); }); +const writeDevelopmentConsoleOutput = ( + streamName: "stdout" | "stderr", + chunk: Uint8Array, +): Effect.Effect => + Effect.sync(() => { + const output = streamName === "stderr" ? process.stderr : process.stdout; + output.write(chunk); + }).pipe(Effect.ignore); + export const DesktopLoggerLive = Layer.unwrap( Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const packagedFileLogger = environment.isPackaged - ? yield* makeDesktopFileLogger.pipe(Effect.option) - : Option.none>(); + const fileLogger = yield* makeDesktopFileLogger.pipe(Effect.option); const loggers: Array> = [ Logger.consolePretty(), Logger.tracerLogger, ]; - if (Option.isSome(packagedFileLogger)) { - loggers.push(packagedFileLogger.value); + if (Option.isSome(fileLogger)) { + loggers.push(fileLogger.value); } return Layer.mergeAll( @@ -222,9 +228,6 @@ export const DesktopBackendOutputLogLive = Layer.effect( DesktopBackendOutputLog, Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; - if (environment.isDevelopment) { - return DesktopBackendOutputLogNoop; - } const writer = yield* makeRotatingLogFileWriter({ filePath: environment.path.join(environment.logDir, "server-child.log"), @@ -242,7 +245,12 @@ export const DesktopBackendOutputLogLive = Layer.effect( `[${timestamp}] ---- APP SESSION ${phase} run=${runId} ${sanitizeLogValue(details)} ----\n`, ); }), - writeOutputChunk: (_streamName, chunk) => logFile.writeBytes(chunk), + writeOutputChunk: (streamName, chunk) => + environment.isDevelopment + ? writeDevelopmentConsoleOutput(streamName, chunk).pipe( + Effect.andThen(logFile.writeBytes(chunk)), + ) + : logFile.writeBytes(chunk), }) satisfies DesktopBackendOutputLogShape, }); }), diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 73a47df16d5..de336ffb89c 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -36,7 +36,13 @@ const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExp getAdvertisedEndpoints: Effect.succeed([]), } satisfies DesktopServerExposure.DesktopServerExposureShape); -function makeEnvironmentLayer(baseDir: string) { +function makeEnvironmentLayer( + baseDir: string, + options?: { + readonly isPackaged?: boolean; + readonly devServerUrl?: string; + }, +) { return DesktopEnvironment.layer({ dirname: "/repo/apps/desktop/src", homeDirectory: baseDir, @@ -44,7 +50,7 @@ function makeEnvironmentLayer(baseDir: string) { processArch: "x64", appVersion: "1.2.3", appPath: "/repo", - isPackaged: true, + isPackaged: options?.isPackaged ?? true, resourcesPath: "/missing/resources", runningUnderArm64Translation: false, }).pipe( @@ -56,6 +62,7 @@ function makeEnvironmentLayer(baseDir: string) { T3CODE_PORT: "9999", T3CODE_MODE: "desktop", T3CODE_DESKTOP_LAN_HOST: "192.168.1.50", + VITE_DEV_SERVER_URL: options?.devServerUrl, }), ), ), @@ -158,4 +165,31 @@ describe("DesktopBackendConfiguration", () => { }), ), ); + + it.effect("captures backend output in development so child process logs can be persisted", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolve; + assert.equal(config.captureOutput, true); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge( + makeEnvironmentLayer(baseDir, { + isPackaged: false, + devServerUrl: "http://127.0.0.1:5733", + }), + ), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 910ff272562..8011ceb8f5e 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -131,7 +131,7 @@ const resolveBackendStartConfig = (input: { }), }, httpBaseUrl: backendExposure.httpBaseUrl, - captureOutput: !environment.isDevelopment, + captureOutput: true, }; }); diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 3fc3e0c6a1a..955813d6d35 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -1,5 +1,6 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import type * as Electron from "electron"; import { beforeEach, vi } from "vitest"; @@ -36,6 +37,32 @@ describe("ElectronProtocol", () => { assert.isTrue(Option.isNone(ElectronProtocol.normalizeDesktopProtocolPathname("/../secret"))); }); + it.effect("registers desktop scheme privileges through a layer", () => + Effect.scoped( + Layer.build(ElectronProtocol.layerSchemePrivileges).pipe( + Effect.andThen( + Effect.sync(() => { + assert.deepEqual(registerSchemesAsPrivilegedMock.mock.calls, [ + [ + [ + { + scheme: "t3", + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + }, + }, + ], + ], + ]); + }), + ), + ), + ), + ); + it.effect("scopes registered file protocols", () => Effect.gen(function* () { let capturedHandler: diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index ef62755b6a6..47126af7fcd 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -34,7 +34,6 @@ export class ElectronProtocolStaticBundleMissingError extends Data.TaggedError( } export interface ElectronProtocolShape { - readonly registerDesktopSchemePrivileges: Effect.Effect; readonly registerFileProtocol: (input: { readonly scheme: string; readonly handler: ( @@ -84,6 +83,8 @@ const registerDesktopSchemePrivileges = Effect.sync(() => { ]); }); +export const layerSchemePrivileges = Layer.effectDiscard(registerDesktopSchemePrivileges); + const resolveDesktopStaticDir: Effect.Effect< Option.Option, never, @@ -261,7 +262,6 @@ const make = Effect.gen(function* () { }); return ElectronProtocol.of({ - registerDesktopSchemePrivileges, registerFileProtocol, registerDesktopFileProtocol, }); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 4aeaeb30d18..bdc6d22095f 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -136,11 +136,15 @@ const desktopApplicationLayer = Layer.mergeAll( desktopSshLayer, ).pipe(Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopBackendLayer)); -const desktopRuntimeLayer = desktopApplicationLayer.pipe( - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(NodeHttpClient.layerUndici), - Layer.provideMerge(NetService.layer), - Layer.provideMerge(electronLayer), +const desktopRuntimeLayer = ElectronProtocol.layerSchemePrivileges.pipe( + Layer.flatMap(() => + desktopApplicationLayer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(NodeHttpClient.layerUndici), + Layer.provideMerge(NetService.layer), + Layer.provideMerge(electronLayer), + ), + ), ); DesktopApp.program.pipe(Effect.provide(desktopRuntimeLayer), NodeRuntime.runMain); diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts new file mode 100644 index 00000000000..e53a02f5394 --- /dev/null +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -0,0 +1,180 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; + +import type * as Electron from "electron"; +import { vi } from "vitest"; + +import * as DesktopAssets from "../app/DesktopAssets.ts"; +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopState from "../app/DesktopState.ts"; +import * as ElectronMenu from "../electron/ElectronMenu.ts"; +import * as ElectronShell from "../electron/ElectronShell.ts"; +import * as ElectronTheme from "../electron/ElectronTheme.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopServerExposure from "../serverExposure/DesktopServerExposure.ts"; +import * as DesktopWindow from "./DesktopWindow.ts"; + +const environmentInput = { + dirname: "/repo/apps/desktop/dist-electron", + homeDirectory: "/Users/alice", + platform: "darwin", + processArch: "arm64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: false, + resourcesPath: "/repo/resources", + runningUnderArm64Translation: false, +} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; + +function makeFakeBrowserWindow() { + const webContents = { + copyImageAt: vi.fn(), + isLoadingMainFrame: vi.fn(() => false), + on: vi.fn(), + once: vi.fn(), + openDevTools: vi.fn(), + replaceMisspelling: vi.fn(), + send: vi.fn(), + setWindowOpenHandler: vi.fn(), + }; + + const window = { + focus: vi.fn(), + isDestroyed: vi.fn(() => false), + isMinimized: vi.fn(() => false), + isVisible: vi.fn(() => true), + loadURL: vi.fn(() => Promise.resolve()), + on: vi.fn(), + once: vi.fn(), + restore: vi.fn(), + setBackgroundColor: vi.fn(), + setTitle: vi.fn(), + setTitleBarOverlay: vi.fn(), + show: vi.fn(), + webContents, + }; + + return { + window: window as unknown as Electron.BrowserWindow, + loadURL: window.loadURL, + openDevTools: webContents.openDevTools, + }; +} + +const desktopAssetsLayer = Layer.succeed(DesktopAssets.DesktopAssets, { + iconPaths: Effect.succeed({ + ico: Option.none(), + icns: Option.none(), + png: Option.none(), + }), + resolveResourcePath: () => Effect.succeed(Option.none()), +} satisfies DesktopAssets.DesktopAssetsShape); + +const desktopServerExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExposure, { + getState: Effect.die("unexpected getState"), + backendConfig: Effect.succeed({ + port: 3773, + bindHost: "127.0.0.1", + httpBaseUrl: new URL("http://127.0.0.1:3773"), + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }), + configureFromSettings: () => Effect.die("unexpected configureFromSettings"), + setMode: () => Effect.die("unexpected setMode"), + setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"), + getAdvertisedEndpoints: Effect.die("unexpected getAdvertisedEndpoints"), +} satisfies DesktopServerExposure.DesktopServerExposureShape); + +const electronMenuLayer = Layer.succeed(ElectronMenu.ElectronMenu, { + setApplicationMenu: () => Effect.void, + popupTemplate: () => Effect.void, + showContextMenu: () => Effect.succeed(Option.none()), +} satisfies ElectronMenu.ElectronMenuShape); + +const electronShellLayer = Layer.succeed(ElectronShell.ElectronShell, { + openExternal: () => Effect.succeed(true), + copyText: () => Effect.void, +} satisfies ElectronShell.ElectronShellShape); + +const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, { + shouldUseDarkColors: Effect.succeed(false), + setSource: () => Effect.void, + onUpdated: () => Effect.void, +} satisfies ElectronTheme.ElectronThemeShape); + +const desktopEnvironmentLayer = DesktopEnvironment.layer(environmentInput).pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + DesktopConfig.layerTest({ + T3CODE_PORT: "3773", + VITE_DEV_SERVER_URL: "http://127.0.0.1:5733", + }), + ), + ), +); + +function makeTestLayer(input: { + readonly window: Electron.BrowserWindow; + readonly createCount: Ref.Ref; + readonly mainWindow: Ref.Ref>; +}) { + const electronWindowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { + create: () => Ref.update(input.createCount, (count) => count + 1).pipe(Effect.as(input.window)), + main: Ref.get(input.mainWindow), + currentMainOrFirst: Ref.get(input.mainWindow), + focusedMainOrFirst: Ref.get(input.mainWindow), + setMain: (window) => Ref.set(input.mainWindow, Option.some(window)), + clearMain: () => Ref.set(input.mainWindow, Option.none()), + reveal: () => Effect.void, + sendAll: () => Effect.void, + destroyAll: Effect.void, + syncAllAppearance: (sync) => sync(input.window), + } satisfies ElectronWindow.ElectronWindowShape); + + return DesktopWindow.layer.pipe( + Layer.provide( + Layer.mergeAll( + desktopAssetsLayer, + desktopEnvironmentLayer, + desktopServerExposureLayer, + DesktopState.layer, + electronMenuLayer, + electronShellLayer, + electronThemeLayer, + electronWindowLayer, + ), + ), + ); +} + +describe("DesktopWindow", () => { + it.effect("does not open a development window until the backend is ready", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.activate; + assert.equal(yield* Ref.get(createCount), 0); + + yield* desktopWindow.handleBackendReady; + assert.equal(yield* Ref.get(createCount), 1); + assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["http://127.0.0.1:5733/"]); + assert.equal(fakeWindow.openDevTools.mock.calls.length, 1); + }).pipe(Effect.provide(layer)); + }), + ); +}); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 4cc45aab4cc..3d4db82f274 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -73,6 +73,15 @@ const logWindowInfo = (message: string, annotations?: Record) = }), ); +const logWindowWarning = (message: string, annotations?: Record) => + Effect.logWarning(message).pipe( + Effect.annotateLogs({ + scope: "desktop", + component: "desktop-window", + ...annotations, + }), + ); + function resolveDesktopDevServerUrl( environment: DesktopEnvironment.DesktopEnvironmentShape, ): Effect.Effect { @@ -249,6 +258,29 @@ const make = Effect.gen(function* () { window.webContents.on("did-finish-load", () => { window.setTitle(environment.displayName); }); + window.webContents.on( + "did-fail-load", + (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { + if (!isMainFrame) { + return; + } + void runPromise( + logWindowWarning("main window failed to load", { + errorCode, + errorDescription, + url: validatedURL, + }), + ); + }, + ); + window.webContents.on("render-process-gone", (_event, details) => { + void runPromise( + logWindowWarning("main window render process gone", { + reason: details.reason, + exitCode: details.exitCode, + }), + ); + }); const revealSubscribers: RevealSubscription[] = [ (fire) => window.once("ready-to-show", fire), @@ -313,21 +345,14 @@ const make = Effect.gen(function* () { const existingWindow = yield* electronWindow.currentMainOrFirst; if (Option.isSome(existingWindow)) { yield* electronWindow.reveal(existingWindow.value); - return; - } - if (environment.isDevelopment) { - yield* createMain; - return; + } else { + yield* createMainIfBackendReady; } - yield* createMainIfBackendReady; }), createMainIfBackendReady, handleBackendReady: Effect.gen(function* () { yield* Ref.set(state.backendReady, true); yield* logWindowInfo("backend ready", { source: "http" }); - if (environment.isDevelopment) { - return; - } yield* createMainIfBackendReady; }), dispatchMenuAction: (action) => diff --git a/bun.lock b/bun.lock index 38c21ec04ee..51002d475a8 100644 --- a/bun.lock +++ b/bun.lock @@ -16,11 +16,11 @@ }, "apps/desktop": { "name": "@t3tools/desktop", - "version": "0.0.21", + "version": "0.0.22", "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", - "electron": "40.9.3", + "electron": "41.5.0", "electron-updater": "^6.6.2", }, "devDependencies": { @@ -51,7 +51,7 @@ }, "apps/server": { "name": "t3", - "version": "0.0.21", + "version": "0.0.22", "bin": { "t3": "./dist/bin.mjs", }, @@ -84,7 +84,7 @@ }, "apps/web": { "name": "@t3tools/web", - "version": "0.0.21", + "version": "0.0.22", "dependencies": { "@base-ui/react": "^1.2.0", "@dnd-kit/core": "^6.3.1", @@ -150,7 +150,7 @@ }, "packages/contracts": { "name": "@t3tools/contracts", - "version": "0.0.21", + "version": "0.0.22", "dependencies": { "effect": "catalog:", }, @@ -1152,7 +1152,7 @@ "effect-codex-app-server": ["effect-codex-app-server@workspace:packages/effect-codex-app-server"], - "electron": ["electron@40.9.3", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-rDcJOT6BBE689Ada+4jD3rVr05pMv9MZOgT0x/rIMVDF9c4ttx4RTb6lVARTyxZC7uqpirttCtcli1eg1DX5qg=="], + "electron": ["electron@41.5.0", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-x9j9//PubUA4EjDtQbZhtk3prolandqCKgit0uCIqc1jb8FTskPbnJtxcDFB1aejczJcuERgjPixBUaMwoWyJg=="], "electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="], From fe6d0ee4c0f8d5c9a219e1f5d97d693ed5a346d9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 7 May 2026 17:18:19 -0700 Subject: [PATCH 39/43] Consolidate desktop and server observability layers - Move trace sink and OTLP helpers into shared observability code - Add desktop trace export and structured backend child logging - Update server observability wiring and tests for new trace records --- apps/desktop/src/app/DesktopApp.ts | 8 +- apps/desktop/src/app/DesktopConfig.ts | 4 + .../src/app/DesktopEnvironment.test.ts | 4 + apps/desktop/src/app/DesktopEnvironment.ts | 4 + apps/desktop/src/app/DesktopLogging.test.ts | 96 ------ .../src/app/DesktopObservability.test.ts | 185 +++++++++++ ...ktopLogging.ts => DesktopObservability.ts} | 182 ++++++++-- .../src/backend/DesktopBackendManager.test.ts | 11 +- .../src/backend/DesktopBackendManager.ts | 4 +- apps/desktop/src/main.ts | 5 +- apps/server/src/http.ts | 2 +- .../src/observability/Attributes.test.ts | 37 +-- apps/server/src/observability/Attributes.ts | 80 ----- .../src/observability/Layers/Observability.ts | 3 +- .../src/observability/LocalFileTracer.test.ts | 117 ------- .../src/observability/LocalFileTracer.ts | 112 ------- .../Services/BrowserTraceCollector.ts | 3 +- .../src/observability/TraceSink.test.ts | 152 --------- apps/server/src/observability/TraceSink.ts | 68 ---- apps/server/src/vcs/GitVcsDriverCore.ts | 4 +- packages/shared/package.json | 4 + packages/shared/src/observability.test.ts | 314 ++++++++++++++++++ .../shared/src/observability.ts | 266 ++++++++++++++- 23 files changed, 949 insertions(+), 716 deletions(-) delete mode 100644 apps/desktop/src/app/DesktopLogging.test.ts create mode 100644 apps/desktop/src/app/DesktopObservability.test.ts rename apps/desktop/src/app/{DesktopLogging.ts => DesktopObservability.ts} (61%) delete mode 100644 apps/server/src/observability/LocalFileTracer.test.ts delete mode 100644 apps/server/src/observability/LocalFileTracer.ts delete mode 100644 apps/server/src/observability/TraceSink.test.ts delete mode 100644 apps/server/src/observability/TraceSink.ts create mode 100644 packages/shared/src/observability.test.ts rename apps/server/src/observability/TraceRecord.ts => packages/shared/src/observability.ts (53%) diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 1acd6ffc10b..8906390ccf7 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -217,14 +217,18 @@ export const program = Effect.scoped( yield* lifecycle.register; yield* electronApp.whenReady.pipe( + Effect.withSpan("desktop.electron.whenReady"), Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)), ); yield* Effect.logInfo("app ready"); yield* appIdentity.configure; yield* applicationMenu.configure; yield* electronProtocol.registerDesktopFileProtocol; - yield* updates.configure; - yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); + yield* updates.configure.pipe(Effect.withSpan("desktop.updates.configure")); + yield* bootstrap.pipe( + Effect.withSpan("desktop.bootstrap"), + Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause)), + ); yield* shutdown.awaitRequest; }), ); diff --git a/apps/desktop/src/app/DesktopConfig.ts b/apps/desktop/src/app/DesktopConfig.ts index 7c0664913d1..a9218314018 100644 --- a/apps/desktop/src/app/DesktopConfig.ts +++ b/apps/desktop/src/app/DesktopConfig.ts @@ -42,6 +42,10 @@ export const DesktopConfig = Config.all({ commitHashOverride: trimmedString("T3CODE_COMMIT_HASH"), desktopLanHostOverride: trimmedString("T3CODE_DESKTOP_LAN_HOST"), desktopHttpsEndpointUrls: commaSeparatedStrings("T3CODE_DESKTOP_HTTPS_ENDPOINTS"), + otlpTracesUrl: trimmedString("T3CODE_OTLP_TRACES_URL"), + otlpExportIntervalMs: Config.int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe( + Config.withDefault(10_000), + ), appImagePath: trimmedString("APPIMAGE"), disableAutoUpdate: optionalBoolean("T3CODE_DISABLE_AUTO_UPDATE"), mockUpdates: optionalBoolean("T3CODE_DESKTOP_MOCK_UPDATES"), diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 04bd8ea3cb9..427b8848833 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -47,6 +47,8 @@ describe("DesktopEnvironment", () => { T3CODE_PORT: "4949", VITE_DEV_SERVER_URL: "http://localhost:5173", T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH: " /remote/server.mjs ", + T3CODE_OTLP_TRACES_URL: " http://127.0.0.1:4318/v1/traces ", + T3CODE_OTLP_EXPORT_INTERVAL_MS: "2500", }, ); @@ -72,6 +74,8 @@ describe("DesktopEnvironment", () => { assert.deepEqual(environment.devRemoteT3ServerEntryPath, Option.some("/remote/server.mjs")); assert.deepEqual(environment.configuredBackendPort, Option.some(4949)); assert.deepEqual(environment.commitHashOverride, Option.some("0123456789abcdef")); + assert.deepEqual(environment.otlpTracesUrl, Option.some("http://127.0.0.1:4318/v1/traces")); + assert.equal(environment.otlpExportIntervalMs, 2500); }), ); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 8db53750557..b7fa0466c83 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -59,6 +59,8 @@ export interface DesktopEnvironmentShape { readonly devRemoteT3ServerEntryPath: Option.Option; readonly configuredBackendPort: Option.Option; readonly commitHashOverride: Option.Option; + readonly otlpTracesUrl: Option.Option; + readonly otlpExportIntervalMs: number; readonly branding: DesktopAppBranding; readonly displayName: string; readonly appUserModelId: string; @@ -194,6 +196,8 @@ const makeDesktopEnvironment = ( devRemoteT3ServerEntryPath: config.devRemoteT3ServerEntryPath, configuredBackendPort: config.configuredBackendPort, commitHashOverride: config.commitHashOverride, + otlpTracesUrl: config.otlpTracesUrl, + otlpExportIntervalMs: config.otlpExportIntervalMs, branding, displayName, appUserModelId: isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code", diff --git a/apps/desktop/src/app/DesktopLogging.test.ts b/apps/desktop/src/app/DesktopLogging.test.ts deleted file mode 100644 index 7b0c473c9bf..00000000000 --- a/apps/desktop/src/app/DesktopLogging.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, describe, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; - -import * as DesktopConfig from "./DesktopConfig.ts"; -import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -import { - DesktopBackendOutputLog, - DesktopBackendOutputLogLive, - DesktopLoggerLive, -} from "./DesktopLogging.ts"; - -const environmentInput = (baseDir: string) => - ({ - dirname: "/repo/apps/desktop/dist-electron", - homeDirectory: baseDir, - platform: "darwin", - processArch: "arm64", - appVersion: "1.2.3", - appPath: "/repo", - isPackaged: false, - resourcesPath: "/repo/resources", - runningUnderArm64Translation: false, - }) satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; - -const makeEnvironmentLayer = (baseDir: string) => - DesktopEnvironment.layer(environmentInput(baseDir)).pipe( - Layer.provide( - Layer.mergeAll( - NodeServices.layer, - DesktopConfig.layerTest({ - T3CODE_HOME: baseDir, - VITE_DEV_SERVER_URL: "http://127.0.0.1:5733", - }), - ), - ), - ); - -describe("DesktopLogging", () => { - it.effect("persists desktop main logs in development", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-logging-test-", - }); - const environmentLayer = makeEnvironmentLayer(baseDir); - const logPath = yield* Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - return environment.path.join(environment.logDir, "desktop-main.log"); - }).pipe(Effect.provide(environmentLayer)); - - yield* Effect.scoped( - Effect.logInfo("desktop file logger test").pipe( - Effect.annotateLogs({ testCase: "desktop-main-dev" }), - Effect.provide(DesktopLoggerLive.pipe(Layer.provideMerge(environmentLayer))), - ), - ); - - const log = yield* fileSystem.readFileString(logPath); - assert.include(log, "desktop file logger test"); - assert.include(log, "desktop-main-dev"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("persists backend child session boundaries in development", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-backend-output-log-test-", - }); - const environmentLayer = makeEnvironmentLayer(baseDir); - const logPath = yield* Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - return environment.path.join(environment.logDir, "server-child.log"); - }).pipe(Effect.provide(environmentLayer)); - - yield* Effect.gen(function* () { - const outputLog = yield* DesktopBackendOutputLog; - yield* outputLog.writeSessionBoundary({ - phase: "START", - details: "pid=123 port=3773 cwd=/repo", - }); - }).pipe( - Effect.annotateLogs({ runId: "test-run" }), - Effect.provide(DesktopBackendOutputLogLive.pipe(Layer.provideMerge(environmentLayer))), - ); - - const log = yield* fileSystem.readFileString(logPath); - assert.include(log, "APP SESSION START"); - assert.include(log, "run=test-run"); - assert.include(log, "pid=123 port=3773 cwd=/repo"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); -}); diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts new file mode 100644 index 00000000000..6d98025ff1a --- /dev/null +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -0,0 +1,185 @@ +import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import * as DesktopConfig from "./DesktopConfig.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopObservability from "./DesktopObservability.ts"; + +const DesktopBackendChildLogRecord = Schema.Struct({ + message: Schema.String, + level: Schema.Literals(["INFO", "ERROR"]), + timestamp: Schema.String, + annotations: Schema.Record(Schema.String, Schema.Unknown), + spans: Schema.Record(Schema.String, Schema.Unknown), + fiberId: Schema.String, +}); + +const decodeDesktopBackendChildLogRecord = Schema.decodeEffect( + Schema.fromJsonString(DesktopBackendChildLogRecord), +); + +const TraceRecordLine = Schema.Struct({ + name: Schema.String, + attributes: Schema.Record(Schema.String, Schema.Unknown), + events: Schema.Array( + Schema.Struct({ + name: Schema.String, + attributes: Schema.Record(Schema.String, Schema.Unknown), + }), + ), +}); + +const decodeTraceRecordLine = Schema.decodeUnknownSync(Schema.fromJsonString(TraceRecordLine)); + +const environmentInput = (baseDir: string) => + ({ + dirname: "/repo/apps/desktop/dist-electron", + homeDirectory: baseDir, + platform: "darwin", + processArch: "arm64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: false, + resourcesPath: "/repo/resources", + runningUnderArm64Translation: false, + }) satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; + +const makeEnvironmentLayer = (baseDir: string) => + DesktopEnvironment.layer(environmentInput(baseDir)).pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + DesktopConfig.layerTest({ + T3CODE_HOME: baseDir, + VITE_DEV_SERVER_URL: "http://127.0.0.1:5733", + }), + ), + ), + ); + +describe("DesktopObservability", () => { + it.effect("persists desktop main logs in development", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-logging-test-", + }); + const environmentLayer = makeEnvironmentLayer(baseDir); + const logPath = yield* Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + return environment.path.join(environment.logDir, "desktop-main.log"); + }).pipe(Effect.provide(environmentLayer)); + + yield* Effect.scoped( + Effect.logInfo("desktop file logger test").pipe( + Effect.annotateLogs({ testCase: "desktop-main-dev" }), + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + ), + ); + + const log = yield* fileSystem.readFileString(logPath); + assert.include(log, "desktop file logger test"); + assert.include(log, "desktop-main-dev"); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + ), + ); + + it.effect("persists desktop Effect spans to desktop.trace.ndjson", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-observability-test-", + }); + const environmentLayer = makeEnvironmentLayer(baseDir); + const tracePath = yield* Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + return environment.path.join(environment.logDir, "desktop.trace.ndjson"); + }).pipe(Effect.provide(environmentLayer)); + + yield* Effect.scoped( + Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ "desktop.test": true }); + yield* Effect.logInfo("desktop trace event"); + }).pipe( + Effect.withSpan("desktop-observability-test"), + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + ), + ); + + const records = (yield* fileSystem.readFileString(tracePath)) + .trim() + .split("\n") + .filter((line) => line.length > 0) + .map((line) => decodeTraceRecordLine(line)); + const record = records.find((entry) => entry.name === "desktop-observability-test"); + + assert.notEqual(record, undefined); + if (!record) { + return; + } + assert.equal(record.attributes["desktop.test"], true); + assert.equal( + record.events.some((event) => event.name === "desktop trace event"), + true, + ); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + ), + ); + + it.effect("persists backend child output as structured JSON records in development", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-output-log-test-", + }); + const environmentLayer = makeEnvironmentLayer(baseDir); + const logPath = yield* Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + return environment.path.join(environment.logDir, "server-child.log"); + }).pipe(Effect.provide(environmentLayer)); + + yield* Effect.gen(function* () { + const outputLog = yield* DesktopObservability.DesktopBackendOutputLog; + yield* outputLog.writeSessionBoundary({ + phase: "START", + details: "pid=123 port=3773 cwd=/repo", + }); + yield* outputLog.writeOutputChunk("stdout", new TextEncoder().encode("hello server\n")); + }).pipe( + Effect.annotateLogs({ runId: "test-run" }), + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + ); + + const log = yield* fileSystem.readFileString(logPath); + const lines = log.trimEnd().split("\n"); + const boundary = yield* decodeDesktopBackendChildLogRecord(lines[0] ?? ""); + const output = yield* decodeDesktopBackendChildLogRecord(lines[1] ?? ""); + + assert.equal(boundary.message, "backend child process session start"); + assert.equal(boundary.level, "INFO"); + assert.equal(boundary.annotations.component, "desktop-backend-child"); + assert.equal(boundary.annotations.runId, "test-run"); + assert.equal(boundary.annotations.phase, "START"); + assert.equal(boundary.annotations.details, "pid=123 port=3773 cwd=/repo"); + + assert.equal(output.message, "backend child process output"); + assert.equal(output.level, "INFO"); + assert.equal(output.annotations.component, "desktop-backend-child"); + assert.equal(output.annotations.runId, "test-run"); + assert.equal(output.annotations.stream, "stdout"); + assert.equal(output.annotations.text, "hello server\n"); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + ), + ); +}); diff --git a/apps/desktop/src/app/DesktopLogging.ts b/apps/desktop/src/app/DesktopObservability.ts similarity index 61% rename from apps/desktop/src/app/DesktopLogging.ts rename to apps/desktop/src/app/DesktopObservability.ts index 60cc8be4283..d7b485f00dd 100644 --- a/apps/desktop/src/app/DesktopLogging.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -1,3 +1,5 @@ +import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; +import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import * as Context from "effect/Context"; import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; @@ -11,13 +13,18 @@ import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as References from "effect/References"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; +import * as Tracer from "effect/Tracer"; +import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; const DESKTOP_LOG_FILE_MAX_FILES = 10; const DESKTOP_LOG_BATCH_WINDOW = Duration.millis(250); +const DESKTOP_BACKEND_CHILD_LOG_FIBER_ID = "#backend-child"; +const DESKTOP_TRACE_BATCH_WINDOW_MS = 200; export interface RotatingLogFileWriter { readonly writeBytes: (chunk: Uint8Array) => Effect.Effect; @@ -41,6 +48,7 @@ export class DesktopBackendOutputLog extends Context.Service< >()("t3/desktop/BackendOutputLog") {} const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); class DesktopLogFileWriterConfigurationError extends Data.TaggedError( "DesktopLogFileWriterConfigurationError", @@ -59,6 +67,19 @@ type DesktopLogFileWriterError = const sanitizeLogValue = (value: string): string => value.replace(/\s+/g, " ").trim(); +const DesktopBackendChildLogRecord = Schema.Struct({ + message: Schema.String, + level: Schema.Literals(["INFO", "ERROR"]), + timestamp: Schema.String, + annotations: Schema.Record(Schema.String, Schema.Unknown), + spans: Schema.Record(Schema.String, Schema.Unknown), + fiberId: Schema.String, +}); + +const encodeDesktopBackendChildLogRecord = Schema.encodeEffect( + Schema.fromJsonString(DesktopBackendChildLogRecord), +); + const DesktopBackendOutputLogNoop: DesktopBackendOutputLogShape = { writeSessionBoundary: () => Effect.void, writeOutputChunk: () => Effect.void, @@ -196,6 +217,30 @@ const makeDesktopFileLogger = Effect.gen(function* () { }); }); +const readPersistedOtlpTracesUrl: Effect.Effect< + Option.Option, + never, + FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment +> = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(Effect.option); + if (Option.isNone(raw)) { + return Option.none(); + } + + const parsed = parsePersistedServerObservabilitySettings(raw.value); + return Option.fromNullishOr(parsed.otlpTracesUrl); +}); + +const resolveOtlpTracesUrl = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + if (Option.isSome(environment.otlpTracesUrl)) { + return environment.otlpTracesUrl; + } + return yield* readPersistedOtlpTracesUrl; +}); + const writeDevelopmentConsoleOutput = ( streamName: "stdout" | "stderr", chunk: Uint8Array, @@ -205,26 +250,28 @@ const writeDevelopmentConsoleOutput = ( output.write(chunk); }).pipe(Effect.ignore); -export const DesktopLoggerLive = Layer.unwrap( +const writeBackendChildLogRecord = ( + logFile: RotatingLogFileWriter, + input: { + readonly message: string; + readonly level: "INFO" | "ERROR"; + readonly annotations: Record; + }, +): Effect.Effect => Effect.gen(function* () { - const fileLogger = yield* makeDesktopFileLogger.pipe(Effect.option); - const loggers: Array> = [ - Logger.consolePretty(), - Logger.tracerLogger, - ]; - - if (Option.isSome(fileLogger)) { - loggers.push(fileLogger.value); - } - - return Layer.mergeAll( - Logger.layer(loggers, { mergeWithExisting: false }), - Layer.succeed(References.MinimumLogLevel, "Info"), - ); - }), -); + const timestamp = DateTime.formatIso(yield* DateTime.now); + const encoded = yield* encodeDesktopBackendChildLogRecord({ + message: input.message, + level: input.level, + timestamp, + annotations: input.annotations, + spans: {}, + fiberId: DESKTOP_BACKEND_CHILD_LOG_FIBER_ID, + }); + yield* logFile.writeText(`${encoded}\n`); + }).pipe(Effect.ignore({ log: true })); -export const DesktopBackendOutputLogLive = Layer.effect( +const backendOutputLogLayer = Layer.effect( DesktopBackendOutputLog, Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; @@ -240,18 +287,99 @@ export const DesktopBackendOutputLogLive = Layer.effect( writeSessionBoundary: ({ phase, details }) => Effect.gen(function* () { const runId = yield* currentDesktopRunId; - const timestamp = DateTime.formatIso(yield* DateTime.now); - yield* logFile.writeText( - `[${timestamp}] ---- APP SESSION ${phase} run=${runId} ${sanitizeLogValue(details)} ----\n`, - ); + yield* writeBackendChildLogRecord(logFile, { + message: `backend child process session ${phase.toLowerCase()}`, + level: "INFO", + annotations: { + component: "desktop-backend-child", + runId, + phase, + details: sanitizeLogValue(details), + }, + }); }), writeOutputChunk: (streamName, chunk) => - environment.isDevelopment - ? writeDevelopmentConsoleOutput(streamName, chunk).pipe( - Effect.andThen(logFile.writeBytes(chunk)), - ) - : logFile.writeBytes(chunk), + Effect.gen(function* () { + if (environment.isDevelopment) { + yield* writeDevelopmentConsoleOutput(streamName, chunk); + } + const runId = yield* currentDesktopRunId; + yield* writeBackendChildLogRecord(logFile, { + message: "backend child process output", + level: streamName === "stderr" ? "ERROR" : "INFO", + annotations: { + component: "desktop-backend-child", + runId, + stream: streamName, + text: textDecoder.decode(chunk), + }, + }); + }), }) satisfies DesktopBackendOutputLogShape, }); }), ); + +const desktopLoggerLayer = Layer.unwrap( + Effect.gen(function* () { + const fileLogger = yield* makeDesktopFileLogger.pipe(Effect.option); + const loggers: Array> = [ + Logger.consolePretty(), + Logger.tracerLogger, + ]; + + if (Option.isSome(fileLogger)) { + loggers.push(fileLogger.value); + } + + return Layer.mergeAll( + Logger.layer(loggers, { mergeWithExisting: false }), + Layer.succeed(References.MinimumLogLevel, "Info"), + ); + }), +); + +const tracerLayer = Layer.unwrap( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const otlpTracesUrl = yield* resolveOtlpTracesUrl; + const tracePath = environment.path.join(environment.logDir, "desktop.trace.ndjson"); + const sink = yield* makeTraceSink({ + filePath: tracePath, + maxBytes: DESKTOP_LOG_FILE_MAX_BYTES, + maxFiles: DESKTOP_LOG_FILE_MAX_FILES, + batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS, + }); + const delegate = Option.isNone(otlpTracesUrl) + ? undefined + : yield* OtlpTracer.make({ + url: otlpTracesUrl.value, + exportInterval: `${environment.otlpExportIntervalMs} millis`, + resource: { + serviceName: "desktop", + attributes: { + "service.runtime": "desktop", + "service.mode": environment.isDevelopment ? "development" : "packaged", + }, + }, + }); + const tracer = yield* makeLocalFileTracer({ + filePath: tracePath, + maxBytes: DESKTOP_LOG_FILE_MAX_BYTES, + maxFiles: DESKTOP_LOG_FILE_MAX_FILES, + batchWindowMs: DESKTOP_TRACE_BATCH_WINDOW_MS, + sink, + ...(delegate ? { delegate } : {}), + }); + + return Layer.succeed(Tracer.Tracer, tracer); + }), +).pipe(Layer.provideMerge(OtlpSerialization.layerJson)); + +export const layer = Layer.mergeAll( + backendOutputLogLayer, + desktopLoggerLayer, + tracerLayer, + Layer.succeed(Tracer.MinimumTraceLevel, "Info"), + Layer.succeed(References.TracerTimingEnabled, true), +); diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 751ee79edcd..f57021e2fe3 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -21,10 +21,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; -import { - DesktopBackendOutputLog, - type DesktopBackendOutputLogShape, -} from "../app/DesktopLogging.ts"; +import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; @@ -103,7 +100,7 @@ function decodeBootstrap(raw: string) { function makeManagerLayer(input: { readonly spawnerLayer: Layer.Layer; readonly httpClientLayer?: Layer.Layer; - readonly backendOutputLog?: Partial; + readonly backendOutputLog?: Partial; readonly desktopState?: DesktopState.DesktopStateShape; readonly desktopWindow?: Partial; readonly config?: DesktopBackendManager.DesktopBackendStartConfig; @@ -122,11 +119,11 @@ function makeManagerLayer(input: { input.desktopState ? Layer.succeed(DesktopState.DesktopState, input.desktopState) : DesktopState.layer, - Layer.succeed(DesktopBackendOutputLog, { + Layer.succeed(DesktopObservability.DesktopBackendOutputLog, { writeSessionBoundary: () => Effect.void, writeOutputChunk: () => Effect.void, ...input.backendOutputLog, - } satisfies DesktopBackendOutputLogShape), + } satisfies DesktopObservability.DesktopBackendOutputLogShape), Layer.succeed(DesktopWindow.DesktopWindow, { createMain: Effect.die("unexpected createMain"), ensureMain: Effect.die("unexpected ensureMain"), diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 2ac126cb5f0..def3b49c319 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -24,7 +24,7 @@ import { } from "@t3tools/contracts"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; -import { DesktopBackendOutputLog } from "../app/DesktopLogging.ts"; +import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; @@ -277,7 +277,7 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio const parentScope = yield* Scope.Scope; const fileSystem = yield* FileSystem.FileSystem; const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; - const backendOutputLog = yield* DesktopBackendOutputLog; + const backendOutputLog = yield* DesktopObservability.DesktopBackendOutputLog; const desktopState = yield* DesktopState.DesktopState; const desktopWindow = yield* DesktopWindow.DesktopWindow; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index bdc6d22095f..576c40ddd88 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -31,7 +31,7 @@ import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfigurat import * as DesktopBackendManager from "./backend/DesktopBackendManager.ts"; import * as DesktopEnvironment from "./app/DesktopEnvironment.ts"; import * as DesktopLifecycle from "./app/DesktopLifecycle.ts"; -import { DesktopBackendOutputLogLive, DesktopLoggerLive } from "./app/DesktopLogging.ts"; +import * as DesktopObservability from "./app/DesktopObservability.ts"; import * as DesktopServerExposure from "./serverExposure/DesktopServerExposure.ts"; import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts"; import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts"; @@ -108,8 +108,7 @@ const desktopFoundationLayer = Layer.mergeAll( DesktopClientSettings.layer, DesktopSavedEnvironments.layer, DesktopAssets.layer, - DesktopLoggerLive, - DesktopBackendOutputLogLive, + DesktopObservability.layer, ).pipe(Layer.provideMerge(desktopEnvironmentLayer)); const desktopSshLayer = Layer.mergeAll(desktopSshEnvironmentLayer, DesktopSshRemoteApi.layer).pipe( diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 88cc5adae92..23ee0f54cf8 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -1,4 +1,5 @@ import Mime from "@effect/platform-node/Mime"; +import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; import { Data, Effect, FileSystem, Option, Path } from "effect"; import { cast } from "effect/Function"; import { @@ -18,7 +19,6 @@ import { } from "./attachmentPaths.ts"; import { resolveAttachmentPathById } from "./attachmentStore.ts"; import { resolveStaticDir, ServerConfig } from "./config.ts"; -import { decodeOtlpTraceRecords } from "./observability/TraceRecord.ts"; import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts"; import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts"; import { ServerAuth } from "./auth/Services/ServerAuth.ts"; diff --git a/apps/server/src/observability/Attributes.test.ts b/apps/server/src/observability/Attributes.test.ts index 4b495598ea3..d9ed2e1271f 100644 --- a/apps/server/src/observability/Attributes.test.ts +++ b/apps/server/src/observability/Attributes.test.ts @@ -1,43 +1,8 @@ import { assert, describe, it } from "@effect/vitest"; -import { compactTraceAttributes, normalizeModelMetricLabel } from "./Attributes.ts"; +import { normalizeModelMetricLabel } from "./Attributes.ts"; describe("Attributes", () => { - it("normalizes circular arrays, maps, and sets without recursing forever", () => { - const array: Array = ["alpha"]; - array.push(array); - - const map = new Map(); - map.set("self", map); - - const set = new Set(); - set.add(set); - - assert.deepStrictEqual( - compactTraceAttributes({ - array, - map, - set, - }), - { - array: ["alpha", "[Circular]"], - map: { self: "[Circular]" }, - set: ["[Circular]"], - }, - ); - }); - - it("normalizes invalid dates without throwing", () => { - assert.deepStrictEqual( - compactTraceAttributes({ - invalidDate: new Date("not-a-real-date"), - }), - { - invalidDate: "Invalid Date", - }, - ); - }); - it("groups GPT-family models under a shared metric label", () => { assert.strictEqual(normalizeModelMetricLabel("gpt-4o"), "gpt"); assert.strictEqual(normalizeModelMetricLabel("gpt-5.4"), "gpt"); diff --git a/apps/server/src/observability/Attributes.ts b/apps/server/src/observability/Attributes.ts index 2251fcfea69..1da76d3b325 100644 --- a/apps/server/src/observability/Attributes.ts +++ b/apps/server/src/observability/Attributes.ts @@ -2,88 +2,8 @@ import { Cause, Exit } from "effect"; export type MetricAttributeValue = string; export type MetricAttributes = Readonly>; -export type TraceAttributes = Readonly>; export type ObservabilityOutcome = "success" | "failure" | "interrupt"; -function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function markSeen(value: object, seen: WeakSet): boolean { - if (seen.has(value)) { - return true; - } - seen.add(value); - return false; -} - -function normalizeJsonValue(value: unknown, seen: WeakSet = new WeakSet()): unknown { - if ( - value === null || - value === undefined || - typeof value === "string" || - typeof value === "number" || - typeof value === "boolean" - ) { - return value ?? null; - } - if (typeof value === "bigint") { - return value.toString(); - } - if (value instanceof Date) { - return Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString(); - } - if (value instanceof Error) { - return { - name: value.name, - message: value.message, - ...(value.stack ? { stack: value.stack } : {}), - }; - } - if (Array.isArray(value)) { - if (markSeen(value, seen)) { - return "[Circular]"; - } - return value.map((entry) => normalizeJsonValue(entry, seen)); - } - if (value instanceof Map) { - if (markSeen(value, seen)) { - return "[Circular]"; - } - return Object.fromEntries( - Array.from(value.entries(), ([key, entryValue]) => [ - String(key), - normalizeJsonValue(entryValue, seen), - ]), - ); - } - if (value instanceof Set) { - if (markSeen(value, seen)) { - return "[Circular]"; - } - return Array.from(value.values(), (entry) => normalizeJsonValue(entry, seen)); - } - if (!isPlainObject(value)) { - return String(value); - } - if (markSeen(value, seen)) { - return "[Circular]"; - } - return Object.fromEntries( - Object.entries(value).map(([key, entryValue]) => [key, normalizeJsonValue(entryValue, seen)]), - ); -} - -export function compactTraceAttributes( - attributes: Readonly>, -): TraceAttributes { - return Object.fromEntries( - Object.entries(attributes) - .filter(([, value]) => value !== undefined) - .map(([key, value]) => [key, normalizeJsonValue(value)]), - ); -} - export function compactMetricAttributes( attributes: Readonly>, ): MetricAttributes { diff --git a/apps/server/src/observability/Layers/Observability.ts b/apps/server/src/observability/Layers/Observability.ts index 7c6a28005f4..ae3c1ecb276 100644 --- a/apps/server/src/observability/Layers/Observability.ts +++ b/apps/server/src/observability/Layers/Observability.ts @@ -1,11 +1,10 @@ +import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; import { Effect, Layer, References, Tracer } from "effect"; import { OtlpMetrics, OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; import { ServerConfig } from "../../config.ts"; import { ServerLoggerLive } from "../../serverLogger.ts"; -import { makeLocalFileTracer } from "../LocalFileTracer.ts"; import { BrowserTraceCollector } from "../Services/BrowserTraceCollector.ts"; -import { makeTraceSink } from "../TraceSink.ts"; const otlpSerializationLayer = OtlpSerialization.layerJson; diff --git a/apps/server/src/observability/LocalFileTracer.test.ts b/apps/server/src/observability/LocalFileTracer.test.ts deleted file mode 100644 index 19efffaf10b..00000000000 --- a/apps/server/src/observability/LocalFileTracer.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { assert, describe, it } from "@effect/vitest"; -import { Effect, Layer, Logger, References, Tracer } from "effect"; - -import type { EffectTraceRecord } from "./TraceRecord.ts"; -import { makeLocalFileTracer } from "./LocalFileTracer.ts"; - -const makeTestLayer = (tracePath: string) => - Layer.mergeAll( - Layer.effect( - Tracer.Tracer, - makeLocalFileTracer({ - filePath: tracePath, - maxBytes: 1024 * 1024, - maxFiles: 2, - batchWindowMs: 10_000, - }), - ), - Logger.layer([Logger.tracerLogger], { mergeWithExisting: false }), - Layer.succeed(References.MinimumLogLevel, "Info"), - ); - -const readTraceRecords = (tracePath: string): Array => - fs - .readFileSync(tracePath, "utf8") - .trim() - .split("\n") - .filter((line) => line.length > 0) - .map((line) => JSON.parse(line) as EffectTraceRecord); - -describe("LocalFileTracer", () => { - it.effect("writes nested spans to disk and captures log messages as span events", () => - Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-local-tracer-")); - const tracePath = path.join(tempDir, "server.trace.ndjson"); - - try { - yield* Effect.scoped( - Effect.gen(function* () { - const program = Effect.gen(function* () { - yield* Effect.annotateCurrentSpan({ - "demo.parent": true, - }); - yield* Effect.logInfo("parent event"); - yield* Effect.gen(function* () { - yield* Effect.annotateCurrentSpan({ - "demo.child": true, - }); - yield* Effect.logInfo("child event"); - }).pipe(Effect.withSpan("child-span")); - }).pipe(Effect.withSpan("parent-span")); - - yield* program.pipe(Effect.provide(makeTestLayer(tracePath))); - }), - ); - - const records = readTraceRecords(tracePath); - assert.equal(records.length, 2); - - const parent = records.find((record) => record.name === "parent-span"); - const child = records.find((record) => record.name === "child-span"); - - assert.notEqual(parent, undefined); - assert.notEqual(child, undefined); - if (!parent || !child) { - return; - } - - assert.equal(child.parentSpanId, parent.spanId); - assert.equal(parent.attributes["demo.parent"], true); - assert.equal(child.attributes["demo.child"], true); - assert.equal( - parent.events.some((event) => event.name === "parent event"), - true, - ); - assert.equal( - child.events.some((event) => event.name === "child event"), - true, - ); - assert.equal( - child.events.some((event) => event.attributes["effect.logLevel"] === "INFO"), - true, - ); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - - it.effect("serializes interrupted spans with an interrupted exit status", () => - Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-local-tracer-")); - const tracePath = path.join(tempDir, "server.trace.ndjson"); - - try { - yield* Effect.scoped( - Effect.exit( - Effect.interrupt.pipe( - Effect.withSpan("interrupt-span"), - Effect.provide(makeTestLayer(tracePath)), - ), - ), - ); - - const records = readTraceRecords(tracePath); - assert.equal(records.length, 1); - assert.equal(records[0]?.name, "interrupt-span"); - assert.equal(records[0]?.exit._tag, "Interrupted"); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); -}); diff --git a/apps/server/src/observability/LocalFileTracer.ts b/apps/server/src/observability/LocalFileTracer.ts deleted file mode 100644 index a3d43ea118c..00000000000 --- a/apps/server/src/observability/LocalFileTracer.ts +++ /dev/null @@ -1,112 +0,0 @@ -import type * as Exit from "effect/Exit"; -import { Effect, Option, Tracer } from "effect"; - -import { spanToTraceRecord } from "./TraceRecord.ts"; -import type { EffectTraceRecord } from "./TraceRecord.ts"; -import { makeTraceSink, type TraceSink } from "./TraceSink.ts"; - -export interface LocalFileTracerOptions { - readonly filePath: string; - readonly maxBytes: number; - readonly maxFiles: number; - readonly batchWindowMs: number; - readonly delegate?: Tracer.Tracer; - readonly sink?: TraceSink; -} - -class LocalFileSpan implements Tracer.Span { - readonly _tag = "Span"; - readonly name: string; - readonly spanId: string; - readonly traceId: string; - readonly parent: Option.Option; - readonly annotations: Tracer.Span["annotations"]; - readonly links: Array; - readonly sampled: boolean; - readonly kind: Tracer.SpanKind; - - status: Tracer.SpanStatus; - attributes: Map; - events: Array<[name: string, startTime: bigint, attributes: Record]>; - private readonly delegate: Tracer.Span; - private readonly push: (record: EffectTraceRecord) => void; - - constructor( - options: Parameters[0], - delegate: Tracer.Span, - push: (record: EffectTraceRecord) => void, - ) { - this.delegate = delegate; - this.push = push; - this.name = delegate.name; - this.spanId = delegate.spanId; - this.traceId = delegate.traceId; - this.parent = options.parent; - this.annotations = options.annotations; - this.links = [...options.links]; - this.sampled = delegate.sampled; - this.kind = delegate.kind; - this.status = { - _tag: "Started", - startTime: options.startTime, - }; - this.attributes = new Map(); - this.events = []; - } - - end(endTime: bigint, exit: Exit.Exit): void { - this.status = { - _tag: "Ended", - startTime: this.status.startTime, - endTime, - exit, - }; - this.delegate.end(endTime, exit); - - if (this.sampled) { - this.push(spanToTraceRecord(this)); - } - } - - attribute(key: string, value: unknown): void { - this.attributes.set(key, value); - this.delegate.attribute(key, value); - } - - event(name: string, startTime: bigint, attributes?: Record): void { - const nextAttributes = attributes ?? {}; - this.events.push([name, startTime, nextAttributes]); - this.delegate.event(name, startTime, nextAttributes); - } - - addLinks(links: ReadonlyArray): void { - this.links.push(...links); - this.delegate.addLinks(links); - } -} - -export const makeLocalFileTracer = Effect.fn("makeLocalFileTracer")(function* ( - options: LocalFileTracerOptions, -) { - const sink = - options.sink ?? - (yield* makeTraceSink({ - filePath: options.filePath, - maxBytes: options.maxBytes, - maxFiles: options.maxFiles, - batchWindowMs: options.batchWindowMs, - })); - - const delegate = - options.delegate ?? - Tracer.make({ - span: (spanOptions) => new Tracer.NativeSpan(spanOptions), - }); - - return Tracer.make({ - span(spanOptions) { - return new LocalFileSpan(spanOptions, delegate.span(spanOptions), sink.push); - }, - ...(delegate.context ? { context: delegate.context } : {}), - }); -}); diff --git a/apps/server/src/observability/Services/BrowserTraceCollector.ts b/apps/server/src/observability/Services/BrowserTraceCollector.ts index e27a53c9c34..1018d536044 100644 --- a/apps/server/src/observability/Services/BrowserTraceCollector.ts +++ b/apps/server/src/observability/Services/BrowserTraceCollector.ts @@ -1,8 +1,7 @@ +import type { TraceRecord } from "@t3tools/shared/observability"; import { Context } from "effect"; import type { Effect } from "effect"; -import type { TraceRecord } from "../TraceRecord.ts"; - export interface BrowserTraceCollectorShape { readonly record: (records: ReadonlyArray) => Effect.Effect; } diff --git a/apps/server/src/observability/TraceSink.test.ts b/apps/server/src/observability/TraceSink.test.ts deleted file mode 100644 index f4db90516b1..00000000000 --- a/apps/server/src/observability/TraceSink.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { assert, describe, it } from "@effect/vitest"; -import { Effect } from "effect"; - -import type { TraceRecord } from "./TraceRecord.ts"; -import { makeTraceSink } from "./TraceSink.ts"; - -const makeRecord = (name: string, suffix = ""): TraceRecord => ({ - type: "effect-span", - name, - traceId: `trace-${name}-${suffix}`, - spanId: `span-${name}-${suffix}`, - sampled: true, - kind: "internal", - startTimeUnixNano: "1", - endTimeUnixNano: "2", - durationMs: 1, - attributes: { - payload: suffix, - }, - events: [], - links: [], - exit: { - _tag: "Success", - }, -}); - -describe("TraceSink", () => { - it.effect("flushes buffered records on close", () => - Effect.scoped( - Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-trace-sink-")); - const tracePath = path.join(tempDir, "server.trace.ndjson"); - - try { - const sink = yield* makeTraceSink({ - filePath: tracePath, - maxBytes: 1024, - maxFiles: 2, - batchWindowMs: 10_000, - }); - - sink.push(makeRecord("alpha")); - sink.push(makeRecord("beta")); - yield* sink.close(); - - const lines = fs - .readFileSync(tracePath, "utf8") - .trim() - .split("\n") - .map((line) => JSON.parse(line) as TraceRecord); - - assert.equal(lines.length, 2); - assert.equal(lines[0]?.name, "alpha"); - assert.equal(lines[1]?.name, "beta"); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ), - ); - - it.effect("rotates the trace file when the configured max size is exceeded", () => - Effect.scoped( - Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-trace-sink-")); - const tracePath = path.join(tempDir, "server.trace.ndjson"); - - try { - const sink = yield* makeTraceSink({ - filePath: tracePath, - maxBytes: 180, - maxFiles: 2, - batchWindowMs: 10_000, - }); - - for (let index = 0; index < 8; index += 1) { - sink.push(makeRecord("rotate", `${index}-${"x".repeat(48)}`)); - yield* sink.flush; - } - yield* sink.close(); - - const matchingFiles = fs - .readdirSync(tempDir) - .filter( - (entry) => - entry === "server.trace.ndjson" || entry.startsWith("server.trace.ndjson."), - ) - .toSorted(); - - assert.equal( - matchingFiles.some((entry) => entry === "server.trace.ndjson.1"), - true, - ); - assert.equal( - matchingFiles.some((entry) => entry === "server.trace.ndjson.3"), - false, - ); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ), - ); - - it.effect("drops only the invalid record when serialization fails", () => - Effect.scoped( - Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-trace-sink-")); - const tracePath = path.join(tempDir, "server.trace.ndjson"); - - try { - const sink = yield* makeTraceSink({ - filePath: tracePath, - maxBytes: 1024, - maxFiles: 2, - batchWindowMs: 10_000, - }); - - const circular: Array = []; - circular.push(circular); - - sink.push(makeRecord("alpha")); - sink.push({ - ...makeRecord("invalid"), - attributes: { - circular, - }, - } as TraceRecord); - sink.push(makeRecord("beta")); - yield* sink.close(); - - const lines = fs - .readFileSync(tracePath, "utf8") - .trim() - .split("\n") - .map((line) => JSON.parse(line) as TraceRecord); - - assert.deepStrictEqual( - lines.map((line) => line.name), - ["alpha", "beta"], - ); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ), - ); -}); diff --git a/apps/server/src/observability/TraceSink.ts b/apps/server/src/observability/TraceSink.ts deleted file mode 100644 index 1bd00b47341..00000000000 --- a/apps/server/src/observability/TraceSink.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { RotatingFileSink } from "@t3tools/shared/logging"; -import { Effect } from "effect"; - -import type { TraceRecord } from "./TraceRecord.ts"; - -const FLUSH_BUFFER_THRESHOLD = 32; - -export interface TraceSinkOptions { - readonly filePath: string; - readonly maxBytes: number; - readonly maxFiles: number; - readonly batchWindowMs: number; -} - -export interface TraceSink { - readonly filePath: string; - push: (record: TraceRecord) => void; - flush: Effect.Effect; - close: () => Effect.Effect; -} - -export const makeTraceSink = Effect.fn("makeTraceSink")(function* (options: TraceSinkOptions) { - const sink = new RotatingFileSink({ - filePath: options.filePath, - maxBytes: options.maxBytes, - maxFiles: options.maxFiles, - }); - - let buffer: Array = []; - - const flushUnsafe = () => { - if (buffer.length === 0) { - return; - } - - const chunk = buffer.join(""); - buffer = []; - - try { - sink.write(chunk); - } catch { - buffer.unshift(chunk); - } - }; - - const flush = Effect.sync(flushUnsafe).pipe(Effect.withTracerEnabled(false)); - - yield* Effect.addFinalizer(() => flush.pipe(Effect.ignore)); - yield* Effect.forkScoped( - Effect.sleep(`${options.batchWindowMs} millis`).pipe(Effect.andThen(flush), Effect.forever), - ); - - return { - filePath: options.filePath, - push(record) { - try { - buffer.push(`${JSON.stringify(record)}\n`); - if (buffer.length >= FLUSH_BUFFER_THRESHOLD) { - flushUnsafe(); - } - } catch { - return; - } - }, - flush, - close: () => flush, - } satisfies TraceSink; -}); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 6569cec2d27..489e3b3583b 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -20,7 +20,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, type VcsRef } from "@t3tools/contracts"; import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; -import { compactTraceAttributes } from "../observability/Attributes.ts"; +import { compactTraceAttributes } from "@t3tools/shared/observability"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; import { @@ -29,7 +30,6 @@ import { parseRemoteRefWithRemoteNames, } from "../git/remoteRefs.ts"; import { ServerConfig } from "../config.ts"; -import { decodeJsonResult } from "@t3tools/shared/schemaJson"; const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; diff --git a/packages/shared/package.json b/packages/shared/package.json index d42c2039a2b..5e785efc4d7 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -20,6 +20,10 @@ "types": "./src/logging.ts", "import": "./src/logging.ts" }, + "./observability": { + "types": "./src/observability.ts", + "import": "./src/observability.ts" + }, "./shell": { "types": "./src/shell.ts", "import": "./src/shell.ts" diff --git a/packages/shared/src/observability.test.ts b/packages/shared/src/observability.test.ts new file mode 100644 index 00000000000..644b9cf0e14 --- /dev/null +++ b/packages/shared/src/observability.test.ts @@ -0,0 +1,314 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as References from "effect/References"; +import * as Schema from "effect/Schema"; +import * as Tracer from "effect/Tracer"; + +import { + compactTraceAttributes, + makeLocalFileTracer, + makeTraceSink, + type TraceRecord, +} from "./observability.ts"; + +const TraceRecordLine = Schema.Struct({ + name: Schema.String, + spanId: Schema.String, + parentSpanId: Schema.optional(Schema.String), + attributes: Schema.Record(Schema.String, Schema.Unknown), + events: Schema.Array( + Schema.Struct({ + name: Schema.String, + attributes: Schema.Record(Schema.String, Schema.Unknown), + }), + ), + exit: Schema.optional( + Schema.Struct({ + _tag: Schema.String, + }), + ), +}); + +const decodeTraceRecordLine = Schema.decodeUnknownSync(Schema.fromJsonString(TraceRecordLine)); + +const makeRecord = (name: string, suffix = ""): TraceRecord => ({ + type: "effect-span", + name, + traceId: `trace-${name}-${suffix}`, + spanId: `span-${name}-${suffix}`, + sampled: true, + kind: "internal", + startTimeUnixNano: "1", + endTimeUnixNano: "2", + durationMs: 1, + attributes: { + payload: suffix, + }, + events: [], + links: [], + exit: { + _tag: "Success", + }, +}); + +const readTraceRecords = (tracePath: string) => + fs + .readFileSync(tracePath, "utf8") + .trim() + .split("\n") + .filter((line) => line.length > 0) + .map((line) => decodeTraceRecordLine(line)); + +const makeTestLayer = (tracePath: string) => + Layer.mergeAll( + Layer.effect( + Tracer.Tracer, + makeLocalFileTracer({ + filePath: tracePath, + maxBytes: 1024 * 1024, + maxFiles: 2, + batchWindowMs: 10_000, + }), + ), + Logger.layer([Logger.tracerLogger], { mergeWithExisting: false }), + Layer.succeed(References.MinimumLogLevel, "Info"), + ); + +describe("observability", () => { + it("normalizes circular arrays, maps, and sets without recursing forever", () => { + const array: Array = ["alpha"]; + array.push(array); + + const map = new Map(); + map.set("self", map); + + const set = new Set(); + set.add(set); + + assert.deepStrictEqual( + compactTraceAttributes({ + array, + map, + set, + }), + { + array: ["alpha", "[Circular]"], + map: { self: "[Circular]" }, + set: ["[Circular]"], + }, + ); + }); + + it("normalizes invalid dates without throwing", () => { + assert.deepStrictEqual( + compactTraceAttributes({ + invalidDate: new Date("not-a-real-date"), + }), + { + invalidDate: "Invalid Date", + }, + ); + }); + + it.effect("flushes buffered trace records on close", () => + Effect.scoped( + Effect.gen(function* () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-trace-sink-")); + const tracePath = path.join(tempDir, "shared.trace.ndjson"); + + try { + const sink = yield* makeTraceSink({ + filePath: tracePath, + maxBytes: 1024, + maxFiles: 2, + batchWindowMs: 10_000, + }); + + sink.push(makeRecord("alpha")); + sink.push(makeRecord("beta")); + yield* sink.close(); + + const lines = readTraceRecords(tracePath); + + assert.equal(lines.length, 2); + assert.equal(lines[0]?.name, "alpha"); + assert.equal(lines[1]?.name, "beta"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ), + ); + + it.effect("rotates the trace file when the configured max size is exceeded", () => + Effect.scoped( + Effect.gen(function* () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-trace-sink-")); + const tracePath = path.join(tempDir, "shared.trace.ndjson"); + + try { + const sink = yield* makeTraceSink({ + filePath: tracePath, + maxBytes: 180, + maxFiles: 2, + batchWindowMs: 10_000, + }); + + for (let index = 0; index < 8; index += 1) { + sink.push(makeRecord("rotate", `${index}-${"x".repeat(48)}`)); + yield* sink.flush; + } + yield* sink.close(); + + const matchingFiles = fs + .readdirSync(tempDir) + .filter( + (entry) => + entry === "shared.trace.ndjson" || entry.startsWith("shared.trace.ndjson."), + ) + .toSorted(); + + assert.equal( + matchingFiles.some((entry) => entry === "shared.trace.ndjson.1"), + true, + ); + assert.equal( + matchingFiles.some((entry) => entry === "shared.trace.ndjson.3"), + false, + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ), + ); + + it.effect("drops only the invalid trace record when serialization fails", () => + Effect.scoped( + Effect.gen(function* () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-trace-sink-")); + const tracePath = path.join(tempDir, "shared.trace.ndjson"); + + try { + const sink = yield* makeTraceSink({ + filePath: tracePath, + maxBytes: 1024, + maxFiles: 2, + batchWindowMs: 10_000, + }); + + const circular: Array = []; + circular.push(circular); + + sink.push(makeRecord("alpha")); + sink.push({ + ...makeRecord("invalid"), + attributes: { + circular, + }, + } as TraceRecord); + sink.push(makeRecord("beta")); + yield* sink.close(); + + const lines = readTraceRecords(tracePath); + + assert.deepStrictEqual( + lines.map((line) => line.name), + ["alpha", "beta"], + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ), + ); + + it.effect("writes nested spans to disk and captures log messages as span events", () => + Effect.gen(function* () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-local-tracer-")); + const tracePath = path.join(tempDir, "shared.trace.ndjson"); + + try { + yield* Effect.scoped( + Effect.gen(function* () { + const program = Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ + "demo.parent": true, + }); + yield* Effect.logInfo("parent event"); + yield* Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ + "demo.child": true, + }); + yield* Effect.logInfo("child event"); + }).pipe(Effect.withSpan("child-span")); + }).pipe(Effect.withSpan("parent-span")); + + yield* program.pipe(Effect.provide(makeTestLayer(tracePath))); + }), + ); + + const records = readTraceRecords(tracePath); + assert.equal(records.length, 2); + + const parent = records.find((record) => record.name === "parent-span"); + const child = records.find((record) => record.name === "child-span"); + + assert.notEqual(parent, undefined); + assert.notEqual(child, undefined); + if (!parent || !child) { + return; + } + + assert.equal(child.parentSpanId, parent.spanId); + assert.equal(parent.attributes["demo.parent"], true); + assert.equal(child.attributes["demo.child"], true); + assert.equal( + parent.events.some((event) => event.name === "parent event"), + true, + ); + assert.equal( + child.events.some((event) => event.name === "child event"), + true, + ); + assert.equal( + child.events.some((event) => event.attributes["effect.logLevel"] === "INFO"), + true, + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); + + it.effect("serializes interrupted spans with an interrupted exit status", () => + Effect.gen(function* () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-local-tracer-")); + const tracePath = path.join(tempDir, "shared.trace.ndjson"); + + try { + yield* Effect.scoped( + Effect.exit( + Effect.interrupt.pipe( + Effect.withSpan("interrupt-span"), + Effect.provide(makeTestLayer(tracePath)), + ), + ), + ); + + const records = readTraceRecords(tracePath); + assert.equal(records.length, 1); + assert.equal(records[0]?.name, "interrupt-span"); + assert.equal(records[0]?.exit?._tag, "Interrupted"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); +}); diff --git a/apps/server/src/observability/TraceRecord.ts b/packages/shared/src/observability.ts similarity index 53% rename from apps/server/src/observability/TraceRecord.ts rename to packages/shared/src/observability.ts index a9a598288b1..4a49545e393 100644 --- a/apps/server/src/observability/TraceRecord.ts +++ b/packages/shared/src/observability.ts @@ -1,15 +1,24 @@ -import { Cause, Exit, Option, Tracer } from "effect"; - -import { compactTraceAttributes } from "./Attributes.ts"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import type * as Exit from "effect/Exit"; +import * as ExitRuntime from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Tracer from "effect/Tracer"; import { OtlpResource, OtlpTracer } from "effect/unstable/observability"; -interface TraceRecordEvent { +import { RotatingFileSink } from "./logging.ts"; + +const FLUSH_BUFFER_THRESHOLD = 32; + +export type TraceAttributes = Readonly>; + +export interface TraceRecordEvent { readonly name: string; readonly timeUnixNano: string; readonly attributes: Readonly>; } -interface TraceRecordLink { +export interface TraceRecordLink { readonly traceId: string; readonly spanId: string; readonly attributes: Readonly>; @@ -46,7 +55,7 @@ export interface EffectTraceRecord extends BaseTraceRecord { }; } -interface OtlpTraceRecord extends BaseTraceRecord { +export interface OtlpTraceRecord extends BaseTraceRecord { readonly type: "otlp-span"; readonly resourceAttributes: Readonly>; readonly scope: Readonly<{ @@ -64,6 +73,25 @@ interface OtlpTraceRecord extends BaseTraceRecord { export type TraceRecord = EffectTraceRecord | OtlpTraceRecord; +export interface TraceSinkOptions { + readonly filePath: string; + readonly maxBytes: number; + readonly maxFiles: number; + readonly batchWindowMs: number; +} + +export interface TraceSink { + readonly filePath: string; + push: (record: TraceRecord) => void; + flush: Effect.Effect; + close: () => Effect.Effect; +} + +export interface LocalFileTracerOptions extends TraceSinkOptions { + readonly delegate?: Tracer.Tracer; + readonly sink?: TraceSink; +} + type OtlpSpan = OtlpTracer.ScopeSpan["spans"][number]; type OtlpSpanEvent = OtlpSpan["events"][number]; type OtlpSpanLink = OtlpSpan["links"][number]; @@ -84,8 +112,87 @@ interface SerializableSpan { >; } +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function markSeen(value: object, seen: WeakSet): boolean { + if (seen.has(value)) { + return true; + } + seen.add(value); + return false; +} + +function normalizeJsonValue(value: unknown, seen: WeakSet = new WeakSet()): unknown { + if ( + value === null || + value === undefined || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return value ?? null; + } + if (typeof value === "bigint") { + return value.toString(); + } + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString(); + } + if (value instanceof Error) { + return { + name: value.name, + message: value.message, + ...(value.stack ? { stack: value.stack } : {}), + }; + } + if (Array.isArray(value)) { + if (markSeen(value, seen)) { + return "[Circular]"; + } + return value.map((entry) => normalizeJsonValue(entry, seen)); + } + if (value instanceof Map) { + if (markSeen(value, seen)) { + return "[Circular]"; + } + return Object.fromEntries( + Array.from(value.entries(), ([key, entryValue]) => [ + String(key), + normalizeJsonValue(entryValue, seen), + ]), + ); + } + if (value instanceof Set) { + if (markSeen(value, seen)) { + return "[Circular]"; + } + return Array.from(value.values(), (entry) => normalizeJsonValue(entry, seen)); + } + if (!isPlainObject(value)) { + return String(value); + } + if (markSeen(value, seen)) { + return "[Circular]"; + } + return Object.fromEntries( + Object.entries(value).map(([key, entryValue]) => [key, normalizeJsonValue(entryValue, seen)]), + ); +} + +export function compactTraceAttributes( + attributes: Readonly>, +): TraceAttributes { + return Object.fromEntries( + Object.entries(attributes) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => [key, normalizeJsonValue(value)]), + ); +} + function formatTraceExit(exit: Exit.Exit): EffectTraceRecord["exit"] { - if (Exit.isSuccess(exit)) { + if (ExitRuntime.isSuccess(exit)) { return { _tag: "Success" }; } if (Cause.hasInterruptsOnly(exit.cause)) { @@ -130,6 +237,151 @@ export function spanToTraceRecord(span: SerializableSpan): EffectTraceRecord { }; } +export const makeTraceSink = Effect.fn("makeTraceSink")(function* (options: TraceSinkOptions) { + const sink = new RotatingFileSink({ + filePath: options.filePath, + maxBytes: options.maxBytes, + maxFiles: options.maxFiles, + }); + + let buffer: Array = []; + + const flushUnsafe = () => { + if (buffer.length === 0) { + return; + } + + const chunk = buffer.join(""); + buffer = []; + + try { + sink.write(chunk); + } catch { + buffer.unshift(chunk); + } + }; + + const flush = Effect.sync(flushUnsafe).pipe(Effect.withTracerEnabled(false)); + + yield* Effect.addFinalizer(() => flush.pipe(Effect.ignore)); + yield* Effect.forkScoped( + Effect.sleep(`${options.batchWindowMs} millis`).pipe(Effect.andThen(flush), Effect.forever), + ); + + return { + filePath: options.filePath, + push(record) { + try { + buffer.push(`${JSON.stringify(record)}\n`); + if (buffer.length >= FLUSH_BUFFER_THRESHOLD) { + flushUnsafe(); + } + } catch { + return; + } + }, + flush, + close: () => flush, + } satisfies TraceSink; +}); + +class LocalFileSpan implements Tracer.Span { + readonly _tag = "Span"; + readonly name: string; + readonly spanId: string; + readonly traceId: string; + readonly parent: Option.Option; + readonly annotations: Tracer.Span["annotations"]; + readonly links: Array; + readonly sampled: boolean; + readonly kind: Tracer.SpanKind; + + status: Tracer.SpanStatus; + attributes: Map; + events: Array<[name: string, startTime: bigint, attributes: Record]>; + private readonly delegate: Tracer.Span; + private readonly push: (record: EffectTraceRecord) => void; + + constructor( + options: Parameters[0], + delegate: Tracer.Span, + push: (record: EffectTraceRecord) => void, + ) { + this.delegate = delegate; + this.push = push; + this.name = delegate.name; + this.spanId = delegate.spanId; + this.traceId = delegate.traceId; + this.parent = options.parent; + this.annotations = options.annotations; + this.links = [...options.links]; + this.sampled = delegate.sampled; + this.kind = delegate.kind; + this.status = { + _tag: "Started", + startTime: options.startTime, + }; + this.attributes = new Map(); + this.events = []; + } + + end(endTime: bigint, exit: Exit.Exit): void { + this.status = { + _tag: "Ended", + startTime: this.status.startTime, + endTime, + exit, + }; + this.delegate.end(endTime, exit); + + if (this.sampled) { + this.push(spanToTraceRecord(this)); + } + } + + attribute(key: string, value: unknown): void { + this.attributes.set(key, value); + this.delegate.attribute(key, value); + } + + event(name: string, startTime: bigint, attributes?: Record): void { + const nextAttributes = attributes ?? {}; + this.events.push([name, startTime, nextAttributes]); + this.delegate.event(name, startTime, nextAttributes); + } + + addLinks(links: ReadonlyArray): void { + this.links.push(...links); + this.delegate.addLinks(links); + } +} + +export const makeLocalFileTracer = Effect.fn("makeLocalFileTracer")(function* ( + options: LocalFileTracerOptions, +) { + const sink = + options.sink ?? + (yield* makeTraceSink({ + filePath: options.filePath, + maxBytes: options.maxBytes, + maxFiles: options.maxFiles, + batchWindowMs: options.batchWindowMs, + })); + + const delegate = + options.delegate ?? + Tracer.make({ + span: (spanOptions) => new Tracer.NativeSpan(spanOptions), + }); + + return Tracer.make({ + span(spanOptions) { + return new LocalFileSpan(spanOptions, delegate.span(spanOptions), sink.push); + }, + ...(delegate.context ? { context: delegate.context } : {}), + }); +}); + const SPAN_KIND_MAP: Record = { 1: "internal", 2: "server", From 79b34209267f9b18abaf1f6086b3b6e6be05bcef Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 7 May 2026 17:49:24 -0700 Subject: [PATCH 40/43] Cancel desktop backend restart when stopped - Ignore stale ready signals from previous runs - Skip scheduled restarts once desiredRunning is false - Add regression coverage for stop cancelling a pending restart --- .../src/backend/DesktopBackendManager.test.ts | 47 ++++++++++++++++++- .../src/backend/DesktopBackendManager.ts | 47 ++++++++++++------- 2 files changed, 77 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index f57021e2fe3..cfd32fee664 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -325,7 +325,6 @@ describe("DesktopBackendManager", () => { yield* manager.start; assert.equal(yield* Queue.take(startedPids), 123); yield* Deferred.await(ready); - yield* Ref.set(backendReady, true); assert.isTrue(yield* Ref.get(backendReady)); assert.deepEqual(yield* manager.currentConfig, Option.some(baseConfig)); @@ -442,4 +441,50 @@ describe("DesktopBackendManager", () => { }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); }), ); + + it.effect("does not restart after stop cancels a scheduled restart", () => + Effect.gen(function* () { + const starts = yield* Queue.unbounded(); + let startCount = 0; + + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.sync(() => { + startCount += 1; + return makeProcess({ + exitCode: Queue.offer(starts, startCount).pipe( + Effect.as(ChildProcessSpawner.ExitCode(1)), + ), + }); + }), + ), + ); + + const managerLayer = makeManagerLayer({ + spawnerLayer, + httpClientLayer: httpClientLayer(() => Effect.never), + }); + + yield* Effect.gen(function* () { + const manager = yield* DesktopBackendManager.DesktopBackendManager; + yield* manager.start; + assert.equal(yield* Queue.take(starts), 1); + + let restartScheduled = false; + while (!restartScheduled) { + restartScheduled = (yield* manager.snapshot).restartScheduled; + if (!restartScheduled) { + yield* Effect.yieldNow; + } + } + + yield* manager.stop(); + yield* TestClock.adjust(Duration.millis(500)); + + assert.equal(yield* Queue.size(starts), 0); + assert.equal((yield* manager.snapshot).desiredRunning, false); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managerLayer))); + }), + ); }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index def3b49c319..91cf7634f3e 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -432,17 +432,26 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio }), onReady: () => Effect.gen(function* () { - yield* Ref.update(state, (latest) => ({ - ...latest, - restartAttempt: Option.match(latest.active, { - onNone: () => latest.restartAttempt, - onSome: (run) => (run.id === runId ? 0 : latest.restartAttempt), - }), - ready: Option.match(latest.active, { - onNone: () => latest.ready, - onSome: (run) => (run.id === runId ? true : latest.ready), - }), - })); + const isCurrentRun = yield* Ref.modify(state, (latest) => { + const activeRun = Option.getOrUndefined(latest.active); + if (activeRun?.id !== runId) { + return [false, latest] as const; + } + + return [ + true, + { + ...latest, + restartAttempt: 0, + ready: true, + }, + ] as const; + }); + if (!isCurrentRun) { + return; + } + + yield* Ref.set(desktopState.backendReady, true); yield* desktopWindow.handleBackendReady.pipe( Effect.catch((error) => Effect.logError("failed to open main window after backend readiness").pipe( @@ -506,12 +515,18 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio const restartFiber = yield* Effect.forkIn( Effect.sleep(delay).pipe( Effect.andThen( - Ref.update(state, (latest) => ({ - ...latest, - restartFiber: Option.none(), - })), + Ref.modify(state, (latest) => { + const shouldRestart = latest.desiredRunning; + return [ + shouldRestart, + { + ...latest, + restartFiber: Option.none(), + }, + ] as const; + }), ), - Effect.andThen(start), + Effect.flatMap((shouldRestart) => (shouldRestart ? start : Effect.void)), Effect.catchCause((cause) => Effect.logError("desktop backend restart fiber failed", { cause }), ), From 6937dabc8f0bc91daa03ce75bb933b21d9121216 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 7 May 2026 19:33:25 -0700 Subject: [PATCH 41/43] Add span tracing to desktop startup and lifecycle - wrap startup, lifecycle, backend, IPC, and settings flows with spans - remove the separate desktop-main.log file logger in favor of trace events - keep observability tests aligned with span-based logging --- apps/desktop/src/app/DesktopApp.ts | 136 ++++---- apps/desktop/src/app/DesktopAppIdentity.ts | 4 +- apps/desktop/src/app/DesktopAssets.ts | 64 ++-- apps/desktop/src/app/DesktopEnvironment.ts | 211 ++++++------ apps/desktop/src/app/DesktopLifecycle.ts | 99 +++--- .../src/app/DesktopObservability.test.ts | 35 +- apps/desktop/src/app/DesktopObservability.ts | 112 +++---- .../backend/DesktopBackendConfiguration.ts | 32 +- .../src/backend/DesktopBackendManager.ts | 254 +++++++-------- apps/desktop/src/electron/ElectronDialog.ts | 80 +++-- apps/desktop/src/electron/ElectronProtocol.ts | 86 ++--- apps/desktop/src/electron/ElectronWindow.ts | 15 +- apps/desktop/src/ipc/DesktopIpc.ts | 91 ++++-- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 +- .../desktop/src/ipc/methods/clientSettings.ts | 18 +- .../src/ipc/methods/savedEnvironments.ts | 54 ++-- .../desktop/src/ipc/methods/serverExposure.ts | 62 ++-- .../desktop/src/ipc/methods/sshEnvironment.ts | 105 +++--- apps/desktop/src/ipc/methods/updates.ts | 45 ++- apps/desktop/src/ipc/methods/window.ts | 123 ++++--- .../serverExposure/DesktopServerExposure.ts | 91 +++--- .../src/settings/DesktopAppSettings.ts | 43 +-- .../src/settings/DesktopClientSettings.ts | 9 +- .../src/settings/DesktopSavedEnvironments.ts | 181 +++++------ .../src/shell/DesktopShellEnvironment.ts | 102 +++--- apps/desktop/src/ssh/DesktopSshEnvironment.ts | 7 +- .../src/ssh/DesktopSshPasswordPrompts.ts | 303 +++++++++--------- apps/desktop/src/ssh/DesktopSshRemoteApi.ts | 4 + apps/desktop/src/updates/DesktopUpdates.ts | 293 +++++++++-------- .../src/window/DesktopApplicationMenu.ts | 59 ++-- apps/desktop/src/window/DesktopWindow.ts | 281 ++++++++-------- 31 files changed, 1513 insertions(+), 1488 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 8906390ccf7..46fbd90068b 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -85,42 +85,41 @@ const resolveDesktopBackendPort = Effect.fn("resolveDesktopBackendPort")(functio }); }); -const handleFatalStartupError = ( +const handleFatalStartupError = Effect.fn("desktop.startup.handleFatalStartupError")(function* ( stage: string, error: unknown, -): Effect.Effect< +): Effect.fn.Return< void, never, | DesktopLifecycle.DesktopShutdown | DesktopState.DesktopState | ElectronApp.ElectronApp | ElectronDialog.ElectronDialog -> => - Effect.gen(function* () { - const shutdown = yield* DesktopLifecycle.DesktopShutdown; - const state = yield* DesktopState.DesktopState; - const electronApp = yield* ElectronApp.ElectronApp; - const electronDialog = yield* ElectronDialog.ElectronDialog; - const message = error instanceof Error ? error.message : String(error); - const detail = - error instanceof Error && typeof error.stack === "string" ? `\n${error.stack}` : ""; - yield* Effect.logError("fatal startup error").pipe( - Effect.annotateLogs({ - stage, - message, - ...(detail.length > 0 ? { detail } : {}), - }), +> { + const shutdown = yield* DesktopLifecycle.DesktopShutdown; + const state = yield* DesktopState.DesktopState; + const electronApp = yield* ElectronApp.ElectronApp; + const electronDialog = yield* ElectronDialog.ElectronDialog; + const message = error instanceof Error ? error.message : String(error); + const detail = + error instanceof Error && typeof error.stack === "string" ? `\n${error.stack}` : ""; + yield* Effect.logError("fatal startup error").pipe( + Effect.annotateLogs({ + stage, + message, + ...(detail.length > 0 ? { detail } : {}), + }), + ); + const wasQuitting = yield* Ref.getAndSet(state.quitting, true); + if (!wasQuitting) { + yield* electronDialog.showErrorBox( + "T3 Code failed to start", + `Stage: ${stage}\n${message}${detail}`, ); - const wasQuitting = yield* Ref.getAndSet(state.quitting, true); - if (!wasQuitting) { - yield* electronDialog.showErrorBox( - "T3 Code failed to start", - `Stage: ${stage}\n${message}${detail}`, - ); - } - yield* shutdown.request; - yield* electronApp.quit; - }); + } + yield* shutdown.request; + yield* electronApp.quit; +}); const fatalStartupCause = (stage: string, cause: Cause.Cause) => handleFatalStartupError(stage, Cause.pretty(cause)).pipe(Effect.andThen(Effect.failCause(cause))); @@ -178,57 +177,62 @@ const bootstrap = Effect.gen(function* () { yield* backendManager.start; yield* Effect.logInfo("bootstrap backend start requested"); } -}); +}).pipe(Effect.withSpan("desktop.bootstrap")); + +const startup = Effect.gen(function* () { + const appIdentity = yield* DesktopAppIdentity.DesktopAppIdentity; + const applicationMenu = yield* DesktopApplicationMenu.DesktopApplicationMenu; + const electronApp = yield* ElectronApp.ElectronApp; + const electronProtocol = yield* ElectronProtocol.ElectronProtocol; + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; + const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; + const updates = yield* DesktopUpdates.DesktopUpdates; + const environment = yield* DesktopEnvironment.DesktopEnvironment; -export const program = Effect.scoped( + yield* shellEnvironment.installIntoProcess; + const userDataPath = yield* appIdentity.resolveUserDataPath; + yield* electronApp.setPath("userData", userDataPath); + yield* Effect.logInfo("runtime logging configured").pipe( + Effect.annotateLogs({ logDir: environment.logDir }), + ); + yield* desktopSettings.load; + + if (environment.platform === "linux") { + yield* electronApp.appendCommandLineSwitch("class", environment.linuxWmClass); + } + + yield* appIdentity.configure; + yield* lifecycle.register; + + yield* electronApp.whenReady.pipe( + Effect.withSpan("desktop.electron.whenReady"), + Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)), + ); + yield* Effect.logInfo("app ready"); + yield* appIdentity.configure; + yield* applicationMenu.configure; + yield* electronProtocol.registerDesktopFileProtocol; + yield* updates.configure; + yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); +}).pipe(Effect.withSpan("desktop.startup")); + +const scopedProgram = Effect.scoped( Effect.gen(function* () { const runId = yield* makeDesktopRunId; yield* Effect.annotateLogsScoped({ scope: "desktop", runId }); + yield* Effect.annotateCurrentSpan({ scope: "desktop", runId }); const shutdown = yield* DesktopLifecycle.DesktopShutdown; - const appIdentity = yield* DesktopAppIdentity.DesktopAppIdentity; - const applicationMenu = yield* DesktopApplicationMenu.DesktopApplicationMenu; const backendManager = yield* DesktopBackendManager.DesktopBackendManager; - const electronApp = yield* ElectronApp.ElectronApp; - const electronProtocol = yield* ElectronProtocol.ElectronProtocol; - const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; - const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; - const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; - const updates = yield* DesktopUpdates.DesktopUpdates; - const environment = yield* DesktopEnvironment.DesktopEnvironment; yield* Effect.addFinalizer(() => backendManager.stop().pipe(Effect.ensuring(shutdown.markComplete)), ); - yield* shellEnvironment.installIntoProcess; - const userDataPath = yield* appIdentity.resolveUserDataPath; - yield* electronApp.setPath("userData", userDataPath); - yield* Effect.logInfo("runtime logging configured").pipe( - Effect.annotateLogs({ logDir: environment.logDir }), - ); - yield* desktopSettings.load; - - if (environment.platform === "linux") { - yield* electronApp.appendCommandLineSwitch("class", environment.linuxWmClass); - } - - yield* appIdentity.configure; - yield* lifecycle.register; - - yield* electronApp.whenReady.pipe( - Effect.withSpan("desktop.electron.whenReady"), - Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)), - ); - yield* Effect.logInfo("app ready"); - yield* appIdentity.configure; - yield* applicationMenu.configure; - yield* electronProtocol.registerDesktopFileProtocol; - yield* updates.configure.pipe(Effect.withSpan("desktop.updates.configure")); - yield* bootstrap.pipe( - Effect.withSpan("desktop.bootstrap"), - Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause)), - ); + yield* startup; yield* shutdown.awaitRequest; }), ); + +export const program = scopedProgram.pipe(Effect.withSpan("desktop.app")); diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts index cb488946450..0b9b8196651 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -90,7 +90,7 @@ const make = Effect.gen(function* () { return legacyPathExists ? legacyPath : environment.path.join(environment.appDataDirectory, environment.userDataDirName); - }); + }).pipe(Effect.withSpan("desktop.appIdentity.resolveUserDataPath")); const configure = Effect.gen(function* () { const commitHash = yield* resolveAboutCommitHash; @@ -116,7 +116,7 @@ const make = Effect.gen(function* () { onSome: electronApp.setDockIcon, }); } - }); + }).pipe(Effect.withSpan("desktop.appIdentity.configure")); return DesktopAppIdentity.of({ resolveUserDataPath, diff --git a/apps/desktop/src/app/DesktopAssets.ts b/apps/desktop/src/app/DesktopAssets.ts index d13bc4d7fe6..60ff477d34f 100644 --- a/apps/desktop/src/app/DesktopAssets.ts +++ b/apps/desktop/src/app/DesktopAssets.ts @@ -21,48 +21,46 @@ export class DesktopAssets extends Context.Service, never, FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment -> => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const candidates = environment.resolveResourcePathCandidates(fileName); - for (const candidate of candidates) { - const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)); - if (exists) { - return Option.some(candidate); - } +> { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const candidates = environment.resolveResourcePathCandidates(fileName); + for (const candidate of candidates) { + const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)); + if (exists) { + return Option.some(candidate); } - return Option.none(); - }); + } + return Option.none(); +}); -const resolveIconPath = ( +const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* ( ext: keyof DesktopIconPaths, -): Effect.Effect< +): Effect.fn.Return< Option.Option, never, FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment -> => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const environment = yield* DesktopEnvironment.DesktopEnvironment; - if (environment.isDevelopment && process.platform === "darwin" && ext === "png") { - const developmentDockIconPath = environment.developmentDockIconPath; - const developmentDockIconExists = yield* fileSystem - .exists(developmentDockIconPath) - .pipe(Effect.orElseSucceed(() => false)); - if (developmentDockIconExists) { - return Option.some(developmentDockIconPath); - } +> { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + if (environment.isDevelopment && process.platform === "darwin" && ext === "png") { + const developmentDockIconPath = environment.developmentDockIconPath; + const developmentDockIconExists = yield* fileSystem + .exists(developmentDockIconPath) + .pipe(Effect.orElseSucceed(() => false)); + if (developmentDockIconExists) { + return Option.some(developmentDockIconPath); } + } - return yield* resolveResourcePath(`icon.${ext}`); - }); + return yield* resolveResourcePath(`icon.${ext}`); +}); const make = Effect.gen(function* () { const context = yield* Effect.context< @@ -76,7 +74,11 @@ const make = Effect.gen(function* () { return DesktopAssets.of({ iconPaths: Effect.succeed(iconPaths), - resolveResourcePath: (fileName) => resolveResourcePath(fileName).pipe(Effect.provide(context)), + resolveResourcePath: Effect.fn("desktop.assets.resolveResourcePath.scoped")( + function* (fileName) { + return yield* resolveResourcePath(fileName).pipe(Effect.provide(context)); + }, + ), }); }); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index b7fa0466c83..a5212f25358 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -135,116 +135,115 @@ function resolveDesktopRuntimeInfo(input: { }; } -const makeDesktopEnvironment = ( +const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* ( input: MakeDesktopEnvironmentInput, -): Effect.Effect => - Effect.gen(function* () { - const path = yield* Path.Path; - const config = yield* DesktopConfig.DesktopConfig; - const homeDirectory = input.homeDirectory; - const devServerUrl = config.devServerUrl; - const isDevelopment = Option.isSome(devServerUrl); - const appDataDirectory = - input.platform === "win32" - ? Option.getOrElse(config.appDataDirectory, () => - path.join(homeDirectory, "AppData", "Roaming"), - ) - : input.platform === "darwin" - ? path.join(homeDirectory, "Library", "Application Support") - : Option.getOrElse(config.xdgConfigHome, () => path.join(homeDirectory, ".config")); - const baseDir = Option.getOrElse(config.t3Home, () => path.join(homeDirectory, ".t3")); - const rootDir = path.resolve(input.dirname, "../../.."); - const appRoot = input.isPackaged ? input.appPath : rootDir; - const branding = resolveDesktopAppBranding({ - isDevelopment, - appVersion: input.appVersion, - }); - const displayName = branding.displayName; - const stateDir = path.join(baseDir, isDevelopment ? "dev" : "userdata"); - const userDataDirName = isDevelopment ? "t3code-dev" : "t3code"; - const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; - const resourcesPath = input.resourcesPath; - - return DesktopEnvironment.of({ - path, - dirname: input.dirname, +): Effect.fn.Return { + const path = yield* Path.Path; + const config = yield* DesktopConfig.DesktopConfig; + const homeDirectory = input.homeDirectory; + const devServerUrl = config.devServerUrl; + const isDevelopment = Option.isSome(devServerUrl); + const appDataDirectory = + input.platform === "win32" + ? Option.getOrElse(config.appDataDirectory, () => + path.join(homeDirectory, "AppData", "Roaming"), + ) + : input.platform === "darwin" + ? path.join(homeDirectory, "Library", "Application Support") + : Option.getOrElse(config.xdgConfigHome, () => path.join(homeDirectory, ".config")); + const baseDir = Option.getOrElse(config.t3Home, () => path.join(homeDirectory, ".t3")); + const rootDir = path.resolve(input.dirname, "../../.."); + const appRoot = input.isPackaged ? input.appPath : rootDir; + const branding = resolveDesktopAppBranding({ + isDevelopment, + appVersion: input.appVersion, + }); + const displayName = branding.displayName; + const stateDir = path.join(baseDir, isDevelopment ? "dev" : "userdata"); + const userDataDirName = isDevelopment ? "t3code-dev" : "t3code"; + const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; + const resourcesPath = input.resourcesPath; + + return DesktopEnvironment.of({ + path, + dirname: input.dirname, + platform: input.platform, + processArch: input.processArch, + isPackaged: input.isPackaged, + isDevelopment, + appVersion: input.appVersion, + appPath: input.appPath, + resourcesPath, + homeDirectory, + appDataDirectory, + baseDir, + stateDir, + desktopSettingsPath: path.join(stateDir, "desktop-settings.json"), + clientSettingsPath: path.join(stateDir, "client-settings.json"), + savedEnvironmentRegistryPath: path.join(stateDir, "saved-environments.json"), + serverSettingsPath: path.join(stateDir, "settings.json"), + logDir: path.join(stateDir, "logs"), + rootDir, + appRoot, + backendEntryPath: path.join(appRoot, "apps/server/dist/bin.mjs"), + backendCwd: input.isPackaged ? homeDirectory : appRoot, + preloadPath: path.join(input.dirname, "preload.cjs"), + appUpdateYmlPath: input.isPackaged + ? path.join(resourcesPath, "app-update.yml") + : path.join(input.appPath, "dev-app-update.yml"), + devServerUrl, + devRemoteT3ServerEntryPath: config.devRemoteT3ServerEntryPath, + configuredBackendPort: config.configuredBackendPort, + commitHashOverride: config.commitHashOverride, + otlpTracesUrl: config.otlpTracesUrl, + otlpExportIntervalMs: config.otlpExportIntervalMs, + branding, + displayName, + appUserModelId: isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code", + linuxDesktopEntryName: isDevelopment ? "t3code-dev.desktop" : "t3code.desktop", + linuxWmClass: isDevelopment ? "t3code-dev" : "t3code", + userDataDirName, + legacyUserDataDirName, + defaultDesktopSettings: resolveDefaultDesktopSettings(input.appVersion), + runtimeInfo: resolveDesktopRuntimeInfo({ platform: input.platform, processArch: input.processArch, - isPackaged: input.isPackaged, - isDevelopment, - appVersion: input.appVersion, - appPath: input.appPath, - resourcesPath, - homeDirectory, - appDataDirectory, - baseDir, - stateDir, - desktopSettingsPath: path.join(stateDir, "desktop-settings.json"), - clientSettingsPath: path.join(stateDir, "client-settings.json"), - savedEnvironmentRegistryPath: path.join(stateDir, "saved-environments.json"), - serverSettingsPath: path.join(stateDir, "settings.json"), - logDir: path.join(stateDir, "logs"), - rootDir, - appRoot, - backendEntryPath: path.join(appRoot, "apps/server/dist/bin.mjs"), - backendCwd: input.isPackaged ? homeDirectory : appRoot, - preloadPath: path.join(input.dirname, "preload.cjs"), - appUpdateYmlPath: input.isPackaged - ? path.join(resourcesPath, "app-update.yml") - : path.join(input.appPath, "dev-app-update.yml"), - devServerUrl, - devRemoteT3ServerEntryPath: config.devRemoteT3ServerEntryPath, - configuredBackendPort: config.configuredBackendPort, - commitHashOverride: config.commitHashOverride, - otlpTracesUrl: config.otlpTracesUrl, - otlpExportIntervalMs: config.otlpExportIntervalMs, - branding, - displayName, - appUserModelId: isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code", - linuxDesktopEntryName: isDevelopment ? "t3code-dev.desktop" : "t3code.desktop", - linuxWmClass: isDevelopment ? "t3code-dev" : "t3code", - userDataDirName, - legacyUserDataDirName, - defaultDesktopSettings: resolveDefaultDesktopSettings(input.appVersion), - runtimeInfo: resolveDesktopRuntimeInfo({ - platform: input.platform, - processArch: input.processArch, - runningUnderArm64Translation: input.runningUnderArm64Translation, - }), - resolvePickFolderDefaultPath: (rawOptions) => { - if (typeof rawOptions !== "object" || rawOptions === null) { - return Option.none(); - } - - const { initialPath } = rawOptions as { initialPath?: unknown }; - if (typeof initialPath !== "string") { - return Option.none(); - } - - const trimmedPath = initialPath.trim(); - if (trimmedPath.length === 0) { - return Option.none(); - } - - if (trimmedPath === "~") { - return Option.some(homeDirectory); - } - - if (trimmedPath.startsWith("~/") || trimmedPath.startsWith("~\\")) { - return Option.some(path.join(homeDirectory, trimmedPath.slice(2))); - } - - return Option.some(path.resolve(trimmedPath)); - }, - resolveResourcePathCandidates: (fileName) => [ - path.join(input.dirname, "../resources", fileName), - path.join(input.dirname, "../prod-resources", fileName), - path.join(resourcesPath, "resources", fileName), - path.join(resourcesPath, fileName), - ], - developmentDockIconPath: path.join(rootDir, "assets", "dev", "blueprint-macos-1024.png"), - }); + runningUnderArm64Translation: input.runningUnderArm64Translation, + }), + resolvePickFolderDefaultPath: (rawOptions) => { + if (typeof rawOptions !== "object" || rawOptions === null) { + return Option.none(); + } + + const { initialPath } = rawOptions as { initialPath?: unknown }; + if (typeof initialPath !== "string") { + return Option.none(); + } + + const trimmedPath = initialPath.trim(); + if (trimmedPath.length === 0) { + return Option.none(); + } + + if (trimmedPath === "~") { + return Option.some(homeDirectory); + } + + if (trimmedPath.startsWith("~/") || trimmedPath.startsWith("~\\")) { + return Option.some(path.join(homeDirectory, trimmedPath.slice(2))); + } + + return Option.some(path.resolve(trimmedPath)); + }, + resolveResourcePathCandidates: (fileName) => [ + path.join(input.dirname, "../resources", fileName), + path.join(input.dirname, "../prod-resources", fileName), + path.join(resourcesPath, "resources", fileName), + path.join(resourcesPath, fileName), + ], + developmentDockIconPath: path.join(rootDir, "assets", "dev", "blueprint-macos-1024.png"), }); +}); export const layer = (input: MakeDesktopEnvironmentInput) => Layer.effect(DesktopEnvironment, makeDesktopEnvironment(input)); diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index 244b2f31efa..ae9090e644c 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -72,11 +72,6 @@ const logLifecycleInfo = (message: string, annotations?: Record }), ); -function makeDesktopEffectRunner(context: Context.Context) { - return (effect: Effect.Effect): Promise => - Effect.runPromiseWith(context as unknown as Context.Context)(effect); -} - function addScopedListener>( target: unknown, eventName: string, @@ -98,17 +93,17 @@ function addScopedListener>( ).pipe(Effect.asVoid); } -function requestDesktopShutdownAndWait(): Effect.Effect { - return Effect.gen(function* () { +const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdownAndWait")( + function* (): Effect.fn.Return { const shutdown = yield* DesktopShutdown; yield* shutdown.request; yield* shutdown.awaitComplete; - }); -} + }, +); function handleBeforeQuit( event: Electron.Event, - runEffect: ReturnType, + runEffect: (effect: Effect.Effect) => Promise, allowQuit: () => boolean, markQuitAllowed: () => void, ): void { @@ -118,7 +113,7 @@ function handleBeforeQuit( const state = yield* DesktopState.DesktopState; yield* Ref.set(state.quitting, true); yield* logLifecycleInfo("before-quit received"); - }), + }).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")), ); return; } @@ -130,24 +125,25 @@ function handleBeforeQuit( yield* Ref.set(state.quitting, true); yield* logLifecycleInfo("before-quit received"); yield* requestDesktopShutdownAndWait(); - }), + }).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")), ).finally(() => { markQuitAllowed(); void runEffect( Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; yield* electronApp.quit; - }), + }).pipe(Effect.withSpan("desktop.lifecycle.quitAfterShutdown")), ); }); } function quitFromSignal( signal: "SIGINT" | "SIGTERM", - runEffect: ReturnType, + runEffect: (effect: Effect.Effect) => Promise, ): void { void runEffect( Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ signal }); const electronApp = yield* ElectronApp.ElectronApp; const state = yield* DesktopState.DesktopState; const wasQuitting = yield* Ref.getAndSet(state.quitting, true); @@ -155,55 +151,56 @@ function quitFromSignal( yield* logLifecycleInfo("process signal received", { signal }); yield* requestDesktopShutdownAndWait(); yield* electronApp.quit; - }), + }).pipe(Effect.withSpan("desktop.lifecycle.processSignal")), ); } export const layer = Layer.succeed( DesktopLifecycle, DesktopLifecycle.of({ - relaunch: (reason) => - Effect.gen(function* () { - const electronApp = yield* ElectronApp.ElectronApp; - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const state = yield* DesktopState.DesktopState; - yield* logLifecycleInfo("desktop relaunch requested", { reason }); - yield* Effect.gen(function* () { - yield* Effect.yieldNow; - yield* Ref.set(state.quitting, true); - yield* requestDesktopShutdownAndWait(); - if (environment.isDevelopment) { - yield* electronApp.exit(75); - return; - } - yield* electronApp.relaunch({ - execPath: process.execPath, - args: process.argv.slice(1), - }); - yield* electronApp.exit(0); - }).pipe( - Effect.catchCause((cause) => - Effect.logError("desktop relaunch failed").pipe( - Effect.annotateLogs({ - component: "desktop-lifecycle", - cause: Cause.pretty(cause), - }), - ), + relaunch: Effect.fn("desktop.lifecycle.relaunch")(function* (reason) { + const electronApp = yield* ElectronApp.ElectronApp; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const state = yield* DesktopState.DesktopState; + yield* logLifecycleInfo("desktop relaunch requested", { reason }); + yield* Effect.gen(function* () { + yield* Effect.yieldNow; + yield* Ref.set(state.quitting, true); + yield* requestDesktopShutdownAndWait(); + if (environment.isDevelopment) { + yield* electronApp.exit(75); + return; + } + yield* electronApp.relaunch({ + execPath: process.execPath, + args: process.argv.slice(1), + }); + yield* electronApp.exit(0); + }).pipe( + Effect.catchCause((cause) => + Effect.logError("desktop relaunch failed").pipe( + Effect.annotateLogs({ + component: "desktop-lifecycle", + cause: Cause.pretty(cause), + }), ), - Effect.forkDetach, - Effect.asVoid, - ); - }), + ), + Effect.forkDetach, + Effect.asVoid, + ); + }), register: Effect.gen(function* () { const desktopWindow = yield* DesktopWindow.DesktopWindow; const electronApp = yield* ElectronApp.ElectronApp; const electronTheme = yield* ElectronTheme.ElectronTheme; const environment = yield* DesktopEnvironment.DesktopEnvironment; const context = yield* Effect.context(); - const runEffect = makeDesktopEffectRunner(context); + const runEffect = Effect.runPromiseWith(context); let quitAllowed = false; yield* electronTheme.onUpdated(() => { - void runEffect(desktopWindow.syncAppearance); + void runEffect( + desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")), + ); }); yield* electronApp.on("before-quit", (event: Electron.Event) => { handleBeforeQuit( @@ -216,7 +213,7 @@ export const layer = Layer.succeed( ); }); yield* electronApp.on("activate", () => { - void runEffect(desktopWindow.activate); + void runEffect(desktopWindow.activate.pipe(Effect.withSpan("desktop.lifecycle.activate"))); }); yield* electronApp.on("window-all-closed", () => { void runEffect( @@ -226,7 +223,7 @@ export const layer = Layer.succeed( if (environment.platform !== "darwin" && !(yield* Ref.get(state.quitting))) { yield* app.quit; } - }), + }).pipe(Effect.withSpan("desktop.lifecycle.windowAllClosed")), ); }); @@ -238,6 +235,6 @@ export const layer = Layer.succeed( quitFromSignal("SIGTERM", runEffect); }); } - }), + }).pipe(Effect.withSpan("desktop.lifecycle.register")), }), ); diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index 6d98025ff1a..a78de48d5e1 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -63,35 +63,7 @@ const makeEnvironmentLayer = (baseDir: string) => ); describe("DesktopObservability", () => { - it.effect("persists desktop main logs in development", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-logging-test-", - }); - const environmentLayer = makeEnvironmentLayer(baseDir); - const logPath = yield* Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - return environment.path.join(environment.logDir, "desktop-main.log"); - }).pipe(Effect.provide(environmentLayer)); - - yield* Effect.scoped( - Effect.logInfo("desktop file logger test").pipe( - Effect.annotateLogs({ testCase: "desktop-main-dev" }), - Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), - ), - ); - - const log = yield* fileSystem.readFileString(logPath); - assert.include(log, "desktop file logger test"); - assert.include(log, "desktop-main-dev"); - }).pipe( - Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), - ), - ); - - it.effect("persists desktop Effect spans to desktop.trace.ndjson", () => + it.effect("persists desktop Effect logs as span events in desktop.trace.ndjson", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ @@ -102,6 +74,10 @@ describe("DesktopObservability", () => { const environment = yield* DesktopEnvironment.DesktopEnvironment; return environment.path.join(environment.logDir, "desktop.trace.ndjson"); }).pipe(Effect.provide(environmentLayer)); + const logPath = yield* Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + return environment.path.join(environment.logDir, "desktop-main.log"); + }).pipe(Effect.provide(environmentLayer)); yield* Effect.scoped( Effect.gen(function* () { @@ -129,6 +105,7 @@ describe("DesktopObservability", () => { record.events.some((event) => event.name === "desktop trace event"), true, ); + assert.isFalse(yield* fileSystem.exists(logPath)); }).pipe( Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index d7b485f00dd..c74d3b6e2b6 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -3,7 +3,6 @@ import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serve import * as Context from "effect/Context"; import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -22,7 +21,6 @@ import * as DesktopEnvironment from "./DesktopEnvironment.ts"; const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; const DESKTOP_LOG_FILE_MAX_FILES = 10; -const DESKTOP_LOG_BATCH_WINDOW = Duration.millis(250); const DESKTOP_BACKEND_CHILD_LOG_FIBER_ID = "#backend-child"; const DESKTOP_TRACE_BATCH_WINDOW_MS = 200; @@ -204,19 +202,6 @@ const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(functio } satisfies RotatingLogFileWriter; }); -const makeDesktopFileLogger = Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const writer = yield* makeRotatingLogFileWriter({ - filePath: environment.path.join(environment.logDir, "desktop-main.log"), - }); - - return yield* Logger.batched(Logger.formatJson, { - window: DESKTOP_LOG_BATCH_WINDOW, - flush: (messages) => - messages.length === 0 ? Effect.void : writer.writeText(`${messages.join("\n")}\n`), - }); -}); - const readPersistedOtlpTracesUrl: Effect.Effect< Option.Option, never, @@ -250,26 +235,29 @@ const writeDevelopmentConsoleOutput = ( output.write(chunk); }).pipe(Effect.ignore); -const writeBackendChildLogRecord = ( - logFile: RotatingLogFileWriter, - input: { - readonly message: string; - readonly level: "INFO" | "ERROR"; - readonly annotations: Record; +const writeBackendChildLogRecord = Effect.fn("desktop.observability.writeBackendChildLogRecord")( + function* ( + logFile: RotatingLogFileWriter, + input: { + readonly message: string; + readonly level: "INFO" | "ERROR"; + readonly annotations: Record; + }, + ): Effect.fn.Return { + return yield* Effect.gen(function* () { + const timestamp = DateTime.formatIso(yield* DateTime.now); + const encoded = yield* encodeDesktopBackendChildLogRecord({ + message: input.message, + level: input.level, + timestamp, + annotations: input.annotations, + spans: {}, + fiberId: DESKTOP_BACKEND_CHILD_LOG_FIBER_ID, + }); + yield* logFile.writeText(`${encoded}\n`); + }).pipe(Effect.ignore({ log: true })); }, -): Effect.Effect => - Effect.gen(function* () { - const timestamp = DateTime.formatIso(yield* DateTime.now); - const encoded = yield* encodeDesktopBackendChildLogRecord({ - message: input.message, - level: input.level, - timestamp, - annotations: input.annotations, - spans: {}, - fiberId: DESKTOP_BACKEND_CHILD_LOG_FIBER_ID, - }); - yield* logFile.writeText(`${encoded}\n`); - }).pipe(Effect.ignore({ log: true })); +); const backendOutputLogLayer = Layer.effect( DesktopBackendOutputLog, @@ -284,22 +272,23 @@ const backendOutputLogLayer = Layer.effect( onNone: () => DesktopBackendOutputLogNoop, onSome: (logFile) => ({ - writeSessionBoundary: ({ phase, details }) => - Effect.gen(function* () { - const runId = yield* currentDesktopRunId; - yield* writeBackendChildLogRecord(logFile, { - message: `backend child process session ${phase.toLowerCase()}`, - level: "INFO", - annotations: { - component: "desktop-backend-child", - runId, - phase, - details: sanitizeLogValue(details), - }, - }); - }), - writeOutputChunk: (streamName, chunk) => - Effect.gen(function* () { + writeSessionBoundary: Effect.fn( + "desktop.observability.backendOutput.writeSessionBoundary", + )(function* ({ phase, details }) { + const runId = yield* currentDesktopRunId; + yield* writeBackendChildLogRecord(logFile, { + message: `backend child process session ${phase.toLowerCase()}`, + level: "INFO", + annotations: { + component: "desktop-backend-child", + runId, + phase, + details: sanitizeLogValue(details), + }, + }); + }), + writeOutputChunk: Effect.fn("desktop.observability.backendOutput.writeOutputChunk")( + function* (streamName, chunk) { if (environment.isDevelopment) { yield* writeDevelopmentConsoleOutput(streamName, chunk); } @@ -314,29 +303,16 @@ const backendOutputLogLayer = Layer.effect( text: textDecoder.decode(chunk), }, }); - }), + }, + ), }) satisfies DesktopBackendOutputLogShape, }); }), ); -const desktopLoggerLayer = Layer.unwrap( - Effect.gen(function* () { - const fileLogger = yield* makeDesktopFileLogger.pipe(Effect.option); - const loggers: Array> = [ - Logger.consolePretty(), - Logger.tracerLogger, - ]; - - if (Option.isSome(fileLogger)) { - loggers.push(fileLogger.value); - } - - return Layer.mergeAll( - Logger.layer(loggers, { mergeWithExisting: false }), - Layer.succeed(References.MinimumLogLevel, "Info"), - ); - }), +const desktopLoggerLayer = Layer.mergeAll( + Logger.layer([Logger.consolePretty(), Logger.tracerLogger], { mergeWithExisting: false }), + Layer.succeed(References.MinimumLogLevel, "Info"), ); const tracerLayer = Layer.unwrap( diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 8011ceb8f5e..8657d001ade 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -73,10 +73,8 @@ const readPersistedBackendObservabilitySettings: Effect.Effect< }; }); -const getOrCreateBootstrapToken = ( - tokenRef: Ref.Ref>, -): Effect.Effect => - Effect.gen(function* () { +const getOrCreateBootstrapToken = Effect.fn("desktop.backendConfiguration.bootstrapToken")( + function* (tokenRef: Ref.Ref>) { const existing = yield* Ref.get(tokenRef); if (Option.isSome(existing)) { return existing.value; @@ -89,17 +87,18 @@ const getOrCreateBootstrapToken = ( token = token.slice(0, 48); yield* Ref.set(tokenRef, Option.some(token)); return token; - }); + }, +); -const resolveBackendStartConfig = (input: { - readonly bootstrapToken: string; - readonly observabilitySettings: BackendObservabilitySettings; -}): Effect.Effect< - DesktopBackendManager.DesktopBackendStartConfig, - never, - DesktopEnvironment.DesktopEnvironment | DesktopServerExposure.DesktopServerExposure -> => - Effect.gen(function* () { +const resolveBackendStartConfig = Effect.fn("desktop.backendConfiguration.resolveStartConfig")( + function* (input: { + readonly bootstrapToken: string; + readonly observabilitySettings: BackendObservabilitySettings; + }): Effect.fn.Return< + DesktopBackendManager.DesktopBackendStartConfig, + never, + DesktopEnvironment.DesktopEnvironment | DesktopServerExposure.DesktopServerExposure + > { const environment = yield* DesktopEnvironment.DesktopEnvironment; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; const backendExposure = yield* serverExposure.backendConfig; @@ -133,7 +132,8 @@ const resolveBackendStartConfig = (input: { httpBaseUrl: backendExposure.httpBaseUrl, captureOutput: true, }; - }); + }, +); export const layer = Layer.effect( DesktopBackendConfiguration, @@ -157,7 +157,7 @@ export const layer = Layer.effect( Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), ); - }), + }).pipe(Effect.withSpan("desktop.backendConfiguration.resolve")), }); }), ); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 91cf7634f3e..af009dddb68 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -358,8 +358,10 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio }, ]); - const finalizeRun = (reason: string) => - mutex.withPermits(1)( + const finalizeRun = Effect.fn("desktop.backendManager.finalizeRun")(function* ( + reason: string, + ) { + yield* mutex.withPermits(1)( Effect.gen(function* () { const { isCurrentRun, nextState, pid } = yield* Ref.modify( state, @@ -416,50 +418,49 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio } }), ); + }); const program = runBackendProcess({ ...config, - onStarted: (pid) => - Effect.gen(function* () { - yield* updateActiveRun(runId, (run) => ({ - ...run, - pid: Option.some(pid), - })); - yield* backendOutputLog.writeSessionBoundary({ - phase: "START", - details: `pid=${pid} port=${config.bootstrap.port} cwd=${config.cwd}`, - }); - }), - onReady: () => - Effect.gen(function* () { - const isCurrentRun = yield* Ref.modify(state, (latest) => { - const activeRun = Option.getOrUndefined(latest.active); - if (activeRun?.id !== runId) { - return [false, latest] as const; - } - - return [ - true, - { - ...latest, - restartAttempt: 0, - ready: true, - }, - ] as const; - }); - if (!isCurrentRun) { - return; + onStarted: Effect.fn("desktop.backendManager.onStarted")(function* (pid) { + yield* updateActiveRun(runId, (run) => ({ + ...run, + pid: Option.some(pid), + })); + yield* backendOutputLog.writeSessionBoundary({ + phase: "START", + details: `pid=${pid} port=${config.bootstrap.port} cwd=${config.cwd}`, + }); + }), + onReady: Effect.fn("desktop.backendManager.onReady")(function* () { + const isCurrentRun = yield* Ref.modify(state, (latest) => { + const activeRun = Option.getOrUndefined(latest.active); + if (activeRun?.id !== runId) { + return [false, latest] as const; } - yield* Ref.set(desktopState.backendReady, true); - yield* desktopWindow.handleBackendReady.pipe( - Effect.catch((error) => - Effect.logError("failed to open main window after backend readiness").pipe( - Effect.annotateLogs({ message: error.message }), - ), + return [ + true, + { + ...latest, + restartAttempt: 0, + ready: true, + }, + ] as const; + }); + if (!isCurrentRun) { + return; + } + + yield* Ref.set(desktopState.backendReady, true); + yield* desktopWindow.handleBackendReady.pipe( + Effect.catch((error) => + Effect.logError("failed to open main window after backend readiness").pipe( + Effect.annotateLogs({ message: error.message }), ), - ); - }), + ), + ); + }), onReadinessFailure: (error) => Effect.logWarning("backend readiness check failed during bootstrap").pipe( Effect.annotateLogs({ error: error.message }), @@ -483,99 +484,100 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio })); }), ), - ); + ).pipe(Effect.withSpan("desktop.backendManager.start")); + + const scheduleRestart = Effect.fn("desktop.backendManager.scheduleRestart")(function* ( + reason: string, + ) { + const scheduled = yield* Ref.modify(state, (latest) => { + if (!latest.desiredRunning || Option.isSome(latest.restartFiber)) { + return [Option.none(), latest] as const; + } + + const delay = calculateRestartDelay(latest.restartAttempt); + return [ + Option.some(delay), + { + ...latest, + restartAttempt: latest.restartAttempt + 1, + }, + ] as const; + }); - const scheduleRestart = (reason: string): Effect.Effect => - Effect.gen(function* () { - const scheduled = yield* Ref.modify(state, (latest) => { - if (!latest.desiredRunning || Option.isSome(latest.restartFiber)) { - return [Option.none(), latest] as const; - } + yield* Option.match(scheduled, { + onNone: () => Effect.void, + onSome: Effect.fn("desktop.backendManager.scheduleRestartFiber")(function* (delay) { + yield* Effect.logError("backend exited unexpectedly; restart scheduled").pipe( + Effect.annotateLogs({ + reason, + delayMs: Duration.toMillis(delay), + }), + ); + const restartFiber = yield* Effect.forkIn( + Effect.sleep(delay).pipe( + Effect.andThen( + Ref.modify(state, (latest) => { + const shouldRestart = latest.desiredRunning; + return [ + shouldRestart, + { + ...latest, + restartFiber: Option.none(), + }, + ] as const; + }), + ), + Effect.flatMap((shouldRestart) => (shouldRestart ? start : Effect.void)), + Effect.catchCause((cause) => + Effect.logError("desktop backend restart fiber failed", { cause }), + ), + ), + parentScope, + ); + yield* Ref.update(state, (latest) => + Option.isNone(latest.restartFiber) + ? { + ...latest, + restartFiber: Option.some(restartFiber), + } + : latest, + ); + }), + }); + }); - const delay = calculateRestartDelay(latest.restartAttempt); - return [ - Option.some(delay), + const stop = Effect.fn("desktop.backendManager.stop")(function* (options?: { + readonly timeout?: Duration.Duration; + }) { + const { active, restartFiber } = yield* mutex.withPermits(1)( + Effect.gen(function* () { + const result = yield* Ref.modify(state, (latest) => [ + { + active: latest.active, + restartFiber: latest.restartFiber, + }, { ...latest, - restartAttempt: latest.restartAttempt + 1, + desiredRunning: false, + ready: false, + active: Option.none(), + restartFiber: Option.none>(), }, - ] as const; - }); - - yield* Option.match(scheduled, { - onNone: () => Effect.void, - onSome: (delay) => - Effect.gen(function* () { - yield* Effect.logError("backend exited unexpectedly; restart scheduled").pipe( - Effect.annotateLogs({ - reason, - delayMs: Duration.toMillis(delay), - }), - ); - const restartFiber = yield* Effect.forkIn( - Effect.sleep(delay).pipe( - Effect.andThen( - Ref.modify(state, (latest) => { - const shouldRestart = latest.desiredRunning; - return [ - shouldRestart, - { - ...latest, - restartFiber: Option.none(), - }, - ] as const; - }), - ), - Effect.flatMap((shouldRestart) => (shouldRestart ? start : Effect.void)), - Effect.catchCause((cause) => - Effect.logError("desktop backend restart fiber failed", { cause }), - ), - ), - parentScope, - ); - yield* Ref.update(state, (latest) => - Option.isNone(latest.restartFiber) - ? { - ...latest, - restartFiber: Option.some(restartFiber), - } - : latest, - ); - }), - }); - }); + ]); + yield* Ref.set(desktopState.backendReady, false); + return result; + }), + ); - const stop = (options?: { readonly timeout?: Duration.Duration }): Effect.Effect => - Effect.gen(function* () { - const { active, restartFiber } = yield* mutex.withPermits(1)( - Effect.gen(function* () { - const result = yield* Ref.modify(state, (latest) => [ - { - active: latest.active, - restartFiber: latest.restartFiber, - }, - { - ...latest, - desiredRunning: false, - ready: false, - active: Option.none(), - restartFiber: Option.none>(), - }, - ]); - yield* Ref.set(desktopState.backendReady, false); - return result; - }), - ); - - yield* Option.match(restartFiber, { - onNone: () => Effect.void, - onSome: (fiber) => Fiber.interrupt(fiber).pipe(Effect.asVoid), - }); - yield* Option.match(active, { - onNone: () => Effect.void, - onSome: (run) => closeRun(run, options), - }); + yield* Option.match(restartFiber, { + onNone: () => Effect.void, + onSome: (fiber) => Fiber.interrupt(fiber).pipe(Effect.asVoid), + }); + yield* Option.match(active, { + onNone: () => Effect.void, + onSome: (run) => closeRun(run, options), }); + }); yield* Effect.addFinalizer(() => stop()); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index 2dd4f5d17a0..5a4fdfd7ac4 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -33,49 +33,47 @@ export class ElectronDialog extends Context.Service - Effect.gen(function* () { - const openDialogOptions: Electron.OpenDialogOptions = Option.match(input.defaultPath, { - onNone: () => ({ - properties: ["openDirectory", "createDirectory"], - }), - onSome: (defaultPath) => ({ - properties: ["openDirectory", "createDirectory"], - defaultPath, - }), - }); - const result = yield* Option.match(input.owner, { - onNone: () => Effect.promise(() => Electron.dialog.showOpenDialog(openDialogOptions)), - onSome: (owner) => - Effect.promise(() => Electron.dialog.showOpenDialog(owner, openDialogOptions)), - }); + pickFolder: Effect.fn("desktop.electron.dialog.pickFolder")(function* (input) { + const openDialogOptions: Electron.OpenDialogOptions = Option.match(input.defaultPath, { + onNone: () => ({ + properties: ["openDirectory", "createDirectory"], + }), + onSome: (defaultPath) => ({ + properties: ["openDirectory", "createDirectory"], + defaultPath, + }), + }); + const result = yield* Option.match(input.owner, { + onNone: () => Effect.promise(() => Electron.dialog.showOpenDialog(openDialogOptions)), + onSome: (owner) => + Effect.promise(() => Electron.dialog.showOpenDialog(owner, openDialogOptions)), + }); - if (result.canceled) { - return Option.none(); - } - return Option.fromNullishOr(result.filePaths[0]); - }), - confirm: (input) => - Effect.gen(function* () { - const normalizedMessage = input.message.trim(); - if (normalizedMessage.length === 0) { - return false; - } + if (result.canceled) { + return Option.none(); + } + return Option.fromNullishOr(result.filePaths[0]); + }), + confirm: Effect.fn("desktop.electron.dialog.confirm")(function* (input) { + const normalizedMessage = input.message.trim(); + if (normalizedMessage.length === 0) { + return false; + } - const options = { - type: "question" as const, - buttons: ["No", "Yes"], - defaultId: 0, - cancelId: 0, - noLink: true, - message: normalizedMessage, - }; - const result = yield* Option.match(input.owner, { - onNone: () => Effect.promise(() => Electron.dialog.showMessageBox(options)), - onSome: (owner) => Effect.promise(() => Electron.dialog.showMessageBox(owner, options)), - }); - return result.response === CONFIRM_BUTTON_INDEX; - }), + const options = { + type: "question" as const, + buttons: ["No", "Yes"], + defaultId: 0, + cancelId: 0, + noLink: true, + message: normalizedMessage, + }; + const result = yield* Option.match(input.owner, { + onNone: () => Effect.promise(() => Electron.dialog.showMessageBox(options)), + onSome: (owner) => Effect.promise(() => Electron.dialog.showMessageBox(owner, options)), + }); + return result.response === CONFIRM_BUTTON_INDEX; + }), showMessageBox: (options) => Effect.promise(() => Electron.dialog.showMessageBox(options)), showErrorBox: (title, content) => Effect.sync(() => { diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 47126af7fcd..32d23ba485d 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -81,7 +81,7 @@ const registerDesktopSchemePrivileges = Effect.sync(() => { }, }, ]); -}); +}).pipe(Effect.withSpan("desktop.electron.protocol.registerSchemePrivileges")); export const layerSchemePrivileges = Layer.effectDiscard(registerDesktopSchemePrivileges); @@ -107,11 +107,11 @@ const resolveDesktopStaticDir: Effect.Effect< return Option.none(); }); -function resolveDesktopStaticPath( - staticRoot: string, - requestUrl: string, -): Effect.Effect { - return Effect.gen(function* () { +const resolveDesktopStaticPath = Effect.fn("desktop.electron.protocol.resolveDesktopStaticPath")( + function* ( + staticRoot: string, + requestUrl: string, + ): Effect.fn.Return { const fileSystem = yield* FileSystem.FileSystem; const environment = yield* DesktopEnvironment; const url = new URL(requestUrl); @@ -137,8 +137,8 @@ function resolveDesktopStaticPath( } return environment.path.join(staticRoot, "index.html"); - }); -} + }, +); function isStaticAssetRequest(requestUrl: string, environment: DesktopEnvironmentShape): boolean { try { @@ -152,21 +152,22 @@ function isStaticAssetRequest(requestUrl: string, environment: DesktopEnvironmen const make = Effect.gen(function* () { const registeredProtocols = yield* Ref.make>(new Set()); - const registerFileProtocol = ({ - scheme, - handler, - onFailure, - }: { - readonly scheme: string; - readonly handler: ( - request: Electron.ProtocolRequest, - ) => Effect.Effect; - readonly onFailure?: ( - request: Electron.ProtocolRequest, - cause: Cause.Cause, - ) => Electron.ProtocolResponse; - }): Effect.Effect => - Effect.gen(function* () { + const registerFileProtocol = Effect.fn("desktop.electron.protocol.registerFileProtocol")( + function* ({ + scheme, + handler, + onFailure, + }: { + readonly scheme: string; + readonly handler: ( + request: Electron.ProtocolRequest, + ) => Effect.Effect; + readonly onFailure?: ( + request: Electron.ProtocolRequest, + cause: Cause.Cause, + ) => Electron.ProtocolResponse; + }): Effect.fn.Return { + yield* Effect.annotateCurrentSpan({ scheme }); const alreadyRegistered = yield* Ref.get(registeredProtocols).pipe( Effect.map((protocols) => protocols.has(scheme)), ); @@ -184,6 +185,7 @@ const make = Effect.gen(function* () { scheme, (request, callback) => { const response = handler(request).pipe( + Effect.withSpan("desktop.electron.protocol.handleFileRequest"), Effect.catchCause((cause) => Effect.succeed(onFailure?.(request, cause) ?? ({ error: -2 } as const)), ), @@ -221,7 +223,8 @@ const make = Effect.gen(function* () { ), ), ); - }); + }, + ); const registerDesktopFileProtocol = Effect.gen(function* () { const environment = yield* DesktopEnvironment; @@ -238,28 +241,27 @@ const make = Effect.gen(function* () { yield* registerFileProtocol({ scheme: DESKTOP_SCHEME, - handler: (request) => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const environment = yield* DesktopEnvironment; - const candidate = yield* resolveDesktopStaticPath(staticRootResolved, request.url); - const resolvedCandidate = environment.path.resolve(candidate); - const isInRoot = - resolvedCandidate === fallbackIndex || resolvedCandidate.startsWith(staticRootPrefix); - const isAssetRequest = isStaticAssetRequest(request.url, environment); - const exists = yield* fileSystem - .exists(resolvedCandidate) - .pipe(Effect.orElseSucceed(() => false)); + handler: Effect.fn("desktop.electron.protocol.handleDesktopFileRequest")(function* (request) { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment; + const candidate = yield* resolveDesktopStaticPath(staticRootResolved, request.url); + const resolvedCandidate = environment.path.resolve(candidate); + const isInRoot = + resolvedCandidate === fallbackIndex || resolvedCandidate.startsWith(staticRootPrefix); + const isAssetRequest = isStaticAssetRequest(request.url, environment); + const exists = yield* fileSystem + .exists(resolvedCandidate) + .pipe(Effect.orElseSucceed(() => false)); - if (!isInRoot || !exists) { - return isAssetRequest ? ({ error: -6 } as const) : ({ path: fallbackIndex } as const); - } + if (!isInRoot || !exists) { + return isAssetRequest ? ({ error: -6 } as const) : ({ path: fallbackIndex } as const); + } - return { path: resolvedCandidate } as const; - }), + return { path: resolvedCandidate } as const; + }), onFailure: () => ({ path: fallbackIndex }), }); - }); + }).pipe(Effect.withSpan("desktop.electron.protocol.registerDesktopFileProtocol")); return ElectronProtocol.of({ registerFileProtocol, diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index b353c922ca3..529f8147903 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -118,13 +118,14 @@ const make = Effect.gen(function* () { window.destroy(); } }), - syncAllAppearance: (sync) => - Effect.gen(function* () { - const windows = Electron.BrowserWindow.getAllWindows(); - for (const window of windows) { - yield* sync(window); - } - }), + syncAllAppearance: Effect.fn("desktop.electron.window.syncAllAppearance")(function* ( + sync: (window: Electron.BrowserWindow) => Effect.Effect, + ) { + const windows = Electron.BrowserWindow.getAllWindows(); + for (const window of windows) { + yield* sync(window); + } + }), }); }); diff --git a/apps/desktop/src/ipc/DesktopIpc.ts b/apps/desktop/src/ipc/DesktopIpc.ts index 2d83ba2550b..05a0f25512e 100644 --- a/apps/desktop/src/ipc/DesktopIpc.ts +++ b/apps/desktop/src/ipc/DesktopIpc.ts @@ -46,35 +46,53 @@ export class DesktopIpc extends Context.Service()(" export const make = (ipcMain: DesktopIpcMain): DesktopIpcShape => DesktopIpc.of({ - handle: ({ channel, handler }: DesktopIpcMethod) => - Effect.gen(function* () { - const context = yield* Effect.context(); - const runPromise = Effect.runPromiseWith(context); - - yield* Effect.acquireRelease( - Effect.sync(() => { - ipcMain.removeHandler(channel); - ipcMain.handle(channel, (_event, raw) => runPromise(handler(raw))); - }), - () => Effect.sync(() => ipcMain.removeHandler(channel)), - ); - }), - - handleSync: ({ channel, handler }: DesktopSyncIpcMethod) => - Effect.gen(function* () { - const context = yield* Effect.context(); - const runSync = Effect.runSyncWith(context); - - yield* Effect.acquireRelease( - Effect.sync(() => { - ipcMain.removeAllListeners(channel); - ipcMain.on(channel, (event) => { - event.returnValue = runSync(handler()); - }); - }), - () => Effect.sync(() => ipcMain.removeAllListeners(channel)), - ); - }), + handle: Effect.fn("desktop.ipc.registerInvoke")(function* ({ + channel, + handler, + }: DesktopIpcMethod) { + yield* Effect.annotateCurrentSpan({ channel }); + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); + + yield* Effect.acquireRelease( + Effect.sync(() => { + ipcMain.removeHandler(channel); + ipcMain.handle(channel, (_event, raw) => + runPromise( + Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ channel }); + return yield* handler(raw); + }).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invoke")), + ), + ); + }), + () => Effect.sync(() => ipcMain.removeHandler(channel)), + ); + }), + + handleSync: Effect.fn("desktop.ipc.registerSync")(function* ({ + channel, + handler, + }: DesktopSyncIpcMethod) { + yield* Effect.annotateCurrentSpan({ channel }); + const context = yield* Effect.context(); + const runSync = Effect.runSyncWith(context); + + yield* Effect.acquireRelease( + Effect.sync(() => { + ipcMain.removeAllListeners(channel); + ipcMain.on(channel, (event) => { + event.returnValue = runSync( + Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ channel }); + return yield* handler(); + }).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invokeSync")), + ); + }); + }), + () => Effect.sync(() => ipcMain.removeAllListeners(channel)), + ); + }), }); /** @@ -142,7 +160,12 @@ export const makeIpcMethod = < return { channel: method.channel, - handler: (raw) => decode(raw).pipe(Effect.flatMap(method.handler), Effect.flatMap(encode)), + handler: (raw) => + decode(raw).pipe( + Effect.flatMap(method.handler), + Effect.flatMap(encode), + Effect.withSpan("desktop.ipc.method", { attributes: { channel: method.channel } }), + ), }; }; @@ -185,6 +208,12 @@ export const makeSyncIpcMethod = < return { channel: method.channel, - handler: () => method.handler().pipe(Effect.flatMap(encode)), + handler: () => + method + .handler() + .pipe( + Effect.flatMap(encode), + Effect.withSpan("desktop.ipc.method", { attributes: { channel: method.channel } }), + ), }; }; diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 2b01630f6b0..8717c877951 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -81,4 +81,4 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { yield* ipc.handle(downloadUpdate); yield* ipc.handle(installUpdate); yield* ipc.handle(checkForUpdate); -}); +}).pipe(Effect.withSpan("desktop.ipc.installHandlers")); diff --git a/apps/desktop/src/ipc/methods/clientSettings.ts b/apps/desktop/src/ipc/methods/clientSettings.ts index e014cbdf21a..52b173266cd 100644 --- a/apps/desktop/src/ipc/methods/clientSettings.ts +++ b/apps/desktop/src/ipc/methods/clientSettings.ts @@ -11,20 +11,18 @@ export const getClientSettings = makeIpcMethod({ channel: IpcChannels.GET_CLIENT_SETTINGS_CHANNEL, payload: Schema.Void, result: Schema.NullOr(ClientSettingsSchema), - handler: () => - Effect.gen(function* () { - const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; - return Option.getOrNull(yield* clientSettings.get); - }), + handler: Effect.fn("desktop.ipc.clientSettings.get")(function* () { + const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; + return Option.getOrNull(yield* clientSettings.get); + }), }); export const setClientSettings = makeIpcMethod({ channel: IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, payload: ClientSettingsSchema, result: Schema.Void, - handler: (settings) => - Effect.gen(function* () { - const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; - yield* clientSettings.set(settings); - }), + handler: Effect.fn("desktop.ipc.clientSettings.set")(function* (settings) { + const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; + yield* clientSettings.set(settings); + }), }); diff --git a/apps/desktop/src/ipc/methods/savedEnvironments.ts b/apps/desktop/src/ipc/methods/savedEnvironments.ts index 569bb3b80bd..bc5e4a9aeb2 100644 --- a/apps/desktop/src/ipc/methods/savedEnvironments.ts +++ b/apps/desktop/src/ipc/methods/savedEnvironments.ts @@ -23,56 +23,54 @@ export const getSavedEnvironmentRegistry = makeIpcMethod({ channel: IpcChannels.GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, payload: Schema.Void, result: SavedEnvironmentRegistryPayload, - handler: () => - Effect.gen(function* () { - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - return yield* savedEnvironments.getRegistry; - }), + handler: Effect.fn("desktop.ipc.savedEnvironments.getRegistry")(function* () { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + return yield* savedEnvironments.getRegistry; + }), }); export const setSavedEnvironmentRegistry = makeIpcMethod({ channel: IpcChannels.SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, payload: SavedEnvironmentRegistryPayload, result: Schema.Void, - handler: (records) => - Effect.gen(function* () { - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - yield* savedEnvironments.setRegistry(records); - }), + handler: Effect.fn("desktop.ipc.savedEnvironments.setRegistry")(function* (records) { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* savedEnvironments.setRegistry(records); + }), }); export const getSavedEnvironmentSecret = makeIpcMethod({ channel: IpcChannels.GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, payload: EnvironmentId, result: Schema.NullOr(Schema.String), - handler: (environmentId) => - Effect.gen(function* () { - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - return Option.getOrNull(yield* savedEnvironments.getSecret(environmentId)); - }), + handler: Effect.fn("desktop.ipc.savedEnvironments.getSecret")(function* (environmentId) { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + return Option.getOrNull(yield* savedEnvironments.getSecret(environmentId)); + }), }); export const setSavedEnvironmentSecret = makeIpcMethod({ channel: IpcChannels.SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, payload: SetSavedEnvironmentSecretInput, result: Schema.Boolean, - handler: ({ environmentId, secret }) => - Effect.gen(function* () { - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - return yield* savedEnvironments.setSecret({ - environmentId, - secret, - }); - }), + handler: Effect.fn("desktop.ipc.savedEnvironments.setSecret")(function* ({ + environmentId, + secret, + }) { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + return yield* savedEnvironments.setSecret({ + environmentId, + secret, + }); + }), }); export const removeSavedEnvironmentSecret = makeIpcMethod({ channel: IpcChannels.REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, payload: EnvironmentId, result: Schema.Void, - handler: (environmentId) => - Effect.gen(function* () { - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - yield* savedEnvironments.removeSecret(environmentId); - }), + handler: Effect.fn("desktop.ipc.savedEnvironments.removeSecret")(function* (environmentId) { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* savedEnvironments.removeSecret(environmentId); + }), }); diff --git a/apps/desktop/src/ipc/methods/serverExposure.ts b/apps/desktop/src/ipc/methods/serverExposure.ts index 280b482c828..2bbb08c3f75 100644 --- a/apps/desktop/src/ipc/methods/serverExposure.ts +++ b/apps/desktop/src/ipc/methods/serverExposure.ts @@ -20,56 +20,50 @@ export const getServerExposureState = makeIpcMethod({ channel: IpcChannels.GET_SERVER_EXPOSURE_STATE_CHANNEL, payload: Schema.Void, result: DesktopServerExposureStateSchema, - handler: () => - Effect.gen(function* () { - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - return yield* serverExposure.getState; - }), + handler: Effect.fn("desktop.ipc.serverExposure.getState")(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + return yield* serverExposure.getState; + }), }); export const setServerExposureMode = makeIpcMethod({ channel: IpcChannels.SET_SERVER_EXPOSURE_MODE_CHANNEL, payload: DesktopServerExposureModeSchema, result: DesktopServerExposureStateSchema, - handler: (mode) => - Effect.gen(function* () { - const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - const change = yield* serverExposure.setMode(mode); - if (change.requiresRelaunch) { - yield* lifecycle.relaunch(`serverExposureMode=${mode}`); - } - return change.state; - }), + handler: Effect.fn("desktop.ipc.serverExposure.setMode")(function* (mode) { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const change = yield* serverExposure.setMode(mode); + if (change.requiresRelaunch) { + yield* lifecycle.relaunch(`serverExposureMode=${mode}`); + } + return change.state; + }), }); export const setTailscaleServeEnabled = makeIpcMethod({ channel: IpcChannels.SET_TAILSCALE_SERVE_ENABLED_CHANNEL, payload: SetTailscaleServeEnabledInput, result: DesktopServerExposureStateSchema, - handler: (input) => - Effect.gen(function* () { - const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - const change = yield* serverExposure.setTailscaleServeEnabled(input); - if (change.requiresRelaunch) { - yield* lifecycle.relaunch( - change.state.tailscaleServeEnabled - ? "tailscale-serve-enabled" - : "tailscale-serve-disabled", - ); - } - return change.state; - }), + handler: Effect.fn("desktop.ipc.serverExposure.setTailscaleServeEnabled")(function* (input) { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const change = yield* serverExposure.setTailscaleServeEnabled(input); + if (change.requiresRelaunch) { + yield* lifecycle.relaunch( + change.state.tailscaleServeEnabled ? "tailscale-serve-enabled" : "tailscale-serve-disabled", + ); + } + return change.state; + }), }); export const getAdvertisedEndpoints = makeIpcMethod({ channel: IpcChannels.GET_ADVERTISED_ENDPOINTS_CHANNEL, payload: Schema.Void, result: Schema.Array(AdvertisedEndpoint), - handler: () => - Effect.gen(function* () { - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - return yield* serverExposure.getAdvertisedEndpoints; - }), + handler: Effect.fn("desktop.ipc.serverExposure.getAdvertisedEndpoints")(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + return yield* serverExposure.getAdvertisedEndpoints; + }), }); diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts index b67bc42e40c..efea3dd132d 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts @@ -26,95 +26,102 @@ export const discoverSshHosts = makeIpcMethod({ channel: IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL, payload: Schema.Void, result: Schema.Array(DesktopDiscoveredSshHostSchema), - handler: () => - Effect.gen(function* () { - const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; - return yield* sshEnvironment.discoverHosts(); - }), + handler: Effect.fn("desktop.ipc.sshEnvironment.discoverHosts")(function* () { + const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; + return yield* sshEnvironment.discoverHosts(); + }), }); export const ensureSshEnvironment = makeIpcMethod({ channel: IpcChannels.ENSURE_SSH_ENVIRONMENT_CHANNEL, payload: DesktopSshEnvironmentEnsureInputSchema, result: DesktopSshEnvironmentEnsureResultSchema, - handler: ({ target, options }) => - Effect.gen(function* () { - const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; - return yield* sshEnvironment.ensureEnvironment(target, options).pipe( - Effect.catch((error) => - DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation(error) - ? Effect.succeed({ - type: DesktopSshPasswordPromptCancelledType, - message: error.message, - }) - : Effect.fail(error), - ), - ); - }), + handler: Effect.fn("desktop.ipc.sshEnvironment.ensureEnvironment")(function* ({ + target, + options, + }) { + const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; + return yield* sshEnvironment.ensureEnvironment(target, options).pipe( + Effect.catch((error) => + DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation(error) + ? Effect.succeed({ + type: DesktopSshPasswordPromptCancelledType, + message: error.message, + }) + : Effect.fail(error), + ), + ); + }), }); export const disconnectSshEnvironment = makeIpcMethod({ channel: IpcChannels.DISCONNECT_SSH_ENVIRONMENT_CHANNEL, payload: DesktopSshEnvironmentTargetSchema, result: Schema.Void, - handler: (target) => - Effect.gen(function* () { - const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; - yield* sshEnvironment.disconnectEnvironment(target); - }), + handler: Effect.fn("desktop.ipc.sshEnvironment.disconnectEnvironment")(function* (target) { + const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; + yield* sshEnvironment.disconnectEnvironment(target); + }), }); export const fetchSshEnvironmentDescriptor = makeIpcMethod({ channel: IpcChannels.FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, payload: DesktopSshHttpBaseUrlInputSchema, result: ExecutionEnvironmentDescriptor, - handler: ({ httpBaseUrl }) => - Effect.gen(function* () { - const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; - return yield* remoteApi.fetchEnvironmentDescriptor({ httpBaseUrl }); - }), + handler: Effect.fn("desktop.ipc.sshEnvironment.fetchDescriptor")(function* ({ httpBaseUrl }) { + const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; + return yield* remoteApi.fetchEnvironmentDescriptor({ httpBaseUrl }); + }), }); export const bootstrapSshBearerSession = makeIpcMethod({ channel: IpcChannels.BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, payload: DesktopSshBearerBootstrapInputSchema, result: AuthBearerBootstrapResult, - handler: ({ httpBaseUrl, credential }) => - Effect.gen(function* () { - const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; - return yield* remoteApi.bootstrapBearerSession({ httpBaseUrl, credential }); - }), + handler: Effect.fn("desktop.ipc.sshEnvironment.bootstrapBearerSession")(function* ({ + httpBaseUrl, + credential, + }) { + const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; + return yield* remoteApi.bootstrapBearerSession({ httpBaseUrl, credential }); + }), }); export const fetchSshSessionState = makeIpcMethod({ channel: IpcChannels.FETCH_SSH_SESSION_STATE_CHANNEL, payload: DesktopSshBearerRequestInputSchema, result: AuthSessionState, - handler: ({ httpBaseUrl, bearerToken }) => - Effect.gen(function* () { - const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; - return yield* remoteApi.fetchSessionState({ httpBaseUrl, bearerToken }); - }), + handler: Effect.fn("desktop.ipc.sshEnvironment.fetchSessionState")(function* ({ + httpBaseUrl, + bearerToken, + }) { + const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; + return yield* remoteApi.fetchSessionState({ httpBaseUrl, bearerToken }); + }), }); export const issueSshWebSocketToken = makeIpcMethod({ channel: IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, payload: DesktopSshBearerRequestInputSchema, result: AuthWebSocketTokenResult, - handler: ({ httpBaseUrl, bearerToken }) => - Effect.gen(function* () { - const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; - return yield* remoteApi.issueWebSocketToken({ httpBaseUrl, bearerToken }); - }), + handler: Effect.fn("desktop.ipc.sshEnvironment.issueWebSocketToken")(function* ({ + httpBaseUrl, + bearerToken, + }) { + const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; + return yield* remoteApi.issueWebSocketToken({ httpBaseUrl, bearerToken }); + }), }); export const resolveSshPasswordPrompt = makeIpcMethod({ channel: IpcChannels.RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, payload: DesktopSshPasswordPromptResolutionInputSchema, result: Schema.Void, - handler: ({ requestId, password }) => - Effect.gen(function* () { - const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; - yield* prompts.resolve({ requestId, password }); - }), + handler: Effect.fn("desktop.ipc.sshEnvironment.resolvePasswordPrompt")(function* ({ + requestId, + password, + }) { + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; + yield* prompts.resolve({ requestId, password }); + }), }); diff --git a/apps/desktop/src/ipc/methods/updates.ts b/apps/desktop/src/ipc/methods/updates.ts index 1e837933788..45ea8502121 100644 --- a/apps/desktop/src/ipc/methods/updates.ts +++ b/apps/desktop/src/ipc/methods/updates.ts @@ -15,53 +15,48 @@ export const getUpdateState = makeIpcMethod({ channel: IpcChannels.UPDATE_GET_STATE_CHANNEL, payload: Schema.Void, result: DesktopUpdateStateSchema, - handler: () => - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - return yield* updates.getState; - }), + handler: Effect.fn("desktop.ipc.updates.getState")(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + return yield* updates.getState; + }), }); export const setUpdateChannel = makeIpcMethod({ channel: IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, payload: DesktopUpdateChannelSchema, result: DesktopUpdateStateSchema, - handler: (channel) => - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - return yield* updates.setChannel(channel); - }), + handler: Effect.fn("desktop.ipc.updates.setChannel")(function* (channel) { + const updates = yield* DesktopUpdates.DesktopUpdates; + return yield* updates.setChannel(channel); + }), }); export const downloadUpdate = makeIpcMethod({ channel: IpcChannels.UPDATE_DOWNLOAD_CHANNEL, payload: Schema.Void, result: DesktopUpdateActionResultSchema, - handler: () => - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - return yield* updates.download; - }), + handler: Effect.fn("desktop.ipc.updates.download")(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + return yield* updates.download; + }), }); export const installUpdate = makeIpcMethod({ channel: IpcChannels.UPDATE_INSTALL_CHANNEL, payload: Schema.Void, result: DesktopUpdateActionResultSchema, - handler: () => - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - return yield* updates.install; - }), + handler: Effect.fn("desktop.ipc.updates.install")(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + return yield* updates.install; + }), }); export const checkForUpdate = makeIpcMethod({ channel: IpcChannels.UPDATE_CHECK_CHANNEL, payload: Schema.Void, result: DesktopUpdateCheckResultSchema, - handler: () => - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - return yield* updates.check("web-ui"); - }), + handler: Effect.fn("desktop.ipc.updates.check")(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + return yield* updates.check("web-ui"); + }), }); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 1557343340c..1cb4d7265a1 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -38,105 +38,98 @@ function toWebSocketBaseUrl(httpBaseUrl: URL): string { export const getAppBranding = makeSyncIpcMethod({ channel: IpcChannels.GET_APP_BRANDING_CHANNEL, result: Schema.NullOr(DesktopAppBrandingSchema), - handler: () => - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - return environment.branding; - }), + handler: Effect.fn("desktop.ipc.window.getAppBranding")(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + return environment.branding; + }), }); export const getLocalEnvironmentBootstrap = makeSyncIpcMethod({ channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, result: Schema.NullOr(DesktopEnvironmentBootstrapSchema), - handler: () => - Effect.gen(function* () { - const backendManager = yield* DesktopBackendManager.DesktopBackendManager; - const config = yield* backendManager.currentConfig; - return Option.match(config, { - onNone: () => null, - onSome: ({ bootstrap, httpBaseUrl }) => ({ - label: "Local environment", - httpBaseUrl: httpBaseUrl.href, - wsBaseUrl: toWebSocketBaseUrl(httpBaseUrl), - ...(bootstrap.desktopBootstrapToken - ? { bootstrapToken: bootstrap.desktopBootstrapToken } - : {}), - }), - }); - }), + handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBootstrap")(function* () { + const backendManager = yield* DesktopBackendManager.DesktopBackendManager; + const config = yield* backendManager.currentConfig; + return Option.match(config, { + onNone: () => null, + onSome: ({ bootstrap, httpBaseUrl }) => ({ + label: "Local environment", + httpBaseUrl: httpBaseUrl.href, + wsBaseUrl: toWebSocketBaseUrl(httpBaseUrl), + ...(bootstrap.desktopBootstrapToken + ? { bootstrapToken: bootstrap.desktopBootstrapToken } + : {}), + }), + }); + }), }); export const pickFolder = makeIpcMethod({ channel: IpcChannels.PICK_FOLDER_CHANNEL, payload: Schema.UndefinedOr(PickFolderOptionsSchema), result: Schema.NullOr(Schema.String), - handler: (options) => - Effect.gen(function* () { - const dialog = yield* ElectronDialog.ElectronDialog; - const electronWindow = yield* ElectronWindow.ElectronWindow; - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const selectedPath = yield* dialog.pickFolder({ - owner: yield* electronWindow.focusedMainOrFirst, - defaultPath: environment.resolvePickFolderDefaultPath(options), - }); - return Option.getOrNull(selectedPath); - }), + handler: Effect.fn("desktop.ipc.window.pickFolder")(function* (options) { + const dialog = yield* ElectronDialog.ElectronDialog; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const selectedPath = yield* dialog.pickFolder({ + owner: yield* electronWindow.focusedMainOrFirst, + defaultPath: environment.resolvePickFolderDefaultPath(options), + }); + return Option.getOrNull(selectedPath); + }), }); export const confirm = makeIpcMethod({ channel: IpcChannels.CONFIRM_CHANNEL, payload: Schema.String, result: Schema.Boolean, - handler: (message) => - Effect.gen(function* () { - const dialog = yield* ElectronDialog.ElectronDialog; - const electronWindow = yield* ElectronWindow.ElectronWindow; - return yield* electronWindow.focusedMainOrFirst.pipe( - Effect.flatMap((owner) => dialog.confirm({ owner, message })), - ); - }), + handler: Effect.fn("desktop.ipc.window.confirm")(function* (message) { + const dialog = yield* ElectronDialog.ElectronDialog; + const electronWindow = yield* ElectronWindow.ElectronWindow; + return yield* electronWindow.focusedMainOrFirst.pipe( + Effect.flatMap((owner) => dialog.confirm({ owner, message })), + ); + }), }); export const setTheme = makeIpcMethod({ channel: IpcChannels.SET_THEME_CHANNEL, payload: DesktopThemeSchema, result: Schema.Void, - handler: (theme) => - Effect.gen(function* () { - const electronTheme = yield* ElectronTheme.ElectronTheme; - yield* electronTheme.setSource(theme); - }), + handler: Effect.fn("desktop.ipc.window.setTheme")(function* (theme) { + const electronTheme = yield* ElectronTheme.ElectronTheme; + yield* electronTheme.setSource(theme); + }), }); export const showContextMenu = makeIpcMethod({ channel: IpcChannels.CONTEXT_MENU_CHANNEL, payload: ContextMenuInput, result: Schema.NullOr(Schema.String), - handler: (input) => - Effect.gen(function* () { - const electronMenu = yield* ElectronMenu.ElectronMenu; - const electronWindow = yield* ElectronWindow.ElectronWindow; - const window = yield* electronWindow.focusedMainOrFirst; - if (Option.isNone(window)) { - return null; - } + handler: Effect.fn("desktop.ipc.window.showContextMenu")(function* (input) { + const electronMenu = yield* ElectronMenu.ElectronMenu; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const window = yield* electronWindow.focusedMainOrFirst; + if (Option.isNone(window)) { + return null; + } - const selectedItemId = yield* electronMenu.showContextMenu({ - window: window.value, - items: input.items, - position: Option.fromNullishOr(input.position), - }); - return Option.getOrNull(selectedItemId); - }), + const selectedItemId = yield* electronMenu.showContextMenu({ + window: window.value, + items: input.items, + position: Option.fromNullishOr(input.position), + }); + return Option.getOrNull(selectedItemId); + }), }); export const openExternal = makeIpcMethod({ channel: IpcChannels.OPEN_EXTERNAL_CHANNEL, payload: Schema.String, result: Schema.Boolean, - handler: (url) => - Effect.gen(function* () { - const shell = yield* ElectronShell.ElectronShell; - return yield* shell.openExternal(url); - }), + handler: Effect.fn("desktop.ipc.window.openExternal")(function* (url) { + const shell = yield* ElectronShell.ElectronShell; + return yield* shell.openExternal(url); + }), }); diff --git a/apps/desktop/src/serverExposure/DesktopServerExposure.ts b/apps/desktop/src/serverExposure/DesktopServerExposure.ts index ecb41c261ed..b83355368a6 100644 --- a/apps/desktop/src/serverExposure/DesktopServerExposure.ts +++ b/apps/desktop/src/serverExposure/DesktopServerExposure.ts @@ -417,8 +417,9 @@ const make = Effect.gen(function* () { const getState = Ref.get(stateRef).pipe(Effect.map(toContractState)); const backendConfig = Ref.get(stateRef).pipe(Effect.map(toBackendConfig)); - const configureFromSettings = ({ port }: { readonly port: number }) => - Effect.gen(function* () { + const configureFromSettings = Effect.fn("desktop.serverExposure.configureFromSettings")( + function* ({ port }: { readonly port: number }) { + yield* Effect.annotateCurrentSpan({ port }); const settings = yield* desktopSettings.get; const currentNetworkInterfaces = yield* readNetworkInterfaces; const resolved = resolveRuntimeState({ @@ -430,48 +431,55 @@ const make = Effect.gen(function* () { }); yield* Ref.set(stateRef, resolved.state); return toContractState(resolved.state); + }, + ); + + const setMode = Effect.fn("desktop.serverExposure.setMode")(function* ( + mode: DesktopServerExposureMode, + ) { + yield* Effect.annotateCurrentSpan({ mode }); + const previous = yield* Ref.get(stateRef); + const currentSettings = yield* desktopSettings.get; + const nextSettings = { + ...currentSettings, + serverExposureMode: mode, + }; + const currentNetworkInterfaces = yield* readNetworkInterfaces; + const resolved = resolveRuntimeState({ + requestedMode: mode, + settings: nextSettings, + port: previous.port, + networkInterfaces: currentNetworkInterfaces, + advertisedHostOverride: config.desktopLanHostOverride, }); - const setMode = (mode: DesktopServerExposureMode) => - Effect.gen(function* () { - const previous = yield* Ref.get(stateRef); - const currentSettings = yield* desktopSettings.get; - const nextSettings = { - ...currentSettings, - serverExposureMode: mode, - }; - const currentNetworkInterfaces = yield* readNetworkInterfaces; - const resolved = resolveRuntimeState({ - requestedMode: mode, - settings: nextSettings, - port: previous.port, - networkInterfaces: currentNetworkInterfaces, - advertisedHostOverride: config.desktopLanHostOverride, - }); + if (resolved.unavailable) { + return yield* new DesktopServerExposureNoNetworkAddressError({ port: previous.port }); + } - if (resolved.unavailable) { - return yield* new DesktopServerExposureNoNetworkAddressError({ port: previous.port }); - } - - const change = yield* desktopSettings.setServerExposureMode(mode).pipe( - Effect.mapError( - (cause) => - new DesktopServerExposurePersistenceError({ - operation: "server-exposure-mode", - cause, - }), - ), - ); + const change = yield* desktopSettings.setServerExposureMode(mode).pipe( + Effect.mapError( + (cause) => + new DesktopServerExposurePersistenceError({ + operation: "server-exposure-mode", + cause, + }), + ), + ); - yield* Ref.set(stateRef, resolved.state); - return { - state: toContractState(resolved.state), - requiresRelaunch: change.changed || requiresBackendRelaunch(previous, resolved.state), - }; - }); + yield* Ref.set(stateRef, resolved.state); + return { + state: toContractState(resolved.state), + requiresRelaunch: change.changed || requiresBackendRelaunch(previous, resolved.state), + }; + }); - const setTailscaleServeEnabled = (input: { readonly enabled: boolean; readonly port?: number }) => - Effect.gen(function* () { + const setTailscaleServeEnabled = Effect.fn("desktop.serverExposure.setTailscaleServeEnabled")( + function* (input: { readonly enabled: boolean; readonly port?: number }) { + yield* Effect.annotateCurrentSpan({ + enabled: input.enabled, + ...(input.port === undefined ? {} : { port: input.port }), + }); const result = yield* desktopSettings .setTailscaleServe({ enabled: input.enabled, @@ -497,7 +505,8 @@ const make = Effect.gen(function* () { state: toContractState(nextState), requiresRelaunch: result.changed, }; - }); + }, + ); const getAdvertisedEndpoints = Effect.gen(function* () { const state = yield* Ref.get(stateRef); @@ -517,7 +526,7 @@ const make = Effect.gen(function* () { Effect.provideService(HttpClient.HttpClient, httpClient), ); return [...coreEndpoints, ...tailscaleEndpoints]; - }); + }).pipe(Effect.withSpan("desktop.serverExposure.getAdvertisedEndpoints")); return DesktopServerExposure.of({ getState, diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 6659562258e..177f05a4b2b 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -216,25 +216,23 @@ function readSettings( ); } -function writeSettings(input: { +const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (input: { readonly fileSystem: FileSystem.FileSystem; readonly path: Path.Path; readonly settingsPath: string; readonly settings: DesktopSettings; readonly defaultSettings: DesktopSettings; -}): Effect.Effect { - return Effect.gen(function* () { - const directory = input.path.dirname(input.settingsPath); - const suffix = (yield* Random.nextUUIDv4).replace(/-/g, ""); - const tempPath = `${input.settingsPath}.${process.pid}.${suffix}.tmp`; - const encoded = yield* encodeDesktopSettingsJson( - toDesktopSettingsDocument(input.settings, input.defaultSettings), - ); - yield* input.fileSystem.makeDirectory(directory, { recursive: true }); - yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`); - yield* input.fileSystem.rename(tempPath, input.settingsPath); - }); -} +}): Effect.fn.Return { + const directory = input.path.dirname(input.settingsPath); + const suffix = (yield* Random.nextUUIDv4).replace(/-/g, ""); + const tempPath = `${input.settingsPath}.${process.pid}.${suffix}.tmp`; + const encoded = yield* encodeDesktopSettingsJson( + toDesktopSettingsDocument(input.settings, input.defaultSettings), + ); + yield* input.fileSystem.makeDirectory(directory, { recursive: true }); + yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`); + yield* input.fileSystem.rename(tempPath, input.settingsPath); +}); export const layer = Layer.effect( DesktopAppSettings, @@ -274,10 +272,19 @@ export const layer = Layer.effect( environment.appVersion, ); return yield* SynchronizedRef.setAndGet(settingsRef, settings); - }), - setServerExposureMode: (mode) => persist((settings) => setServerExposureMode(settings, mode)), - setTailscaleServe: (input) => persist((settings) => setTailscaleServe(settings, input)), - setUpdateChannel: (channel) => persist((settings) => setUpdateChannel(settings, channel)), + }).pipe(Effect.withSpan("desktop.settings.load")), + setServerExposureMode: (mode) => + persist((settings) => setServerExposureMode(settings, mode)).pipe( + Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }), + ), + setTailscaleServe: (input) => + persist((settings) => setTailscaleServe(settings, input)).pipe( + Effect.withSpan("desktop.settings.setTailscaleServe", { attributes: input }), + ), + setUpdateChannel: (channel) => + persist((settings) => setUpdateChannel(settings, channel)).pipe( + Effect.withSpan("desktop.settings.setUpdateChannel", { attributes: { channel } }), + ), }); }), ); diff --git a/apps/desktop/src/settings/DesktopClientSettings.ts b/apps/desktop/src/settings/DesktopClientSettings.ts index 4da260684ef..41f300ecc5a 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.ts @@ -88,14 +88,19 @@ export const layer = Layer.effect( const path = yield* Path.Path; return DesktopClientSettings.of({ - get: readClientSettings(fileSystem, environment.clientSettingsPath), + get: readClientSettings(fileSystem, environment.clientSettingsPath).pipe( + Effect.withSpan("desktop.clientSettings.get"), + ), set: (settings) => writeClientSettings({ fileSystem, path, settingsPath: environment.clientSettingsPath, settings, - }).pipe(Effect.mapError((cause) => new DesktopClientSettingsWriteError({ cause }))), + }).pipe( + Effect.mapError((cause) => new DesktopClientSettingsWriteError({ cause })), + Effect.withSpan("desktop.clientSettings.set"), + ), }); }), ); diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.ts index 0d86f27db7a..ec36aa4f6ef 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.ts @@ -194,13 +194,13 @@ function readRegistryDocument( ); } -function writeRegistryDocument(input: { - readonly fileSystem: FileSystem.FileSystem; - readonly path: Path.Path; - readonly registryPath: string; - readonly document: SavedEnvironmentRegistryDocument; -}): Effect.Effect { - return Effect.gen(function* () { +const writeRegistryDocument = Effect.fn("desktop.savedEnvironments.writeRegistryDocument")( + function* (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly registryPath: string; + readonly document: SavedEnvironmentRegistryDocument; + }): Effect.fn.Return { const directory = input.path.dirname(input.registryPath); const suffix = (yield* Random.nextUUIDv4).replace(/-/g, ""); const tempPath = `${input.registryPath}.${process.pid}.${suffix}.tmp`; @@ -208,8 +208,8 @@ function writeRegistryDocument(input: { yield* input.fileSystem.makeDirectory(directory, { recursive: true }); yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`); yield* input.fileSystem.rename(tempPath, input.registryPath); - }); -} + }, +); function preserveExistingSecrets( currentDocument: SavedEnvironmentRegistryDocument, @@ -261,89 +261,90 @@ export const layer = Layer.effect( Effect.map((document) => document.records.map((record) => toPersistedSavedEnvironmentRecord(record)), ), + Effect.withSpan("desktop.savedEnvironments.getRegistry"), ), - setRegistry: (records) => - Effect.gen(function* () { - const currentDocument = yield* readRegistryDocument( - fileSystem, - environment.savedEnvironmentRegistryPath, - ); - yield* writeDocument(preserveExistingSecrets(currentDocument, records)); - }), - getSecret: (environmentId) => - Effect.gen(function* () { - const document = yield* readRegistryDocument( - fileSystem, - environment.savedEnvironmentRegistryPath, - ); - const encoded = Option.fromNullishOr( - document.records.find((record) => record.environmentId === environmentId) - ?.encryptedBearerToken, - ); - if (Option.isNone(encoded) || !(yield* safeStorage.isEncryptionAvailable)) { - return Option.none(); - } - - const secretBytes = yield* decodeSecretBytes(encoded.value); - return Option.some(yield* safeStorage.decryptString(secretBytes)); - }), - setSecret: ({ environmentId, secret }) => - Effect.gen(function* () { - const document = yield* readRegistryDocument( - fileSystem, - environment.savedEnvironmentRegistryPath, - ); - - if (!(yield* safeStorage.isEncryptionAvailable)) { - return false; - } - - const encryptedBearerToken = Encoding.encodeBase64( - yield* safeStorage.encryptString(secret), - ); - let found = false; - const nextDocument: SavedEnvironmentRegistryDocument = { - version: document.version, - records: document.records.map((record) => { - if (record.environmentId !== environmentId) { - return record; - } + setRegistry: Effect.fn("desktop.savedEnvironments.setRegistry")(function* (records) { + const currentDocument = yield* readRegistryDocument( + fileSystem, + environment.savedEnvironmentRegistryPath, + ); + yield* writeDocument(preserveExistingSecrets(currentDocument, records)); + }), + getSecret: Effect.fn("desktop.savedEnvironments.getSecret")(function* (environmentId) { + yield* Effect.annotateCurrentSpan({ environmentId }); + const document = yield* readRegistryDocument( + fileSystem, + environment.savedEnvironmentRegistryPath, + ); + const encoded = Option.fromNullishOr( + document.records.find((record) => record.environmentId === environmentId) + ?.encryptedBearerToken, + ); + if (Option.isNone(encoded) || !(yield* safeStorage.isEncryptionAvailable)) { + return Option.none(); + } + + const secretBytes = yield* decodeSecretBytes(encoded.value); + return Option.some(yield* safeStorage.decryptString(secretBytes)); + }), + setSecret: Effect.fn("desktop.savedEnvironments.setSecret")(function* (input) { + const { environmentId, secret } = input; + yield* Effect.annotateCurrentSpan({ environmentId }); + const document = yield* readRegistryDocument( + fileSystem, + environment.savedEnvironmentRegistryPath, + ); + + if (!(yield* safeStorage.isEncryptionAvailable)) { + return false; + } + + const encryptedBearerToken = Encoding.encodeBase64( + yield* safeStorage.encryptString(secret), + ); + let found = false; + const nextDocument: SavedEnvironmentRegistryDocument = { + version: document.version, + records: document.records.map((record) => { + if (record.environmentId !== environmentId) { + return record; + } + + found = true; + return toSavedEnvironmentStorageRecord(record, Option.some(encryptedBearerToken)); + }), + }; - found = true; - return toSavedEnvironmentStorageRecord(record, Option.some(encryptedBearerToken)); - }), - }; - - if (found) { - yield* writeDocument(nextDocument); - } - return found; - }), - removeSecret: (environmentId) => - Effect.gen(function* () { - const document = yield* readRegistryDocument( - fileSystem, - environment.savedEnvironmentRegistryPath, - ); - if ( - !document.records.some( - (record) => - record.environmentId === environmentId && record.encryptedBearerToken !== undefined, - ) - ) { - return; - } - - yield* writeDocument({ - version: document.version, - records: document.records.map((record) => { - if (record.environmentId !== environmentId) { - return record; - } - return toPersistedSavedEnvironmentRecord(record); - }), - }); - }), + if (found) { + yield* writeDocument(nextDocument); + } + return found; + }), + removeSecret: Effect.fn("desktop.savedEnvironments.removeSecret")(function* (environmentId) { + yield* Effect.annotateCurrentSpan({ environmentId }); + const document = yield* readRegistryDocument( + fileSystem, + environment.savedEnvironmentRegistryPath, + ); + if ( + !document.records.some( + (record) => + record.environmentId === environmentId && record.encryptedBearerToken !== undefined, + ) + ) { + return; + } + + yield* writeDocument({ + version: document.version, + records: document.records.map((record) => { + if (record.environmentId !== environmentId) { + return record; + } + return toPersistedSavedEnvironmentRecord(record); + }), + }); + }), }); }), ); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 8588383a19f..358729e05ef 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -175,31 +175,30 @@ const extractEnvironment = (output: string, names: ReadonlyArray): Envir return environment; }; -const runCommandOutput = (input: { +const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")(function* (input: { readonly command: string; readonly args: ReadonlyArray; readonly timeout: Duration.Duration; readonly shell?: boolean; -}): Effect.Effect => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - return yield* spawner - .string( - ChildProcess.make(input.command, input.args, { - shell: input.shell ?? false, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - killSignal: "SIGTERM", - forceKillAfter: PROCESS_TERMINATE_GRACE, - }), - ) - .pipe( - Effect.timeoutOption(input.timeout), - Effect.map(Option.getOrElse(() => "")), - Effect.catch(() => Effect.succeed("")), - ); - }); +}): Effect.fn.Return { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return yield* spawner + .string( + ChildProcess.make(input.command, input.args, { + shell: input.shell ?? false, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + killSignal: "SIGTERM", + forceKillAfter: PROCESS_TERMINATE_GRACE, + }), + ) + .pipe( + Effect.timeoutOption(input.timeout), + Effect.map(Option.getOrElse(() => "")), + Effect.catch(() => Effect.succeed("")), + ); +}); const readLoginShellEnvironment = ( shell: string, @@ -223,21 +222,21 @@ const readLaunchctlPath: Effect.Effect< timeout: LAUNCHCTL_TIMEOUT, }).pipe(Effect.map(trimNonEmpty)); -const readWindowsEnvironment = ( - names: ReadonlyArray, - options: WindowsProbeOptions, -): Effect.Effect => { - if (names.length === 0) return Effect.succeed({}); - - const args = [ - "-NoLogo", - ...(options.loadProfile ? ([] as const) : (["-NoProfile"] as const)), - "-NonInteractive", - "-Command", - captureWindowsEnvironmentCommand(names), - ]; - - return Effect.gen(function* () { +const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEnvironment")( + function* ( + names: ReadonlyArray, + options: WindowsProbeOptions, + ): Effect.fn.Return { + if (names.length === 0) return {}; + + const args = [ + "-NoLogo", + ...(options.loadProfile ? ([] as const) : (["-NoProfile"] as const)), + "-NonInteractive", + "-Command", + captureWindowsEnvironmentCommand(names), + ]; + for (const command of WINDOWS_SHELL_CANDIDATES) { const output = yield* runCommandOutput({ command, @@ -252,13 +251,13 @@ const readWindowsEnvironment = ( } return {}; - }); -}; + }, +); -const installWindowsEnvironment = ( - config: ShellEnvironmentConfig, -): Effect.Effect => - Effect.gen(function* () { +const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWindowsEnvironment")( + function* ( + config: ShellEnvironmentConfig, + ): Effect.fn.Return { const noProfile = yield* readWindowsEnvironment(["PATH"], { loadProfile: false }); const profile = yield* readWindowsEnvironment(WINDOWS_PROFILE_ENV_NAMES, { loadProfile: true, @@ -279,12 +278,13 @@ const installWindowsEnvironment = ( if (!config.env.FNM_MULTISHELL_PATH && profile.FNM_MULTISHELL_PATH) { config.env.FNM_MULTISHELL_PATH = profile.FNM_MULTISHELL_PATH; } - }); + }, +); -const installPosixEnvironment = ( - config: ShellEnvironmentConfig, -): Effect.Effect => - Effect.gen(function* () { +const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosixEnvironment")( + function* ( + config: ShellEnvironmentConfig, + ): Effect.fn.Return { const shellEnvironment: EnvironmentPatch = {}; for (const shell of listLoginShellCandidates(config)) { @@ -322,7 +322,8 @@ const installPosixEnvironment = ( config.env[name] = shellEnvironment[name]; } } - }); + }, +); const installShellEnvironment = ( config: ShellEnvironmentConfig, @@ -346,7 +347,10 @@ export const layer = Layer.effect( env: process.env, platform: environment.platform, userShell: Option.none(), - }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)), + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.withSpan("desktop.shellEnvironment.installIntoProcess"), + ), }); }), ); diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index efada095021..98984a417c6 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -112,13 +112,17 @@ const make = Effect.gen(function* () { return DesktopSshEnvironment.of({ discoverHosts: (input) => - discoverDesktopSshHostsEffect(input).pipe(Effect.provide(runtimeContext)), + discoverDesktopSshHostsEffect(input).pipe( + Effect.provide(runtimeContext), + Effect.withSpan("desktop.ssh.discoverHosts"), + ), ensureEnvironment: (target, ensureOptions) => manager .ensureEnvironment(target, ensureOptions) .pipe( Effect.provideService(SshPasswordPrompt, passwordPrompt), Effect.provide(runtimeContext), + Effect.withSpan("desktop.ssh.ensureEnvironment"), ), disconnectEnvironment: (target) => manager @@ -126,6 +130,7 @@ const make = Effect.gen(function* () { .pipe( Effect.provideService(SshPasswordPrompt, passwordPrompt), Effect.provide(runtimeContext), + Effect.withSpan("desktop.ssh.disconnectEnvironment"), ), }); }); diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts index 0e0db3b8e83..a53de9fd8e4 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts @@ -161,175 +161,172 @@ const failPending = ( error: DesktopSshPasswordPromptRequestError, ) => Deferred.fail(pending.deferred, error).pipe(Effect.asVoid); -const make = (options: LayerOptions = {}) => - Effect.gen(function* () { - const electronWindow = yield* ElectronWindow.ElectronWindow; - const pendingRef = yield* Ref.make(new Map()); - const passwordPromptTimeoutMs = - options.passwordPromptTimeoutMs ?? DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS; - - const cancelPending = (reason: string): Effect.Effect => - Ref.getAndSet(pendingRef, new Map()).pipe( - Effect.flatMap((pending) => - Effect.forEach( - pending.values(), - (entry) => - failPending( - entry, - new DesktopSshPromptCancelledError({ - requestId: entry.requestId, - destination: entry.destination, - reason, - }), - ), - { discard: true }, - ), +const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: LayerOptions = {}) { + const electronWindow = yield* ElectronWindow.ElectronWindow; + const pendingRef = yield* Ref.make(new Map()); + const passwordPromptTimeoutMs = + options.passwordPromptTimeoutMs ?? DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS; + + const cancelPending = (reason: string): Effect.Effect => + Ref.getAndSet(pendingRef, new Map()).pipe( + Effect.flatMap((pending) => + Effect.forEach( + pending.values(), + (entry) => + failPending( + entry, + new DesktopSshPromptCancelledError({ + requestId: entry.requestId, + destination: entry.destination, + reason, + }), + ), + { discard: true }, ), - Effect.asVoid, - ); - - yield* Effect.addFinalizer(() => - cancelPending("SSH password prompt service stopped.").pipe(Effect.ignore), + ), + Effect.asVoid, ); - const resolve = ( - input: DesktopSshPasswordPromptResolutionInput, - ): Effect.Effect => - Effect.gen(function* () { - const requestId = input.requestId.trim(); - if (requestId.length === 0) { - return yield* new DesktopSshPromptInvalidRequestIdError({ requestId: input.requestId }); - } + yield* Effect.addFinalizer(() => + cancelPending("SSH password prompt service stopped.").pipe(Effect.ignore), + ); - const pending = yield* removePending(pendingRef, requestId); - if (Option.isNone(pending)) { - return yield* new DesktopSshPromptExpiredError({ requestId }); - } + const resolve = Effect.fn("desktop.sshPasswordPrompts.resolve")(function* ( + input: DesktopSshPasswordPromptResolutionInput, + ): Effect.fn.Return { + const requestId = input.requestId.trim(); + if (requestId.length === 0) { + return yield* new DesktopSshPromptInvalidRequestIdError({ requestId: input.requestId }); + } - const entry = pending.value; - if (input.password === null) { - yield* failPending( - entry, - new DesktopSshPromptCancelledError({ - requestId, - destination: entry.destination, - reason: `SSH authentication cancelled for ${entry.destination}.`, - }), - ); - return; - } + const pending = yield* removePending(pendingRef, requestId); + if (Option.isNone(pending)) { + return yield* new DesktopSshPromptExpiredError({ requestId }); + } - yield* Deferred.succeed(entry.deferred, input.password).pipe(Effect.asVoid); - }); + const entry = pending.value; + if (input.password === null) { + yield* failPending( + entry, + new DesktopSshPromptCancelledError({ + requestId, + destination: entry.destination, + reason: `SSH authentication cancelled for ${entry.destination}.`, + }), + ); + return; + } - const request = ( - input: SshPasswordRequest, - ): Effect.Effect => - Effect.gen(function* () { - const window = yield* electronWindow.main; - if (Option.isNone(window) || window.value.isDestroyed()) { - return yield* new DesktopSshPromptWindowUnavailableError({ - destination: input.destination, - }); - } + yield* Deferred.succeed(entry.deferred, input.password).pipe(Effect.asVoid); + }); - const requestId = yield* Random.nextUUIDv4; - const now = yield* DateTime.now; - const expiresAt = DateTime.formatIso( - DateTime.add(now, { milliseconds: passwordPromptTimeoutMs }), - ); - const promptRequest: DesktopSshPasswordPromptRequest = { - requestId, - destination: input.destination, - username: input.username, - prompt: input.prompt, - expiresAt, - }; - const deferred = yield* Deferred.make(); - const pending: PendingSshPasswordPrompt = { - requestId, - destination: input.destination, - deferred, - }; - yield* Ref.update(pendingRef, (entries) => new Map(entries).set(requestId, pending)); - - const context = yield* Effect.context(); - const runFork = Effect.runForkWith(context); - - const cancelOnWindowClosed = () => { - runFork( - removePending(pendingRef, requestId).pipe( - Effect.flatMap((entry) => - Option.match(entry, { - onNone: () => Effect.void, - onSome: (pending) => - failPending( - pending, - new DesktopSshPromptCancelledError({ - requestId, - destination: input.destination, - reason: "SSH authentication was cancelled because the app window closed.", - }), - ), - }), - ), - ), - ); - }; - const cleanup = Effect.sync(() => { - if (!window.value.isDestroyed()) { - window.value.removeListener("closed", cancelOnWindowClosed); - } - }).pipe(Effect.andThen(removePending(pendingRef, requestId)), Effect.asVoid); - const waitForPassword = Deferred.await(deferred).pipe( - Effect.timeoutOption(Duration.millis(passwordPromptTimeoutMs)), - Effect.flatMap( - Option.match({ - onNone: () => - Effect.fail( - new DesktopSshPromptTimedOutError({ + const request = Effect.fn("desktop.sshPasswordPrompts.request")(function* ( + input: SshPasswordRequest, + ): Effect.fn.Return { + const window = yield* electronWindow.main; + if (Option.isNone(window) || window.value.isDestroyed()) { + return yield* new DesktopSshPromptWindowUnavailableError({ + destination: input.destination, + }); + } + + const requestId = yield* Random.nextUUIDv4; + const now = yield* DateTime.now; + const expiresAt = DateTime.formatIso( + DateTime.add(now, { milliseconds: passwordPromptTimeoutMs }), + ); + const promptRequest: DesktopSshPasswordPromptRequest = { + requestId, + destination: input.destination, + username: input.username, + prompt: input.prompt, + expiresAt, + }; + const deferred = yield* Deferred.make(); + const pending: PendingSshPasswordPrompt = { + requestId, + destination: input.destination, + deferred, + }; + yield* Ref.update(pendingRef, (entries) => new Map(entries).set(requestId, pending)); + + const context = yield* Effect.context(); + const runFork = Effect.runForkWith(context); + + const cancelOnWindowClosed = () => { + runFork( + removePending(pendingRef, requestId).pipe( + Effect.flatMap((entry) => + Option.match(entry, { + onNone: () => Effect.void, + onSome: (pending) => + failPending( + pending, + new DesktopSshPromptCancelledError({ requestId, destination: input.destination, + reason: "SSH authentication was cancelled because the app window closed.", }), ), - onSome: Effect.succeed, }), ), - ); - - return yield* Effect.try({ - try: () => { - if (window.value.isDestroyed()) { - throw new Error(WINDOW_UNAVAILABLE_MESSAGE); - } - window.value.once("closed", cancelOnWindowClosed); - window.value.webContents.send(IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL, promptRequest); - if (window.value.isDestroyed()) { - throw new Error(WINDOW_UNAVAILABLE_MESSAGE); - } - if (window.value.isMinimized()) { - window.value.restore(); - } - if (window.value.isDestroyed()) { - throw new Error(WINDOW_UNAVAILABLE_MESSAGE); - } - window.value.focus(); - }, - catch: (cause) => - new DesktopSshPromptSendError({ - requestId, - destination: input.destination, - cause, - }), - }).pipe(Effect.andThen(waitForPassword), Effect.ensuring(cleanup)); - }); + ), + ); + }; + const cleanup = Effect.sync(() => { + if (!window.value.isDestroyed()) { + window.value.removeListener("closed", cancelOnWindowClosed); + } + }).pipe(Effect.andThen(removePending(pendingRef, requestId)), Effect.asVoid); + const waitForPassword = Deferred.await(deferred).pipe( + Effect.timeoutOption(Duration.millis(passwordPromptTimeoutMs)), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new DesktopSshPromptTimedOutError({ + requestId, + destination: input.destination, + }), + ), + onSome: Effect.succeed, + }), + ), + ); + + return yield* Effect.try({ + try: () => { + if (window.value.isDestroyed()) { + throw new Error(WINDOW_UNAVAILABLE_MESSAGE); + } + window.value.once("closed", cancelOnWindowClosed); + window.value.webContents.send(IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL, promptRequest); + if (window.value.isDestroyed()) { + throw new Error(WINDOW_UNAVAILABLE_MESSAGE); + } + if (window.value.isMinimized()) { + window.value.restore(); + } + if (window.value.isDestroyed()) { + throw new Error(WINDOW_UNAVAILABLE_MESSAGE); + } + window.value.focus(); + }, + catch: (cause) => + new DesktopSshPromptSendError({ + requestId, + destination: input.destination, + cause, + }), + }).pipe(Effect.andThen(waitForPassword), Effect.ensuring(cleanup)); + }); - return DesktopSshPasswordPrompts.of({ - request, - resolve, - cancelPending, - }); + return DesktopSshPasswordPrompts.of({ + request, + resolve, + cancelPending, }); +}); export const layer = (options: LayerOptions = {}) => Layer.effect(DesktopSshPasswordPrompts, make(options)); diff --git a/apps/desktop/src/ssh/DesktopSshRemoteApi.ts b/apps/desktop/src/ssh/DesktopSshRemoteApi.ts index 898686d7ca0..60184d098a6 100644 --- a/apps/desktop/src/ssh/DesktopSshRemoteApi.ts +++ b/apps/desktop/src/ssh/DesktopSshRemoteApi.ts @@ -81,6 +81,7 @@ const make = Effect.gen(function* () { Effect.flatMap(decodeExecutionEnvironmentDescriptor), Effect.mapError(mapError("fetch-environment-descriptor")), provideHttpClient, + Effect.withSpan("desktop.sshRemoteApi.fetchEnvironmentDescriptor"), ), bootstrapBearerSession: ({ httpBaseUrl, credential }) => fetchLoopbackSshJson({ @@ -92,6 +93,7 @@ const make = Effect.gen(function* () { Effect.flatMap(decodeAuthBearerBootstrapResult), Effect.mapError(mapError("bootstrap-bearer-session")), provideHttpClient, + Effect.withSpan("desktop.sshRemoteApi.bootstrapBearerSession"), ), fetchSessionState: ({ httpBaseUrl, bearerToken }) => fetchLoopbackSshJson({ @@ -102,6 +104,7 @@ const make = Effect.gen(function* () { Effect.flatMap(decodeAuthSessionState), Effect.mapError(mapError("fetch-session-state")), provideHttpClient, + Effect.withSpan("desktop.sshRemoteApi.fetchSessionState"), ), issueWebSocketToken: ({ httpBaseUrl, bearerToken }) => fetchLoopbackSshJson({ @@ -113,6 +116,7 @@ const make = Effect.gen(function* () { Effect.flatMap(decodeAuthWebSocketTokenResult), Effect.mapError(mapError("issue-websocket-token")), provideHttpClient, + Effect.withSpan("desktop.sshRemoteApi.issueWebSocketToken"), ), }); }); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index c19954f7b2d..4e981021d05 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -285,56 +285,58 @@ const make = Effect.gen(function* () { return Option.none<"check" | "download" | "install">(); }); - const applyAutoUpdaterChannel = (channel: DesktopUpdateChannel): Effect.Effect => - Effect.gen(function* () { - const allowsPrerelease = channel === "nightly"; - yield* electronUpdater.setChannel(channel); - yield* electronUpdater.setAllowPrerelease(allowsPrerelease); - yield* electronUpdater.setAllowDowngrade(allowsPrerelease); - yield* logUpdaterInfo("using update channel", { - channel, - allowPrerelease: allowsPrerelease, - allowDowngrade: allowsPrerelease, - }); + const applyAutoUpdaterChannel = Effect.fn("desktop.updates.applyAutoUpdaterChannel")(function* ( + channel: DesktopUpdateChannel, + ) { + yield* Effect.annotateCurrentSpan({ channel }); + const allowsPrerelease = channel === "nightly"; + yield* electronUpdater.setChannel(channel); + yield* electronUpdater.setAllowPrerelease(allowsPrerelease); + yield* electronUpdater.setAllowDowngrade(allowsPrerelease); + yield* logUpdaterInfo("using update channel", { + channel, + allowPrerelease: allowsPrerelease, + allowDowngrade: allowsPrerelease, }); + }); const shouldEnableAutoUpdates = resolveDisabledReason.pipe(Effect.map(Option.isNone)); - const checkForUpdates = (reason: string): Effect.Effect => - Effect.gen(function* () { - if (yield* Ref.get(desktopState.quitting)) return false; - if (!(yield* Ref.get(updaterConfiguredRef))) return false; - if (yield* Ref.get(updateCheckInFlightRef)) return false; + const checkForUpdates = Effect.fn("desktop.updates.checkForUpdates")(function* (reason: string) { + yield* Effect.annotateCurrentSpan({ reason }); + if (yield* Ref.get(desktopState.quitting)) return false; + if (!(yield* Ref.get(updaterConfiguredRef))) return false; + if (yield* Ref.get(updateCheckInFlightRef)) return false; - const state = yield* Ref.get(updateStateRef); - if (state.status === "downloading" || state.status === "downloaded") { - yield* logUpdaterInfo("skipping update check while update is active", { - reason, - status: state.status, - }); - return false; - } + const state = yield* Ref.get(updateStateRef); + if (state.status === "downloading" || state.status === "downloaded") { + yield* logUpdaterInfo("skipping update check while update is active", { + reason, + status: state.status, + }); + return false; + } - yield* Ref.set(updateCheckInFlightRef, true); - const checkedAt = yield* currentIsoTimestamp; - yield* setState(reduceDesktopUpdateStateOnCheckStart(state, checkedAt)); - yield* logUpdaterInfo("checking for updates", { reason }); - - return yield* electronUpdater.checkForUpdates.pipe( - Effect.as(true), - Effect.catch((error) => - Effect.gen(function* () { - const failedAt = yield* currentIsoTimestamp; - yield* updateState((current) => - reduceDesktopUpdateStateOnCheckFailure(current, error.message, failedAt), - ); - yield* logUpdaterError("failed to check for updates", { message: error.message }); - return true; - }), - ), - Effect.ensuring(Ref.set(updateCheckInFlightRef, false)), - ); - }); + yield* Ref.set(updateCheckInFlightRef, true); + const checkedAt = yield* currentIsoTimestamp; + yield* setState(reduceDesktopUpdateStateOnCheckStart(state, checkedAt)); + yield* logUpdaterInfo("checking for updates", { reason }); + + return yield* electronUpdater.checkForUpdates.pipe( + Effect.as(true), + Effect.catch( + Effect.fn("desktop.updates.handleCheckForUpdatesFailure")(function* (error) { + const failedAt = yield* currentIsoTimestamp; + yield* updateState((current) => + reduceDesktopUpdateStateOnCheckFailure(current, error.message, failedAt), + ); + yield* logUpdaterError("failed to check for updates", { message: error.message }); + return true; + }), + ), + Effect.ensuring(Ref.set(updateCheckInFlightRef, false)), + ); + }); const downloadAvailableUpdate = Effect.gen(function* () { const state = yield* Ref.get(updateStateRef); @@ -356,8 +358,8 @@ const make = Effect.gen(function* () { yield* electronUpdater.downloadUpdate; return { accepted: true, completed: true }; }).pipe( - Effect.catch((error) => - Effect.gen(function* () { + Effect.catch( + Effect.fn("desktop.updates.handleDownloadFailure")(function* (error) { yield* updateState((current) => reduceDesktopUpdateStateOnDownloadFailure(current, error.message), ); @@ -367,7 +369,7 @@ const make = Effect.gen(function* () { ), Effect.ensuring(Ref.set(updateDownloadInFlightRef, false)), ); - }); + }).pipe(Effect.withSpan("desktop.updates.downloadAvailableUpdate")); const installDownloadedUpdate = Effect.gen(function* () { const state = yield* Ref.get(updateStateRef); @@ -391,8 +393,8 @@ const make = Effect.gen(function* () { }); return { accepted: true, completed: false }; }).pipe( - Effect.catch((error) => - Effect.gen(function* () { + Effect.catch( + Effect.fn("desktop.updates.handleInstallFailure")(function* (error) { yield* Ref.set(updateInstallInFlightRef, false); yield* updateState((current) => reduceDesktopUpdateStateOnInstallFailure(current, error.message), @@ -403,7 +405,7 @@ const make = Effect.gen(function* () { }), ), ); - }); + }).pipe(Effect.withSpan("desktop.updates.installDownloadedUpdate")); const startUpdatePollers: Effect.Effect = Effect.gen(function* () { yield* Effect.sleep(AUTO_UPDATE_STARTUP_DELAY).pipe( @@ -421,12 +423,14 @@ const make = Effect.gen(function* () { ), Effect.forkScoped, ); - }); - - const handleUpdateAvailable = (raw: unknown) => - Schema.decodeUnknownEffect(UpdateInfo)(raw).pipe( - Effect.flatMap((info) => - Effect.gen(function* () { + }).pipe(Effect.withSpan("desktop.updates.startPollers")); + + const handleUpdateAvailable = Effect.fn("desktop.updates.handleUpdateAvailable")(function* ( + raw: unknown, + ) { + yield* Schema.decodeUnknownEffect(UpdateInfo)(raw).pipe( + Effect.flatMap( + Effect.fn("desktop.updates.applyUpdateAvailable")(function* (info) { const state = yield* Ref.get(updateStateRef); if (resolveDefaultDesktopUpdateChannel(info.version) !== state.channel) { yield* logUpdaterInfo("ignoring update that does not match selected channel", { @@ -453,6 +457,7 @@ const make = Effect.gen(function* () { }), ), ); + }); const handleUpdateNotAvailable = Effect.gen(function* () { const checkedAt = yield* currentIsoTimestamp; @@ -460,43 +465,43 @@ const make = Effect.gen(function* () { yield* setState(reduceDesktopUpdateStateOnNoUpdate(state, checkedAt)); yield* Ref.set(lastLoggedDownloadMilestoneRef, -1); yield* logUpdaterInfo("no updates available"); - }); - - const handleUpdaterError = (error: unknown) => - Effect.gen(function* () { - const message = error instanceof Error ? error.message : String(error); - if (yield* Ref.get(updateInstallInFlightRef)) { - yield* Ref.set(updateInstallInFlightRef, false); - yield* Ref.set(desktopState.quitting, false); - yield* updateState((current) => reduceDesktopUpdateStateOnInstallFailure(current, message)); - yield* logUpdaterError("updater error", { message }); - return; - } + }).pipe(Effect.withSpan("desktop.updates.handleUpdateNotAvailable")); + + const handleUpdaterError = Effect.fn("desktop.updates.handleUpdaterError")(function* ( + error: unknown, + ) { + const message = error instanceof Error ? error.message : String(error); + if (yield* Ref.get(updateInstallInFlightRef)) { + yield* Ref.set(updateInstallInFlightRef, false); + yield* Ref.set(desktopState.quitting, false); + yield* updateState((current) => reduceDesktopUpdateStateOnInstallFailure(current, message)); + yield* logUpdaterError("updater error", { message }); + return; + } - if ( - !(yield* Ref.get(updateCheckInFlightRef)) && - !(yield* Ref.get(updateDownloadInFlightRef)) - ) { - const errorContext = yield* resolveUpdaterErrorContext; - const checkedAt = yield* currentIsoTimestamp; - yield* updateState((current) => ({ - ...current, - status: "error", - message, - checkedAt, - downloadPercent: null, - errorContext, - canRetry: getCanRetryFromState(current), - })); - } + if (!(yield* Ref.get(updateCheckInFlightRef)) && !(yield* Ref.get(updateDownloadInFlightRef))) { + const errorContext = yield* resolveUpdaterErrorContext; + const checkedAt = yield* currentIsoTimestamp; + yield* updateState((current) => ({ + ...current, + status: "error", + message, + checkedAt, + downloadPercent: null, + errorContext, + canRetry: getCanRetryFromState(current), + })); + } - yield* logUpdaterError("updater error", { message }); - }); + yield* logUpdaterError("updater error", { message }); + }); - const handleDownloadProgress = (raw: unknown) => - Schema.decodeUnknownEffect(DownloadProgressInfo)(raw).pipe( - Effect.flatMap((progress) => - Effect.gen(function* () { + const handleDownloadProgress = Effect.fn("desktop.updates.handleDownloadProgress")(function* ( + raw: unknown, + ) { + yield* Schema.decodeUnknownEffect(DownloadProgressInfo)(raw).pipe( + Effect.flatMap( + Effect.fn("desktop.updates.applyDownloadProgress")(function* (progress) { const state = yield* Ref.get(updateStateRef); const percent = Math.floor(progress.percent); if (shouldBroadcastDownloadProgress(state, progress.percent) || state.message !== null) { @@ -516,11 +521,14 @@ const make = Effect.gen(function* () { }), ), ); + }); - const handleUpdateDownloaded = (raw: unknown) => - Schema.decodeUnknownEffect(UpdateInfo)(raw).pipe( - Effect.flatMap((info) => - Effect.gen(function* () { + const handleUpdateDownloaded = Effect.fn("desktop.updates.handleUpdateDownloaded")(function* ( + raw: unknown, + ) { + yield* Schema.decodeUnknownEffect(UpdateInfo)(raw).pipe( + Effect.flatMap( + Effect.fn("desktop.updates.applyUpdateDownloaded")(function* (info) { const state = yield* Ref.get(updateStateRef); yield* setState(reduceDesktopUpdateStateOnDownloadComplete(state, info.version)); yield* logUpdaterInfo("update downloaded", { version: info.version }); @@ -532,6 +540,7 @@ const make = Effect.gen(function* () { }), ), ); + }); return DesktopUpdates.of({ getState: Ref.get(updateStateRef), @@ -575,7 +584,11 @@ const make = Effect.gen(function* () { } yield* electronUpdater.on("checking-for-update", () => { - runEffect(logUpdaterInfo("looking for updates")); + runEffect( + logUpdaterInfo("looking for updates").pipe( + Effect.withSpan("desktop.updates.handleCheckingForUpdate"), + ), + ); }); yield* electronUpdater.on("update-available", (info: unknown) => { runEffect(handleUpdateAvailable(info)); @@ -594,52 +607,54 @@ const make = Effect.gen(function* () { }); yield* startUpdatePollers; - }), - setChannel: (nextChannel) => - Effect.gen(function* () { - const activeAction = yield* activeUpdateAction; - if (Option.isSome(activeAction)) { - return yield* new DesktopUpdateActionInProgressError({ action: activeAction.value }); - } - - const state = yield* Ref.get(updateStateRef); - if (nextChannel === state.channel) { - return state; - } - - yield* desktopSettings - .setUpdateChannel(nextChannel) - .pipe(Effect.mapError((cause) => new DesktopUpdatePersistenceError({ cause }))); - - const enabled = yield* shouldEnableAutoUpdates; - yield* setState(createBaseUpdateState(nextChannel, enabled, environment)); - - if (!enabled || !(yield* Ref.get(updaterConfiguredRef))) { - return yield* Ref.get(updateStateRef); - } - - yield* applyAutoUpdaterChannel(nextChannel); - const allowDowngrade = yield* electronUpdater.allowDowngrade; - yield* electronUpdater.setAllowDowngrade(true); - yield* checkForUpdates("channel-change").pipe( - Effect.ensuring(electronUpdater.setAllowDowngrade(allowDowngrade).pipe(Effect.ignore)), - ); + }).pipe(Effect.withSpan("desktop.updates.configure")), + setChannel: Effect.fn("desktop.updates.setChannel")(function* ( + nextChannel: DesktopUpdateChannel, + ) { + yield* Effect.annotateCurrentSpan({ channel: nextChannel }); + const activeAction = yield* activeUpdateAction; + if (Option.isSome(activeAction)) { + return yield* new DesktopUpdateActionInProgressError({ action: activeAction.value }); + } + + const state = yield* Ref.get(updateStateRef); + if (nextChannel === state.channel) { + return state; + } + + yield* desktopSettings + .setUpdateChannel(nextChannel) + .pipe(Effect.mapError((cause) => new DesktopUpdatePersistenceError({ cause }))); + + const enabled = yield* shouldEnableAutoUpdates; + yield* setState(createBaseUpdateState(nextChannel, enabled, environment)); + + if (!enabled || !(yield* Ref.get(updaterConfiguredRef))) { return yield* Ref.get(updateStateRef); - }), - check: (reason) => - Effect.gen(function* () { - if (!(yield* Ref.get(updaterConfiguredRef))) { - return { - checked: false, - state: yield* Ref.get(updateStateRef), - }; - } - const checked = yield* checkForUpdates(reason); + } + + yield* applyAutoUpdaterChannel(nextChannel); + const allowDowngrade = yield* electronUpdater.allowDowngrade; + yield* electronUpdater.setAllowDowngrade(true); + yield* checkForUpdates("channel-change").pipe( + Effect.ensuring(electronUpdater.setAllowDowngrade(allowDowngrade).pipe(Effect.ignore)), + ); + return yield* Ref.get(updateStateRef); + }), + check: Effect.fn("desktop.updates.check")(function* (reason: string) { + yield* Effect.annotateCurrentSpan({ reason }); + if (!(yield* Ref.get(updaterConfiguredRef))) { return { - checked, + checked: false, state: yield* Ref.get(updateStateRef), }; - }), + } + const checked = yield* checkForUpdates(reason); + return { + checked, + state: yield* Ref.get(updateStateRef), + }; + }), download: Effect.gen(function* () { const result = yield* downloadAvailableUpdate; return { @@ -647,7 +662,7 @@ const make = Effect.gen(function* () { completed: result.completed, state: yield* Ref.get(updateStateRef), }; - }), + }).pipe(Effect.withSpan("desktop.updates.download")), install: Effect.gen(function* () { if (yield* Ref.get(desktopState.quitting)) { return { @@ -662,7 +677,7 @@ const make = Effect.gen(function* () { completed: result.completed, state: yield* Ref.get(updateStateRef), }; - }), + }).pipe(Effect.withSpan("desktop.updates.install")), }); }); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index c0957c3c634..dc73aaa7e4b 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -22,13 +22,17 @@ export class DesktopApplicationMenu extends Context.Service< DesktopApplicationMenuShape >()("t3/desktop/ApplicationMenu") {} -const dispatchMenuAction = ( +type DesktopApplicationMenuRuntimeServices = + | DesktopUpdates.DesktopUpdates + | DesktopWindow.DesktopWindow + | ElectronDialog.ElectronDialog; + +const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function* ( action: string, -): Effect.Effect => - Effect.gen(function* () { - const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.dispatchMenuAction(action); - }); +): Effect.fn.Return { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.dispatchMenuAction(action); +}); const checkForUpdatesFromMenu: Effect.Effect< void, @@ -56,7 +60,7 @@ const checkForUpdatesFromMenu: Effect.Effect< buttons: ["OK"], }); } -}); +}).pipe(Effect.withSpan("desktop.menu.checkForUpdates")); const handleCheckForUpdatesMenuClick: Effect.Effect< void, @@ -86,30 +90,37 @@ const handleCheckForUpdatesMenuClick: Effect.Effect< const desktopWindow = yield* DesktopWindow.DesktopWindow; yield* desktopWindow.ensureMain; yield* checkForUpdatesFromMenu; -}); +}).pipe(Effect.withSpan("desktop.menu.handleCheckForUpdatesClick")); const make = Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; const electronMenu = yield* ElectronMenu.ElectronMenu; const environment = yield* DesktopEnvironment.DesktopEnvironment; const appName = yield* electronApp.name; - - const configure = Effect.gen(function* () { - const context = yield* Effect.context(); - const runMenuEffect = (action: string, effect: Effect.Effect) => { - void Effect.runPromiseWith(context as unknown as Context.Context)( - effect.pipe( - Effect.catchCause((cause) => - Effect.logError("desktop menu action failed").pipe( - Effect.annotateLogs({ - action, - cause: Cause.pretty(cause), - }), - ), + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); + + const runMenuEffect = ( + action: string, + effect: Effect.Effect, + ) => { + void runPromise( + effect.pipe( + Effect.annotateLogs({ action }), + Effect.withSpan("desktop.menu.action"), + Effect.catchCause((cause) => + Effect.logError("desktop menu action failed").pipe( + Effect.annotateLogs({ + action, + cause: Cause.pretty(cause), + }), ), ), - ); - }; + ), + ); + }; + + const configure = Effect.gen(function* () { const checkForUpdatesClick = () => { runMenuEffect("check-for-updates", handleCheckForUpdatesMenuClick); }; @@ -191,7 +202,7 @@ const make = Effect.gen(function* () { ); yield* electronMenu.setApplicationMenu(template); - }); + }).pipe(Effect.withSpan("desktop.menu.configure")); return DesktopApplicationMenu.of({ configure, diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 3d4db82f274..adb4b240620 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -170,150 +170,147 @@ const make = Effect.gen(function* () { const context = yield* Effect.context(); const runPromise = Effect.runPromiseWith(context); - const createWindow = ( + const createWindow = Effect.fn("desktop.window.createWindow")(function* ( backendHttpUrl: URL, - ): Effect.Effect => - Effect.gen(function* () { - const iconPaths = yield* assets.iconPaths; - const iconOption = getIconOption(iconPaths); - const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; - const window = yield* electronWindow.create({ - width: 1100, - height: 780, - minWidth: 840, - minHeight: 620, - show: false, - autoHideMenuBar: true, - backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors), - ...iconOption, - title: environment.displayName, - ...getWindowTitleBarOptions(shouldUseDarkColors), - webPreferences: { - preload: environment.preloadPath, - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - }, - }); - - window.webContents.on("context-menu", (event, params) => { - event.preventDefault(); - - const menuTemplate: Electron.MenuItemConstructorOptions[] = []; - - if (params.misspelledWord) { - for (const suggestion of params.dictionarySuggestions.slice(0, 5)) { - menuTemplate.push({ - label: suggestion, - click: () => window.webContents.replaceMisspelling(suggestion), - }); - } - if (params.dictionarySuggestions.length === 0) { - menuTemplate.push({ label: "No suggestions", enabled: false }); - } - menuTemplate.push({ type: "separator" }); - } + ): Effect.fn.Return { + const iconPaths = yield* assets.iconPaths; + const iconOption = getIconOption(iconPaths); + const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; + const window = yield* electronWindow.create({ + width: 1100, + height: 780, + minWidth: 840, + minHeight: 620, + show: false, + autoHideMenuBar: true, + backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors), + ...iconOption, + title: environment.displayName, + ...getWindowTitleBarOptions(shouldUseDarkColors), + webPreferences: { + preload: environment.preloadPath, + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }); - if (Option.isSome(ElectronShell.parseSafeExternalUrl(params.linkURL))) { - menuTemplate.push( - { - label: "Copy Link", - click: () => { - void runPromise(electronShell.copyText(params.linkURL)); - }, - }, - { type: "separator" }, - ); - } + window.webContents.on("context-menu", (event, params) => { + event.preventDefault(); + + const menuTemplate: Electron.MenuItemConstructorOptions[] = []; - if (params.mediaType === "image") { + if (params.misspelledWord) { + for (const suggestion of params.dictionarySuggestions.slice(0, 5)) { menuTemplate.push({ - label: "Copy Image", - click: () => window.webContents.copyImageAt(params.x, params.y), + label: suggestion, + click: () => window.webContents.replaceMisspelling(suggestion), }); - menuTemplate.push({ type: "separator" }); } + if (params.dictionarySuggestions.length === 0) { + menuTemplate.push({ label: "No suggestions", enabled: false }); + } + menuTemplate.push({ type: "separator" }); + } + if (Option.isSome(ElectronShell.parseSafeExternalUrl(params.linkURL))) { menuTemplate.push( - { role: "cut", enabled: params.editFlags.canCut }, - { role: "copy", enabled: params.editFlags.canCopy }, - { role: "paste", enabled: params.editFlags.canPaste }, - { role: "selectAll", enabled: params.editFlags.canSelectAll }, + { + label: "Copy Link", + click: () => { + void runPromise(electronShell.copyText(params.linkURL)); + }, + }, + { type: "separator" }, ); + } + + if (params.mediaType === "image") { + menuTemplate.push({ + label: "Copy Image", + click: () => window.webContents.copyImageAt(params.x, params.y), + }); + menuTemplate.push({ type: "separator" }); + } + + menuTemplate.push( + { role: "cut", enabled: params.editFlags.canCut }, + { role: "copy", enabled: params.editFlags.canCopy }, + { role: "paste", enabled: params.editFlags.canPaste }, + { role: "selectAll", enabled: params.editFlags.canSelectAll }, + ); - void runPromise(electronMenu.popupTemplate({ window, template: menuTemplate })); - }); + void runPromise(electronMenu.popupTemplate({ window, template: menuTemplate })); + }); - window.webContents.setWindowOpenHandler(({ url }) => { - if (Option.isSome(ElectronShell.parseSafeExternalUrl(url))) { - void runPromise(electronShell.openExternal(url)); + window.webContents.setWindowOpenHandler(({ url }) => { + if (Option.isSome(ElectronShell.parseSafeExternalUrl(url))) { + void runPromise(electronShell.openExternal(url)); + } + return { action: "deny" }; + }); + + window.on("page-title-updated", (event) => { + event.preventDefault(); + window.setTitle(environment.displayName); + }); + window.webContents.on("did-finish-load", () => { + window.setTitle(environment.displayName); + }); + window.webContents.on( + "did-fail-load", + (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { + if (!isMainFrame) { + return; } - return { action: "deny" }; - }); - - window.on("page-title-updated", (event) => { - event.preventDefault(); - window.setTitle(environment.displayName); - }); - window.webContents.on("did-finish-load", () => { - window.setTitle(environment.displayName); - }); - window.webContents.on( - "did-fail-load", - (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { - if (!isMainFrame) { - return; - } - void runPromise( - logWindowWarning("main window failed to load", { - errorCode, - errorDescription, - url: validatedURL, - }), - ); - }, - ); - window.webContents.on("render-process-gone", (_event, details) => { void runPromise( - logWindowWarning("main window render process gone", { - reason: details.reason, - exitCode: details.exitCode, + logWindowWarning("main window failed to load", { + errorCode, + errorDescription, + url: validatedURL, }), ); - }); + }, + ); + window.webContents.on("render-process-gone", (_event, details) => { + void runPromise( + logWindowWarning("main window render process gone", { + reason: details.reason, + exitCode: details.exitCode, + }), + ); + }); - const revealSubscribers: RevealSubscription[] = [ - (fire) => window.once("ready-to-show", fire), - ]; - if (process.platform === "linux") { - revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire)); - } - bindFirstRevealTrigger(revealSubscribers, () => { - void runPromise(electronWindow.reveal(window)); - }); - - if (environment.isDevelopment) { - const devServerUrl = yield* resolveDesktopDevServerUrl(environment); - void window.loadURL(devServerUrl); - window.webContents.openDevTools({ mode: "detach" }); - } else { - void window.loadURL(backendHttpUrl.href); - } + const revealSubscribers: RevealSubscription[] = [(fire) => window.once("ready-to-show", fire)]; + if (process.platform === "linux") { + revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire)); + } + bindFirstRevealTrigger(revealSubscribers, () => { + void runPromise(electronWindow.reveal(window)); + }); - window.on("closed", () => { - void runPromise(electronWindow.clearMain(Option.some(window))); - }); + if (environment.isDevelopment) { + const devServerUrl = yield* resolveDesktopDevServerUrl(environment); + void window.loadURL(devServerUrl); + window.webContents.openDevTools({ mode: "detach" }); + } else { + void window.loadURL(backendHttpUrl.href); + } - return window; + window.on("closed", () => { + void runPromise(electronWindow.clearMain(Option.some(window))); }); + return window; + }); + const createMain = Effect.gen(function* () { const backendConfig = yield* serverExposure.backendConfig; const window = yield* createWindow(backendConfig.httpBaseUrl); yield* electronWindow.setMain(window); yield* logWindowInfo("main window created"); return window; - }); + }).pipe(Effect.withSpan("desktop.window.createMain")); const ensureMain = Effect.gen(function* () { const existingWindow = yield* electronWindow.currentMainOrFirst; @@ -321,13 +318,13 @@ const make = Effect.gen(function* () { return existingWindow.value; } return yield* createMain; - }); + }).pipe(Effect.withSpan("desktop.window.ensureMain")); const revealOrCreateMain = Effect.gen(function* () { const window = yield* ensureMain; yield* electronWindow.reveal(window); return window; - }); + }).pipe(Effect.withSpan("desktop.window.revealOrCreateMain")); const createMainIfBackendReady = Effect.gen(function* () { const backendReady = yield* Ref.get(state.backendReady); @@ -335,7 +332,7 @@ const make = Effect.gen(function* () { const existingWindow = yield* electronWindow.currentMainOrFirst; if (Option.isSome(existingWindow)) return; yield* createMain; - }); + }).pipe(Effect.withSpan("desktop.window.createMainIfBackendReady")); return DesktopWindow.of({ createMain, @@ -348,39 +345,37 @@ const make = Effect.gen(function* () { } else { yield* createMainIfBackendReady; } - }), + }).pipe(Effect.withSpan("desktop.window.activate")), createMainIfBackendReady, handleBackendReady: Effect.gen(function* () { yield* Ref.set(state.backendReady, true); yield* logWindowInfo("backend ready", { source: "http" }); yield* createMainIfBackendReady; - }), - dispatchMenuAction: (action) => - Effect.gen(function* () { - const existingWindow = yield* electronWindow.focusedMainOrFirst; - const targetWindow = Option.isSome(existingWindow) - ? existingWindow.value - : yield* createMain; - - const send = () => { - if (targetWindow.isDestroyed()) return; - targetWindow.webContents.send(IpcChannels.MENU_ACTION_CHANNEL, action); - void runPromise(electronWindow.reveal(targetWindow)); - }; - - if (targetWindow.webContents.isLoadingMainFrame()) { - targetWindow.webContents.once("did-finish-load", send); - return; - } + }).pipe(Effect.withSpan("desktop.window.handleBackendReady")), + dispatchMenuAction: Effect.fn("desktop.window.dispatchMenuAction")(function* (action) { + yield* Effect.annotateCurrentSpan({ action }); + const existingWindow = yield* electronWindow.focusedMainOrFirst; + const targetWindow = Option.isSome(existingWindow) ? existingWindow.value : yield* createMain; + + const send = () => { + if (targetWindow.isDestroyed()) return; + targetWindow.webContents.send(IpcChannels.MENU_ACTION_CHANNEL, action); + void runPromise(electronWindow.reveal(targetWindow)); + }; + + if (targetWindow.webContents.isLoadingMainFrame()) { + targetWindow.webContents.once("did-finish-load", send); + return; + } - send(); - }), + send(); + }), syncAppearance: Effect.gen(function* () { const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; yield* electronWindow.syncAllAppearance((window) => syncWindowAppearance(window, shouldUseDarkColors), ); - }), + }).pipe(Effect.withSpan("desktop.window.syncAppearance")), }); }); From dc3aa278f8e8c9c2f627b14f72fd7ac585abc010 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 7 May 2026 20:37:03 -0700 Subject: [PATCH 42/43] Skip destroyed Electron windows during appearance sync - Ignore windows that are destroyed before sync runs - Add coverage for appearance sync filtering --- .../src/electron/ElectronWindow.test.ts | 51 +++++++++++++++++++ apps/desktop/src/electron/ElectronWindow.ts | 3 ++ 2 files changed, 54 insertions(+) create mode 100644 apps/desktop/src/electron/ElectronWindow.test.ts diff --git a/apps/desktop/src/electron/ElectronWindow.test.ts b/apps/desktop/src/electron/ElectronWindow.test.ts new file mode 100644 index 00000000000..bc8a4cdd282 --- /dev/null +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -0,0 +1,51 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import type * as Electron from "electron"; +import { beforeEach, vi } from "vitest"; + +const { appFocusMock, getAllWindowsMock } = vi.hoisted(() => ({ + appFocusMock: vi.fn(), + getAllWindowsMock: vi.fn(), +})); + +vi.mock("electron", () => ({ + app: { + focus: appFocusMock, + }, + BrowserWindow: { + getAllWindows: getAllWindowsMock, + }, +})); + +import * as ElectronWindow from "./ElectronWindow.ts"; + +function makeBrowserWindow(input: { readonly destroyed: boolean }) { + return { + isDestroyed: vi.fn(() => input.destroyed), + } as unknown as Electron.BrowserWindow; +} + +describe("ElectronWindow", () => { + beforeEach(() => { + appFocusMock.mockReset(); + getAllWindowsMock.mockReset(); + }); + + it.effect("skips windows destroyed before appearance sync runs", () => + Effect.gen(function* () { + const liveWindow = makeBrowserWindow({ destroyed: false }); + const destroyedWindow = makeBrowserWindow({ destroyed: true }); + getAllWindowsMock.mockReturnValue([destroyedWindow, liveWindow]); + + const syncedWindows: Electron.BrowserWindow[] = []; + const electronWindow = yield* ElectronWindow.ElectronWindow; + yield* electronWindow.syncAllAppearance((window) => + Effect.sync(() => { + syncedWindows.push(window); + }), + ); + + assert.deepEqual(syncedWindows, [liveWindow]); + }).pipe(Effect.provide(ElectronWindow.layer)), + ); +}); diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index 529f8147903..ed31fb0700e 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -123,6 +123,9 @@ const make = Effect.gen(function* () { ) { const windows = Electron.BrowserWindow.getAllWindows(); for (const window of windows) { + if (window.isDestroyed()) { + continue; + } yield* sync(window); } }), From 35835a7a84759ebf2731272728570c5fcc332ef5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 7 May 2026 21:07:09 -0700 Subject: [PATCH 43/43] Unify desktop component logging - Introduce a shared component logger helper for desktop services - Replace ad hoc log annotations across startup, backend, updates, menu, and window code --- apps/desktop/src/app/DesktopApp.ts | 58 ++++++++++--------- apps/desktop/src/app/DesktopLifecycle.ts | 19 ++---- apps/desktop/src/app/DesktopObservability.ts | 34 +++++++++++ .../backend/DesktopBackendConfiguration.ts | 9 ++- .../src/backend/DesktopBackendManager.ts | 48 ++++++++------- apps/desktop/src/updates/DesktopUpdates.ts | 40 ++++--------- .../src/window/DesktopApplicationMenu.ts | 24 ++++---- apps/desktop/src/window/DesktopWindow.ts | 24 ++------ 8 files changed, 131 insertions(+), 125 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 46fbd90068b..e13ae08d5ff 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -15,6 +15,7 @@ import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopLifecycle from "./DesktopLifecycle.ts"; +import * as DesktopObservability from "./DesktopObservability.ts"; import * as DesktopServerExposure from "../serverExposure/DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; @@ -49,6 +50,12 @@ class DesktopDevelopmentBackendPortRequiredError extends Data.TaggedError( } } +const { logInfo: logBootstrapInfo, logWarning: logBootstrapWarning } = + DesktopObservability.makeComponentLogger("desktop-bootstrap"); + +const { logInfo: logStartupInfo, logError: logStartupError } = + DesktopObservability.makeComponentLogger("desktop-startup"); + const resolveDesktopBackendPort = Effect.fn("resolveDesktopBackendPort")(function* ( configuredPort: Option.Option, ) { @@ -103,13 +110,11 @@ const handleFatalStartupError = Effect.fn("desktop.startup.handleFatalStartupErr const message = error instanceof Error ? error.message : String(error); const detail = error instanceof Error && typeof error.stack === "string" ? `\n${error.stack}` : ""; - yield* Effect.logError("fatal startup error").pipe( - Effect.annotateLogs({ - stage, - message, - ...(detail.length > 0 ? { detail } : {}), - }), - ); + yield* logStartupError("fatal startup error", { + stage, + message, + ...(detail.length > 0 ? { detail } : {}), + }); const wasQuitting = yield* Ref.getAndSet(state.quitting, true); if (!wasQuitting) { yield* electronDialog.showErrorBox( @@ -130,7 +135,7 @@ const bootstrap = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - yield* Effect.logInfo("bootstrap start"); + yield* logBootstrapInfo("bootstrap start"); if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) { return yield* new DesktopDevelopmentBackendPortRequiredError(); @@ -138,44 +143,43 @@ const bootstrap = Effect.gen(function* () { const backendPortSelection = yield* resolveDesktopBackendPort(environment.configuredBackendPort); const backendPort = backendPortSelection.port; - yield* Effect.logInfo( + yield* logBootstrapInfo( backendPortSelection.selectedByScan ? "selected backend port via sequential scan" : "using configured backend port", - ).pipe( - Effect.annotateLogs({ + { port: backendPort, ...(backendPortSelection.selectedByScan ? { startPort: DEFAULT_DESKTOP_BACKEND_PORT } : {}), - }), + }, ); const settings = yield* desktopSettings.get; if (settings.serverExposureMode !== environment.defaultDesktopSettings.serverExposureMode) { - yield* Effect.logInfo("bootstrap restoring persisted server exposure mode").pipe( - Effect.annotateLogs({ mode: settings.serverExposureMode }), - ); + yield* logBootstrapInfo("bootstrap restoring persisted server exposure mode", { + mode: settings.serverExposureMode, + }); } const serverExposureState = yield* serverExposure.configureFromSettings({ port: backendPort }); const backendConfig = yield* serverExposure.backendConfig; - yield* Effect.logInfo("bootstrap resolved backend endpoint").pipe( - Effect.annotateLogs({ baseUrl: backendConfig.httpBaseUrl.href }), - ); + yield* logBootstrapInfo("bootstrap resolved backend endpoint", { + baseUrl: backendConfig.httpBaseUrl.href, + }); if (serverExposureState.endpointUrl) { - yield* Effect.logInfo("bootstrap enabled network access").pipe( - Effect.annotateLogs({ endpointUrl: serverExposureState.endpointUrl }), - ); + yield* logBootstrapInfo("bootstrap enabled network access", { + endpointUrl: serverExposureState.endpointUrl, + }); } else if (settings.serverExposureMode === "network-accessible") { - yield* Effect.logWarning( + yield* logBootstrapWarning( "bootstrap fell back to local-only because no advertised network host was available", ); } yield* installDesktopIpcHandlers; - yield* Effect.logInfo("bootstrap ipc handlers registered"); + yield* logBootstrapInfo("bootstrap ipc handlers registered"); if (!(yield* Ref.get(state.quitting))) { yield* backendManager.start; - yield* Effect.logInfo("bootstrap backend start requested"); + yield* logBootstrapInfo("bootstrap backend start requested"); } }).pipe(Effect.withSpan("desktop.bootstrap")); @@ -193,9 +197,7 @@ const startup = Effect.gen(function* () { yield* shellEnvironment.installIntoProcess; const userDataPath = yield* appIdentity.resolveUserDataPath; yield* electronApp.setPath("userData", userDataPath); - yield* Effect.logInfo("runtime logging configured").pipe( - Effect.annotateLogs({ logDir: environment.logDir }), - ); + yield* logStartupInfo("runtime logging configured", { logDir: environment.logDir }); yield* desktopSettings.load; if (environment.platform === "linux") { @@ -209,7 +211,7 @@ const startup = Effect.gen(function* () { Effect.withSpan("desktop.electron.whenReady"), Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)), ); - yield* Effect.logInfo("app ready"); + yield* logStartupInfo("app ready"); yield* appIdentity.configure; yield* applicationMenu.configure; yield* electronProtocol.registerDesktopFileProtocol; diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index ae9090e644c..b9a7636a411 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -9,6 +9,7 @@ import * as Scope from "effect/Scope"; import type * as Electron from "electron"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopObservability from "./DesktopObservability.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as DesktopState from "./DesktopState.ts"; @@ -64,13 +65,8 @@ export class DesktopLifecycle extends Context.Service) => - Effect.logInfo(message).pipe( - Effect.annotateLogs({ - component: "desktop-lifecycle", - ...annotations, - }), - ); +const { logInfo: logLifecycleInfo, logError: logLifecycleError } = + DesktopObservability.makeComponentLogger("desktop-lifecycle"); function addScopedListener>( target: unknown, @@ -178,12 +174,9 @@ export const layer = Layer.succeed( yield* electronApp.exit(0); }).pipe( Effect.catchCause((cause) => - Effect.logError("desktop relaunch failed").pipe( - Effect.annotateLogs({ - component: "desktop-lifecycle", - cause: Cause.pretty(cause), - }), - ), + logLifecycleError("desktop relaunch failed", { + cause: Cause.pretty(cause), + }), ), Effect.forkDetach, Effect.asVoid, diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index c74d3b6e2b6..4eeb76bd62a 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -48,6 +48,40 @@ export class DesktopBackendOutputLog extends Context.Service< const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); +export type DesktopLogAnnotations = Record; + +export interface DesktopComponentLogger { + readonly annotate: ( + effect: Effect.Effect, + annotations?: DesktopLogAnnotations, + ) => Effect.Effect; + readonly logDebug: (message: string, annotations?: DesktopLogAnnotations) => Effect.Effect; + readonly logInfo: (message: string, annotations?: DesktopLogAnnotations) => Effect.Effect; + readonly logWarning: ( + message: string, + annotations?: DesktopLogAnnotations, + ) => Effect.Effect; + readonly logError: (message: string, annotations?: DesktopLogAnnotations) => Effect.Effect; +} + +export function makeComponentLogger(component: string): DesktopComponentLogger { + const annotate: DesktopComponentLogger["annotate"] = (effect, annotations) => + effect.pipe( + Effect.annotateLogs({ + component, + ...annotations, + }), + ); + + return { + annotate, + logDebug: (message, annotations) => annotate(Effect.logDebug(message), annotations), + logInfo: (message, annotations) => annotate(Effect.logInfo(message), annotations), + logWarning: (message, annotations) => annotate(Effect.logWarning(message), annotations), + logError: (message, annotations) => annotate(Effect.logError(message), annotations), + }; +} + class DesktopLogFileWriterConfigurationError extends Data.TaggedError( "DesktopLogFileWriterConfigurationError", )<{ diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 8657d001ade..45103bee92e 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -9,6 +9,7 @@ import * as Ref from "effect/Ref"; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopServerExposure from "../serverExposure/DesktopServerExposure.ts"; export interface DesktopBackendConfigurationShape { @@ -46,6 +47,10 @@ const DESKTOP_BACKEND_ENV_NAMES = [ const backendChildEnvPatch = (): Record => Object.fromEntries(DESKTOP_BACKEND_ENV_NAMES.map((name) => [name, undefined])); +const { logWarning: logBackendConfigurationWarning } = DesktopObservability.makeComponentLogger( + "desktop-backend-configuration", +); + const readPersistedBackendObservabilitySettings: Effect.Effect< BackendObservabilitySettings, never, @@ -62,7 +67,9 @@ const readPersistedBackendObservabilitySettings: Effect.Effect< const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(Effect.option); if (Option.isNone(raw)) { - yield* Effect.logWarning("failed to read persisted backend observability settings"); + yield* logBackendConfigurationWarning( + "failed to read persisted backend observability settings", + ); return emptyBackendObservabilitySettings; } diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index af009dddb68..97931f42dbd 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -1,20 +1,21 @@ +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Scope from "effect/Scope"; -import * as Stream from "effect/Stream"; -import * as Result from "effect/Result"; import * as PlatformError from "effect/PlatformError"; -import * as Data from "effect/Data"; -import * as Context from "effect/Context"; -import * as Fiber from "effect/Fiber"; -import * as Exit from "effect/Exit"; -import * as Schedule from "effect/Schedule"; import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; import { HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -117,6 +118,9 @@ export class DesktopBackendManager extends Context.Service< DesktopBackendManagerShape >()("t3/desktop/BackendManager") {} +const { logWarning: logBackendManagerWarning, logError: logBackendManagerError } = + DesktopObservability.makeComponentLogger("desktop-backend-manager"); + interface ActiveBackendRun { readonly id: number; readonly scope: Scope.Closeable; @@ -455,16 +459,16 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio yield* Ref.set(desktopState.backendReady, true); yield* desktopWindow.handleBackendReady.pipe( Effect.catch((error) => - Effect.logError("failed to open main window after backend readiness").pipe( - Effect.annotateLogs({ message: error.message }), - ), + logBackendManagerError("failed to open main window after backend readiness", { + message: error.message, + }), ), ); }), onReadinessFailure: (error) => - Effect.logWarning("backend readiness check failed during bootstrap").pipe( - Effect.annotateLogs({ error: error.message }), - ), + logBackendManagerWarning("backend readiness check failed during bootstrap", { + error: error.message, + }), onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), @@ -507,12 +511,10 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio yield* Option.match(scheduled, { onNone: () => Effect.void, onSome: Effect.fn("desktop.backendManager.scheduleRestartFiber")(function* (delay) { - yield* Effect.logError("backend exited unexpectedly; restart scheduled").pipe( - Effect.annotateLogs({ - reason, - delayMs: Duration.toMillis(delay), - }), - ); + yield* logBackendManagerError("backend exited unexpectedly; restart scheduled", { + reason, + delayMs: Duration.toMillis(delay), + }); const restartFiber = yield* Effect.forkIn( Effect.sleep(delay).pipe( Effect.andThen( @@ -529,7 +531,9 @@ const makeDesktopBackendManager = Effect.fn("makeDesktopBackendManager")(functio ), Effect.flatMap((shouldRestart) => (shouldRestart ? start : Effect.void)), Effect.catchCause((cause) => - Effect.logError("desktop backend restart fiber failed", { cause }), + logBackendManagerError("desktop backend restart fiber failed", { + cause: Cause.pretty(cause), + }), ), ), parentScope, diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 4e981021d05..9e052067bee 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -18,14 +18,15 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; -import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; -import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; -import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; -import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as DesktopState from "../app/DesktopState.ts"; +import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; import { createInitialDesktopUpdateState, @@ -99,32 +100,11 @@ export class DesktopUpdates extends Context.Service, - annotations?: Record, -): Effect.Effect => - effect.pipe( - Effect.annotateLogs({ - scope: "desktop", - component: "desktop-updater", - ...annotations, - }), - ); - -const logUpdaterInfo = ( - message: string, - annotations?: Record, -): Effect.Effect => withUpdaterLogAnnotations(Effect.logInfo(message), annotations); - -const logUpdaterWarning = ( - message: string, - annotations?: Record, -): Effect.Effect => withUpdaterLogAnnotations(Effect.logWarning(message), annotations); - -const logUpdaterError = ( - message: string, - annotations?: Record, -): Effect.Effect => withUpdaterLogAnnotations(Effect.logError(message), annotations); +const { + logInfo: logUpdaterInfo, + logWarning: logUpdaterWarning, + logError: logUpdaterError, +} = DesktopObservability.makeComponentLogger("desktop-updater"); function parseAppUpdateYml(raw: string): Effect.Effect> { const entries: Record = {}; diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index dc73aaa7e4b..5e65d81910a 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -6,6 +6,7 @@ import * as Option from "effect/Option"; import type * as Electron from "electron"; +import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; @@ -27,6 +28,10 @@ type DesktopApplicationMenuRuntimeServices = | DesktopWindow.DesktopWindow | ElectronDialog.ElectronDialog; +const { logInfo: logUpdaterInfo } = DesktopObservability.makeComponentLogger("desktop-updater"); + +const { logError: logMenuError } = DesktopObservability.makeComponentLogger("desktop-menu"); + const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function* ( action: string, ): Effect.fn.Return { @@ -71,12 +76,9 @@ const handleCheckForUpdatesMenuClick: Effect.Effect< const electronDialog = yield* ElectronDialog.ElectronDialog; const disabledReason = yield* updates.disabledReason; if (Option.isSome(disabledReason)) { - yield* Effect.logInfo("manual update check requested, but updates are disabled").pipe( - Effect.annotateLogs({ - component: "desktop-updater", - disabledReason: disabledReason.value, - }), - ); + yield* logUpdaterInfo("manual update check requested, but updates are disabled", { + disabledReason: disabledReason.value, + }); yield* electronDialog.showMessageBox({ type: "info", title: "Updates unavailable", @@ -109,12 +111,10 @@ const make = Effect.gen(function* () { Effect.annotateLogs({ action }), Effect.withSpan("desktop.menu.action"), Effect.catchCause((cause) => - Effect.logError("desktop menu action failed").pipe( - Effect.annotateLogs({ - action, - cause: Cause.pretty(cause), - }), - ), + logMenuError("desktop menu action failed", { + action, + cause: Cause.pretty(cause), + }), ), ), ); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index adb4b240620..2cf69ae8b7c 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -7,15 +7,16 @@ import * as Ref from "effect/Ref"; import type * as Electron from "electron"; +import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as DesktopState from "../app/DesktopState.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; import * as DesktopServerExposure from "../serverExposure/DesktopServerExposure.ts"; -import * as DesktopState from "../app/DesktopState.ts"; -import * as DesktopAssets from "../app/DesktopAssets.ts"; const TITLEBAR_HEIGHT = 40; const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux @@ -64,23 +65,8 @@ export class DesktopWindow extends Context.Service) => - Effect.logInfo(message).pipe( - Effect.annotateLogs({ - scope: "desktop", - component: "desktop-window", - ...annotations, - }), - ); - -const logWindowWarning = (message: string, annotations?: Record) => - Effect.logWarning(message).pipe( - Effect.annotateLogs({ - scope: "desktop", - component: "desktop-window", - ...annotations, - }), - ); +const { logInfo: logWindowInfo, logWarning: logWindowWarning } = + DesktopObservability.makeComponentLogger("desktop-window"); function resolveDesktopDevServerUrl( environment: DesktopEnvironment.DesktopEnvironmentShape,