diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4409c54c8ef..8ca8c92df5a 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: Build desktop artifact shell: bash diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index b9817552969..4029a09c350 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,9 +201,16 @@ 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") { + const passwordStore = resolveLinuxPasswordStoreSwitch({ + preference: settings.linuxPasswordStore, + env: process.env, + }); + if (passwordStore !== null) { + yield* electronApp.appendCommandLineSwitch("password-store", passwordStore); + } yield* electronApp.appendCommandLineSwitch("class", environment.linuxWmClass); } @@ -212,6 +222,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/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/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 f71ff1fbe67..de50682818c 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -101,16 +101,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()), ); diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index db6194cf8f7..bf7a1d6d2c8 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, @@ -108,9 +112,11 @@ describe("DesktopSettings", () => { tailscaleServePort: 8443, updateChannel: "latest", updateChannelConfiguredByUser: true, + linuxPasswordStore: "gnome-libsecret", }); 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..c66be44638d 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", + }, ), ); diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.ts index ec36aa4f6ef..b5aafb90712 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; @@ -240,12 +256,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) => @@ -296,7 +329,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( diff --git a/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts b/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts index 04aa11e86d1..17d3c04de94 100644 --- a/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts +++ b/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts @@ -244,6 +244,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 = [ { diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index 60c05fc217c..3983f7a5b81 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -675,22 +675,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]; } - 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; + } + 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 +847,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, @@ -1681,14 +1717,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/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; }