diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 35bcaaf3129..0488ceebc7e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -232,17 +232,32 @@ jobs: shell: pwsh run: | $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" - $installPath = & $vswhere -products * -latest -property installationPath - $setupExe = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\setup.exe" - $proc = Start-Process -FilePath $setupExe ` - -ArgumentList "modify", "--installPath", "`"$installPath`"", "--add", ` - "Microsoft.VisualStudio.Component.VC.Tools.x86.x64.Spectre", "--quiet", "--norestart" ` - -Wait -PassThru -NoNewWindow - if ($null -eq $proc -or $proc.ExitCode -ne 0) { - $code = if ($null -ne $proc) { $proc.ExitCode } else { 1 } - Write-Error "Visual Studio Installer failed with exit code $code" - exit $code - } + $installPath = & $vswhere -products * -latest -property installationPath + $setupExe = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\setup.exe" + $proc = Start-Process -FilePath $setupExe ` + -ArgumentList "modify", "--installPath", "`"$installPath`"", "--add", ` + "Microsoft.VisualStudio.Component.VC.Tools.x86.x64.Spectre", "--quiet", "--norestart" ` + -Wait -PassThru -NoNewWindow + if ($null -eq $proc -or $proc.ExitCode -ne 0) { + $code = if ($null -ne $proc) { $proc.ExitCode } else { 1 } + Write-Error "Visual Studio Installer failed with exit code $code" + exit $code + } + + - name: Install ImageMagick + if: matrix.platform == 'linux' + shell: bash + run: | + if ! command -v magick >/dev/null 2>&1 && ! command -v convert >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y imagemagick + fi + + if command -v magick >/dev/null 2>&1; then + magick -version + else + convert -version + fi - name: Prepare Azure Trusted Signing if: matrix.platform == 'win' diff --git a/AGENTS.md b/AGENTS.md index cea5090cce0..81d7fdc3566 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,12 @@ If a tradeoff is required, choose correctness and robustness over short-term con Long term maintainability is a core priority. If you add new functionality, first check if there is shared logic that can be extracted to a separate module. Duplicate logic across multiple files is a code smell and should be avoided. Don't be afraid to change existing code. Don't take shortcuts by just adding local logic to solve a problem. +## Effect + +- In Effect-owned code, prefer named `Effect.fn` helpers, `Context.Service`, and `Layer.effect` over ad hoc promises or process-wide mutable state. +- Keep `Option`, `Either`, and schema-validated values intact across internal boundaries. Convert to `null`, `undefined`, strings, or JSON only at external API, storage, or platform boundaries. +- Prefer Effect platform services for filesystem, path, timers, HTTP, and process work once inside the runtime. Direct Node or Electron APIs belong in entrypoints, tests, or narrow adapter boundaries, with explicit diagnostic suppressions when required. + ## Package Roles - `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions. diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index b9817552969..bc6215ed956 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -9,7 +9,9 @@ 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 * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; +import { resolveLinuxPasswordStoreSwitch } from "../linuxSecretStorage.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; @@ -191,6 +193,7 @@ const startup = Effect.gen(function* () { const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; + const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; const updates = yield* DesktopUpdates.DesktopUpdates; const environment = yield* DesktopEnvironment.DesktopEnvironment; @@ -198,10 +201,18 @@ const startup = Effect.gen(function* () { const userDataPath = yield* appIdentity.resolveUserDataPath; yield* electronApp.setPath("userData", userDataPath); yield* logStartupInfo("runtime logging configured", { logDir: environment.logDir }); - yield* desktopSettings.load; + const settings = yield* desktopSettings.load; if (environment.platform === "linux") { - yield* electronApp.appendCommandLineSwitch("class", environment.linuxWmClass); + const passwordStore = resolveLinuxPasswordStoreSwitch({ + preference: settings.linuxPasswordStore, + env: process.env, + }); + yield* logStartupInfo("linux password store configured", { + passwordStore: passwordStore ?? "electron-default", + xdgCurrentDesktop: process.env.XDG_CURRENT_DESKTOP ?? null, + xdgSessionDesktop: process.env.XDG_SESSION_DESKTOP ?? null, + }); } yield* appIdentity.configure; @@ -212,6 +223,16 @@ const startup = Effect.gen(function* () { Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)), ); yield* logStartupInfo("app ready"); + if (environment.platform === "linux") { + const selectedBackend = yield* safeStorage.selectedStorageBackend; + const encryptionAvailable = yield* safeStorage.isEncryptionAvailable.pipe( + Effect.catch(() => Effect.succeed(false)), + ); + yield* logStartupInfo("safe storage ready", { + backend: Option.getOrElse(selectedBackend, () => "unknown"), + encryptionAvailable, + }); + } yield* appIdentity.configure; yield* applicationMenu.configure; yield* electronProtocol.registerDesktopFileProtocol; diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts new file mode 100644 index 00000000000..435612880e1 --- /dev/null +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts @@ -0,0 +1,83 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + resolveEarlyLinuxElectronOptions, + resolveEarlyLinuxPasswordStorePreference, +} from "./DesktopEarlyElectronStartup.ts"; + +describe("DesktopEarlyElectronStartup", () => { + it("reads the persisted linux password-store preference before Electron is ready", () => { + const preference = resolveEarlyLinuxPasswordStorePreference({ + env: { T3CODE_HOME: "/home/user/.t3-test" }, + homeDirectory: "/home/user", + readFileString: (path) => { + assert.equal(path, "/home/user/.t3-test/userdata/desktop-settings.json"); + return JSON.stringify({ linuxPasswordStore: "kwallet6" }); + }, + }); + + assert.equal(preference, "kwallet6"); + }); + + it("accepts JSONC in the early desktop settings file", () => { + const preference = resolveEarlyLinuxPasswordStorePreference({ + env: { T3CODE_HOME: "/home/user/.t3-test" }, + homeDirectory: "/home/user", + readFileString: () => `{ + // manually edited setting + "linuxPasswordStore": "gnome-libsecret", + }`, + }); + + assert.equal(preference, "gnome-libsecret"); + }); + + it("falls back to auto when the early settings document is missing or invalid", () => { + const preference = resolveEarlyLinuxPasswordStorePreference({ + env: {}, + homeDirectory: "/home/user", + readFileString: () => { + throw new Error("missing"); + }, + }); + + assert.equal(preference, "auto"); + }); + + it("preserves absolute root paths when resolving early settings", () => { + const preference = resolveEarlyLinuxPasswordStorePreference({ + env: { T3CODE_HOME: "/" }, + homeDirectory: "/home/user", + readFileString: (path) => { + assert.equal(path, "/userdata/desktop-settings.json"); + return JSON.stringify({ linuxPasswordStore: "kwallet6" }); + }, + }); + + assert.equal(preference, "kwallet6"); + }); + + it("resolves the early linux Electron switches and DBus fallback", () => { + const options = resolveEarlyLinuxElectronOptions({ + env: { + T3CODE_HOME: "/home/user/.t3-test", + XDG_CURRENT_DESKTOP: "niri", + XDG_RUNTIME_DIR: "/run/user/1000", + VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", + }, + exists: (path) => path === "/run/user/1000/bus", + homeDirectory: "/home/user", + readFileString: (path) => { + assert.equal(path, "/home/user/.t3-test/dev/desktop-settings.json"); + return JSON.stringify({ linuxPasswordStore: "auto" }); + }, + uid: 1000, + }); + + assert.deepEqual(options, { + dbusSessionBusAddress: "unix:path=/run/user/1000/bus", + linuxWmClass: "t3code-dev", + passwordStore: "gnome-libsecret", + }); + }); +}); diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts new file mode 100644 index 00000000000..880fbc669c2 --- /dev/null +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts @@ -0,0 +1,105 @@ +import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { + DEFAULT_LINUX_PASSWORD_STORE, + normalizeLinuxPasswordStorePreference, + resolveLinuxPasswordStoreSwitch, + type LinuxPasswordStoreSwitch, + type LinuxPasswordStorePreference, +} from "../linuxSecretStorage.ts"; +import { resolveDefaultLinuxDbusSessionBusAddress } from "../shell/DesktopShellEnvironment.ts"; +import { resolveDesktopBaseDir, resolveDesktopStateDir } from "./DesktopStatePaths.ts"; + +interface EarlyDesktopSettingsInput { + readonly env: NodeJS.ProcessEnv; + readonly homeDirectory: string; + readonly readFileString: (path: string) => string; +} + +interface EarlyLinuxElectronOptionsInput extends EarlyDesktopSettingsInput { + readonly exists: (path: string) => boolean; + readonly uid: number | undefined; +} + +export interface EarlyLinuxElectronOptions { + readonly dbusSessionBusAddress: string | null; + readonly linuxWmClass: string; + readonly passwordStore: LinuxPasswordStoreSwitch | null; +} + +const trimNonEmpty = (value: string | undefined): string | null => { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : null; +}; + +const EarlyDesktopSettingsJson = fromLenientJson( + Schema.Struct({ + linuxPasswordStore: Schema.optionalKey(Schema.Unknown), + }), +); +const decodeEarlyDesktopSettingsJson = Schema.decodeSync(EarlyDesktopSettingsJson); + +const isDevelopmentEnvironment = (env: NodeJS.ProcessEnv): boolean => + trimNonEmpty(env.VITE_DEV_SERVER_URL) !== null; + +const joinLinuxPath = (first: string, ...segments: string[]): string => { + const normalizedFirst = first.replace(/\/+$/u, ""); + const normalizedSegments = segments.map((segment) => segment.replace(/^\/+|\/+$/gu, "")); + if (first.length > 0 && normalizedFirst.length === 0 && first.startsWith("/")) { + const normalizedPath = normalizedSegments.filter((segment) => segment.length > 0).join("/"); + return normalizedPath.length > 0 ? `/${normalizedPath}` : "/"; + } + return [normalizedFirst, ...normalizedSegments].filter((segment) => segment.length > 0).join("/"); +}; + +function resolveEarlyDesktopSettingsPath(input: { + readonly env: NodeJS.ProcessEnv; + readonly homeDirectory: string; +}): string { + const baseDir = resolveDesktopBaseDir({ + homeDirectory: input.homeDirectory, + joinPath: joinLinuxPath, + t3Home: Option.fromUndefinedOr(input.env.T3CODE_HOME), + }); + const stateDir = resolveDesktopStateDir({ + baseDir, + isDevelopment: isDevelopmentEnvironment(input.env), + joinPath: joinLinuxPath, + }); + return joinLinuxPath(stateDir, "desktop-settings.json"); +} + +export function resolveEarlyLinuxPasswordStorePreference( + input: EarlyDesktopSettingsInput, +): LinuxPasswordStorePreference { + const settingsPath = resolveEarlyDesktopSettingsPath(input); + try { + const parsed = decodeEarlyDesktopSettingsJson(input.readFileString(settingsPath)); + return normalizeLinuxPasswordStorePreference(parsed.linuxPasswordStore); + } catch { + return DEFAULT_LINUX_PASSWORD_STORE; + } +} + +export function resolveEarlyLinuxElectronOptions( + input: EarlyLinuxElectronOptionsInput, +): EarlyLinuxElectronOptions { + const preference = resolveEarlyLinuxPasswordStorePreference(input); + return { + dbusSessionBusAddress: + trimNonEmpty(input.env.DBUS_SESSION_BUS_ADDRESS) === null + ? resolveDefaultLinuxDbusSessionBusAddress({ + env: input.env, + exists: input.exists, + uid: input.uid, + }) + : null, + linuxWmClass: isDevelopmentEnvironment(input.env) ? "t3code-dev" : "t3code", + passwordStore: resolveLinuxPasswordStoreSwitch({ + preference, + env: input.env, + }), + }; +} diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index a5212f25358..7d40bc693dc 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -15,6 +15,7 @@ import { type DesktopSettings, resolveDefaultDesktopSettings, } from "../settings/DesktopAppSettings.ts"; +import { resolveDesktopBaseDir, resolveDesktopStateDir } from "./DesktopStatePaths.ts"; import * as DesktopConfig from "./DesktopConfig.ts"; import { isNightlyDesktopVersion } from "../updates/updateChannels.ts"; @@ -151,7 +152,11 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* ( : 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 baseDir = resolveDesktopBaseDir({ + homeDirectory, + joinPath: path.join, + t3Home: config.t3Home, + }); const rootDir = path.resolve(input.dirname, "../../.."); const appRoot = input.isPackaged ? input.appPath : rootDir; const branding = resolveDesktopAppBranding({ @@ -159,7 +164,7 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* ( appVersion: input.appVersion, }); const displayName = branding.displayName; - const stateDir = path.join(baseDir, isDevelopment ? "dev" : "userdata"); + const stateDir = resolveDesktopStateDir({ baseDir, isDevelopment, joinPath: path.join }); 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/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts new file mode 100644 index 00000000000..745ba9a2df7 --- /dev/null +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -0,0 +1,15 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off - pre-ready Electron setup reads persisted settings synchronously before app services are available. +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; + +import * as DesktopEarlyElectronStartup from "./DesktopEarlyElectronStartup.ts"; + +export const resolveEarlyLinuxElectronOptionsFromProcess = + (): DesktopEarlyElectronStartup.EarlyLinuxElectronOptions => + DesktopEarlyElectronStartup.resolveEarlyLinuxElectronOptions({ + env: process.env, + exists: existsSync, + homeDirectory: homedir(), + readFileString: (path) => readFileSync(path, "utf8"), + uid: process.getuid?.(), + }); diff --git a/apps/desktop/src/app/DesktopStatePaths.ts b/apps/desktop/src/app/DesktopStatePaths.ts new file mode 100644 index 00000000000..ea41254a418 --- /dev/null +++ b/apps/desktop/src/app/DesktopStatePaths.ts @@ -0,0 +1,26 @@ +import * as Option from "effect/Option"; + +export type JoinPath = (first: string, ...segments: string[]) => string; + +export function resolveDesktopBaseDir(input: { + readonly homeDirectory: string; + readonly joinPath: JoinPath; + readonly t3Home: Option.Option; +}): string { + if (Option.isSome(input.t3Home)) { + const trimmed = input.t3Home.value.trim(); + if (trimmed.length > 0) { + return trimmed; + } + } + + return input.joinPath(input.homeDirectory, ".t3"); +} + +export function resolveDesktopStateDir(input: { + readonly baseDir: string; + readonly isDevelopment: boolean; + readonly joinPath: JoinPath; +}): string { + return input.joinPath(input.baseDir, input.isDevelopment ? "dev" : "userdata"); +} diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 955813d6d35..329a87682b3 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -1,6 +1,5 @@ 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"; @@ -37,31 +36,25 @@ 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("registers desktop scheme privileges synchronously", () => { + ElectronProtocol.registerDesktopSchemePrivilegesSync(); + + 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* () { diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 32d23ba485d..d613f15271a 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -69,7 +69,10 @@ export function normalizeDesktopProtocolPathname(rawPath: string): Option.Option return Option.some(segments.join("/")); } -const registerDesktopSchemePrivileges = Effect.sync(() => { +/** + * Must run synchronously during process bootstrap, before Electron emits `ready`. + */ +export function registerDesktopSchemePrivilegesSync(): void { Electron.protocol.registerSchemesAsPrivileged([ { scheme: DESKTOP_SCHEME, @@ -81,9 +84,7 @@ const registerDesktopSchemePrivileges = Effect.sync(() => { }, }, ]); -}).pipe(Effect.withSpan("desktop.electron.protocol.registerSchemePrivileges")); - -export const layerSchemePrivileges = Layer.effectDiscard(registerDesktopSchemePrivileges); +} const resolveDesktopStaticDir: Effect.Effect< Option.Option, diff --git a/apps/desktop/src/electron/ElectronSafeStorage.ts b/apps/desktop/src/electron/ElectronSafeStorage.ts index eebb3e2b2f8..91e4d7f0c8a 100644 --- a/apps/desktop/src/electron/ElectronSafeStorage.ts +++ b/apps/desktop/src/electron/ElectronSafeStorage.ts @@ -2,6 +2,7 @@ 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 Electron from "electron"; @@ -37,6 +38,7 @@ export class ElectronSafeStorageDecryptError extends Data.TaggedError( export interface ElectronSafeStorageShape { readonly isEncryptionAvailable: Effect.Effect; + readonly selectedStorageBackend: Effect.Effect>; readonly encryptString: ( value: string, ) => Effect.Effect; @@ -55,6 +57,16 @@ const make = ElectronSafeStorage.of({ try: () => Electron.safeStorage.isEncryptionAvailable(), catch: (cause) => new ElectronSafeStorageAvailabilityError({ cause }), }), + selectedStorageBackend: Effect.sync(() => { + if (process.platform !== "linux") { + return Option.none(); + } + try { + return Option.fromNullishOr(Electron.safeStorage.getSelectedStorageBackend()); + } catch { + return Option.none(); + } + }), encryptString: (value) => Effect.try({ try: () => Electron.safeStorage.encryptString(value), diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 8717c877951..f77d7d4312c 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -5,6 +5,7 @@ import { getClientSettings, setClientSettings } from "./methods/clientSettings.t import { getSavedEnvironmentRegistry, getSavedEnvironmentSecret, + removeSavedEnvironment, removeSavedEnvironmentSecret, setSavedEnvironmentRegistry, setSavedEnvironmentSecret, @@ -52,6 +53,7 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { yield* ipc.handle(setClientSettings); yield* ipc.handle(getSavedEnvironmentRegistry); yield* ipc.handle(setSavedEnvironmentRegistry); + yield* ipc.handle(removeSavedEnvironment); yield* ipc.handle(getSavedEnvironmentSecret); yield* ipc.handle(setSavedEnvironmentSecret); yield* ipc.handle(removeSavedEnvironmentSecret); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 2715b20cb36..bde1b05c5b4 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -16,6 +16,7 @@ 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 REMOVE_SAVED_ENVIRONMENT_CHANNEL = "desktop:remove-saved-environment"; 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"; diff --git a/apps/desktop/src/ipc/methods/savedEnvironments.ts b/apps/desktop/src/ipc/methods/savedEnvironments.ts index bc5e4a9aeb2..215d4811dcd 100644 --- a/apps/desktop/src/ipc/methods/savedEnvironments.ts +++ b/apps/desktop/src/ipc/methods/savedEnvironments.ts @@ -39,6 +39,16 @@ export const setSavedEnvironmentRegistry = makeIpcMethod({ }), }); +export const removeSavedEnvironment = makeIpcMethod({ + channel: IpcChannels.REMOVE_SAVED_ENVIRONMENT_CHANNEL, + payload: EnvironmentId, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.savedEnvironments.removeEnvironment")(function* (environmentId) { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* savedEnvironments.removeEnvironment(environmentId); + }), +}); + export const getSavedEnvironmentSecret = makeIpcMethod({ channel: IpcChannels.GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, payload: EnvironmentId, diff --git a/apps/desktop/src/linuxSecretStorage.test.ts b/apps/desktop/src/linuxSecretStorage.test.ts new file mode 100644 index 00000000000..653b77a34c1 --- /dev/null +++ b/apps/desktop/src/linuxSecretStorage.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; + +import { + normalizeLinuxPasswordStorePreference, + resolveLinuxPasswordStoreSwitch, + resolveLinuxSecretStorageUnavailableMessage, +} from "./linuxSecretStorage.ts"; + +describe("linuxSecretStorage", () => { + it("preserves explicit supported password-store preferences", () => { + expect(normalizeLinuxPasswordStorePreference("gnome-libsecret")).toBe("gnome-libsecret"); + expect(normalizeLinuxPasswordStorePreference("kwallet")).toBe("kwallet"); + expect(normalizeLinuxPasswordStorePreference("kwallet5")).toBe("kwallet5"); + expect(normalizeLinuxPasswordStorePreference("kwallet6")).toBe("kwallet6"); + }); + + it("falls back to auto for missing or unsupported preferences", () => { + expect(normalizeLinuxPasswordStorePreference(undefined)).toBe("auto"); + expect(normalizeLinuxPasswordStorePreference("basic")).toBe("auto"); + }); + + it("does not force a password-store for desktops Electron already recognizes", () => { + expect( + resolveLinuxPasswordStoreSwitch({ + preference: "auto", + env: { XDG_CURRENT_DESKTOP: "GNOME" }, + }), + ).toBeNull(); + expect( + resolveLinuxPasswordStoreSwitch({ + preference: "auto", + env: { XDG_CURRENT_DESKTOP: "KDE", KDE_SESSION_VERSION: "6" }, + }), + ).toBeNull(); + }); + + it("forces gnome-libsecret for unrecognized Linux desktop sessions", () => { + expect( + resolveLinuxPasswordStoreSwitch({ + preference: "auto", + env: { XDG_CURRENT_DESKTOP: "niri" }, + }), + ).toBe("gnome-libsecret"); + }); + + it("uses explicit preferences instead of the auto heuristic", () => { + expect( + resolveLinuxPasswordStoreSwitch({ + preference: "kwallet6", + env: { XDG_CURRENT_DESKTOP: "niri" }, + }), + ).toBe("kwallet6"); + }); + + it("uses GNOME Keyring remediation for libsecret and unknown backends", () => { + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "auto", + selectedBackend: "gnome_libsecret", + env: { XDG_CURRENT_DESKTOP: "niri" }, + }), + ).toContain("GNOME Keyring"); + }); + + it("prefers explicit libsecret selection over KDE desktop heuristics", () => { + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "gnome-libsecret", + selectedBackend: "unknown", + env: { XDG_CURRENT_DESKTOP: "KDE" }, + }), + ).toContain("GNOME Keyring"); + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "auto", + selectedBackend: "gnome_libsecret", + env: { XDG_CURRENT_DESKTOP: "KDE" }, + }), + ).toContain("GNOME Keyring"); + }); + + it("uses KWallet remediation for KDE desktops and selected backends", () => { + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "auto", + selectedBackend: "kwallet6", + env: {}, + }), + ).toContain("KWallet"); + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "auto", + selectedBackend: "unknown", + env: { XDG_CURRENT_DESKTOP: "KDE" }, + }), + ).toContain("KWallet"); + }); +}); diff --git a/apps/desktop/src/linuxSecretStorage.ts b/apps/desktop/src/linuxSecretStorage.ts new file mode 100644 index 00000000000..fb67f1864dd --- /dev/null +++ b/apps/desktop/src/linuxSecretStorage.ts @@ -0,0 +1,108 @@ +export type LinuxPasswordStorePreference = + | "auto" + | "gnome-libsecret" + | "kwallet" + | "kwallet5" + | "kwallet6"; +export type LinuxPasswordStoreSwitch = Exclude; + +export const DEFAULT_LINUX_PASSWORD_STORE: LinuxPasswordStorePreference = "auto"; + +const ELECTRON_LIBSECRET_DESKTOPS = new Set([ + "deepin", + "gnome", + "pantheon", + "ukui", + "unity", + "x-cinnamon", + "xfce", +]); + +const KDE_DESKTOPS = new Set(["kde", "kde4", "kde5", "kde6", "plasma"]); + +export function normalizeLinuxPasswordStorePreference( + value: unknown, +): LinuxPasswordStorePreference { + return value === "gnome-libsecret" || + value === "kwallet" || + value === "kwallet5" || + value === "kwallet6" + ? value + : DEFAULT_LINUX_PASSWORD_STORE; +} + +export function resolveLinuxPasswordStoreSwitch(input: { + readonly preference: LinuxPasswordStorePreference; + readonly env: NodeJS.ProcessEnv; +}): LinuxPasswordStoreSwitch | null { + if (input.preference !== "auto") { + return input.preference; + } + + return isElectronKnownLinuxSecretStorageDesktop(input.env) ? null : "gnome-libsecret"; +} + +export function resolveLinuxSecretStorageUnavailableMessage(input: { + readonly configuredPreference: LinuxPasswordStorePreference; + readonly selectedBackend: string | null; + readonly env: NodeJS.ProcessEnv; +}): string { + const backend = normalizeSelectedStorageBackend(input.selectedBackend); + if (input.configuredPreference === "gnome-libsecret" || backend === "gnome-libsecret") { + return getGnomeKeyringRemediationMessage(); + } + + if ( + input.configuredPreference === "kwallet" || + input.configuredPreference === "kwallet5" || + input.configuredPreference === "kwallet6" || + backend === "kwallet" || + backend === "kwallet5" || + backend === "kwallet6" || + isKdeDesktop(input.env) + ) { + return "T3 Code could not access KWallet to save this environment credential. Enable the KDE wallet subsystem in System Settings, then restart T3 Code."; + } + + return getGnomeKeyringRemediationMessage(); +} + +function getGnomeKeyringRemediationMessage(): string { + return "T3 Code could not access GNOME Keyring to save this environment credential. Install and start GNOME Keyring, then restart T3 Code."; +} + +function isElectronKnownLinuxSecretStorageDesktop(env: NodeJS.ProcessEnv): boolean { + return resolveLinuxDesktopNames(env).some( + (name) => ELECTRON_LIBSECRET_DESKTOPS.has(name) || KDE_DESKTOPS.has(name), + ); +} + +function isKdeDesktop(env: NodeJS.ProcessEnv): boolean { + return resolveLinuxDesktopNames(env).some((name) => KDE_DESKTOPS.has(name)); +} + +function resolveLinuxDesktopNames(env: NodeJS.ProcessEnv): string[] { + return [ + ...splitDesktopNameList(env.XDG_CURRENT_DESKTOP), + env.DESKTOP_SESSION, + env.GDMSESSION, + env.KDE_SESSION_VERSION ? `kde${env.KDE_SESSION_VERSION}` : undefined, + ].flatMap((entry) => { + const normalized = normalizeDesktopName(entry); + return normalized ? [normalized] : []; + }); +} + +function splitDesktopNameList(value: string | undefined): string[] { + return value?.split(":") ?? []; +} + +function normalizeDesktopName(value: string | undefined): string | null { + const normalized = value?.trim().toLowerCase(); + return normalized && normalized.length > 0 ? normalized : null; +} + +function normalizeSelectedStorageBackend(value: string | null): string | null { + const normalized = value?.trim().toLowerCase().replace(/_/gu, "-"); + return normalized && normalized.length > 0 ? normalized : null; +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0bc1badff2d..0e20e11c7f2 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,7 +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 { homedir } from "node:os"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -37,6 +37,7 @@ import * as DesktopServerExposure from "./backend/DesktopServerExposure.ts"; import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts"; import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts"; import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts"; +import * as DesktopPreReadyPlatform from "./app/DesktopPreReadyPlatform.ts"; import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; @@ -45,6 +46,24 @@ import * as DesktopState from "./app/DesktopState.ts"; import * as DesktopUpdates from "./updates/DesktopUpdates.ts"; import * as DesktopWindow from "./window/DesktopWindow.ts"; +const configureElectronBeforeReady = Effect.sync(() => { + if (process.platform === "linux") { + const options = DesktopPreReadyPlatform.resolveEarlyLinuxElectronOptionsFromProcess(); + if (options.dbusSessionBusAddress !== null) { + process.env.DBUS_SESSION_BUS_ADDRESS = options.dbusSessionBusAddress; + } + if (options.passwordStore !== null) { + Electron.app.commandLine.appendSwitch("password-store", options.passwordStore); + } + + Electron.app.commandLine.appendSwitch("class", options.linuxWmClass); + } + + ElectronProtocol.registerDesktopSchemePrivilegesSync(); +}).pipe(Effect.withSpan("desktop.electron.configureBeforeReady")); + +const desktopElectronPreReadyLayer = Layer.effectDiscard(configureElectronBeforeReady); + const desktopEnvironmentLayer = Layer.unwrap( Effect.gen(function* () { const metadata = yield* Effect.service(ElectronApp.ElectronApp).pipe( @@ -52,7 +71,7 @@ const desktopEnvironmentLayer = Layer.unwrap( ); return DesktopEnvironment.layer({ dirname: __dirname, - homeDirectory: NodeOS.homedir(), + homeDirectory: homedir(), platform: process.platform, processArch: process.arch, ...metadata, @@ -106,16 +125,19 @@ const electronLayer = Layer.mergeAll( Layer.succeed(DesktopIpc.DesktopIpc, DesktopIpc.make(Electron.ipcMain)), ); -const desktopFoundationLayer = Layer.mergeAll( +const desktopFoundationBaseLayer = Layer.mergeAll( DesktopState.layer, DesktopLifecycle.layerShutdown, DesktopAppSettings.layer, DesktopClientSettings.layer, - DesktopSavedEnvironments.layer, DesktopAssets.layer, DesktopObservability.layer, ).pipe(Layer.provideMerge(desktopEnvironmentLayer)); +const desktopFoundationLayer = DesktopSavedEnvironments.layer.pipe( + Layer.provideMerge(desktopFoundationBaseLayer), +); + const desktopSshLayer = Layer.mergeAll(desktopSshEnvironmentLayer, DesktopSshRemoteApi.layer).pipe( Layer.provideMerge(DesktopSshPasswordPrompts.layer()), ); @@ -140,7 +162,8 @@ const desktopApplicationLayer = Layer.mergeAll( desktopSshLayer, ).pipe(Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopBackendLayer)); -const desktopRuntimeLayer = ElectronProtocol.layerSchemePrivileges.pipe( +// Electron requires these switches and scheme privileges before app ready. +const desktopRuntimeLayer = desktopElectronPreReadyLayer.pipe( Layer.flatMap(() => desktopApplicationLayer.pipe( Layer.provideMerge(NodeServices.layer), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 173be8fb54a..754b3e337dd 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -41,6 +41,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL), setSavedEnvironmentRegistry: (records) => ipcRenderer.invoke(IpcChannels.SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, records), + removeSavedEnvironment: (environmentId) => + ipcRenderer.invoke(IpcChannels.REMOVE_SAVED_ENVIRONMENT_CHANNEL, environmentId), getSavedEnvironmentSecret: (environmentId) => ipcRenderer.invoke(IpcChannels.GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), setSavedEnvironmentSecret: (environmentId, secret) => diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index db6194cf8f7..4d496103bc6 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -16,6 +16,9 @@ import { import * as DesktopAppSettings from "./DesktopAppSettings.ts"; const DesktopSettingsPatch = Schema.Struct({ + linuxPasswordStore: Schema.optionalKey( + Schema.Literals(["auto", "gnome-libsecret", "kwallet", "kwallet5", "kwallet6"]), + ), serverExposureMode: Schema.optionalKey(Schema.Literals(["local-only", "network-accessible"])), tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), tailscaleServePort: Schema.optionalKey(Schema.Number), @@ -90,6 +93,7 @@ describe("DesktopSettings", () => { it("defaults packaged nightly builds to the nightly update channel", () => { assert.deepEqual(resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), { + linuxPasswordStore: "auto", serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: 443, @@ -103,6 +107,7 @@ describe("DesktopSettings", () => { Effect.gen(function* () { const settings = yield* DesktopAppSettings.DesktopAppSettings; yield* writeSettingsPatch({ + linuxPasswordStore: "gnome-libsecret", serverExposureMode: "network-accessible", tailscaleServeEnabled: true, tailscaleServePort: 8443, @@ -111,6 +116,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + linuxPasswordStore: "gnome-libsecret", serverExposureMode: "network-accessible", tailscaleServeEnabled: true, tailscaleServePort: 8443, @@ -190,6 +196,7 @@ describe("DesktopSettings", () => { ); assert.deepEqual(yield* settings.load, { + linuxPasswordStore: "auto", serverExposureMode: "network-accessible", tailscaleServeEnabled: true, tailscaleServePort: 8443, @@ -229,6 +236,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + linuxPasswordStore: "auto", serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: 443, @@ -251,6 +259,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + linuxPasswordStore: "auto", serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: 443, @@ -272,6 +281,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + linuxPasswordStore: "auto", serverExposureMode: "local-only", tailscaleServeEnabled: true, tailscaleServePort: 443, diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 177f05a4b2b..479b8feee8b 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -18,9 +18,15 @@ import * as Schema from "effect/Schema"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { + DEFAULT_LINUX_PASSWORD_STORE, + normalizeLinuxPasswordStorePreference, + type LinuxPasswordStorePreference, +} from "../linuxSecretStorage.ts"; import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts"; export interface DesktopSettings { + readonly linuxPasswordStore: LinuxPasswordStorePreference; readonly serverExposureMode: DesktopServerExposureMode; readonly tailscaleServeEnabled: boolean; readonly tailscaleServePort: number; @@ -36,6 +42,7 @@ export interface DesktopSettingsChange { export const DEFAULT_TAILSCALE_SERVE_PORT = 443; export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { + linuxPasswordStore: DEFAULT_LINUX_PASSWORD_STORE, serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, @@ -43,7 +50,16 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { updateChannelConfiguredByUser: false, }; +const LinuxPasswordStorePreferenceSchema = Schema.Literals([ + "auto", + "gnome-libsecret", + "kwallet", + "kwallet5", + "kwallet6", +]); + const DesktopSettingsDocument = Schema.Struct({ + linuxPasswordStore: Schema.optionalKey(LinuxPasswordStorePreferenceSchema), serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema), tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), tailscaleServePort: Schema.optionalKey(Schema.Number), @@ -116,6 +132,7 @@ function normalizeDesktopSettingsDocument( (isLegacySettings && Option.contains(parsedUpdateChannel, "nightly")); return { + linuxPasswordStore: normalizeLinuxPasswordStorePreference(parsed.linuxPasswordStore), serverExposureMode: parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", tailscaleServeEnabled: parsed.tailscaleServeEnabled === true, @@ -133,6 +150,9 @@ function toDesktopSettingsDocument( ): DesktopSettingsDocument { const document: Mutable = {}; + if (settings.linuxPasswordStore !== defaults.linuxPasswordStore) { + document.linuxPasswordStore = settings.linuxPasswordStore; + } if (settings.serverExposureMode !== defaults.serverExposureMode) { document.serverExposureMode = settings.serverExposureMode; } diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts index d1d37b96e11..5285519ba70 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -10,6 +10,7 @@ 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 DesktopAppSettings from "./DesktopAppSettings.ts"; import * as DesktopSavedEnvironments from "./DesktopSavedEnvironments.ts"; const textDecoder = new TextDecoder(); @@ -43,6 +44,7 @@ function makeSafeStorageLayer(input: { readonly availabilityError?: unknown; readonly encryptError?: unknown; readonly decryptError?: unknown; + readonly selectedStorageBackend?: string; }) { return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, { isEncryptionAvailable: @@ -80,6 +82,7 @@ function makeSafeStorageLayer(input: { } return Effect.succeed(decoded.slice("enc:".length)); }, + selectedStorageBackend: Effect.succeed(Option.fromNullishOr(input.selectedStorageBackend)), } satisfies ElectronSafeStorage.ElectronSafeStorageShape); } @@ -90,12 +93,14 @@ function makeLayer( readonly availabilityError?: unknown; readonly encryptError?: unknown; readonly decryptError?: unknown; + readonly platform?: NodeJS.Platform; + readonly selectedStorageBackend?: string; }, ) { const environmentLayer = DesktopEnvironment.layer({ dirname: "/repo/apps/desktop/src", homeDirectory: baseDir, - platform: "darwin", + platform: options?.platform ?? "darwin", processArch: "x64", appVersion: "1.2.3", appPath: "/repo", @@ -116,8 +121,12 @@ function makeLayer( availabilityError: options?.availabilityError, encryptError: options?.encryptError, decryptError: options?.decryptError, + ...(options?.selectedStorageBackend === undefined + ? {} + : { selectedStorageBackend: options.selectedStorageBackend }), }), ), + Layer.provideMerge(DesktopAppSettings.layerTest()), Layer.provideMerge(NodeServices.layer), ); } @@ -129,6 +138,8 @@ const withSavedEnvironments = ( readonly availabilityError?: unknown; readonly encryptError?: unknown; readonly decryptError?: unknown; + readonly platform?: NodeJS.Platform; + readonly selectedStorageBackend?: string; }, ) => Effect.gen(function* () { @@ -215,20 +226,30 @@ describe("DesktopSavedEnvironments", () => { ), ); - it.effect("returns false when writing secrets while encryption is unavailable", () => + it.effect("surfaces remediation 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({ + const error = yield* savedEnvironments + .setSecret({ environmentId: savedRegistryRecord.environmentId, secret: "next-token", - }), + }) + .pipe(Effect.flip); + + assert.instanceOf( + error, + DesktopSavedEnvironments.DesktopSavedEnvironmentSecretUnavailableError, ); + assert.include(error.message, "GNOME Keyring"); }), - { availableSecretStorage: false }, + { + availableSecretStorage: false, + platform: "linux", + selectedStorageBackend: "gnome_libsecret", + }, ), ); @@ -272,6 +293,26 @@ describe("DesktopSavedEnvironments", () => { ), ); + it.effect("removes saved environment metadata and its embedded secret atomically", () => + withSavedEnvironments( + Effect.gen(function* () { + const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + yield* savedEnvironments.setRegistry([savedRegistryRecord]); + yield* savedEnvironments.setSecret({ + environmentId: savedRegistryRecord.environmentId, + secret: "bearer-token", + }); + + yield* savedEnvironments.removeEnvironment(savedRegistryRecord.environmentId); + + assert.deepEqual(yield* savedEnvironments.getRegistry, []); + assert.isTrue( + Option.isNone(yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId)), + ); + }), + ), + ); + it.effect("treats empty saved environment documents as empty", () => withSavedEnvironments( Effect.gen(function* () { diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.ts index ec36aa4f6ef..026d28ad80b 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.ts @@ -15,6 +15,11 @@ import * as Ref from "effect/Ref"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; +import { + resolveLinuxSecretStorageUnavailableMessage, + type LinuxPasswordStorePreference, +} from "../linuxSecretStorage.ts"; +import * as DesktopAppSettings from "./DesktopAppSettings.ts"; type PersistedSavedEnvironmentDesktopSsh = NonNullable< PersistedSavedEnvironmentRecord["desktopSsh"] @@ -91,12 +96,23 @@ export class DesktopSavedEnvironmentSecretDecodeError extends Data.TaggedError( } } +export class DesktopSavedEnvironmentSecretUnavailableError extends Data.TaggedError( + "DesktopSavedEnvironmentSecretUnavailableError", +)<{ + readonly detail: string; +}> { + override get message() { + return this.detail; + } +} + export type DesktopSavedEnvironmentsGetSecretError = | DesktopSavedEnvironmentSecretDecodeError | ElectronSafeStorage.ElectronSafeStorageAvailabilityError | ElectronSafeStorage.ElectronSafeStorageDecryptError; export type DesktopSavedEnvironmentsSetSecretError = + | DesktopSavedEnvironmentSecretUnavailableError | DesktopSavedEnvironmentsWriteError | ElectronSafeStorage.ElectronSafeStorageAvailabilityError | ElectronSafeStorage.ElectronSafeStorageEncryptError; @@ -106,6 +122,9 @@ export interface DesktopSavedEnvironmentsShape { readonly setRegistry: ( records: readonly PersistedSavedEnvironmentRecord[], ) => Effect.Effect; + readonly removeEnvironment: ( + environmentId: string, + ) => Effect.Effect; readonly getSecret: ( environmentId: string, ) => Effect.Effect, DesktopSavedEnvironmentsGetSecretError>; @@ -240,12 +259,29 @@ function decodeSecretBytes( ); } +function secretStorageUnavailableMessage(input: { + readonly platform: NodeJS.Platform; + readonly linuxPasswordStore: LinuxPasswordStorePreference; + readonly selectedBackend: Option.Option; +}): string { + if (input.platform !== "linux") { + return "Unable to persist saved environment credentials."; + } + + return resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: input.linuxPasswordStore, + selectedBackend: Option.getOrNull(input.selectedBackend), + env: process.env, + }); +} + 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 settings = yield* DesktopAppSettings.DesktopAppSettings; const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; const writeDocument = (document: SavedEnvironmentRegistryDocument) => @@ -270,6 +306,23 @@ export const layer = Layer.effect( ); yield* writeDocument(preserveExistingSecrets(currentDocument, records)); }), + removeEnvironment: Effect.fn("desktop.savedEnvironments.removeEnvironment")( + function* (environmentId) { + yield* Effect.annotateCurrentSpan({ environmentId }); + const document = yield* readRegistryDocument( + fileSystem, + environment.savedEnvironmentRegistryPath, + ); + if (!document.records.some((record) => record.environmentId === environmentId)) { + return; + } + + yield* writeDocument({ + version: document.version, + records: document.records.filter((record) => record.environmentId !== environmentId), + }); + }, + ), getSecret: Effect.fn("desktop.savedEnvironments.getSecret")(function* (environmentId) { yield* Effect.annotateCurrentSpan({ environmentId }); const document = yield* readRegistryDocument( @@ -296,7 +349,15 @@ export const layer = Layer.effect( ); if (!(yield* safeStorage.isEncryptionAvailable)) { - return false; + const desktopSettings = yield* settings.get; + const selectedBackend = yield* safeStorage.selectedStorageBackend; + return yield* new DesktopSavedEnvironmentSecretUnavailableError({ + detail: secretStorageUnavailableMessage({ + platform: environment.platform, + linuxPasswordStore: desktopSettings.linuxPasswordStore, + selectedBackend, + }), + }); } const encryptedBearerToken = Encoding.encodeBase64( @@ -362,6 +423,18 @@ export const layerTest = (input?: { return DesktopSavedEnvironments.of({ getRegistry: Ref.get(recordsRef), setRegistry: (records) => Ref.set(recordsRef, records), + removeEnvironment: (environmentId) => + Ref.update(recordsRef, (records) => + records.filter((record) => record.environmentId !== environmentId), + ).pipe( + Effect.andThen( + Ref.update(secretsRef, (secrets) => { + const nextSecrets = new Map(secrets); + nextSecrets.delete(environmentId); + return nextSecrets; + }), + ), + ), getSecret: (environmentId) => Ref.get(secretsRef).pipe( Effect.map((secrets) => Option.fromNullishOr(secrets.get(environmentId))), diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 897e7336a24..efddbf12035 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -1,5 +1,6 @@ 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 Sink from "effect/Sink"; import * as Stream from "effect/Stream"; @@ -57,6 +58,7 @@ function withProcessEnv( function runShellEnvironment(input: { readonly env: NodeJS.ProcessEnv; + readonly existingPaths?: ReadonlySet; readonly platform: NodeJS.Platform; readonly handler: (command: ChildProcess.Command) => string; }) { @@ -70,6 +72,9 @@ function runShellEnvironment(input: { ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => Effect.succeed(makeProcess(input.handler(command)))), ); + const fileSystemLayer = FileSystem.layerNoop({ + exists: (candidate) => Effect.succeed(input.existingPaths?.has(candidate) ?? false), + }); const program = Effect.gen(function* () { const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; @@ -77,7 +82,7 @@ function runShellEnvironment(input: { }).pipe( Effect.provide( DesktopShellEnvironment.layer.pipe( - Layer.provide(Layer.mergeAll(environmentLayer, spawnerLayer)), + Layer.provide(Layer.mergeAll(environmentLayer, fileSystemLayer, spawnerLayer)), ), ), ); @@ -160,6 +165,69 @@ describe("DesktopShellEnvironment", () => { }), ); + it.effect("hydrates missing DBUS_SESSION_BUS_ADDRESS from the linux runtime bus", () => + Effect.gen(function* () { + const runtimeDir = "/run/user/1234"; + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + XDG_RUNTIME_DIR: runtimeDir, + }; + + yield* runShellEnvironment({ + env, + existingPaths: new Set([`${runtimeDir}/bus`]), + platform: "linux", + handler: () => + envOutput({ + PATH: "/usr/bin", + }), + }); + + assert.equal(env.DBUS_SESSION_BUS_ADDRESS, `unix:path=${runtimeDir}/bus`); + }), + ); + + it("resolves the default linux DBus session bus from the user runtime dir", () => { + const busPaths: string[] = []; + const address = DesktopShellEnvironment.resolveDefaultLinuxDbusSessionBusAddress({ + env: {}, + exists: (busPath) => { + busPaths.push(busPath); + return true; + }, + uid: 1000, + }); + + assert.equal(address, "unix:path=/run/user/1000/bus"); + assert.deepEqual(busPaths, ["/run/user/1000/bus"]); + }); + + it.effect("preserves inherited linux DBUS_SESSION_BUS_ADDRESS", () => + Effect.gen(function* () { + const runtimeDir = "/run/user/1234"; + const env: NodeJS.ProcessEnv = { + DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/custom/bus", + SHELL: "/bin/zsh", + PATH: "/usr/bin", + XDG_RUNTIME_DIR: runtimeDir, + }; + + yield* runShellEnvironment({ + env, + existingPaths: new Set([`${runtimeDir}/bus`]), + platform: "linux", + handler: () => + envOutput({ + DBUS_SESSION_BUS_ADDRESS: `unix:path=${runtimeDir}/bus`, + PATH: "/usr/bin", + }), + }); + + assert.equal(env.DBUS_SESSION_BUS_ADDRESS, "unix:path=/run/custom/bus"); + }), + ); + it.effect("falls back to launchctl PATH on macOS when shell probing does not return one", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 358729e05ef..97bb6751a66 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -1,6 +1,7 @@ import * as Context from "effect/Context"; 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 { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -11,6 +12,7 @@ type EnvironmentPatch = Record; interface ShellEnvironmentConfig { readonly env: NodeJS.ProcessEnv; + readonly fileSystem: FileSystem.FileSystem; readonly platform: NodeJS.Platform; readonly userShell: Option.Option; } @@ -30,12 +32,19 @@ export class DesktopShellEnvironment extends Context.Service< const LOGIN_SHELL_ENV_NAMES = [ "PATH", + "DBUS_SESSION_BUS_ADDRESS", + "DISPLAY", "SSH_AUTH_SOCK", "HOMEBREW_PREFIX", "HOMEBREW_CELLAR", "HOMEBREW_REPOSITORY", "XDG_CONFIG_HOME", + "XDG_CURRENT_DESKTOP", "XDG_DATA_HOME", + "XDG_RUNTIME_DIR", + "XDG_SESSION_DESKTOP", + "XDG_SESSION_TYPE", + "WAYLAND_DISPLAY", ] 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; @@ -54,6 +63,32 @@ const pathDelimiter = (platform: NodeJS.Platform) => (platform === "win32" ? ";" const readEnvPath = (env: NodeJS.ProcessEnv): Option.Option => trimNonEmpty(env.PATH ?? env.Path ?? env.path); +const linuxRuntimeDir = (env: NodeJS.ProcessEnv, uid: number | undefined): Option.Option => + trimNonEmpty(env.XDG_RUNTIME_DIR).pipe( + Option.orElse(() => (uid === undefined ? Option.none() : Option.some(`/run/user/${uid}`))), + Option.map((value) => value.replace(/\/+$/u, "")), + Option.filter((value) => value.length > 0), + ); + +function resolveDefaultLinuxDbusSessionBusPath(input: { + readonly env: NodeJS.ProcessEnv; + readonly uid: number | undefined; +}): string | null { + const runtimeDir = linuxRuntimeDir(input.env, input.uid); + if (Option.isNone(runtimeDir)) return null; + + return `${runtimeDir.value}/bus`; +} + +export function resolveDefaultLinuxDbusSessionBusAddress(input: { + readonly env: NodeJS.ProcessEnv; + readonly exists: (path: string) => boolean; + readonly uid: number | undefined; +}): string | null { + const busPath = resolveDefaultLinuxDbusSessionBusPath(input); + return busPath !== null && input.exists(busPath) ? `unix:path=${busPath}` : null; +} + const pathComparisonKey = (entry: string, platform: NodeJS.Platform) => { const normalized = entry.trim().replace(/^"+|"+$/g, ""); return platform === "win32" ? normalized.toLowerCase() : normalized; @@ -312,16 +347,41 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix } for (const name of [ + "DBUS_SESSION_BUS_ADDRESS", + "DISPLAY", "HOMEBREW_PREFIX", "HOMEBREW_CELLAR", "HOMEBREW_REPOSITORY", "XDG_CONFIG_HOME", + "XDG_CURRENT_DESKTOP", "XDG_DATA_HOME", + "XDG_RUNTIME_DIR", + "XDG_SESSION_DESKTOP", + "XDG_SESSION_TYPE", + "WAYLAND_DISPLAY", ] as const) { if (!config.env[name] && shellEnvironment[name]) { config.env[name] = shellEnvironment[name]; } } + + if ( + config.platform === "linux" && + Option.isNone(trimNonEmpty(config.env.DBUS_SESSION_BUS_ADDRESS)) + ) { + const dbusSessionBusPath = resolveDefaultLinuxDbusSessionBusPath({ + env: config.env, + uid: process.getuid?.(), + }); + if (dbusSessionBusPath !== null) { + const busExists = yield* config.fileSystem + .exists(dbusSessionBusPath) + .pipe(Effect.orElseSucceed(() => false)); + if (busExists) { + config.env.DBUS_SESSION_BUS_ADDRESS = `unix:path=${dbusSessionBusPath}`; + } + } + } }, ); @@ -341,10 +401,12 @@ export const layer = Layer.effect( DesktopShellEnvironment, Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; return DesktopShellEnvironment.of({ installIntoProcess: installShellEnvironment({ env: process.env, + fileSystem, platform: environment.platform, userShell: Option.none(), }).pipe( diff --git a/apps/desktop/src/ssh/DesktopSshRemoteApi.test.ts b/apps/desktop/src/ssh/DesktopSshRemoteApi.test.ts index 8b6798d38cb..38db2771f2d 100644 --- a/apps/desktop/src/ssh/DesktopSshRemoteApi.test.ts +++ b/apps/desktop/src/ssh/DesktopSshRemoteApi.test.ts @@ -1,11 +1,17 @@ import { assert, describe, it } from "@effect/vitest"; +import { AuthBearerBootstrapResult } from "@t3tools/contracts"; import { SshHttpBridgeError } from "@t3tools/ssh/errors"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import * as DesktopSshRemoteApi from "./DesktopSshRemoteApi.ts"; +const encodeAuthBearerBootstrapResult = Schema.encodeUnknownEffect(AuthBearerBootstrapResult); +const encodeUnknownJsonString = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString); + function jsonResponse(request: HttpClientRequest.HttpClientRequest, body: unknown, status = 200) { return HttpClientResponse.fromWeb( request, @@ -58,6 +64,111 @@ describe("DesktopSshRemoteApi", () => { }).pipe(Effect.provide(layer)); }); + it.effect("decodes bearer bootstrap JSON dates from SSH HTTP responses", () => { + const layer = makeLayer((request) => + Effect.sync(() => + jsonResponse(request, { + authenticated: true, + role: "owner", + sessionMethod: "bearer-session-token", + expiresAt: "2026-06-07T19:31:50.534Z", + sessionToken: "bearer-token", + }), + ), + ); + + return Effect.gen(function* () { + const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; + const session = yield* remoteApi.bootstrapBearerSession({ + httpBaseUrl: "http://127.0.0.1:41773/", + credential: "pairing-token", + }); + + assert.equal(session.sessionToken, "bearer-token"); + assert.isTrue(DateTime.isDateTime(session.expiresAt)); + assert.equal(DateTime.formatIso(session.expiresAt), "2026-06-07T19:31:50.534Z"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("decodes session state JSON dates from SSH HTTP responses", () => { + const layer = makeLayer((request) => + Effect.sync(() => + jsonResponse(request, { + authenticated: true, + auth: { + policy: "remote-reachable", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["bearer-session-token"], + sessionCookieName: "t3_session", + }, + role: "client", + sessionMethod: "bearer-session-token", + expiresAt: "2026-06-07T19:31:50.534Z", + }), + ), + ); + + return Effect.gen(function* () { + const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; + const session = yield* remoteApi.fetchSessionState({ + httpBaseUrl: "http://127.0.0.1:41773/", + bearerToken: "bearer-token", + }); + + assert.equal(session.role, "client"); + assert.isTrue(session.expiresAt ? DateTime.isDateTime(session.expiresAt) : false); + assert.equal( + session.expiresAt ? DateTime.formatIso(session.expiresAt) : null, + "2026-06-07T19:31:50.534Z", + ); + }).pipe(Effect.provide(layer)); + }); + + it.effect("decodes websocket token JSON dates from SSH HTTP responses", () => { + const layer = makeLayer((request) => + Effect.sync(() => + jsonResponse(request, { + token: "websocket-token", + expiresAt: "2026-06-07T19:31:50.534Z", + }), + ), + ); + + return Effect.gen(function* () { + const remoteApi = yield* DesktopSshRemoteApi.DesktopSshRemoteApi; + const token = yield* remoteApi.issueWebSocketToken({ + httpBaseUrl: "http://127.0.0.1:41773/", + bearerToken: "bearer-token", + }); + + assert.equal(token.token, "websocket-token"); + assert.isTrue(DateTime.isDateTime(token.expiresAt)); + assert.equal(DateTime.formatIso(token.expiresAt), "2026-06-07T19:31:50.534Z"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("encodes bearer bootstrap dates as JSON-safe strings for IPC", () => + Effect.gen(function* () { + const encoded = yield* encodeAuthBearerBootstrapResult({ + authenticated: true, + role: "owner", + sessionMethod: "bearer-session-token", + expiresAt: DateTime.makeUnsafe("2026-06-07T19:31:50.534Z"), + sessionToken: "bearer-token", + }); + + assert.deepEqual(encoded, { + authenticated: true, + role: "owner", + sessionMethod: "bearer-session-token", + expiresAt: "2026-06-07T19:31:50.534Z", + sessionToken: "bearer-token", + }); + const json = yield* encodeUnknownJsonString(encoded); + assert.equal(json.includes('"expiresAt":"2026-06-07T19:31:50.534Z"'), true); + }), + ); + it.effect("wraps schema decode failures in a typed remote api error", () => { const layer = makeLayer((request) => Effect.succeed(jsonResponse(request, { environmentId: "remote-env" })), diff --git a/apps/web/src/clientPersistenceStorage.ts b/apps/web/src/clientPersistenceStorage.ts index 30c949b37ac..96e569322f6 100644 --- a/apps/web/src/clientPersistenceStorage.ts +++ b/apps/web/src/clientPersistenceStorage.ts @@ -152,6 +152,14 @@ export function writeBrowserSavedEnvironmentRegistry( ); } +export function removeBrowserSavedEnvironment(environmentId: EnvironmentIdValue): void { + const document = readBrowserSavedEnvironmentRegistryDocument(); + writeBrowserSavedEnvironmentRegistryDocument({ + version: document.version ?? 1, + records: (document.records ?? []).filter((record) => record.environmentId !== environmentId), + }); +} + export function readBrowserSavedEnvironmentSecret( environmentId: EnvironmentIdValue, ): string | null { diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 4abefc5425b..37ff585dcec 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -352,6 +352,7 @@ const createDesktopBridgeStub = (overrides?: { setClientSettings: vi.fn().mockResolvedValue(undefined), getSavedEnvironmentRegistry: vi.fn().mockResolvedValue([]), setSavedEnvironmentRegistry: vi.fn().mockResolvedValue(undefined), + removeSavedEnvironment: vi.fn().mockResolvedValue(undefined), getSavedEnvironmentSecret: vi.fn().mockResolvedValue(null), setSavedEnvironmentSecret: vi.fn().mockResolvedValue(true), removeSavedEnvironmentSecret: vi.fn().mockResolvedValue(undefined), diff --git a/apps/web/src/environments/runtime/catalog.test.ts b/apps/web/src/environments/runtime/catalog.test.ts index f078129463a..7f60a6fe88c 100644 --- a/apps/web/src/environments/runtime/catalog.test.ts +++ b/apps/web/src/environments/runtime/catalog.test.ts @@ -22,6 +22,7 @@ describe("environment runtime catalog stores", () => { setClientSettings: async () => undefined, getSavedEnvironmentRegistry: async () => [], setSavedEnvironmentRegistry: async () => undefined, + removeSavedEnvironment: async () => undefined, getSavedEnvironmentSecret: async () => null, setSavedEnvironmentSecret: async () => true, removeSavedEnvironmentSecret: async () => undefined, @@ -109,6 +110,7 @@ describe("environment runtime catalog stores", () => { resolveRegistryRead = () => resolve([]); }), setSavedEnvironmentRegistry: async () => undefined, + removeSavedEnvironment: async () => undefined, getSavedEnvironmentSecret: async () => null, setSavedEnvironmentSecret: async () => true, removeSavedEnvironmentSecret: async () => undefined, diff --git a/apps/web/src/environments/runtime/catalog.ts b/apps/web/src/environments/runtime/catalog.ts index 7ece1ccb0ca..9851ab66e1a 100644 --- a/apps/web/src/environments/runtime/catalog.ts +++ b/apps/web/src/environments/runtime/catalog.ts @@ -244,6 +244,10 @@ export async function persistSavedEnvironmentRecord(record: SavedEnvironmentReco ); } +export async function removePersistedSavedEnvironment(environmentId: EnvironmentId): Promise { + await ensureLocalApi().persistence.removeSavedEnvironment(environmentId); +} + export async function readSavedEnvironmentBearerToken( environmentId: EnvironmentId, ): Promise { diff --git a/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts b/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts index 04aa11e86d1..0494d882d9e 100644 --- a/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts +++ b/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts @@ -12,6 +12,7 @@ const mockResolveRemoteWebSocketConnectionUrl = vi.fn(); const mockBootstrapSshBearerSession = vi.fn(); const mockFetchSshSessionState = vi.fn(); const mockPersistSavedEnvironmentRecord = vi.fn(); +const mockRemovePersistedSavedEnvironment = vi.fn(); const mockWriteSavedEnvironmentBearerToken = vi.fn(); const mockSetSavedEnvironmentRegistry = vi.fn(); const mockGetSavedEnvironmentRecord = vi.fn((environmentId: EnvironmentId) => { @@ -21,9 +22,21 @@ const mockReadSavedEnvironmentBearerToken = vi.fn(); const mockRemoveSavedEnvironmentBearerToken = vi.fn(); const mockPatchRuntime = vi.fn(); const mockClearRuntime = vi.fn(); -const mockRegistrySetState = vi.fn((next: { byId: Record> }) => { - mockSavedRecords = Object.values(next.byId); -}); +const mockRegistrySetState = vi.fn( + ( + next: + | { byId: Record> } + | ((state: { byId: Record> }) => { + byId: Record>; + }), + ) => { + const current = Object.fromEntries( + mockSavedRecords.map((record) => [record.environmentId, record]), + ) as Record>; + const resolved = typeof next === "function" ? next({ byId: current }) : next; + mockSavedRecords = Object.values(resolved.byId); + }, +); const mockRemove = vi.fn((environmentId: EnvironmentId) => { mockSavedRecords = mockSavedRecords.filter((record) => record.environmentId !== environmentId); }); @@ -72,6 +85,7 @@ vi.mock("~/localApi", () => ({ ensureLocalApi: () => ({ persistence: { setSavedEnvironmentRegistry: mockSetSavedEnvironmentRegistry, + removeSavedEnvironment: mockRemovePersistedSavedEnvironment, }, }), })); @@ -82,6 +96,7 @@ vi.mock("./catalog", () => ({ listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, persistSavedEnvironmentRecord: mockPersistSavedEnvironmentRecord, readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken, + removePersistedSavedEnvironment: mockRemovePersistedSavedEnvironment, removeSavedEnvironmentBearerToken: mockRemoveSavedEnvironmentBearerToken, toPersistedSavedEnvironmentRecord: mockToPersistedSavedEnvironmentRecord, useSavedEnvironmentRegistryStore: { @@ -183,6 +198,7 @@ describe("addSavedEnvironment", () => { role: "owner", }); mockPersistSavedEnvironmentRecord.mockResolvedValue(undefined); + mockRemovePersistedSavedEnvironment.mockResolvedValue(undefined); mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false); mockSetSavedEnvironmentRegistry.mockResolvedValue(undefined); mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); @@ -244,6 +260,47 @@ describe("addSavedEnvironment", () => { await resetEnvironmentServiceForTests(); }); + it("preserves credential persistence error details during rollback", async () => { + mockWriteSavedEnvironmentBearerToken.mockRejectedValue( + new Error("T3 Code could not access GNOME Keyring to save this environment credential."), + ); + const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); + + await expect( + addSavedEnvironment({ + label: "Remote environment", + host: "remote.example.com", + pairingCode: "123456", + }), + ).rejects.toThrow("T3 Code could not access GNOME Keyring"); + + expect(mockSetSavedEnvironmentRegistry).toHaveBeenCalledWith([]); + expect(mockUpsert).not.toHaveBeenCalled(); + + await resetEnvironmentServiceForTests(); + }); + + it("preserves credential persistence error details when rollback fails", async () => { + mockWriteSavedEnvironmentBearerToken.mockRejectedValue( + new Error("T3 Code could not access GNOME Keyring to save this environment credential."), + ); + mockSetSavedEnvironmentRegistry.mockRejectedValue(new Error("Registry rollback failed.")); + const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); + + await expect( + addSavedEnvironment({ + label: "Remote environment", + host: "remote.example.com", + pairingCode: "123456", + }), + ).rejects.toThrow("T3 Code could not access GNOME Keyring"); + + expect(mockSetSavedEnvironmentRegistry).toHaveBeenCalledWith([]); + expect(mockUpsert).not.toHaveBeenCalled(); + + await resetEnvironmentServiceForTests(); + }); + it("restores unrelated saved environments when credential persistence rollback runs", async () => { mockSavedRecords = [ { @@ -356,10 +413,10 @@ describe("addSavedEnvironment", () => { environmentId: EnvironmentId.make("environment-2"), }), ); - expect(mockRemove).toHaveBeenCalledWith(EnvironmentId.make("environment-1")); - expect(mockRemoveSavedEnvironmentBearerToken).toHaveBeenCalledWith( + expect(mockRemovePersistedSavedEnvironment).toHaveBeenCalledWith( EnvironmentId.make("environment-1"), ); + expect(mockRemoveSavedEnvironmentBearerToken).not.toHaveBeenCalled(); await resetEnvironmentServiceForTests(); }); @@ -602,7 +659,7 @@ describe("addSavedEnvironment", () => { await resetEnvironmentServiceForTests(); }); - it("disconnects the desktop ssh process before removing a saved ssh environment", async () => { + it("removes a saved ssh environment before cleaning up the desktop ssh process", async () => { mockSavedRecords = [ { environmentId: EnvironmentId.make("environment-1"), @@ -630,17 +687,126 @@ describe("addSavedEnvironment", () => { username: "julius", port: 22, }); - expect(mockRemove).toHaveBeenCalledWith(EnvironmentId.make("environment-1")); - expect(mockRemoveSavedEnvironmentBearerToken).toHaveBeenCalledWith( + expect(mockRemovePersistedSavedEnvironment).toHaveBeenCalledWith( EnvironmentId.make("environment-1"), ); - expect(mockDisconnectSshEnvironment.mock.invocationCallOrder[0]).toBeLessThan( - mockRemove.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + expect(mockRemoveSavedEnvironmentBearerToken).not.toHaveBeenCalled(); + expect(mockSavedRecords).toEqual([]); + expect(mockRemovePersistedSavedEnvironment.mock.invocationCallOrder[0]).toBeLessThan( + mockDisconnectSshEnvironment.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, ); await resetEnvironmentServiceForTests(); }); + it("does not wait for desktop ssh cleanup while removing a saved ssh environment", async () => { + mockSavedRecords = [ + { + environmentId: EnvironmentId.make("environment-1"), + label: "Remote environment", + httpBaseUrl: "http://127.0.0.1:3774/", + wsBaseUrl: "ws://127.0.0.1:3774/", + createdAt: "2026-04-14T00:00:00.000Z", + lastConnectedAt: null, + desktopSsh: { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 22, + }, + }, + ]; + mockDisconnectSshEnvironment.mockReturnValue(new Promise(() => undefined)); + + const { removeSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); + + await expect(removeSavedEnvironment(EnvironmentId.make("environment-1"))).resolves.toBe( + undefined, + ); + + expect(mockDisconnectSshEnvironment).toHaveBeenCalledWith({ + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 22, + }); + expect(mockRemovePersistedSavedEnvironment).toHaveBeenCalledWith( + EnvironmentId.make("environment-1"), + ); + expect(mockSavedRecords).toEqual([]); + + await resetEnvironmentServiceForTests(); + }); + + it("logs desktop ssh cleanup failures after removing a saved ssh environment", async () => { + mockSavedRecords = [ + { + environmentId: EnvironmentId.make("environment-1"), + label: "Remote environment", + httpBaseUrl: "http://127.0.0.1:3774/", + wsBaseUrl: "ws://127.0.0.1:3774/", + createdAt: "2026-04-14T00:00:00.000Z", + lastConnectedAt: null, + desktopSsh: { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 22, + }, + }, + ]; + const cleanupError = new Error("cleanup failed"); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + mockDisconnectSshEnvironment.mockRejectedValue(cleanupError); + + const { removeSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); + + await removeSavedEnvironment(EnvironmentId.make("environment-1")); + await Promise.resolve(); + + expect(warn).toHaveBeenCalledWith( + "[SAVED_ENVIRONMENTS] SSH cleanup after removal failed", + cleanupError, + ); + + warn.mockRestore(); + await resetEnvironmentServiceForTests(); + }); + + it("removes a saved ssh environment when the desktop bridge is unavailable", async () => { + mockSavedRecords = [ + { + environmentId: EnvironmentId.make("environment-1"), + label: "Remote environment", + httpBaseUrl: "http://127.0.0.1:3774/", + wsBaseUrl: "ws://127.0.0.1:3774/", + createdAt: "2026-04-14T00:00:00.000Z", + lastConnectedAt: null, + desktopSsh: { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 22, + }, + }, + ]; + vi.stubGlobal("window", {}); + + const { removeSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); + + await expect(removeSavedEnvironment(EnvironmentId.make("environment-1"))).resolves.toBe( + undefined, + ); + + expect(mockRemovePersistedSavedEnvironment).toHaveBeenCalledWith( + EnvironmentId.make("environment-1"), + ); + expect(mockDisconnectSshEnvironment).not.toHaveBeenCalled(); + expect(mockSavedRecords).toEqual([]); + + await resetEnvironmentServiceForTests(); + }); + it("disconnects a saved ssh environment without removing its saved record", async () => { mockSavedRecords = [ { diff --git a/apps/web/src/environments/runtime/service.savedEnvironments.test.ts b/apps/web/src/environments/runtime/service.savedEnvironments.test.ts index b1d59bb49e1..0517f3fce7b 100644 --- a/apps/web/src/environments/runtime/service.savedEnvironments.test.ts +++ b/apps/web/src/environments/runtime/service.savedEnvironments.test.ts @@ -89,6 +89,7 @@ vi.mock("~/localApi", () => ({ ensureLocalApi: vi.fn(() => ({ persistence: { setSavedEnvironmentRegistry: vi.fn(async () => undefined), + removeSavedEnvironment: vi.fn(async () => undefined), }, })), })); diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index 60c05fc217c..e66f0baa834 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -45,6 +45,7 @@ import { listSavedEnvironmentRecords, persistSavedEnvironmentRecord, readSavedEnvironmentBearerToken, + removePersistedSavedEnvironment, removeSavedEnvironmentBearerToken, type SavedEnvironmentRecord, toPersistedSavedEnvironmentRecord, @@ -675,22 +676,61 @@ function snapshotSavedEnvironmentRegistry( async function persistSavedEnvironmentRegistryRollback( snapshot: SavedEnvironmentRegistrySnapshot, + primaryError?: unknown, ): Promise { - const byId = buildSavedEnvironmentRegistryById(listSavedEnvironmentRecords()); - for (const [environmentId, record] of snapshot) { - if (record) { - byId[environmentId] = record; - continue; + try { + const byId = buildSavedEnvironmentRegistryById(listSavedEnvironmentRecords()); + for (const [environmentId, record] of snapshot) { + if (record) { + byId[environmentId] = record; + continue; + } + delete byId[environmentId]; + } + const records = Object.values(byId); + await ensureLocalApi().persistence.setSavedEnvironmentRegistry( + records.map((entry) => toPersistedSavedEnvironmentRecord(entry)), + ); + useSavedEnvironmentRegistryStore.setState({ + byId, + }); + } catch (rollbackError) { + if (primaryError === undefined) { + throw rollbackError; } - delete byId[environmentId]; + const message = + primaryError instanceof Error && primaryError.message.trim().length > 0 + ? primaryError.message + : String(primaryError); + const error = new Error(message, { + cause: rollbackError, + }); + Object.assign(error, { errors: [primaryError, rollbackError] }); + throw error; + } +} + +async function persistSavedEnvironmentBearerTokenOrRollback(input: { + readonly environmentId: EnvironmentId; + readonly bearerToken: string; + readonly registrySnapshot: SavedEnvironmentRegistrySnapshot; +}): Promise { + let didPersistBearerToken = false; + try { + didPersistBearerToken = await writeSavedEnvironmentBearerToken( + input.environmentId, + input.bearerToken, + ); + } catch (error) { + await persistSavedEnvironmentRegistryRollback(input.registrySnapshot, error); + throw error; + } + + if (!didPersistBearerToken) { + const error = new Error("Unable to persist saved environment credentials."); + await persistSavedEnvironmentRegistryRollback(input.registrySnapshot, error); + throw error; } - const records = Object.values(byId); - await ensureLocalApi().persistence.setSavedEnvironmentRegistry( - records.map((entry) => toPersistedSavedEnvironmentRecord(entry)), - ); - useSavedEnvironmentRegistryStore.setState({ - byId, - }); } async function resolveDesktopSshEnvironmentBootstrap( @@ -808,14 +848,11 @@ async function issueDesktopSshBearerSession(record: SavedEnvironmentRecord): Pro const message = error instanceof Error ? error.message : String(error); throw new Error(`${message} (${detail})`); }); - const didPersistBearerToken = await writeSavedEnvironmentBearerToken( - prepared.record.environmentId, - bearerSession.sessionToken, - ); - if (!didPersistBearerToken) { - await persistSavedEnvironmentRegistryRollback(registrySnapshot); - throw new Error("Unable to persist saved environment credentials."); - } + await persistSavedEnvironmentBearerTokenOrRollback({ + environmentId: prepared.record.environmentId, + bearerToken: bearerSession.sessionToken, + registrySnapshot, + }); return { record: prepared.record, @@ -1555,7 +1592,9 @@ export function getPrimaryEnvironmentConnection(): EnvironmentConnection { return createPrimaryEnvironmentConnection(); } -export async function disconnectSavedEnvironment(environmentId: EnvironmentId): Promise { +async function detachSavedEnvironment( + environmentId: EnvironmentId, +): Promise { const record = getSavedEnvironmentRecord(environmentId); const pendingConnection = pendingSavedEnvironmentConnections.get(environmentId); if (pendingConnection) { @@ -1569,6 +1608,12 @@ export async function disconnectSavedEnvironment(environmentId: EnvironmentId): } setRuntimeDisconnected(environmentId); + return record; +} + +export async function disconnectSavedEnvironment(environmentId: EnvironmentId): Promise { + const record = await detachSavedEnvironment(environmentId); + if (record?.desktopSsh && typeof window !== "undefined") { await window.desktopBridge?.disconnectSshEnvironment(record.desktopSsh); await removeSavedEnvironmentBearerToken(environmentId); @@ -1628,12 +1673,21 @@ export async function reconnectSavedEnvironment(environmentId: EnvironmentId): P } export async function removeSavedEnvironment(environmentId: EnvironmentId): Promise { - await disconnectSavedEnvironment(environmentId); + const record = await detachSavedEnvironment(environmentId); disposeThreadDetailSubscriptionsForEnvironment(environmentId); - useSavedEnvironmentRegistryStore.getState().remove(environmentId); + await removePersistedSavedEnvironment(environmentId); + useSavedEnvironmentRegistryStore.setState((state) => { + const { [environmentId]: _removed, ...byId } = state.byId; + return { byId }; + }); useSavedEnvironmentRuntimeStore.getState().clear(environmentId); useStore.getState().removeEnvironmentState(environmentId); - await removeSavedEnvironmentBearerToken(environmentId); + + if (record?.desktopSsh && typeof window !== "undefined") { + void window.desktopBridge?.disconnectSshEnvironment(record.desktopSsh)?.catch((error) => { + console.warn("[SAVED_ENVIRONMENTS] SSH cleanup after removal failed", error); + }); + } } export async function addSavedEnvironment(input: { @@ -1681,14 +1735,11 @@ export async function addSavedEnvironment(input: { }; await persistSavedEnvironmentRecord(record); - const didPersistBearerToken = await writeSavedEnvironmentBearerToken( + await persistSavedEnvironmentBearerTokenOrRollback({ environmentId, - bearerSession.sessionToken, - ); - if (!didPersistBearerToken) { - await persistSavedEnvironmentRegistryRollback(registrySnapshot); - throw new Error("Unable to persist saved environment credentials."); - } + bearerToken: bearerSession.sessionToken, + registrySnapshot, + }); useSavedEnvironmentRegistryStore.getState().upsert(record); if (staleDesktopSshRecord) { await removeSavedEnvironment(staleDesktopSshRecord.environmentId); diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 8bfb0e599ad..3ab2268ec65 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -176,6 +176,7 @@ function makeDesktopBridge(overrides: Partial = {}): DesktopBridg setClientSettings: async () => undefined, getSavedEnvironmentRegistry: async () => [], setSavedEnvironmentRegistry: async () => undefined, + removeSavedEnvironment: async () => undefined, getSavedEnvironmentSecret: async () => null, setSavedEnvironmentSecret: async () => true, removeSavedEnvironmentSecret: async () => undefined, @@ -622,6 +623,7 @@ describe("wsApi", () => { const setClientSettings = vi.fn().mockResolvedValue(undefined); const getSavedEnvironmentRegistry = vi.fn().mockResolvedValue([]); const setSavedEnvironmentRegistry = vi.fn().mockResolvedValue(undefined); + const removeSavedEnvironment = vi.fn().mockResolvedValue(undefined); const getSavedEnvironmentSecret = vi.fn().mockResolvedValue("bearer-token"); const setSavedEnvironmentSecret = vi.fn().mockResolvedValue(true); const removeSavedEnvironmentSecret = vi.fn().mockResolvedValue(undefined); @@ -630,6 +632,7 @@ describe("wsApi", () => { setClientSettings, getSavedEnvironmentRegistry, setSavedEnvironmentRegistry, + removeSavedEnvironment, getSavedEnvironmentSecret, setSavedEnvironmentSecret, removeSavedEnvironmentSecret, @@ -642,6 +645,7 @@ describe("wsApi", () => { await api.persistence.setClientSettings(clientSettings); await api.persistence.getSavedEnvironmentRegistry(); await api.persistence.setSavedEnvironmentRegistry([]); + await api.persistence.removeSavedEnvironment(EnvironmentId.make("environment-local")); await api.persistence.getSavedEnvironmentSecret(EnvironmentId.make("environment-local")); await api.persistence.setSavedEnvironmentSecret( EnvironmentId.make("environment-local"), @@ -653,6 +657,7 @@ describe("wsApi", () => { expect(setClientSettings).toHaveBeenCalledWith(clientSettings); expect(getSavedEnvironmentRegistry).toHaveBeenCalledWith(); expect(setSavedEnvironmentRegistry).toHaveBeenCalledWith([]); + expect(removeSavedEnvironment).toHaveBeenCalledWith("environment-local"); expect(getSavedEnvironmentSecret).toHaveBeenCalledWith("environment-local"); expect(setSavedEnvironmentSecret).toHaveBeenCalledWith("environment-local", "bearer-token"); expect(removeSavedEnvironmentSecret).toHaveBeenCalledWith("environment-local"); @@ -716,5 +721,9 @@ describe("wsApi", () => { await expect( api.persistence.getSavedEnvironmentSecret(EnvironmentId.make("environment-local")), ).resolves.toBeNull(); + + await api.persistence.removeSavedEnvironment(EnvironmentId.make("environment-local")); + + await expect(api.persistence.getSavedEnvironmentRegistry()).resolves.toEqual([]); }); }); diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index cbb3427b004..0616eaa730c 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -20,6 +20,7 @@ import { readBrowserClientSettings, readBrowserSavedEnvironmentRegistry, readBrowserSavedEnvironmentSecret, + removeBrowserSavedEnvironment, removeBrowserSavedEnvironmentSecret, writeBrowserClientSettings, writeBrowserSavedEnvironmentRegistry, @@ -99,6 +100,12 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { } writeBrowserSavedEnvironmentRegistry(records); }, + removeSavedEnvironment: async (environmentId) => { + if (window.desktopBridge) { + return window.desktopBridge.removeSavedEnvironment(environmentId); + } + removeBrowserSavedEnvironment(environmentId); + }, getSavedEnvironmentSecret: async (environmentId) => { if (window.desktopBridge) { return window.desktopBridge.getSavedEnvironmentSecret(environmentId); diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 8439d12b069..dbf8d0ba3cb 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -109,7 +109,7 @@ export const AuthBootstrapResult = Schema.Struct({ authenticated: Schema.Literal(true), role: AuthSessionRole, sessionMethod: ServerAuthSessionMethod, - expiresAt: Schema.DateTimeUtc, + expiresAt: Schema.DateTimeUtcFromString, }); export type AuthBootstrapResult = typeof AuthBootstrapResult.Type; @@ -117,14 +117,14 @@ export const AuthBearerBootstrapResult = Schema.Struct({ authenticated: Schema.Literal(true), role: AuthSessionRole, sessionMethod: Schema.Literal("bearer-session-token"), - expiresAt: Schema.DateTimeUtc, + expiresAt: Schema.DateTimeUtcFromString, sessionToken: TrimmedNonEmptyString, }); export type AuthBearerBootstrapResult = typeof AuthBearerBootstrapResult.Type; export const AuthWebSocketTokenResult = Schema.Struct({ token: TrimmedNonEmptyString, - expiresAt: Schema.DateTimeUtc, + expiresAt: Schema.DateTimeUtcFromString, }); export type AuthWebSocketTokenResult = typeof AuthWebSocketTokenResult.Type; @@ -261,6 +261,6 @@ export const AuthSessionState = Schema.Struct({ auth: ServerAuthDescriptor, role: Schema.optionalKey(AuthSessionRole), sessionMethod: Schema.optionalKey(ServerAuthSessionMethod), - expiresAt: Schema.optionalKey(Schema.DateTimeUtc), + expiresAt: Schema.optionalKey(Schema.DateTimeUtcFromString), }); export type AuthSessionState = typeof AuthSessionState.Type; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 58894adac1a..a7a00acc137 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -376,6 +376,7 @@ export interface DesktopBridge { setSavedEnvironmentRegistry: ( records: readonly PersistedSavedEnvironmentRecord[], ) => Promise; + removeSavedEnvironment: (environmentId: EnvironmentId) => Promise; getSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise; setSavedEnvironmentSecret: (environmentId: EnvironmentId, secret: string) => Promise; removeSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise; @@ -453,6 +454,7 @@ export interface LocalApi { setSavedEnvironmentRegistry: ( records: readonly PersistedSavedEnvironmentRecord[], ) => Promise; + removeSavedEnvironment: (environmentId: EnvironmentId) => Promise; getSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise; setSavedEnvironmentSecret: (environmentId: EnvironmentId, secret: string) => Promise; removeSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise; diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index f395e08f35b..b7a1276e833 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -23,6 +23,8 @@ import * as Stream from "effect/Stream"; import { Command, Flag } from "effect/unstable/cli"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +const LINUX_ICON_SIZES = [16, 22, 24, 32, 48, 64, 128, 256, 512] as const; + const BuildPlatform = Schema.Literals(["mac", "linux", "win"]); const BuildArch = Schema.Literals(["arm64", "x64", "universal"]); @@ -417,7 +419,7 @@ function stageMacIcons(stageResourcesDir: string, sourcePng: string, verbose: bo }); } -function stageLinuxIcons(stageResourcesDir: string, sourcePng: string) { +function stageLinuxIcons(stageResourcesDir: string, sourcePng: string, verbose: boolean) { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -429,9 +431,48 @@ function stageLinuxIcons(stageResourcesDir: string, sourcePng: string) { const iconPath = path.join(stageResourcesDir, "icon.png"); yield* fs.copyFile(sourcePng, iconPath); + + const iconsDir = path.join(stageResourcesDir, "icons"); + yield* fs.makeDirectory(iconsDir, { recursive: true }); + for (const iconSize of LINUX_ICON_SIZES) { + yield* stageLinuxIconSize( + sourcePng, + path.join(iconsDir, `${iconSize}x${iconSize}.png`), + iconSize, + verbose, + ); + } }); } +function stageLinuxIconSize( + sourcePng: string, + targetPng: string, + iconSize: number, + verbose: boolean, +) { + const resize = (command: string) => + runCommand( + ChildProcess.make(command, [sourcePng, "-resize", `${iconSize}x${iconSize}`, targetPng], { + ...commandOutputOptions(verbose), + }), + ); + + return resize("magick").pipe( + Effect.catch(() => + resize("convert").pipe( + Effect.mapError( + () => + new BuildScriptError({ + message: + "ImageMagick is required to generate Linux desktop icon sizes. Install ImageMagick so either `magick` or `convert` is available.", + }), + ), + ), + ), + ); +} + function stageWindowsIcons(stageResourcesDir: string, sourceIco: string) { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -597,7 +638,7 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( buildConfig.linux = { target: [target], executableName: "t3code", - icon: "icon.png", + icon: "icons", category: "Development", desktop: { entry: { @@ -636,7 +677,7 @@ const assertPlatformBuildResources = Effect.fn("assertPlatformBuildResources")(f } if (platform === "linux") { - yield* stageLinuxIcons(stageResourcesDir, iconAssets.linuxIconPng); + yield* stageLinuxIcons(stageResourcesDir, iconAssets.linuxIconPng, verbose); return; }