From e739ccb452875e4ae821e2d6878ddbd2df72e22c Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Sun, 21 Jun 2026 17:01:18 -0400 Subject: [PATCH] fix(desktop): Select Linux secret storage backend Address Bugbot review: authoritative desktop hints and shell DBUS override --- apps/desktop/src/app/DesktopApp.ts | 40 ++++- .../src/app/DesktopAppIdentity.test.ts | 1 + .../app/DesktopConnectionCatalogStore.test.ts | 1 + .../app/DesktopEarlyElectronStartup.test.ts | 88 ++++++++++ .../src/app/DesktopEarlyElectronStartup.ts | 88 ++++++++++ .../src/app/DesktopPreReadyPlatform.test.ts | 86 ++++++++++ .../src/app/DesktopPreReadyPlatform.ts | 57 +++++++ apps/desktop/src/app/DesktopStatePaths.ts | 26 +++ apps/desktop/src/electron/ElectronApp.test.ts | 13 ++ apps/desktop/src/electron/ElectronApp.ts | 5 + apps/desktop/src/electron/ElectronProtocol.ts | 32 ++++ .../src/electron/ElectronSafeStorage.ts | 45 +++-- apps/desktop/src/linuxSecretStorage.test.ts | 158 ++++++++++++++++++ apps/desktop/src/linuxSecretStorage.ts | 139 +++++++++++++++ apps/desktop/src/main.ts | 50 +++++- .../src/settings/DesktopAppSettings.test.ts | 43 +++++ .../src/settings/DesktopAppSettings.ts | 12 ++ .../settings/DesktopSavedEnvironments.test.ts | 1 + .../src/shell/DesktopShellEnvironment.test.ts | 62 ++++++- .../src/shell/DesktopShellEnvironment.ts | 83 +++++++++ .../src/window/DesktopApplicationMenu.test.ts | 1 + 21 files changed, 1009 insertions(+), 22 deletions(-) create mode 100644 apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts create mode 100644 apps/desktop/src/app/DesktopEarlyElectronStartup.ts create mode 100644 apps/desktop/src/app/DesktopPreReadyPlatform.test.ts create mode 100644 apps/desktop/src/app/DesktopPreReadyPlatform.ts create mode 100644 apps/desktop/src/app/DesktopStatePaths.ts create mode 100644 apps/desktop/src/linuxSecretStorage.test.ts create mode 100644 apps/desktop/src/linuxSecretStorage.ts diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 214fd383e04..f56b5a1861c 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -9,6 +9,7 @@ import * as Crypto from "effect/Crypto"; 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 * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; @@ -17,6 +18,7 @@ import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopLifecycle from "./DesktopLifecycle.ts"; import * as DesktopObservability from "./DesktopObservability.ts"; +import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; import * as DesktopShutdown from "./DesktopShutdown.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; @@ -206,17 +208,45 @@ const startup = Effect.gen(function* () { const clerk = yield* DesktopClerk.DesktopClerk; const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; + const preReadyElectronOptions = yield* DesktopPreReadyPlatform.DesktopPreReadyElectronOptions; + const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; const updates = yield* DesktopUpdates.DesktopUpdates; const environment = yield* DesktopEnvironment.DesktopEnvironment; yield* shellEnvironment.installIntoProcess; + const hasCommandLinePasswordStore = + preReadyElectronOptions.linuxPasswordStoreCommandLine !== null; + const linuxElectronOptions = + environment.platform === "linux" && !hasCommandLinePasswordStore + ? DesktopPreReadyPlatform.resolveEarlyLinuxElectronOptionsFromProcess() + : preReadyElectronOptions.linux; + if (linuxElectronOptions !== null && !hasCommandLinePasswordStore) { + if ( + linuxElectronOptions.passwordStore !== null || + preReadyElectronOptions.linux?.passwordStore !== null + ) { + yield* electronApp.removeCommandLineSwitch("password-store"); + } + if (linuxElectronOptions.passwordStore !== null) { + yield* electronApp.appendCommandLineSwitch( + "password-store", + linuxElectronOptions.passwordStore, + ); + } + } const userDataPath = yield* appIdentity.resolveUserDataPath; yield* electronApp.setPath("userData", userDataPath); yield* logStartupInfo("runtime logging configured", { logDir: environment.logDir }); yield* desktopSettings.load; - if (environment.platform === "linux") { - yield* electronApp.appendCommandLineSwitch("class", environment.linuxWmClass); + if (linuxElectronOptions !== null) { + yield* logStartupInfo("linux password store configured", { + passwordStore: hasCommandLinePasswordStore + ? "command-line" + : (linuxElectronOptions.passwordStore ?? "electron-default"), + xdgCurrentDesktop: process.env.XDG_CURRENT_DESKTOP ?? null, + xdgSessionDesktop: process.env.XDG_SESSION_DESKTOP ?? null, + }); } yield* appIdentity.configure; @@ -228,6 +258,12 @@ const startup = Effect.gen(function* () { Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)), ); yield* logStartupInfo("app ready"); + if (environment.platform === "linux") { + const selectedBackend = yield* safeStorage.selectedStorageBackend; + yield* logStartupInfo("safe storage ready", { + backend: Option.getOrElse(selectedBackend, () => "unknown"), + }); + } yield* appIdentity.configure; yield* applicationMenu.configure; yield* updates.configure; diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 3c95b266bc1..0877805fe18 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -63,6 +63,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => calls.setDockIcon.push(iconPath); }), appendCommandLineSwitch: () => Effect.void, + removeCommandLineSwitch: () => Effect.void, on: () => Effect.void, } satisfies ElectronApp.ElectronApp["Service"]); diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index 7c7818994f6..c58830b30a7 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -39,6 +39,7 @@ function makeSafeStorageLayer(available: boolean, failDecrypt: Ref.Ref return decoded.slice("encrypted:".length); }); }, + selectedStorageBackend: Effect.succeed(Option.none()), } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]); } diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts new file mode 100644 index 00000000000..7060ba46a7c --- /dev/null +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts @@ -0,0 +1,88 @@ +// @effect-diagnostics nodeBuiltinImport:off - tests use POSIX path joining to match the Linux startup boundary. +import * as NodePath from "node:path"; +import { assert, describe, it } from "@effect/vitest"; + +import { + resolveEarlyLinuxElectronOptions, + resolveEarlyLinuxPasswordStorePreference, +} from "./DesktopEarlyElectronStartup.ts"; + +describe("DesktopEarlyElectronStartup", () => { + const joinPath = NodePath.posix.join; + + 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", + joinPath, + 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", + joinPath, + 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", + joinPath, + 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", + joinPath, + 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", () => { + const options = resolveEarlyLinuxElectronOptions({ + env: { + T3CODE_HOME: "/home/user/.t3-test", + XDG_CURRENT_DESKTOP: "niri", + VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", + }, + homeDirectory: "/home/user", + joinPath, + readFileString: (path) => { + assert.equal(path, "/home/user/.t3-test/dev/desktop-settings.json"); + return JSON.stringify({ linuxPasswordStore: "auto" }); + }, + }); + + assert.deepEqual(options, { + 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..766a32ba1de --- /dev/null +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts @@ -0,0 +1,88 @@ +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 { + resolveDesktopBaseDir, + resolveDesktopStateDir, + type JoinPath, +} from "./DesktopStatePaths.ts"; + +interface EarlyDesktopSettingsInput { + readonly env: NodeJS.ProcessEnv; + readonly homeDirectory: string; + readonly joinPath: JoinPath; + readonly readFileString: (path: string) => string; +} + +type EarlyLinuxElectronOptionsInput = EarlyDesktopSettingsInput; + +export interface EarlyLinuxElectronOptions { + 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; + +function resolveEarlyDesktopSettingsPath(input: { + readonly env: NodeJS.ProcessEnv; + readonly homeDirectory: string; + readonly joinPath: JoinPath; +}): string { + const baseDir = resolveDesktopBaseDir({ + homeDirectory: input.homeDirectory, + joinPath: input.joinPath, + t3Home: Option.fromUndefinedOr(input.env.T3CODE_HOME), + }); + const stateDir = resolveDesktopStateDir({ + baseDir, + isDevelopment: isDevelopmentEnvironment(input.env), + joinPath: input.joinPath, + }); + return input.joinPath(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 { + linuxWmClass: isDevelopmentEnvironment(input.env) ? "t3code-dev" : "t3code", + passwordStore: resolveLinuxPasswordStoreSwitch({ + preference, + env: input.env, + }), + }; +} diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts new file mode 100644 index 00000000000..2a4b0dcd195 --- /dev/null +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -0,0 +1,86 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { + DesktopPreReadyElectronOptions, + makeDesktopElectronPreReadyLayer, + readCommandLineSwitchValue, +} from "./DesktopPreReadyPlatform.ts"; + +describe("DesktopPreReadyPlatform", () => { + it("reads an explicit Electron command-line switch value", () => { + const value = readCommandLineSwitchValue( + { + hasSwitch: (switchName) => switchName === "password-store", + getSwitchValue: (switchName) => { + assert.equal(switchName, "password-store"); + return "basic"; + }, + }, + "password-store", + ); + + assert.equal(value, "basic"); + }); + + it("treats valueless Electron command-line switches as absent", () => { + const value = readCommandLineSwitchValue( + { + hasSwitch: () => true, + getSwitchValue: () => "", + }, + "password-store", + ); + + assert.isNull(value); + }); + + it("returns null for missing Electron command-line switches", () => { + const value = readCommandLineSwitchValue( + { + hasSwitch: () => false, + getSwitchValue: () => { + throw new Error("Unexpected switch value read."); + }, + }, + "password-store", + ); + + assert.isNull(value); + }); + + it.effect("builds scheme privileges and command-line setup as sibling pre-ready effects", () => + Effect.gen(function* () { + const schemeStarted = yield* Deferred.make(); + const configureStarted = yield* Deferred.make(); + + const layer = makeDesktopElectronPreReadyLayer({ + schemePrivilegesLayer: Layer.effectDiscard( + Deferred.succeed(schemeStarted, undefined).pipe( + Effect.andThen(Deferred.await(configureStarted)), + ), + ), + configureElectronBeforeReady: Deferred.succeed(configureStarted, undefined).pipe( + Effect.andThen(Deferred.await(schemeStarted)), + Effect.as({ + linux: null, + linuxPasswordStoreCommandLine: null, + }), + ), + }); + + const options = yield* DesktopPreReadyElectronOptions.pipe( + Effect.provide(layer), + Effect.timeoutOption("50 millis"), + ); + + assert.deepEqual(Option.getOrNull(options), { + linux: null, + linuxPasswordStoreCommandLine: null, + }); + }), + ); +}); diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts new file mode 100644 index 00000000000..1a24934b563 --- /dev/null +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -0,0 +1,57 @@ +// @effect-diagnostics nodeBuiltinImport:off - pre-ready Electron setup reads persisted settings synchronously before app services are available. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as DesktopEarlyElectronStartup from "./DesktopEarlyElectronStartup.ts"; + +export class DesktopPreReadyElectronOptions extends Context.Service< + DesktopPreReadyElectronOptions, + { + readonly linux: DesktopEarlyElectronStartup.EarlyLinuxElectronOptions | null; + readonly linuxPasswordStoreCommandLine: string | null; + } +>()("@t3tools/desktop/app/DesktopPreReadyPlatform/DesktopPreReadyElectronOptions") {} + +export interface DesktopPreReadyCommandLineReader { + readonly hasSwitch: (switchName: string) => boolean; + readonly getSwitchValue: (switchName: string) => string; +} + +export function readCommandLineSwitchValue( + commandLine: DesktopPreReadyCommandLineReader, + switchName: string, +): string | null { + if (!commandLine.hasSwitch(switchName)) { + return null; + } + + const value = commandLine.getSwitchValue(switchName).trim(); + return value.length > 0 ? value : null; +} + +export function makeDesktopElectronPreReadyLayer(input: { + readonly schemePrivilegesLayer: Layer.Layer; + readonly configureElectronBeforeReady: Effect.Effect< + DesktopPreReadyElectronOptions["Service"], + E2, + R2 + >; +}): Layer.Layer { + return Layer.mergeAll( + input.schemePrivilegesLayer, + Layer.effect(DesktopPreReadyElectronOptions, input.configureElectronBeforeReady), + ); +} + +export const resolveEarlyLinuxElectronOptionsFromProcess = + (): DesktopEarlyElectronStartup.EarlyLinuxElectronOptions => + DesktopEarlyElectronStartup.resolveEarlyLinuxElectronOptions({ + env: process.env, + homeDirectory: NodeOS.homedir(), + joinPath: NodePath.posix.join, + readFileString: (path) => NodeFS.readFileSync(path, "utf8"), + }); 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/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts index f3ce3b4b5f4..d35eba99d22 100644 --- a/apps/desktop/src/electron/ElectronApp.test.ts +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -13,6 +13,7 @@ const { relaunchMock, removeListenerMock, requestSingleInstanceLockMock, + removeSwitchMock, setAboutPanelOptionsMock, setAppUserModelIdMock, setAsDefaultProtocolClientMock, @@ -32,6 +33,7 @@ const { relaunchMock: vi.fn(), removeListenerMock: vi.fn(), requestSingleInstanceLockMock: vi.fn(() => true), + removeSwitchMock: vi.fn(), setAboutPanelOptionsMock: vi.fn(), setAppUserModelIdMock: vi.fn(), setAsDefaultProtocolClientMock: vi.fn(() => true), @@ -46,6 +48,7 @@ vi.mock("electron", () => ({ app: { commandLine: { appendSwitch: appendSwitchMock, + removeSwitch: removeSwitchMock, }, dock: { setIcon: setDockIconMock, @@ -82,6 +85,7 @@ describe("ElectronApp", () => { quitMock.mockClear(); relaunchMock.mockClear(); removeListenerMock.mockClear(); + removeSwitchMock.mockClear(); setPathMock.mockClear(); }); @@ -153,4 +157,13 @@ describe("ElectronApp", () => { assert.deepEqual(removeListenerMock.mock.calls, [["activate", listener]]); }).pipe(Effect.provide(ElectronApp.layer)), ); + + it.effect("removes command-line switches through the service", () => + Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + yield* electronApp.removeCommandLineSwitch("password-store"); + + assert.deepEqual(removeSwitchMock.mock.calls, [["password-store"]]); + }).pipe(Effect.provide(ElectronApp.layer)), + ); }); diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 0af8691f6c4..481e74e3b8a 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -66,6 +66,7 @@ export class ElectronApp extends Context.Service< readonly setDesktopName: (desktopName: string) => Effect.Effect; readonly setDockIcon: (iconPath: string) => Effect.Effect; readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect; + readonly removeCommandLineSwitch: (switchName: string) => Effect.Effect; readonly on: >( eventName: string, listener: (...args: Args) => void, @@ -178,6 +179,10 @@ export const make = ElectronApp.of({ } Electron.app.commandLine.appendSwitch(switchName, value); }), + removeCommandLineSwitch: (switchName) => + Effect.sync(() => { + Electron.app.commandLine.removeSwitch(switchName); + }), on: addScopedAppListener, }); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 757c26178d0..e955f7a90fa 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -103,6 +103,38 @@ function withContentSecurityPolicy(response: Response, policy: string): Response }); } +/** + * Must run synchronously during process bootstrap, before Electron emits `ready`. + */ +export function registerDesktopSchemePrivilegesSync(): void { + Electron.protocol.registerSchemesAsPrivileged([ + { + scheme: DESKTOP_PRODUCTION_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + }, + }, + { + scheme: DESKTOP_DEVELOPMENT_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + }, + }, + ]); +} + +const registerDesktopSchemePrivileges = Effect.sync(registerDesktopSchemePrivilegesSync).pipe( + Effect.withSpan("desktop.electron.protocol.registerSchemePrivileges"), +); + +export const layerSchemePrivileges = Layer.effectDiscard(registerDesktopSchemePrivileges); + async function proxyRequest( request: Request, targetOrigin: URL, diff --git a/apps/desktop/src/electron/ElectronSafeStorage.ts b/apps/desktop/src/electron/ElectronSafeStorage.ts index 76162c1647a..8d734aad102 100644 --- a/apps/desktop/src/electron/ElectronSafeStorage.ts +++ b/apps/desktop/src/electron/ElectronSafeStorage.ts @@ -1,9 +1,11 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Electron from "electron"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; const electronSafeStorageErrorFields = { cause: Schema.Defect(), @@ -60,24 +62,39 @@ export class ElectronSafeStorage extends Context.Service< readonly decryptString: ( value: Uint8Array, ) => Effect.Effect; + readonly selectedStorageBackend: Effect.Effect>; } >()("@t3tools/desktop/electron/ElectronSafeStorage") {} -export const make = ElectronSafeStorage.of({ - isEncryptionAvailable: Effect.try({ - try: () => Electron.safeStorage.isEncryptionAvailable(), - catch: (cause) => new ElectronSafeStorageAvailabilityError({ cause }), - }), - encryptString: (value) => - Effect.try({ - try: () => Electron.safeStorage.encryptString(value), - catch: (cause) => new ElectronSafeStorageEncryptError({ cause }), +const make = Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + + return ElectronSafeStorage.of({ + isEncryptionAvailable: Effect.try({ + try: () => Electron.safeStorage.isEncryptionAvailable(), + catch: (cause) => new ElectronSafeStorageAvailabilityError({ cause }), }), - decryptString: (value) => - Effect.try({ - try: () => Electron.safeStorage.decryptString(Buffer.from(value)), - catch: (cause) => new ElectronSafeStorageDecryptError({ cause }), + encryptString: (value) => + Effect.try({ + try: () => Electron.safeStorage.encryptString(value), + catch: (cause) => new ElectronSafeStorageEncryptError({ cause }), + }), + decryptString: (value) => + Effect.try({ + try: () => Electron.safeStorage.decryptString(Buffer.from(value)), + catch: (cause) => new ElectronSafeStorageDecryptError({ cause }), + }), + selectedStorageBackend: Effect.sync(() => { + if (platform !== "linux") { + return Option.none(); + } + try { + return Option.fromNullishOr(Electron.safeStorage.getSelectedStorageBackend()); + } catch { + return Option.none(); + } }), + }); }); -export const layer = Layer.succeed(ElectronSafeStorage, make); +export const layer = Layer.effect(ElectronSafeStorage, make); diff --git a/apps/desktop/src/linuxSecretStorage.test.ts b/apps/desktop/src/linuxSecretStorage.test.ts new file mode 100644 index 00000000000..d852730d019 --- /dev/null +++ b/apps/desktop/src/linuxSecretStorage.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vite-plus/test"; + +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("uses KWallet for unversioned KDE sessions Electron may not recognize", () => { + expect( + resolveLinuxPasswordStoreSwitch({ + preference: "auto", + env: { XDG_CURRENT_DESKTOP: "KDE" }, + }), + ).toBe("kwallet"); + expect( + resolveLinuxPasswordStoreSwitch({ + preference: "auto", + env: { DESKTOP_SESSION: "plasma" }, + }), + ).toBe("kwallet"); + expect( + resolveLinuxPasswordStoreSwitch({ + preference: "auto", + env: { XDG_SESSION_DESKTOP: "plasma" }, + }), + ).toBe("kwallet"); + }); + + it("forces gnome-libsecret for unrecognized Linux desktop sessions", () => { + expect( + resolveLinuxPasswordStoreSwitch({ + preference: "auto", + env: { XDG_CURRENT_DESKTOP: "niri" }, + }), + ).toBe("gnome-libsecret"); + }); + + it("ignores stale legacy desktop hints when XDG_CURRENT_DESKTOP is authoritative", () => { + expect( + resolveLinuxPasswordStoreSwitch({ + preference: "auto", + env: { + XDG_CURRENT_DESKTOP: "niri", + DESKTOP_SESSION: "gnome", + GDMSESSION: "gnome", + }, + }), + ).toBe("gnome-libsecret"); + expect( + resolveLinuxPasswordStoreSwitch({ + preference: "auto", + env: { + XDG_CURRENT_DESKTOP: "Hyprland", + DESKTOP_SESSION: "gnome", + }, + }), + ).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("prefers explicit KWallet preference over selected gnome-libsecret backend", () => { + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "kwallet6", + selectedBackend: "gnome_libsecret", + env: { XDG_CURRENT_DESKTOP: "niri" }, + }), + ).toContain("KWallet"); + expect( + resolveLinuxSecretStorageUnavailableMessage({ + configuredPreference: "kwallet", + selectedBackend: "gnome-libsecret", + env: {}, + }), + ).toContain("KWallet"); + }); + + 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..67cca7f8a38 --- /dev/null +++ b/apps/desktop/src/linuxSecretStorage.ts @@ -0,0 +1,139 @@ +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 ELECTRON_KWALLET_DESKTOPS = new Set(["kde4", "kde5", "kde6"]); +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; + } + + if (isElectronKnownLinuxSecretStorageDesktop(input.env)) { + return null; + } + + return isKdeDesktop(input.env) ? "kwallet" : "gnome-libsecret"; +} + +export function resolveLinuxSecretStorageUnavailableMessage(input: { + readonly configuredPreference: LinuxPasswordStorePreference; + readonly selectedBackend: string | null; + readonly env: NodeJS.ProcessEnv; +}): string { + if (input.configuredPreference === "gnome-libsecret") { + return getGnomeKeyringRemediationMessage(); + } + + if ( + input.configuredPreference === "kwallet" || + input.configuredPreference === "kwallet5" || + input.configuredPreference === "kwallet6" + ) { + return getKWalletRemediationMessage(); + } + + const backend = normalizeSelectedStorageBackend(input.selectedBackend); + if (backend === "gnome-libsecret") { + return getGnomeKeyringRemediationMessage(); + } + + if ( + backend === "kwallet" || + backend === "kwallet5" || + backend === "kwallet6" || + isKdeDesktop(input.env) + ) { + return getKWalletRemediationMessage(); + } + + 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 getKWalletRemediationMessage(): string { + return "T3 Code could not access KWallet to save this environment credential. Enable the KDE wallet subsystem in System Settings, then restart T3 Code."; +} + +function isElectronKnownLinuxSecretStorageDesktop(env: NodeJS.ProcessEnv): boolean { + return resolveAuthoritativeLinuxDesktopNames(env).some( + (name) => ELECTRON_LIBSECRET_DESKTOPS.has(name) || ELECTRON_KWALLET_DESKTOPS.has(name), + ); +} + +function isKdeDesktop(env: NodeJS.ProcessEnv): boolean { + return resolveAuthoritativeLinuxDesktopNames(env).some((name) => KDE_DESKTOPS.has(name)); +} + +function resolveAuthoritativeLinuxDesktopNames(env: NodeJS.ProcessEnv): string[] { + const authoritative = [ + ...splitDesktopNameList(env.XDG_CURRENT_DESKTOP), + env.XDG_SESSION_DESKTOP, + env.KDE_SESSION_VERSION ? `kde${env.KDE_SESSION_VERSION}` : undefined, + ].flatMap((entry) => { + const normalized = normalizeDesktopName(entry); + return normalized ? [normalized] : []; + }); + return authoritative.length > 0 ? authoritative : resolveLinuxDesktopNames(env); +} + +function resolveLinuxDesktopNames(env: NodeJS.ProcessEnv): string[] { + return [ + ...splitDesktopNameList(env.XDG_CURRENT_DESKTOP), + env.XDG_SESSION_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 b88eb18e57f..93fbda93e96 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -42,6 +42,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"; @@ -51,6 +52,41 @@ import * as BrowserSession from "./preview/BrowserSession.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as DesktopWindow from "./window/DesktopWindow.ts"; +const configureElectronBeforeReady = Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + return yield* Effect.sync( + (): DesktopPreReadyPlatform.DesktopPreReadyElectronOptions["Service"] => { + const linuxPasswordStoreCommandLine = + platform === "linux" + ? DesktopPreReadyPlatform.readCommandLineSwitchValue( + Electron.app.commandLine, + "password-store", + ) + : null; + const linux = + platform === "linux" + ? DesktopPreReadyPlatform.resolveEarlyLinuxElectronOptionsFromProcess() + : null; + + if (linux !== null) { + Electron.app.commandLine.appendSwitch("class", linux.linuxWmClass); + if (linux.passwordStore !== null && linuxPasswordStoreCommandLine === null) { + Electron.app.commandLine.appendSwitch("password-store", linux.passwordStore); + } + } + + return { linux, linuxPasswordStoreCommandLine }; + }, + ); +}).pipe(Effect.withSpan("desktop.electron.configureBeforeReady")); + +// Keep Electron's strict pre-ready setup isolated so later runtime layers cannot +// observe app readiness before scheme privileges and command-line switches exist. +const desktopElectronPreReadyLayer = DesktopPreReadyPlatform.makeDesktopElectronPreReadyLayer({ + schemePrivilegesLayer: ElectronProtocol.layerSchemePrivileges, + configureElectronBeforeReady, +}); + const desktopEnvironmentLayer = Layer.unwrap( Effect.gen(function* () { const metadata = yield* Effect.service(ElectronApp.ElectronApp).pipe( @@ -169,14 +205,18 @@ const desktopClerkLayer = DesktopClerk.layer.pipe( Layer.provideMerge(ElectronApp.layer), ); +const desktopApplicationRuntimeLayer = desktopApplicationLayer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(NodeHttpClient.layerUndici), + Layer.provideMerge(NetService.layer), + Layer.provideMerge(electronLayer), +); + const desktopRuntimeLayer = desktopClerkLayer.pipe( Layer.flatMap((clerkContext) => - desktopApplicationLayer.pipe( + desktopApplicationRuntimeLayer.pipe( Layer.provideMerge(Layer.succeedContext(clerkContext)), - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(NodeHttpClient.layerUndici), - Layer.provideMerge(NetService.layer), - Layer.provideMerge(electronLayer), + Layer.provideMerge(desktopElectronPreReadyLayer), ), ), ); diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index c76ffa8bbda..98cbd670bcf 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -11,6 +11,9 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; 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), @@ -87,6 +90,7 @@ describe("DesktopSettings", () => { assert.deepEqual( DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), { + linuxPasswordStore: "auto", serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: 443, @@ -101,6 +105,7 @@ describe("DesktopSettings", () => { Effect.gen(function* () { const settings = yield* DesktopAppSettings.DesktopAppSettings; yield* writeSettingsPatch({ + linuxPasswordStore: "gnome-libsecret", serverExposureMode: "network-accessible", tailscaleServeEnabled: true, tailscaleServePort: 8443, @@ -109,6 +114,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + linuxPasswordStore: "gnome-libsecret", serverExposureMode: "network-accessible", tailscaleServeEnabled: true, tailscaleServePort: 8443, @@ -209,6 +215,7 @@ describe("DesktopSettings", () => { ); assert.deepEqual(yield* settings.load, { + linuxPasswordStore: "auto", serverExposureMode: "network-accessible", tailscaleServeEnabled: true, tailscaleServePort: 8443, @@ -219,6 +226,39 @@ describe("DesktopSettings", () => { ), ); + it.effect( + "normalizes unsupported linux password-store values without dropping other settings", + () => + withSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString( + environment.desktopSettingsPath, + `{ + "linuxPasswordStore": "unsupported-store", + "serverExposureMode": "network-accessible", + "tailscaleServeEnabled": true, + "tailscaleServePort": 8443, + "updateChannel": "nightly", + "updateChannelConfiguredByUser": true + }\n`, + ); + + assert.deepEqual(yield* settings.load, { + linuxPasswordStore: "auto", + serverExposureMode: "network-accessible", + tailscaleServeEnabled: true, + tailscaleServePort: 8443, + updateChannel: "nightly", + updateChannelConfiguredByUser: true, + } satisfies DesktopAppSettings.DesktopSettings); + }), + ), + ); + it.effect("persists sparse desktop settings documents", () => withSettings( Effect.gen(function* () { @@ -248,6 +288,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + linuxPasswordStore: "auto", serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: 443, @@ -270,6 +311,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + linuxPasswordStore: "auto", serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: 443, @@ -291,6 +333,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 81aae92f0a3..85b698f01e6 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -16,9 +16,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; @@ -34,6 +40,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, @@ -42,6 +49,7 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { }; const DesktopSettingsDocument = Schema.Struct({ + linuxPasswordStore: Schema.optionalKey(Schema.Unknown), serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema), tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), tailscaleServePort: Schema.optionalKey(Schema.Number), @@ -126,6 +134,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, @@ -143,6 +152,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 ec70308b3d3..05b1ca14444 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -86,6 +86,7 @@ function makeSafeStorageLayer(input: { } return Effect.succeed(decoded.slice("enc:".length)); }, + selectedStorageBackend: Effect.succeed(Option.none()), } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]); } diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 7ec0ab80ae7..b8c66e9b745 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -1,3 +1,4 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -90,7 +91,7 @@ function runShellEnvironment(input: { }).pipe( Effect.provide( DesktopShellEnvironment.layer.pipe( - Layer.provide(Layer.mergeAll(environmentLayer, spawnerLayer)), + Layer.provide(Layer.mergeAll(environmentLayer, NodeServices.layer, spawnerLayer)), ), ), ); @@ -243,6 +244,65 @@ describe("DesktopShellEnvironment", () => { }), ); + it.effect("prefers login-shell desktop session hints over inherited values on linux", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + XDG_CURRENT_DESKTOP: "wrong-launcher", + XDG_SESSION_DESKTOP: "wrong-launcher", + }; + + yield* runShellEnvironment({ + env, + platform: "linux", + handler: () => + envOutput({ + PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin", + XDG_CURRENT_DESKTOP: "KDE", + XDG_SESSION_DESKTOP: "KDE", + XDG_SESSION_TYPE: "wayland", + }), + }); + + assert.equal(env.XDG_CURRENT_DESKTOP, "KDE"); + assert.equal(env.XDG_SESSION_DESKTOP, "KDE"); + assert.equal(env.XDG_SESSION_TYPE, "wayland"); + }), + ); + + it.effect("overrides stale dbus session addresses from the login shell", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + DBUS_SESSION_BUS_ADDRESS: "unix:path=/tmp/stale-bus", + }; + + yield* runShellEnvironment({ + env, + platform: "linux", + handler: () => + envOutput({ + PATH: "/usr/bin", + DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/1000/bus", + }), + }); + + assert.equal(env.DBUS_SESSION_BUS_ADDRESS, "unix:path=/run/user/1000/bus"); + }), + ); + + it("resolves dbus runtime dir candidates with existence checks", () => { + const busPath = DesktopShellEnvironment.resolveDefaultLinuxDbusSessionBusAddress({ + env: { XDG_RUNTIME_DIR: "/tmp/stale-runtime" }, + uid: 1000, + exists: (path) => path === "/run/user/1000/bus", + }); + + assert.equal(busPath, "unix:path=/run/user/1000/bus"); + }); + it.effect("logs command failures with safe probe context and the exact cause", () => { const env: NodeJS.ProcessEnv = { SHELL: "/bin/bash", diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 8219f18b7a5..710b7044a7a 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 * as Schema from "effect/Schema"; @@ -13,6 +14,7 @@ type EnvironmentPatch = Record; interface ShellEnvironmentConfig { readonly env: NodeJS.ProcessEnv; + readonly fileSystem: FileSystem.FileSystem; readonly platform: NodeJS.Platform; readonly userShell: Option.Option; } @@ -68,12 +70,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; @@ -92,6 +101,47 @@ const pathDelimiter = (platform: NodeJS.Platform) => (platform === "win32" ? ";" const readEnvPath = (env: NodeJS.ProcessEnv): Option.Option => trimNonEmpty(env.PATH ?? env.Path ?? env.path); +const normalizeRuntimeDir = (value: string): string => value.replace(/\/+$/u, ""); + +const linuxRuntimeDirCandidates = ( + env: NodeJS.ProcessEnv, + uid: number | undefined, +): ReadonlyArray => { + const candidates: string[] = []; + const fromEnv = trimNonEmpty(env.XDG_RUNTIME_DIR); + if (Option.isSome(fromEnv)) { + candidates.push(normalizeRuntimeDir(fromEnv.value)); + } + if (uid !== undefined) { + candidates.push(`/run/user/${uid}`); + } + return candidates.filter((candidate) => candidate.length > 0); +}; + +function resolveDefaultLinuxDbusSessionBusPath(input: { + readonly env: NodeJS.ProcessEnv; + readonly uid: number | undefined; + readonly exists?: (path: string) => boolean; +}): string | null { + for (const runtimeDir of linuxRuntimeDirCandidates(input.env, input.uid)) { + const busPath = `${runtimeDir}/bus`; + if (input.exists === undefined || input.exists(busPath)) { + return busPath; + } + } + + return null; +} + +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; @@ -383,17 +433,48 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix config.env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK; } + const shellPreferredEnvNames = [ + "DBUS_SESSION_BUS_ADDRESS", + "XDG_CURRENT_DESKTOP", + "XDG_SESSION_DESKTOP", + "XDG_SESSION_TYPE", + ] as const; + for (const name of shellPreferredEnvNames) { + if (shellEnvironment[name]) { + config.env[name] = shellEnvironment[name]; + } + } + for (const name of [ + "DISPLAY", "HOMEBREW_PREFIX", "HOMEBREW_CELLAR", "HOMEBREW_REPOSITORY", "XDG_CONFIG_HOME", "XDG_DATA_HOME", + "XDG_RUNTIME_DIR", + "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)) + ) { + for (const runtimeDir of linuxRuntimeDirCandidates(config.env, process.getuid?.())) { + const dbusSessionBusPath = `${runtimeDir}/bus`; + const busExists = yield* config.fileSystem + .exists(dbusSessionBusPath) + .pipe(Effect.orElseSucceed(() => false)); + if (busExists) { + config.env.DBUS_SESSION_BUS_ADDRESS = `unix:path=${dbusSessionBusPath}`; + break; + } + } + } }, ); @@ -411,10 +492,12 @@ const installShellEnvironment = ( export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const installIntoProcess: DesktopShellEnvironment["Service"]["installIntoProcess"] = installShellEnvironment({ env: process.env, + fileSystem, platform: environment.platform, userShell: Option.none(), }).pipe( diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 04a1971ce46..3022b25441b 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -45,6 +45,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { setDesktopName: () => Effect.void, setDockIcon: () => Effect.void, appendCommandLineSwitch: () => Effect.void, + removeCommandLineSwitch: () => Effect.void, on: () => Effect.void, } satisfies ElectronApp.ElectronApp["Service"]);