| author | |
| committer | |
| log | 6f04a5cffb8fcad95f709af69eb2da2605c4d472 |
| tree | 629a95783fe41b9ffafca1dfcfac70f9c6f49b78 |
| parent | 30c96228067bcd3a49e432ec898e52d4acb04297 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius0216@outlook.com>24 files changed, 1186 insertions(+), 31 deletions(-)
apps/desktop/src/app/DesktopApp.ts+38-2| ... | ... | @@ -9,6 +9,7 @@ import * as Crypto from "effect/Crypto"; |
| 9 | 9 | import * as ElectronApp from "../electron/ElectronApp.ts"; |
| 10 | 10 | import * as ElectronDialog from "../electron/ElectronDialog.ts"; |
| 11 | 11 | import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; |
| 12 | import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; | |
| 12 | 13 | import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; |
| 13 | 14 | import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; |
| 14 | 15 | import * as DesktopClerk from "./DesktopClerk.ts"; |
| ... | ... | @@ -19,6 +20,7 @@ import * as DesktopEnvironment from "./DesktopEnvironment.ts"; |
| 19 | 20 | import * as DesktopLifecycle from "./DesktopLifecycle.ts"; |
| 20 | 21 | import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts"; |
| 21 | 22 | import * as DesktopObservability from "./DesktopObservability.ts"; |
| 23 | import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; | |
| 22 | 24 | import * as DesktopShutdown from "./DesktopShutdown.ts"; |
| 23 | 25 | import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; |
| 24 | 26 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| ... | ... | @@ -225,17 +227,45 @@ const startup = Effect.gen(function* () { |
| 225 | 227 | const clerk = yield* DesktopClerk.DesktopClerk; |
| 226 | 228 | const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; |
| 227 | 229 | const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; |
| 230 | const preReadyElectronOptions = yield* DesktopPreReadyPlatform.DesktopPreReadyElectronOptions; | |
| 231 | const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; | |
| 228 | 232 | const updates = yield* DesktopUpdates.DesktopUpdates; |
| 229 | 233 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 230 | 234 | |
| 231 | 235 | yield* shellEnvironment.installIntoProcess; |
| 236 | const hasCommandLinePasswordStore = | |
| 237 | preReadyElectronOptions.linuxPasswordStoreCommandLine !== null; | |
| 238 | const linuxElectronOptions = | |
| 239 | environment.platform === "linux" && !hasCommandLinePasswordStore | |
| 240 | ? DesktopPreReadyPlatform.resolveEarlyLinuxElectronOptionsFromProcess() | |
| 241 | : preReadyElectronOptions.linux; | |
| 242 | if (linuxElectronOptions !== null && !hasCommandLinePasswordStore) { | |
| 243 | if ( | |
| 244 | linuxElectronOptions.passwordStore !== null || | |
| 245 | preReadyElectronOptions.linux?.passwordStore !== null | |
| 246 | ) { | |
| 247 | yield* electronApp.removeCommandLineSwitch("password-store"); | |
| 248 | } | |
| 249 | if (linuxElectronOptions.passwordStore !== null) { | |
| 250 | yield* electronApp.appendCommandLineSwitch( | |
| 251 | "password-store", | |
| 252 | linuxElectronOptions.passwordStore, | |
| 253 | ); | |
| 254 | } | |
| 255 | } | |
| 232 | 256 | const userDataPath = yield* appIdentity.resolveUserDataPath; |
| 233 | 257 | yield* electronApp.setPath("userData", userDataPath); |
| 234 | 258 | yield* logStartupInfo("runtime logging configured", { logDir: environment.logDir }); |
| 235 | 259 | yield* desktopSettings.load; |
| 236 | 260 | |
| 237 | if (environment.platform === "linux") { | |
| 238 | yield* electronApp.appendCommandLineSwitch("class", environment.linuxWmClass); | |
| 261 | if (linuxElectronOptions !== null) { | |
| 262 | yield* logStartupInfo("linux password store configured", { | |
| 263 | passwordStore: hasCommandLinePasswordStore | |
| 264 | ? "command-line" | |
| 265 | : (linuxElectronOptions.passwordStore ?? "electron-default"), | |
| 266 | xdgCurrentDesktop: process.env.XDG_CURRENT_DESKTOP ?? null, | |
| 267 | xdgSessionDesktop: process.env.XDG_SESSION_DESKTOP ?? null, | |
| 268 | }); | |
| 239 | 269 | } |
| 240 | 270 | |
| 241 | 271 | yield* appIdentity.configure; |
| ... | ... | @@ -247,6 +277,12 @@ const startup = Effect.gen(function* () { |
| 247 | 277 | Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)), |
| 248 | 278 | ); |
| 249 | 279 | yield* logStartupInfo("app ready"); |
| 280 | if (environment.platform === "linux") { | |
| 281 | const selectedBackend = yield* safeStorage.selectedStorageBackend; | |
| 282 | yield* logStartupInfo("safe storage ready", { | |
| 283 | backend: Option.getOrElse(selectedBackend, () => "unknown"), | |
| 284 | }); | |
| 285 | } | |
| 250 | 286 | yield* appIdentity.configure; |
| 251 | 287 | yield* applicationMenu.configure; |
| 252 | 288 | yield* updates.configure; |
apps/desktop/src/app/DesktopAppIdentity.test.ts+1| ... | ... | @@ -64,6 +64,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => |
| 64 | 64 | }), |
| 65 | 65 | appendCommandLineSwitch: () => Effect.void, |
| 66 | 66 | onBeforeQuitForUpdate: () => Effect.void, |
| 67 | removeCommandLineSwitch: () => Effect.void, | |
| 67 | 68 | on: () => Effect.void, |
| 68 | 69 | } satisfies ElectronApp.ElectronApp["Service"]); |
| 69 | 70 |
apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts+1| ... | ... | @@ -39,6 +39,7 @@ function makeSafeStorageLayer(available: boolean, failDecrypt: Ref.Ref<boolean> |
| 39 | 39 | return decoded.slice("encrypted:".length); |
| 40 | 40 | }); |
| 41 | 41 | }, |
| 42 | selectedStorageBackend: Effect.succeed(Option.none()), | |
| 42 | 43 | } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]); |
| 43 | 44 | } |
| 44 | 45 |
apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts created+121| ... | ... | @@ -0,0 +1,121 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off - tests use POSIX path joining to match the Linux startup boundary. | |
| 2 | import * as NodePath from "node:path"; | |
| 3 | import { assert, describe, it } from "@effect/vitest"; | |
| 4 | ||
| 5 | import { | |
| 6 | resolveEarlyLinuxElectronOptions, | |
| 7 | resolveEarlyLinuxPasswordStorePreference, | |
| 8 | } from "./DesktopEarlyElectronStartup.ts"; | |
| 9 | ||
| 10 | describe("DesktopEarlyElectronStartup", () => { | |
| 11 | const joinPath = NodePath.posix.join; | |
| 12 | ||
| 13 | it("reads the persisted linux password-store preference before Electron is ready", () => { | |
| 14 | const preference = resolveEarlyLinuxPasswordStorePreference({ | |
| 15 | env: { T3CODE_HOME: "/home/user/.t3-test" }, | |
| 16 | homeDirectory: "/home/user", | |
| 17 | joinPath, | |
| 18 | readFileString: (path) => { | |
| 19 | assert.equal(path, "/home/user/.t3-test/userdata/desktop-settings.json"); | |
| 20 | return JSON.stringify({ linuxPasswordStore: "kwallet6" }); | |
| 21 | }, | |
| 22 | }); | |
| 23 | ||
| 24 | assert.equal(preference, "kwallet6"); | |
| 25 | }); | |
| 26 | ||
| 27 | it("accepts JSONC in the early desktop settings file", () => { | |
| 28 | const preference = resolveEarlyLinuxPasswordStorePreference({ | |
| 29 | env: { T3CODE_HOME: "/home/user/.t3-test" }, | |
| 30 | homeDirectory: "/home/user", | |
| 31 | joinPath, | |
| 32 | readFileString: () => `{ | |
| 33 | // manually edited setting | |
| 34 | "linuxPasswordStore": "gnome-libsecret", | |
| 35 | }`, | |
| 36 | }); | |
| 37 | ||
| 38 | assert.equal(preference, "gnome-libsecret"); | |
| 39 | }); | |
| 40 | ||
| 41 | it("falls back to auto when the early settings document is missing or invalid", () => { | |
| 42 | const preference = resolveEarlyLinuxPasswordStorePreference({ | |
| 43 | env: {}, | |
| 44 | homeDirectory: "/home/user", | |
| 45 | joinPath, | |
| 46 | readFileString: () => { | |
| 47 | throw new Error("missing"); | |
| 48 | }, | |
| 49 | }); | |
| 50 | ||
| 51 | assert.equal(preference, "auto"); | |
| 52 | }); | |
| 53 | ||
| 54 | it("preserves absolute root paths when resolving early settings", () => { | |
| 55 | const preference = resolveEarlyLinuxPasswordStorePreference({ | |
| 56 | env: { T3CODE_HOME: "/" }, | |
| 57 | homeDirectory: "/home/user", | |
| 58 | joinPath, | |
| 59 | readFileString: (path) => { | |
| 60 | assert.equal(path, "/userdata/desktop-settings.json"); | |
| 61 | return JSON.stringify({ linuxPasswordStore: "kwallet6" }); | |
| 62 | }, | |
| 63 | }); | |
| 64 | ||
| 65 | assert.equal(preference, "kwallet6"); | |
| 66 | }); | |
| 67 | ||
| 68 | it("resolves the early linux Electron switches", () => { | |
| 69 | const options = resolveEarlyLinuxElectronOptions({ | |
| 70 | env: { | |
| 71 | T3CODE_HOME: "/home/user/.t3-test", | |
| 72 | XDG_CURRENT_DESKTOP: "niri", | |
| 73 | VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", | |
| 74 | }, | |
| 75 | homeDirectory: "/home/user", | |
| 76 | joinPath, | |
| 77 | readFileString: (path) => { | |
| 78 | assert.equal(path, "/home/user/.t3-test/userdata/desktop-settings.json"); | |
| 79 | return JSON.stringify({ linuxPasswordStore: "auto" }); | |
| 80 | }, | |
| 81 | }); | |
| 82 | ||
| 83 | assert.deepEqual(options, { | |
| 84 | linuxWmClass: "t3code-dev", | |
| 85 | passwordStore: "gnome-libsecret", | |
| 86 | }); | |
| 87 | }); | |
| 88 | ||
| 89 | it("keeps implicit development state under ~/.t3/dev when T3CODE_HOME is unset", () => { | |
| 90 | const preference = resolveEarlyLinuxPasswordStorePreference({ | |
| 91 | env: { | |
| 92 | VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", | |
| 93 | }, | |
| 94 | homeDirectory: "/home/user", | |
| 95 | joinPath, | |
| 96 | readFileString: (path) => { | |
| 97 | assert.equal(path, "/home/user/.t3/dev/desktop-settings.json"); | |
| 98 | return JSON.stringify({ linuxPasswordStore: "kwallet" }); | |
| 99 | }, | |
| 100 | }); | |
| 101 | ||
| 102 | assert.equal(preference, "kwallet"); | |
| 103 | }); | |
| 104 | ||
| 105 | it("treats whitespace-only T3CODE_HOME as unconfigured in development", () => { | |
| 106 | const preference = resolveEarlyLinuxPasswordStorePreference({ | |
| 107 | env: { | |
| 108 | T3CODE_HOME: " ", | |
| 109 | VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", | |
| 110 | }, | |
| 111 | homeDirectory: "/home/user", | |
| 112 | joinPath, | |
| 113 | readFileString: (path) => { | |
| 114 | assert.equal(path, "/home/user/.t3/dev/desktop-settings.json"); | |
| 115 | return JSON.stringify({ linuxPasswordStore: "gnome-libsecret" }); | |
| 116 | }, | |
| 117 | }); | |
| 118 | ||
| 119 | assert.equal(preference, "gnome-libsecret"); | |
| 120 | }); | |
| 121 | }); |
apps/desktop/src/app/DesktopEarlyElectronStartup.ts created+90| ... | ... | @@ -0,0 +1,90 @@ |
| 1 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; | |
| 2 | import * as Option from "effect/Option"; | |
| 3 | import * as Schema from "effect/Schema"; | |
| 4 | ||
| 5 | import { | |
| 6 | DEFAULT_LINUX_PASSWORD_STORE, | |
| 7 | normalizeLinuxPasswordStorePreference, | |
| 8 | resolveLinuxPasswordStoreSwitch, | |
| 9 | type LinuxPasswordStoreSwitch, | |
| 10 | type LinuxPasswordStorePreference, | |
| 11 | } from "../linuxSecretStorage.ts"; | |
| 12 | import { | |
| 13 | resolveDesktopBaseDir, | |
| 14 | resolveDesktopStateDir, | |
| 15 | type JoinPath, | |
| 16 | } from "./DesktopStatePaths.ts"; | |
| 17 | ||
| 18 | interface EarlyDesktopSettingsInput { | |
| 19 | readonly env: NodeJS.ProcessEnv; | |
| 20 | readonly homeDirectory: string; | |
| 21 | readonly joinPath: JoinPath; | |
| 22 | readonly readFileString: (path: string) => string; | |
| 23 | } | |
| 24 | ||
| 25 | type EarlyLinuxElectronOptionsInput = EarlyDesktopSettingsInput; | |
| 26 | ||
| 27 | export interface EarlyLinuxElectronOptions { | |
| 28 | readonly linuxWmClass: string; | |
| 29 | readonly passwordStore: LinuxPasswordStoreSwitch | null; | |
| 30 | } | |
| 31 | ||
| 32 | const trimNonEmpty = (value: string | undefined): string | null => { | |
| 33 | const trimmed = value?.trim(); | |
| 34 | return trimmed && trimmed.length > 0 ? trimmed : null; | |
| 35 | }; | |
| 36 | ||
| 37 | const EarlyDesktopSettingsJson = fromLenientJson( | |
| 38 | Schema.Struct({ | |
| 39 | linuxPasswordStore: Schema.optionalKey(Schema.Unknown), | |
| 40 | }), | |
| 41 | ); | |
| 42 | const decodeEarlyDesktopSettingsJson = Schema.decodeSync(EarlyDesktopSettingsJson); | |
| 43 | ||
| 44 | const isDevelopmentEnvironment = (env: NodeJS.ProcessEnv): boolean => | |
| 45 | trimNonEmpty(env.VITE_DEV_SERVER_URL) !== null; | |
| 46 | ||
| 47 | function resolveEarlyDesktopSettingsPath(input: { | |
| 48 | readonly env: NodeJS.ProcessEnv; | |
| 49 | readonly homeDirectory: string; | |
| 50 | readonly joinPath: JoinPath; | |
| 51 | }): string { | |
| 52 | const t3Home = Option.fromUndefinedOr(input.env.T3CODE_HOME); | |
| 53 | const baseDir = resolveDesktopBaseDir({ | |
| 54 | homeDirectory: input.homeDirectory, | |
| 55 | joinPath: input.joinPath, | |
| 56 | t3Home, | |
| 57 | }); | |
| 58 | const stateDir = resolveDesktopStateDir({ | |
| 59 | baseDir, | |
| 60 | isDevelopment: isDevelopmentEnvironment(input.env), | |
| 61 | joinPath: input.joinPath, | |
| 62 | t3Home, | |
| 63 | }); | |
| 64 | return input.joinPath(stateDir, "desktop-settings.json"); | |
| 65 | } | |
| 66 | ||
| 67 | export function resolveEarlyLinuxPasswordStorePreference( | |
| 68 | input: EarlyDesktopSettingsInput, | |
| 69 | ): LinuxPasswordStorePreference { | |
| 70 | const settingsPath = resolveEarlyDesktopSettingsPath(input); | |
| 71 | try { | |
| 72 | const parsed = decodeEarlyDesktopSettingsJson(input.readFileString(settingsPath)); | |
| 73 | return normalizeLinuxPasswordStorePreference(parsed.linuxPasswordStore); | |
| 74 | } catch { | |
| 75 | return DEFAULT_LINUX_PASSWORD_STORE; | |
| 76 | } | |
| 77 | } | |
| 78 | ||
| 79 | export function resolveEarlyLinuxElectronOptions( | |
| 80 | input: EarlyLinuxElectronOptionsInput, | |
| 81 | ): EarlyLinuxElectronOptions { | |
| 82 | const preference = resolveEarlyLinuxPasswordStorePreference(input); | |
| 83 | return { | |
| 84 | linuxWmClass: isDevelopmentEnvironment(input.env) ? "t3code-dev" : "t3code", | |
| 85 | passwordStore: resolveLinuxPasswordStoreSwitch({ | |
| 86 | preference, | |
| 87 | env: input.env, | |
| 88 | }), | |
| 89 | }; | |
| 90 | } |
apps/desktop/src/app/DesktopEnvironment.ts+11-5| ... | ... | @@ -13,6 +13,7 @@ import * as Path from "effect/Path"; |
| 13 | 13 | |
| 14 | 14 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 15 | 15 | import * as DesktopConfig from "./DesktopConfig.ts"; |
| 16 | import { resolveDesktopBaseDir, resolveDesktopStateDir } from "./DesktopStatePaths.ts"; | |
| 16 | 17 | import { isNightlyDesktopVersion } from "../updates/updateChannels.ts"; |
| 17 | 18 | |
| 18 | 19 | export interface MakeDesktopEnvironmentInput { |
| ... | ... | @@ -149,8 +150,11 @@ const make = Effect.fn("desktop.environment.make")(function* ( |
| 149 | 150 | : input.platform === "darwin" |
| 150 | 151 | ? path.join(homeDirectory, "Library", "Application Support") |
| 151 | 152 | : Option.getOrElse(config.xdgConfigHome, () => path.join(homeDirectory, ".config")); |
| 152 | const configuredBaseDir = config.t3Home; | |
| 153 | const baseDir = Option.getOrElse(configuredBaseDir, () => path.join(homeDirectory, ".t3")); | |
| 153 | const baseDir = resolveDesktopBaseDir({ | |
| 154 | homeDirectory, | |
| 155 | joinPath: path.join, | |
| 156 | t3Home: config.t3Home, | |
| 157 | }); | |
| 154 | 158 | const rootDir = path.resolve(input.dirname, "../../.."); |
| 155 | 159 | const appRoot = input.isPackaged ? input.appPath : rootDir; |
| 156 | 160 | const branding = resolveDesktopAppBranding({ |
| ... | ... | @@ -158,10 +162,12 @@ const make = Effect.fn("desktop.environment.make")(function* ( |
| 158 | 162 | appVersion: input.appVersion, |
| 159 | 163 | }); |
| 160 | 164 | const displayName = branding.displayName; |
| 161 | const stateDir = path.join( | |
| 165 | const stateDir = resolveDesktopStateDir({ | |
| 162 | 166 | baseDir, |
| 163 | isDevelopment && Option.isNone(configuredBaseDir) ? "dev" : "userdata", | |
| 164 | ); | |
| 167 | isDevelopment, | |
| 168 | joinPath: path.join, | |
| 169 | t3Home: config.t3Home, | |
| 170 | }); | |
| 165 | 171 | const userDataDirName = isDevelopment ? "t3code-dev" : "t3code"; |
| 166 | 172 | const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; |
| 167 | 173 | const linuxApplicationsDir = path.join( |
apps/desktop/src/app/DesktopLifecycle.test.ts+1| ... | ... | @@ -35,6 +35,7 @@ describe("DesktopLifecycle", () => { |
| 35 | 35 | setDesktopName: () => Effect.void, |
| 36 | 36 | setDockIcon: () => Effect.void, |
| 37 | 37 | appendCommandLineSwitch: () => Effect.void, |
| 38 | removeCommandLineSwitch: () => Effect.void, | |
| 38 | 39 | onBeforeQuitForUpdate: (listener) => |
| 39 | 40 | Effect.acquireRelease( |
| 40 | 41 | Effect.sync(() => { |
apps/desktop/src/app/DesktopPreReadyPlatform.test.ts created+130| ... | ... | @@ -0,0 +1,130 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 3 | import * as Context from "effect/Context"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import { beforeEach, vi } from "vite-plus/test"; | |
| 7 | ||
| 8 | const { appendSwitchMock, getSwitchValueMock, hasSwitchMock, registerSchemesMock } = vi.hoisted( | |
| 9 | () => ({ | |
| 10 | appendSwitchMock: vi.fn(), | |
| 11 | getSwitchValueMock: vi.fn(), | |
| 12 | hasSwitchMock: vi.fn(), | |
| 13 | registerSchemesMock: vi.fn(), | |
| 14 | }), | |
| 15 | ); | |
| 16 | ||
| 17 | vi.mock("electron", () => ({ | |
| 18 | app: { | |
| 19 | commandLine: { | |
| 20 | appendSwitch: appendSwitchMock, | |
| 21 | getSwitchValue: getSwitchValueMock, | |
| 22 | hasSwitch: hasSwitchMock, | |
| 23 | }, | |
| 24 | }, | |
| 25 | protocol: { | |
| 26 | registerSchemesAsPrivileged: registerSchemesMock, | |
| 27 | }, | |
| 28 | })); | |
| 29 | ||
| 30 | import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; | |
| 31 | ||
| 32 | describe("DesktopPreReadyPlatform", () => { | |
| 33 | beforeEach(() => { | |
| 34 | appendSwitchMock.mockReset(); | |
| 35 | getSwitchValueMock.mockReset(); | |
| 36 | hasSwitchMock.mockReset(); | |
| 37 | registerSchemesMock.mockReset(); | |
| 38 | }); | |
| 39 | ||
| 40 | it("reads an explicit Electron command-line switch value", () => { | |
| 41 | const value = DesktopPreReadyPlatform.readCommandLineSwitchValue( | |
| 42 | { | |
| 43 | hasSwitch: (switchName) => switchName === "password-store", | |
| 44 | getSwitchValue: (switchName) => { | |
| 45 | assert.equal(switchName, "password-store"); | |
| 46 | return "basic"; | |
| 47 | }, | |
| 48 | }, | |
| 49 | "password-store", | |
| 50 | ); | |
| 51 | ||
| 52 | assert.equal(value, "basic"); | |
| 53 | }); | |
| 54 | ||
| 55 | it("treats valueless Electron command-line switches as absent", () => { | |
| 56 | const value = DesktopPreReadyPlatform.readCommandLineSwitchValue( | |
| 57 | { | |
| 58 | hasSwitch: () => true, | |
| 59 | getSwitchValue: () => "", | |
| 60 | }, | |
| 61 | "password-store", | |
| 62 | ); | |
| 63 | ||
| 64 | assert.isNull(value); | |
| 65 | }); | |
| 66 | ||
| 67 | it("returns null for missing Electron command-line switches", () => { | |
| 68 | const value = DesktopPreReadyPlatform.readCommandLineSwitchValue( | |
| 69 | { | |
| 70 | hasSwitch: () => false, | |
| 71 | getSwitchValue: () => { | |
| 72 | throw new Error("Unexpected switch value read."); | |
| 73 | }, | |
| 74 | }, | |
| 75 | "password-store", | |
| 76 | ); | |
| 77 | ||
| 78 | assert.isNull(value); | |
| 79 | }); | |
| 80 | ||
| 81 | it.effect( | |
| 82 | "acquires a synchronous pre-ready layer before an asynchronous Clerk-shaped layer", | |
| 83 | () => | |
| 84 | Effect.gen(function* () { | |
| 85 | class ClerkShaped extends Context.Service<ClerkShaped, { readonly ready: true }>()( | |
| 86 | "@t3tools/desktop/app/DesktopPreReadyPlatform.test/ClerkShaped", | |
| 87 | ) {} | |
| 88 | ||
| 89 | const events: Array<string> = []; | |
| 90 | registerSchemesMock.mockImplementation(() => { | |
| 91 | events.push("pre-ready"); | |
| 92 | }); | |
| 93 | ||
| 94 | const preReadyLayer = DesktopPreReadyPlatform.layer.pipe( | |
| 95 | Layer.provide(Layer.succeed(HostProcessPlatform, "darwin")), | |
| 96 | ); | |
| 97 | ||
| 98 | const clerkShapedLayer = Layer.effect( | |
| 99 | ClerkShaped, | |
| 100 | Effect.promise(() => Promise.resolve()).pipe( | |
| 101 | Effect.map(() => { | |
| 102 | events.push("clerk"); | |
| 103 | return { ready: true as const }; | |
| 104 | }), | |
| 105 | ), | |
| 106 | ); | |
| 107 | ||
| 108 | const runtimeLayer = clerkShapedLayer.pipe( | |
| 109 | Layer.flatMap((clerkContext) => Layer.succeedContext(clerkContext)), | |
| 110 | Layer.provideMerge(preReadyLayer), | |
| 111 | ); | |
| 112 | ||
| 113 | const result = yield* Effect.all({ | |
| 114 | clerk: ClerkShaped, | |
| 115 | preReady: DesktopPreReadyPlatform.DesktopPreReadyElectronOptions, | |
| 116 | }).pipe(Effect.provide(runtimeLayer)); | |
| 117 | ||
| 118 | assert.deepEqual(result, { | |
| 119 | clerk: { ready: true }, | |
| 120 | preReady: { | |
| 121 | linux: null, | |
| 122 | linuxPasswordStoreCommandLine: null, | |
| 123 | }, | |
| 124 | }); | |
| 125 | assert.deepEqual(events, ["pre-ready", "clerk"]); | |
| 126 | assert.equal(registerSchemesMock.mock.calls.length, 1); | |
| 127 | assert.equal(appendSwitchMock.mock.calls.length, 0); | |
| 128 | }), | |
| 129 | ); | |
| 130 | }); |
apps/desktop/src/app/DesktopPreReadyPlatform.ts created+74| ... | ... | @@ -0,0 +1,74 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off - pre-ready Electron setup reads persisted settings synchronously before app services are available. | |
| 2 | import * as NodeFS from "node:fs"; | |
| 3 | import * as NodeOS from "node:os"; | |
| 4 | import * as NodePath from "node:path"; | |
| 5 | import * as Context from "effect/Context"; | |
| 6 | import * as Effect from "effect/Effect"; | |
| 7 | import * as Layer from "effect/Layer"; | |
| 8 | ||
| 9 | import * as Electron from "electron"; | |
| 10 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 11 | ||
| 12 | import * as DesktopEarlyElectronStartup from "./DesktopEarlyElectronStartup.ts"; | |
| 13 | import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; | |
| 14 | ||
| 15 | export interface DesktopPreReadyCommandLineReader { | |
| 16 | readonly hasSwitch: (switchName: string) => boolean; | |
| 17 | readonly getSwitchValue: (switchName: string) => string; | |
| 18 | } | |
| 19 | ||
| 20 | export function readCommandLineSwitchValue( | |
| 21 | commandLine: DesktopPreReadyCommandLineReader, | |
| 22 | switchName: string, | |
| 23 | ): string | null { | |
| 24 | if (!commandLine.hasSwitch(switchName)) { | |
| 25 | return null; | |
| 26 | } | |
| 27 | ||
| 28 | const value = commandLine.getSwitchValue(switchName).trim(); | |
| 29 | return value.length > 0 ? value : null; | |
| 30 | } | |
| 31 | ||
| 32 | export const resolveEarlyLinuxElectronOptionsFromProcess = | |
| 33 | (): DesktopEarlyElectronStartup.EarlyLinuxElectronOptions => | |
| 34 | DesktopEarlyElectronStartup.resolveEarlyLinuxElectronOptions({ | |
| 35 | env: process.env, | |
| 36 | homeDirectory: NodeOS.homedir(), | |
| 37 | joinPath: NodePath.posix.join, | |
| 38 | readFileString: (path) => NodeFS.readFileSync(path, "utf8"), | |
| 39 | }); | |
| 40 | ||
| 41 | export class DesktopPreReadyElectronOptions extends Context.Service< | |
| 42 | DesktopPreReadyElectronOptions, | |
| 43 | { | |
| 44 | readonly linux: DesktopEarlyElectronStartup.EarlyLinuxElectronOptions | null; | |
| 45 | readonly linuxPasswordStoreCommandLine: string | null; | |
| 46 | } | |
| 47 | >()("@t3tools/desktop/app/DesktopPreReadyPlatform/DesktopPreReadyElectronOptions") {} | |
| 48 | ||
| 49 | export const make = Effect.gen(function* () { | |
| 50 | const platform = yield* HostProcessPlatform; | |
| 51 | return yield* Effect.sync((): DesktopPreReadyElectronOptions["Service"] => { | |
| 52 | const linuxPasswordStoreCommandLine = | |
| 53 | platform === "linux" | |
| 54 | ? readCommandLineSwitchValue(Electron.app.commandLine, "password-store") | |
| 55 | : null; | |
| 56 | const linux = platform === "linux" ? resolveEarlyLinuxElectronOptionsFromProcess() : null; | |
| 57 | ||
| 58 | if (linux !== null) { | |
| 59 | Electron.app.commandLine.appendSwitch("class", linux.linuxWmClass); | |
| 60 | if (linux.passwordStore !== null && linuxPasswordStoreCommandLine === null) { | |
| 61 | Electron.app.commandLine.appendSwitch("password-store", linux.passwordStore); | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 65 | return { linux, linuxPasswordStoreCommandLine }; | |
| 66 | }); | |
| 67 | }).pipe(Effect.withSpan("desktop.electron.configureBeforeReady")); | |
| 68 | ||
| 69 | // Keep Electron's strict pre-ready setup isolated so later runtime layers cannot | |
| 70 | // observe app readiness before scheme privileges and command-line switches exist. | |
| 71 | export const layer = Layer.mergeAll( | |
| 72 | ElectronProtocol.layerSchemePrivileges, | |
| 73 | Layer.effect(DesktopPreReadyElectronOptions, make), | |
| 74 | ); |
apps/desktop/src/app/DesktopStatePaths.ts created+32| ... | ... | @@ -0,0 +1,32 @@ |
| 1 | import * as Option from "effect/Option"; | |
| 2 | ||
| 3 | export type JoinPath = (first: string, ...segments: string[]) => string; | |
| 4 | ||
| 5 | function normalizeConfiguredBaseDir(t3Home: Option.Option<string>): Option.Option<string> { | |
| 6 | if (Option.isNone(t3Home)) { | |
| 7 | return Option.none(); | |
| 8 | } | |
| 9 | const trimmed = t3Home.value.trim(); | |
| 10 | return trimmed.length > 0 ? Option.some(trimmed) : Option.none(); | |
| 11 | } | |
| 12 | ||
| 13 | export function resolveDesktopBaseDir(input: { | |
| 14 | readonly homeDirectory: string; | |
| 15 | readonly joinPath: JoinPath; | |
| 16 | readonly t3Home: Option.Option<string>; | |
| 17 | }): string { | |
| 18 | return Option.getOrElse(normalizeConfiguredBaseDir(input.t3Home), () => | |
| 19 | input.joinPath(input.homeDirectory, ".t3"), | |
| 20 | ); | |
| 21 | } | |
| 22 | ||
| 23 | export function resolveDesktopStateDir(input: { | |
| 24 | readonly baseDir: string; | |
| 25 | readonly isDevelopment: boolean; | |
| 26 | readonly joinPath: JoinPath; | |
| 27 | readonly t3Home: Option.Option<string>; | |
| 28 | }): string { | |
| 29 | const useDevSubdir = | |
| 30 | input.isDevelopment && Option.isNone(normalizeConfiguredBaseDir(input.t3Home)); | |
| 31 | return input.joinPath(input.baseDir, useDevSubdir ? "dev" : "userdata"); | |
| 32 | } |
apps/desktop/src/electron/ElectronApp.test.ts+13| ... | ... | @@ -14,6 +14,7 @@ const { |
| 14 | 14 | quitMock, |
| 15 | 15 | relaunchMock, |
| 16 | 16 | removeListenerMock, |
| 17 | removeSwitchMock, | |
| 17 | 18 | setAboutPanelOptionsMock, |
| 18 | 19 | setAppUserModelIdMock, |
| 19 | 20 | setAsDefaultProtocolClientMock, |
| ... | ... | @@ -34,6 +35,7 @@ const { |
| 34 | 35 | quitMock: vi.fn(), |
| 35 | 36 | relaunchMock: vi.fn(), |
| 36 | 37 | removeListenerMock: vi.fn(), |
| 38 | removeSwitchMock: vi.fn(), | |
| 37 | 39 | setAboutPanelOptionsMock: vi.fn(), |
| 38 | 40 | setAppUserModelIdMock: vi.fn(), |
| 39 | 41 | setAsDefaultProtocolClientMock: vi.fn(() => true), |
| ... | ... | @@ -52,6 +54,7 @@ vi.mock("electron", () => ({ |
| 52 | 54 | app: { |
| 53 | 55 | commandLine: { |
| 54 | 56 | appendSwitch: appendSwitchMock, |
| 57 | removeSwitch: removeSwitchMock, | |
| 55 | 58 | }, |
| 56 | 59 | dock: { |
| 57 | 60 | setIcon: setDockIconMock, |
| ... | ... | @@ -89,6 +92,7 @@ describe("ElectronApp", () => { |
| 89 | 92 | quitMock.mockClear(); |
| 90 | 93 | relaunchMock.mockClear(); |
| 91 | 94 | removeListenerMock.mockClear(); |
| 95 | removeSwitchMock.mockClear(); | |
| 92 | 96 | setPathMock.mockClear(); |
| 93 | 97 | }); |
| 94 | 98 | |
| ... | ... | @@ -178,4 +182,13 @@ describe("ElectronApp", () => { |
| 178 | 182 | ]); |
| 179 | 183 | }).pipe(Effect.provide(ElectronApp.layer)), |
| 180 | 184 | ); |
| 185 | ||
| 186 | it.effect("removes command-line switches through the service", () => | |
| 187 | Effect.gen(function* () { | |
| 188 | const electronApp = yield* ElectronApp.ElectronApp; | |
| 189 | yield* electronApp.removeCommandLineSwitch("password-store"); | |
| 190 | ||
| 191 | assert.deepEqual(removeSwitchMock.mock.calls, [["password-store"]]); | |
| 192 | }).pipe(Effect.provide(ElectronApp.layer)), | |
| 193 | ); | |
| 181 | 194 | }); |
apps/desktop/src/electron/ElectronApp.ts+5| ... | ... | @@ -69,6 +69,7 @@ export class ElectronApp extends Context.Service< |
| 69 | 69 | readonly onBeforeQuitForUpdate: ( |
| 70 | 70 | listener: () => void, |
| 71 | 71 | ) => Effect.Effect<void, never, Scope.Scope>; |
| 72 | readonly removeCommandLineSwitch: (switchName: string) => Effect.Effect<void>; | |
| 72 | 73 | readonly on: <Args extends ReadonlyArray<unknown>>( |
| 73 | 74 | eventName: string, |
| 74 | 75 | listener: (...args: Args) => void, |
| ... | ... | @@ -191,6 +192,10 @@ export const make = ElectronApp.of({ |
| 191 | 192 | Electron.autoUpdater.removeListener("before-quit-for-update", listener); |
| 192 | 193 | }), |
| 193 | 194 | ).pipe(Effect.asVoid), |
| 195 | removeCommandLineSwitch: (switchName) => | |
| 196 | Effect.sync(() => { | |
| 197 | Electron.app.commandLine.removeSwitch(switchName); | |
| 198 | }), | |
| 194 | 199 | on: addScopedAppListener, |
| 195 | 200 | }); |
| 196 | 201 |
apps/desktop/src/electron/ElectronProtocol.ts+32| ... | ... | @@ -105,6 +105,38 @@ function withContentSecurityPolicy(response: Response, policy: string): Response |
| 105 | 105 | }); |
| 106 | 106 | } |
| 107 | 107 | |
| 108 | /** | |
| 109 | * Must run synchronously during process bootstrap, before Electron emits `ready`. | |
| 110 | */ | |
| 111 | export function registerDesktopSchemePrivilegesSync(): void { | |
| 112 | Electron.protocol.registerSchemesAsPrivileged([ | |
| 113 | { | |
| 114 | scheme: DESKTOP_PRODUCTION_SCHEME, | |
| 115 | privileges: { | |
| 116 | standard: true, | |
| 117 | secure: true, | |
| 118 | supportFetchAPI: true, | |
| 119 | corsEnabled: true, | |
| 120 | }, | |
| 121 | }, | |
| 122 | { | |
| 123 | scheme: DESKTOP_DEVELOPMENT_SCHEME, | |
| 124 | privileges: { | |
| 125 | standard: true, | |
| 126 | secure: true, | |
| 127 | supportFetchAPI: true, | |
| 128 | corsEnabled: true, | |
| 129 | }, | |
| 130 | }, | |
| 131 | ]); | |
| 132 | } | |
| 133 | ||
| 134 | const registerDesktopSchemePrivileges = Effect.sync(registerDesktopSchemePrivilegesSync).pipe( | |
| 135 | Effect.withSpan("desktop.electron.protocol.registerSchemePrivileges"), | |
| 136 | ); | |
| 137 | ||
| 138 | export const layerSchemePrivileges = Layer.effectDiscard(registerDesktopSchemePrivileges); | |
| 139 | ||
| 108 | 140 | async function proxyRequest( |
| 109 | 141 | request: Request, |
| 110 | 142 | targetOrigin: URL, |
apps/desktop/src/electron/ElectronSafeStorage.ts+31-14| ... | ... | @@ -1,9 +1,11 @@ |
| 1 | 1 | import * as Context from "effect/Context"; |
| 2 | 2 | import * as Effect from "effect/Effect"; |
| 3 | 3 | import * as Layer from "effect/Layer"; |
| 4 | import * as Option from "effect/Option"; | |
| 4 | 5 | import * as Schema from "effect/Schema"; |
| 5 | 6 | |
| 6 | 7 | import * as Electron from "electron"; |
| 8 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 7 | 9 | |
| 8 | 10 | const electronSafeStorageErrorFields = { |
| 9 | 11 | cause: Schema.Defect(), |
| ... | ... | @@ -60,24 +62,39 @@ export class ElectronSafeStorage extends Context.Service< |
| 60 | 62 | readonly decryptString: ( |
| 61 | 63 | value: Uint8Array, |
| 62 | 64 | ) => Effect.Effect<string, ElectronSafeStorageDecryptError>; |
| 65 | readonly selectedStorageBackend: Effect.Effect<Option.Option<string>>; | |
| 63 | 66 | } |
| 64 | 67 | >()("@t3tools/desktop/electron/ElectronSafeStorage") {} |
| 65 | 68 | |
| 66 | export const make = ElectronSafeStorage.of({ | |
| 67 | isEncryptionAvailable: Effect.try({ | |
| 68 | try: () => Electron.safeStorage.isEncryptionAvailable(), | |
| 69 | catch: (cause) => new ElectronSafeStorageAvailabilityError({ cause }), | |
| 70 | }), | |
| 71 | encryptString: (value) => | |
| 72 | Effect.try({ | |
| 73 | try: () => Electron.safeStorage.encryptString(value), | |
| 74 | catch: (cause) => new ElectronSafeStorageEncryptError({ cause }), | |
| 69 | export const make = Effect.gen(function* () { | |
| 70 | const platform = yield* HostProcessPlatform; | |
| 71 | ||
| 72 | return ElectronSafeStorage.of({ | |
| 73 | isEncryptionAvailable: Effect.try({ | |
| 74 | try: () => Electron.safeStorage.isEncryptionAvailable(), | |
| 75 | catch: (cause) => new ElectronSafeStorageAvailabilityError({ cause }), | |
| 75 | 76 | }), |
| 76 | decryptString: (value) => | |
| 77 | Effect.try({ | |
| 78 | try: () => Electron.safeStorage.decryptString(Buffer.from(value)), | |
| 79 | catch: (cause) => new ElectronSafeStorageDecryptError({ cause }), | |
| 77 | encryptString: (value) => | |
| 78 | Effect.try({ | |
| 79 | try: () => Electron.safeStorage.encryptString(value), | |
| 80 | catch: (cause) => new ElectronSafeStorageEncryptError({ cause }), | |
| 81 | }), | |
| 82 | decryptString: (value) => | |
| 83 | Effect.try({ | |
| 84 | try: () => Electron.safeStorage.decryptString(Buffer.from(value)), | |
| 85 | catch: (cause) => new ElectronSafeStorageDecryptError({ cause }), | |
| 86 | }), | |
| 87 | selectedStorageBackend: Effect.sync(() => { | |
| 88 | if (platform !== "linux") { | |
| 89 | return Option.none(); | |
| 90 | } | |
| 91 | try { | |
| 92 | return Option.fromNullishOr(Electron.safeStorage.getSelectedStorageBackend()); | |
| 93 | } catch { | |
| 94 | return Option.none(); | |
| 95 | } | |
| 80 | 96 | }), |
| 97 | }); | |
| 81 | 98 | }); |
| 82 | 99 | |
| 83 | export const layer = Layer.succeed(ElectronSafeStorage, make); | |
| 100 | export const layer = Layer.effect(ElectronSafeStorage, make); |
apps/desktop/src/linuxSecretStorage.test.ts created+203| ... | ... | @@ -0,0 +1,203 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | normalizeLinuxPasswordStorePreference, | |
| 5 | resolveLinuxPasswordStoreSwitch, | |
| 6 | resolveLinuxSecretStorageUnavailableMessage, | |
| 7 | } from "./linuxSecretStorage.ts"; | |
| 8 | ||
| 9 | const autoSwitch = (env: NodeJS.ProcessEnv) => | |
| 10 | resolveLinuxPasswordStoreSwitch({ preference: "auto", env }); | |
| 11 | ||
| 12 | describe("linuxSecretStorage", () => { | |
| 13 | it("preserves explicit supported password-store preferences", () => { | |
| 14 | expect(normalizeLinuxPasswordStorePreference("gnome-libsecret")).toBe("gnome-libsecret"); | |
| 15 | expect(normalizeLinuxPasswordStorePreference("kwallet")).toBe("kwallet"); | |
| 16 | expect(normalizeLinuxPasswordStorePreference("kwallet5")).toBe("kwallet5"); | |
| 17 | expect(normalizeLinuxPasswordStorePreference("kwallet6")).toBe("kwallet6"); | |
| 18 | }); | |
| 19 | ||
| 20 | it("falls back to auto for missing or unsupported preferences", () => { | |
| 21 | expect(normalizeLinuxPasswordStorePreference(undefined)).toBe("auto"); | |
| 22 | expect(normalizeLinuxPasswordStorePreference("basic")).toBe("auto"); | |
| 23 | }); | |
| 24 | ||
| 25 | it("uses explicit preferences instead of the auto heuristic", () => { | |
| 26 | for (const preference of ["gnome-libsecret", "kwallet", "kwallet5", "kwallet6"] as const) { | |
| 27 | expect( | |
| 28 | resolveLinuxPasswordStoreSwitch({ preference, env: { XDG_CURRENT_DESKTOP: "niri" } }), | |
| 29 | ).toBe(preference); | |
| 30 | // An explicit preference also wins where auto would have stayed out of the way. | |
| 31 | expect( | |
| 32 | resolveLinuxPasswordStoreSwitch({ preference, env: { XDG_CURRENT_DESKTOP: "KDE" } }), | |
| 33 | ).toBe(preference); | |
| 34 | } | |
| 35 | }); | |
| 36 | ||
| 37 | it("leaves canonical KDE sessions to Electron's own wallet selection", () => { | |
| 38 | expect(autoSwitch({ XDG_CURRENT_DESKTOP: "KDE" })).toBeNull(); | |
| 39 | expect(autoSwitch({ XDG_CURRENT_DESKTOP: "KDE", KDE_SESSION_VERSION: "6" })).toBeNull(); | |
| 40 | expect(autoSwitch({ XDG_CURRENT_DESKTOP: "KDE", KDE_SESSION_VERSION: "5" })).toBeNull(); | |
| 41 | expect(autoSwitch({ XDG_CURRENT_DESKTOP: "KDE:plasma" })).toBeNull(); | |
| 42 | }); | |
| 43 | ||
| 44 | it("does not force a password-store for desktops Electron already recognizes", () => { | |
| 45 | expect(autoSwitch({ XDG_CURRENT_DESKTOP: "GNOME" })).toBeNull(); | |
| 46 | for (const desktop of ["Deepin", "Pantheon", "UKUI", "Unity", "X-Cinnamon", "XFCE"]) { | |
| 47 | expect(autoSwitch({ XDG_CURRENT_DESKTOP: desktop })).toBeNull(); | |
| 48 | } | |
| 49 | }); | |
| 50 | ||
| 51 | it("recognizes a known desktop later in a colon-separated XDG_CURRENT_DESKTOP list", () => { | |
| 52 | expect(autoSwitch({ XDG_CURRENT_DESKTOP: "niri:GNOME" })).toBeNull(); | |
| 53 | expect(autoSwitch({ XDG_CURRENT_DESKTOP: "ubuntu:GNOME" })).toBeNull(); | |
| 54 | }); | |
| 55 | ||
| 56 | it("forces gnome-libsecret for unrecognized Linux desktop sessions", () => { | |
| 57 | expect(autoSwitch({ XDG_CURRENT_DESKTOP: "niri" })).toBe("gnome-libsecret"); | |
| 58 | expect(autoSwitch({ XDG_CURRENT_DESKTOP: "Hyprland" })).toBe("gnome-libsecret"); | |
| 59 | expect(autoSwitch({})).toBe("gnome-libsecret"); | |
| 60 | }); | |
| 61 | ||
| 62 | it("forces gnome-libsecret for desktops Electron recognizes but leaves on basic text", () => { | |
| 63 | expect(autoSwitch({ XDG_CURRENT_DESKTOP: "LXQt" })).toBe("gnome-libsecret"); | |
| 64 | // Chromium stops at the first recognized value, so a later name cannot rescue the session. | |
| 65 | expect(autoSwitch({ XDG_CURRENT_DESKTOP: "LXQt:GNOME" })).toBe("gnome-libsecret"); | |
| 66 | expect(autoSwitch({ XDG_CURRENT_DESKTOP: "LXQt:KDE" })).toBe("gnome-libsecret"); | |
| 67 | expect(autoSwitch({ XDG_CURRENT_DESKTOP: "LXQt:plasma" })).toBe("gnome-libsecret"); | |
| 68 | }); | |
| 69 | ||
| 70 | it("does not treat lowercase desktop names as ones Electron recognizes", () => { | |
| 71 | // Chromium matches these case-sensitively, so lowercase spellings reach basic text. | |
| 72 | expect(autoSwitch({ XDG_CURRENT_DESKTOP: "gnome" })).toBe("gnome-libsecret"); | |
| 73 | expect(autoSwitch({ XDG_CURRENT_DESKTOP: "xfce" })).toBe("gnome-libsecret"); | |
| 74 | expect(autoSwitch({ XDG_CURRENT_DESKTOP: "kde", KDE_SESSION_VERSION: "6" })).toBe( | |
| 75 | "gnome-libsecret", | |
| 76 | ); | |
| 77 | }); | |
| 78 | ||
| 79 | it("overrides KDE sessions identified only by legacy variables", () => { | |
| 80 | // Chromium reaches KWallet4 for some of these, such as DESKTOP_SESSION=kde with a version, and | |
| 81 | // basic text for the rest. Either way these are the variables a previous session leaves behind, | |
| 82 | // so they are treated as unproven and forced to a real keyring rather than a guessed wallet. | |
| 83 | expect(autoSwitch({ XDG_CURRENT_DESKTOP: "plasma" })).toBe("gnome-libsecret"); | |
| 84 | for (const session of [ | |
| 85 | "kde", | |
| 86 | "kde-plasma", | |
| 87 | "kde4", | |
| 88 | "plasma", | |
| 89 | "plasmawayland", | |
| 90 | "plasmawayland-dev", | |
| 91 | "plasmax11", | |
| 92 | "plasmax11-dev", | |
| 93 | ]) { | |
| 94 | expect(autoSwitch({ DESKTOP_SESSION: session })).toBe("gnome-libsecret"); | |
| 95 | expect(autoSwitch({ XDG_SESSION_DESKTOP: session })).toBe("gnome-libsecret"); | |
| 96 | expect(autoSwitch({ GDMSESSION: session })).toBe("gnome-libsecret"); | |
| 97 | } | |
| 98 | expect(autoSwitch({ DESKTOP_SESSION: "kde", KDE_SESSION_VERSION: "6" })).toBe( | |
| 99 | "gnome-libsecret", | |
| 100 | ); | |
| 101 | expect(autoSwitch({ KDE_SESSION_VERSION: "6" })).toBe("gnome-libsecret"); | |
| 102 | expect(autoSwitch({ KDE_FULL_SESSION: "true", KDE_SESSION_VERSION: "6" })).toBe( | |
| 103 | "gnome-libsecret", | |
| 104 | ); | |
| 105 | }); | |
| 106 | ||
| 107 | it("ignores stale session hints when XDG_CURRENT_DESKTOP is authoritative", () => { | |
| 108 | expect( | |
| 109 | autoSwitch({ XDG_CURRENT_DESKTOP: "niri", DESKTOP_SESSION: "gnome", GDMSESSION: "gnome" }), | |
| 110 | ).toBe("gnome-libsecret"); | |
| 111 | // A previous KDE session left these behind; the compositor running now is not KDE. | |
| 112 | expect(autoSwitch({ XDG_CURRENT_DESKTOP: "niri", KDE_SESSION_VERSION: "6" })).toBe( | |
| 113 | "gnome-libsecret", | |
| 114 | ); | |
| 115 | expect(autoSwitch({ XDG_CURRENT_DESKTOP: "niri", XDG_SESSION_DESKTOP: "GNOME" })).toBe( | |
| 116 | "gnome-libsecret", | |
| 117 | ); | |
| 118 | expect(autoSwitch({ XDG_CURRENT_DESKTOP: "niri:plasma" })).toBe("gnome-libsecret"); | |
| 119 | expect( | |
| 120 | autoSwitch({ | |
| 121 | XDG_CURRENT_DESKTOP: "Hyprland", | |
| 122 | XDG_SESSION_DESKTOP: "KDE", | |
| 123 | KDE_SESSION_VERSION: "6", | |
| 124 | }), | |
| 125 | ).toBe("gnome-libsecret"); | |
| 126 | }); | |
| 127 | ||
| 128 | it("uses GNOME Keyring remediation for libsecret and unknown backends", () => { | |
| 129 | expect( | |
| 130 | resolveLinuxSecretStorageUnavailableMessage({ | |
| 131 | configuredPreference: "auto", | |
| 132 | selectedBackend: "gnome_libsecret", | |
| 133 | env: { XDG_CURRENT_DESKTOP: "niri" }, | |
| 134 | }), | |
| 135 | ).toContain("GNOME Keyring"); | |
| 136 | }); | |
| 137 | ||
| 138 | it("prefers explicit libsecret selection over KDE desktop heuristics", () => { | |
| 139 | expect( | |
| 140 | resolveLinuxSecretStorageUnavailableMessage({ | |
| 141 | configuredPreference: "gnome-libsecret", | |
| 142 | selectedBackend: "unknown", | |
| 143 | env: { XDG_CURRENT_DESKTOP: "KDE" }, | |
| 144 | }), | |
| 145 | ).toContain("GNOME Keyring"); | |
| 146 | expect( | |
| 147 | resolveLinuxSecretStorageUnavailableMessage({ | |
| 148 | configuredPreference: "auto", | |
| 149 | selectedBackend: "gnome_libsecret", | |
| 150 | env: { XDG_CURRENT_DESKTOP: "KDE" }, | |
| 151 | }), | |
| 152 | ).toContain("GNOME Keyring"); | |
| 153 | }); | |
| 154 | ||
| 155 | it("prefers explicit KWallet preference over selected gnome-libsecret backend", () => { | |
| 156 | expect( | |
| 157 | resolveLinuxSecretStorageUnavailableMessage({ | |
| 158 | configuredPreference: "kwallet6", | |
| 159 | selectedBackend: "gnome_libsecret", | |
| 160 | env: { XDG_CURRENT_DESKTOP: "niri" }, | |
| 161 | }), | |
| 162 | ).toContain("KWallet"); | |
| 163 | expect( | |
| 164 | resolveLinuxSecretStorageUnavailableMessage({ | |
| 165 | configuredPreference: "kwallet", | |
| 166 | selectedBackend: "gnome-libsecret", | |
| 167 | env: {}, | |
| 168 | }), | |
| 169 | ).toContain("KWallet"); | |
| 170 | }); | |
| 171 | ||
| 172 | it("uses KWallet remediation wording for KDE-looking sessions", () => { | |
| 173 | expect( | |
| 174 | resolveLinuxSecretStorageUnavailableMessage({ | |
| 175 | configuredPreference: "auto", | |
| 176 | selectedBackend: "kwallet6", | |
| 177 | env: {}, | |
| 178 | }), | |
| 179 | ).toContain("KWallet"); | |
| 180 | expect( | |
| 181 | resolveLinuxSecretStorageUnavailableMessage({ | |
| 182 | configuredPreference: "auto", | |
| 183 | selectedBackend: "unknown", | |
| 184 | env: { XDG_CURRENT_DESKTOP: "KDE" }, | |
| 185 | }), | |
| 186 | ).toContain("KWallet"); | |
| 187 | expect( | |
| 188 | resolveLinuxSecretStorageUnavailableMessage({ | |
| 189 | configuredPreference: "auto", | |
| 190 | selectedBackend: "unknown", | |
| 191 | env: { DESKTOP_SESSION: "plasmawayland" }, | |
| 192 | }), | |
| 193 | ).toContain("KWallet"); | |
| 194 | // A desktop name outranks a bare KDE marker when choosing the wording. | |
| 195 | expect( | |
| 196 | resolveLinuxSecretStorageUnavailableMessage({ | |
| 197 | configuredPreference: "auto", | |
| 198 | selectedBackend: "unknown", | |
| 199 | env: { GDMSESSION: "gnome", KDE_FULL_SESSION: "true" }, | |
| 200 | }), | |
| 201 | ).toContain("GNOME Keyring"); | |
| 202 | }); | |
| 203 | }); |
apps/desktop/src/linuxSecretStorage.ts created+178| ... | ... | @@ -0,0 +1,178 @@ |
| 1 | export type LinuxPasswordStorePreference = | |
| 2 | | "auto" | |
| 3 | | "gnome-libsecret" | |
| 4 | | "kwallet" | |
| 5 | | "kwallet5" | |
| 6 | | "kwallet6"; | |
| 7 | export type LinuxPasswordStoreSwitch = Exclude<LinuxPasswordStorePreference, "auto">; | |
| 8 | ||
| 9 | export const DEFAULT_LINUX_PASSWORD_STORE: LinuxPasswordStorePreference = "auto"; | |
| 10 | ||
| 11 | // Chromium matches XDG_CURRENT_DESKTOP values case-sensitively and returns on the first value it | |
| 12 | // recognizes, so these stay exact literals and are scanned in order. Omitting a real desktop fails | |
| 13 | // safe: we force gnome-libsecret, which is the backend Chromium selects for all of these anyway. | |
| 14 | const ELECTRON_LIBSECRET_DESKTOPS = new Set([ | |
| 15 | "Deepin", | |
| 16 | "GNOME", | |
| 17 | "Pantheon", | |
| 18 | "UKUI", | |
| 19 | "Unity", | |
| 20 | "X-Cinnamon", | |
| 21 | "XFCE", | |
| 22 | ]); | |
| 23 | // Chromium selects a KWallet generation for KDE from KDE_SESSION_VERSION, so it needs no help. | |
| 24 | const ELECTRON_KDE_DESKTOP = "KDE"; | |
| 25 | // Chromium recognizes LXQt and still selects basic text for it, so it does need a forced backend. | |
| 26 | const ELECTRON_UNPROTECTED_DESKTOPS = new Set(["LXQt"]); | |
| 27 | ||
| 28 | const KDE_NAME_PREFIXES = ["kde", "plasma"]; | |
| 29 | const NEGATIVE_FLAG_VALUES = new Set(["0", "false", "no", "off"]); | |
| 30 | ||
| 31 | export function normalizeLinuxPasswordStorePreference( | |
| 32 | value: unknown, | |
| 33 | ): LinuxPasswordStorePreference { | |
| 34 | return value === "gnome-libsecret" || | |
| 35 | value === "kwallet" || | |
| 36 | value === "kwallet5" || | |
| 37 | value === "kwallet6" | |
| 38 | ? value | |
| 39 | : DEFAULT_LINUX_PASSWORD_STORE; | |
| 40 | } | |
| 41 | ||
| 42 | // Auto mode asks one question: will Electron select a real keyring on its own? If so, stay out of | |
| 43 | // the way, which is how canonical KDE sessions keep the KWallet generation Chromium picks for them. | |
| 44 | // Otherwise force gnome-libsecret, because the alternative is basic text, which is barely | |
| 45 | // encryption at all. Forcing never guesses a KWallet generation; a KDE session that needs a | |
| 46 | // specific one sets linuxPasswordStore explicitly. | |
| 47 | export function resolveLinuxPasswordStoreSwitch(input: { | |
| 48 | readonly preference: LinuxPasswordStorePreference; | |
| 49 | readonly env: NodeJS.ProcessEnv; | |
| 50 | }): LinuxPasswordStoreSwitch | null { | |
| 51 | if (input.preference !== "auto") { | |
| 52 | return input.preference; | |
| 53 | } | |
| 54 | ||
| 55 | return electronSelectsProtectedBackend(input.env) ? null : "gnome-libsecret"; | |
| 56 | } | |
| 57 | ||
| 58 | // Only an exact XDG_CURRENT_DESKTOP literal proves Electron will protect the session. Chromium can | |
| 59 | // also reach a real backend through DESKTOP_SESSION and the legacy KDE markers, but those are the | |
| 60 | // variables a previous session leaves behind, and trusting them is what let stale hints suppress | |
| 61 | // the forced backend before. Forcing where Chromium would have chosen libsecret is harmless, since | |
| 62 | // it lands on the same backend. | |
| 63 | function electronSelectsProtectedBackend(env: NodeJS.ProcessEnv): boolean { | |
| 64 | for (const name of splitDesktopNameList(env.XDG_CURRENT_DESKTOP)) { | |
| 65 | const trimmed = name.trim(); | |
| 66 | if (trimmed.length === 0) { | |
| 67 | continue; | |
| 68 | } | |
| 69 | if (trimmed === ELECTRON_KDE_DESKTOP || ELECTRON_LIBSECRET_DESKTOPS.has(trimmed)) { | |
| 70 | return true; | |
| 71 | } | |
| 72 | if (ELECTRON_UNPROTECTED_DESKTOPS.has(trimmed)) { | |
| 73 | return false; | |
| 74 | } | |
| 75 | } | |
| 76 | ||
| 77 | return false; | |
| 78 | } | |
| 79 | ||
| 80 | export function resolveLinuxSecretStorageUnavailableMessage(input: { | |
| 81 | readonly configuredPreference: LinuxPasswordStorePreference; | |
| 82 | readonly selectedBackend: string | null; | |
| 83 | readonly env: NodeJS.ProcessEnv; | |
| 84 | }): string { | |
| 85 | if (input.configuredPreference === "gnome-libsecret") { | |
| 86 | return getGnomeKeyringRemediationMessage(); | |
| 87 | } | |
| 88 | ||
| 89 | if ( | |
| 90 | input.configuredPreference === "kwallet" || | |
| 91 | input.configuredPreference === "kwallet5" || | |
| 92 | input.configuredPreference === "kwallet6" | |
| 93 | ) { | |
| 94 | return getKWalletRemediationMessage(); | |
| 95 | } | |
| 96 | ||
| 97 | const backend = normalizeSelectedStorageBackend(input.selectedBackend); | |
| 98 | if (backend === "gnome-libsecret") { | |
| 99 | return getGnomeKeyringRemediationMessage(); | |
| 100 | } | |
| 101 | ||
| 102 | if ( | |
| 103 | backend === "kwallet" || | |
| 104 | backend === "kwallet5" || | |
| 105 | backend === "kwallet6" || | |
| 106 | looksLikeKdeSession(input.env) | |
| 107 | ) { | |
| 108 | return getKWalletRemediationMessage(); | |
| 109 | } | |
| 110 | ||
| 111 | return getGnomeKeyringRemediationMessage(); | |
| 112 | } | |
| 113 | ||
| 114 | function getGnomeKeyringRemediationMessage(): string { | |
| 115 | return "T3 Code could not access GNOME Keyring to save this environment credential. Install and start GNOME Keyring, then restart T3 Code."; | |
| 116 | } | |
| 117 | ||
| 118 | function getKWalletRemediationMessage(): string { | |
| 119 | return "T3 Code could not access KWallet to save this environment credential. Enable the KDE wallet subsystem in System Settings, then restart T3 Code."; | |
| 120 | } | |
| 121 | ||
| 122 | // Advisory only: this picks between the GNOME Keyring and KWallet wording in the failure notice. It | |
| 123 | // never decides which backend to select, so a loose match costs a user slightly wrong instructions | |
| 124 | // rather than an unprotected credential store. | |
| 125 | function looksLikeKdeSession(env: NodeJS.ProcessEnv): boolean { | |
| 126 | const currentDesktopNames = nonEmptyDesktopNames(env.XDG_CURRENT_DESKTOP); | |
| 127 | if (currentDesktopNames.length > 0) { | |
| 128 | return currentDesktopNames.some(isKdeDesktopName); | |
| 129 | } | |
| 130 | ||
| 131 | const legacyNames = legacyDesktopNames(env); | |
| 132 | if (legacyNames.length > 0) { | |
| 133 | return legacyNames.some(isKdeDesktopName); | |
| 134 | } | |
| 135 | ||
| 136 | return isSet(env.KDE_SESSION_VERSION) || isAffirmativeFlag(env.KDE_FULL_SESSION); | |
| 137 | } | |
| 138 | ||
| 139 | function isKdeDesktopName(name: string): boolean { | |
| 140 | return KDE_NAME_PREFIXES.some((prefix) => name.startsWith(prefix)); | |
| 141 | } | |
| 142 | ||
| 143 | function legacyDesktopNames(env: NodeJS.ProcessEnv): string[] { | |
| 144 | return [env.XDG_SESSION_DESKTOP, env.DESKTOP_SESSION, env.GDMSESSION].flatMap((entry) => { | |
| 145 | const normalized = normalizeDesktopName(entry); | |
| 146 | return normalized ? [normalized] : []; | |
| 147 | }); | |
| 148 | } | |
| 149 | ||
| 150 | function nonEmptyDesktopNames(value: string | undefined): string[] { | |
| 151 | return splitDesktopNameList(value).flatMap((entry) => { | |
| 152 | const normalized = normalizeDesktopName(entry); | |
| 153 | return normalized ? [normalized] : []; | |
| 154 | }); | |
| 155 | } | |
| 156 | ||
| 157 | function isSet(value: string | undefined): boolean { | |
| 158 | return Boolean(value?.trim()); | |
| 159 | } | |
| 160 | ||
| 161 | function isAffirmativeFlag(value: string | undefined): boolean { | |
| 162 | const normalized = value?.trim().toLowerCase(); | |
| 163 | return normalized ? !NEGATIVE_FLAG_VALUES.has(normalized) : false; | |
| 164 | } | |
| 165 | ||
| 166 | function splitDesktopNameList(value: string | undefined): string[] { | |
| 167 | return value?.split(":") ?? []; | |
| 168 | } | |
| 169 | ||
| 170 | function normalizeDesktopName(value: string | undefined): string | null { | |
| 171 | const normalized = value?.trim().toLowerCase(); | |
| 172 | return normalized && normalized.length > 0 ? normalized : null; | |
| 173 | } | |
| 174 | ||
| 175 | function normalizeSelectedStorageBackend(value: string | null): string | null { | |
| 176 | const normalized = value?.trim().toLowerCase().replace(/_/gu, "-"); | |
| 177 | return normalized && normalized.length > 0 ? normalized : null; | |
| 178 | } |
apps/desktop/src/main.ts+12-7| ... | ... | @@ -50,6 +50,7 @@ import * as DesktopServerExposure from "./backend/DesktopServerExposure.ts"; |
| 50 | 50 | import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts"; |
| 51 | 51 | import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts"; |
| 52 | 52 | import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts"; |
| 53 | import * as DesktopPreReadyPlatform from "./app/DesktopPreReadyPlatform.ts"; | |
| 53 | 54 | import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; |
| 54 | 55 | import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; |
| 55 | 56 | import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; |
| ... | ... | @@ -197,16 +198,20 @@ const desktopClerkLayer = DesktopClerk.layer.pipe( |
| 197 | 198 | Layer.provideMerge(ElectronApp.layer), |
| 198 | 199 | ); |
| 199 | 200 | |
| 201 | const desktopApplicationRuntimeLayer = desktopApplicationLayer.pipe( | |
| 202 | Layer.provideMerge(NodeServices.layer), | |
| 203 | Layer.provideMerge(NodeHttpClient.layerUndici), | |
| 204 | Layer.provideMerge(NetService.layer), | |
| 205 | Layer.provideMerge(electronLayer), | |
| 206 | ); | |
| 207 | ||
| 208 | // Acquire strict pre-ready setup before Clerk, whose userData resolution can | |
| 209 | // yield and let Electron emit ready. | |
| 200 | 210 | const desktopRuntimeLayer = desktopClerkLayer.pipe( |
| 201 | 211 | Layer.flatMap((clerkContext) => |
| 202 | desktopApplicationLayer.pipe( | |
| 203 | Layer.provideMerge(Layer.succeedContext(clerkContext)), | |
| 204 | Layer.provideMerge(NodeServices.layer), | |
| 205 | Layer.provideMerge(NodeHttpClient.layerUndici), | |
| 206 | Layer.provideMerge(NetService.layer), | |
| 207 | Layer.provideMerge(electronLayer), | |
| 208 | ), | |
| 212 | desktopApplicationRuntimeLayer.pipe(Layer.provideMerge(Layer.succeedContext(clerkContext))), | |
| 209 | 213 | ), |
| 214 | Layer.provideMerge(DesktopPreReadyPlatform.layer), | |
| 210 | 215 | ); |
| 211 | 216 | |
| 212 | 217 | DesktopApp.program.pipe(Effect.provide(desktopRuntimeLayer), NodeRuntime.runMain); |
apps/desktop/src/settings/DesktopAppSettings.test.ts+48| ... | ... | @@ -11,6 +11,9 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 11 | 11 | import * as DesktopAppSettings from "./DesktopAppSettings.ts"; |
| 12 | 12 | |
| 13 | 13 | const DesktopSettingsPatch = Schema.Struct({ |
| 14 | linuxPasswordStore: Schema.optionalKey( | |
| 15 | Schema.Literals(["auto", "gnome-libsecret", "kwallet", "kwallet5", "kwallet6"]), | |
| 16 | ), | |
| 14 | 17 | mainWindowBounds: Schema.optionalKey( |
| 15 | 18 | Schema.NullOr( |
| 16 | 19 | Schema.Struct({ |
| ... | ... | @@ -102,6 +105,7 @@ describe("DesktopSettings", () => { |
| 102 | 105 | assert.deepEqual( |
| 103 | 106 | DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), |
| 104 | 107 | { |
| 108 | linuxPasswordStore: "auto", | |
| 105 | 109 | mainWindowBounds: null, |
| 106 | 110 | mainWindowMaximized: false, |
| 107 | 111 | serverExposureMode: "local-only", |
| ... | ... | @@ -121,6 +125,7 @@ describe("DesktopSettings", () => { |
| 121 | 125 | Effect.gen(function* () { |
| 122 | 126 | const settings = yield* DesktopAppSettings.DesktopAppSettings; |
| 123 | 127 | yield* writeSettingsPatch({ |
| 128 | linuxPasswordStore: "gnome-libsecret", | |
| 124 | 129 | serverExposureMode: "network-accessible", |
| 125 | 130 | tailscaleServeEnabled: true, |
| 126 | 131 | tailscaleServePort: 8443, |
| ... | ... | @@ -129,6 +134,7 @@ describe("DesktopSettings", () => { |
| 129 | 134 | }); |
| 130 | 135 | |
| 131 | 136 | assert.deepEqual(yield* settings.load, { |
| 137 | linuxPasswordStore: "gnome-libsecret", | |
| 132 | 138 | mainWindowBounds: null, |
| 133 | 139 | mainWindowMaximized: false, |
| 134 | 140 | serverExposureMode: "network-accessible", |
| ... | ... | @@ -235,6 +241,7 @@ describe("DesktopSettings", () => { |
| 235 | 241 | ); |
| 236 | 242 | |
| 237 | 243 | assert.deepEqual(yield* settings.load, { |
| 244 | linuxPasswordStore: "auto", | |
| 238 | 245 | mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 }, |
| 239 | 246 | mainWindowMaximized: false, |
| 240 | 247 | serverExposureMode: "network-accessible", |
| ... | ... | @@ -268,6 +275,44 @@ describe("DesktopSettings", () => { |
| 268 | 275 | ), |
| 269 | 276 | ); |
| 270 | 277 | |
| 278 | it.effect( | |
| 279 | "normalizes unsupported linux password-store values without dropping other settings", | |
| 280 | () => | |
| 281 | withSettings( | |
| 282 | Effect.gen(function* () { | |
| 283 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 284 | const fileSystem = yield* FileSystem.FileSystem; | |
| 285 | const settings = yield* DesktopAppSettings.DesktopAppSettings; | |
| 286 | yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); | |
| 287 | yield* fileSystem.writeFileString( | |
| 288 | environment.desktopSettingsPath, | |
| 289 | `{ | |
| 290 | "linuxPasswordStore": "unsupported-store", | |
| 291 | "serverExposureMode": "network-accessible", | |
| 292 | "tailscaleServeEnabled": true, | |
| 293 | "tailscaleServePort": 8443, | |
| 294 | "updateChannel": "nightly", | |
| 295 | "updateChannelConfiguredByUser": true | |
| 296 | }\n`, | |
| 297 | ); | |
| 298 | ||
| 299 | assert.deepEqual(yield* settings.load, { | |
| 300 | linuxPasswordStore: "auto", | |
| 301 | mainWindowBounds: null, | |
| 302 | mainWindowMaximized: false, | |
| 303 | serverExposureMode: "network-accessible", | |
| 304 | tailscaleServeEnabled: true, | |
| 305 | tailscaleServePort: 8443, | |
| 306 | updateChannel: "nightly", | |
| 307 | updateChannelConfiguredByUser: true, | |
| 308 | wslBackendEnabled: false, | |
| 309 | wslOnly: false, | |
| 310 | wslDistro: null, | |
| 311 | } satisfies DesktopAppSettings.DesktopSettings); | |
| 312 | }), | |
| 313 | ), | |
| 314 | ); | |
| 315 | ||
| 271 | 316 | it.effect("persists sparse desktop settings documents", () => |
| 272 | 317 | withSettings( |
| 273 | 318 | Effect.gen(function* () { |
| ... | ... | @@ -300,6 +345,7 @@ describe("DesktopSettings", () => { |
| 300 | 345 | }); |
| 301 | 346 | |
| 302 | 347 | assert.deepEqual(yield* settings.load, { |
| 348 | linuxPasswordStore: "auto", | |
| 303 | 349 | mainWindowBounds: null, |
| 304 | 350 | mainWindowMaximized: false, |
| 305 | 351 | serverExposureMode: "local-only", |
| ... | ... | @@ -327,6 +373,7 @@ describe("DesktopSettings", () => { |
| 327 | 373 | }); |
| 328 | 374 | |
| 329 | 375 | assert.deepEqual(yield* settings.load, { |
| 376 | linuxPasswordStore: "auto", | |
| 330 | 377 | mainWindowBounds: null, |
| 331 | 378 | mainWindowMaximized: false, |
| 332 | 379 | serverExposureMode: "local-only", |
| ... | ... | @@ -353,6 +400,7 @@ describe("DesktopSettings", () => { |
| 353 | 400 | }); |
| 354 | 401 | |
| 355 | 402 | assert.deepEqual(yield* settings.load, { |
| 403 | linuxPasswordStore: "auto", | |
| 356 | 404 | mainWindowBounds: null, |
| 357 | 405 | mainWindowMaximized: false, |
| 358 | 406 | serverExposureMode: "local-only", |
apps/desktop/src/settings/DesktopAppSettings.ts+12| ... | ... | @@ -16,10 +16,16 @@ import * as Schema from "effect/Schema"; |
| 16 | 16 | import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 17 | 17 | |
| 18 | 18 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 19 | import { | |
| 20 | DEFAULT_LINUX_PASSWORD_STORE, | |
| 21 | normalizeLinuxPasswordStorePreference, | |
| 22 | type LinuxPasswordStorePreference, | |
| 23 | } from "../linuxSecretStorage.ts"; | |
| 19 | 24 | import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts"; |
| 20 | 25 | import { isValidDistroName } from "../wsl/wslPathParsing.ts"; |
| 21 | 26 | |
| 22 | 27 | export interface DesktopSettings { |
| 28 | readonly linuxPasswordStore: LinuxPasswordStorePreference; | |
| 23 | 29 | readonly mainWindowBounds: DesktopWindowBounds | null; |
| 24 | 30 | readonly mainWindowMaximized: boolean; |
| 25 | 31 | readonly serverExposureMode: DesktopServerExposureMode; |
| ... | ... | @@ -67,6 +73,7 @@ export const DEFAULT_MAIN_WINDOW_SIZE = { |
| 67 | 73 | } as const; |
| 68 | 74 | |
| 69 | 75 | export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { |
| 76 | linuxPasswordStore: DEFAULT_LINUX_PASSWORD_STORE, | |
| 70 | 77 | mainWindowBounds: null, |
| 71 | 78 | mainWindowMaximized: false, |
| 72 | 79 | serverExposureMode: "local-only", |
| ... | ... | @@ -87,6 +94,7 @@ const DesktopWindowBoundsDocument = Schema.Struct({ |
| 87 | 94 | }); |
| 88 | 95 | |
| 89 | 96 | const DesktopSettingsDocument = Schema.Struct({ |
| 97 | linuxPasswordStore: Schema.optionalKey(Schema.Unknown), | |
| 90 | 98 | mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)), |
| 91 | 99 | mainWindowMaximized: Schema.optionalKey(Schema.Boolean), |
| 92 | 100 | serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema), |
| ... | ... | @@ -216,6 +224,7 @@ function normalizeDesktopSettingsDocument( |
| 216 | 224 | (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl"); |
| 217 | 225 | |
| 218 | 226 | return { |
| 227 | linuxPasswordStore: normalizeLinuxPasswordStorePreference(parsed.linuxPasswordStore), | |
| 219 | 228 | mainWindowBounds, |
| 220 | 229 | mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true, |
| 221 | 230 | serverExposureMode: |
| ... | ... | @@ -238,6 +247,9 @@ function toDesktopSettingsDocument( |
| 238 | 247 | ): DesktopSettingsDocument { |
| 239 | 248 | const document: Mutable<DesktopSettingsDocument> = {}; |
| 240 | 249 | |
| 250 | if (settings.linuxPasswordStore !== defaults.linuxPasswordStore) { | |
| 251 | document.linuxPasswordStore = settings.linuxPasswordStore; | |
| 252 | } | |
| 241 | 253 | if (settings.mainWindowBounds !== null) { |
| 242 | 254 | document.mainWindowBounds = settings.mainWindowBounds; |
| 243 | 255 | } |
apps/desktop/src/settings/DesktopSavedEnvironments.test.ts+1| ... | ... | @@ -86,6 +86,7 @@ function makeSafeStorageLayer(input: { |
| 86 | 86 | } |
| 87 | 87 | return Effect.succeed(decoded.slice("enc:".length)); |
| 88 | 88 | }, |
| 89 | selectedStorageBackend: Effect.succeed(Option.none()), | |
| 89 | 90 | } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]); |
| 90 | 91 | } |
| 91 | 92 |
apps/desktop/src/shell/DesktopShellEnvironment.test.ts+61-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 1 | 2 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | 3 | import * as Effect from "effect/Effect"; |
| 3 | 4 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -90,7 +91,7 @@ function runShellEnvironment(input: { |
| 90 | 91 | }).pipe( |
| 91 | 92 | Effect.provide( |
| 92 | 93 | DesktopShellEnvironment.layer.pipe( |
| 93 | Layer.provide(Layer.mergeAll(environmentLayer, spawnerLayer)), | |
| 94 | Layer.provide(Layer.mergeAll(environmentLayer, NodeServices.layer, spawnerLayer)), | |
| 94 | 95 | ), |
| 95 | 96 | ), |
| 96 | 97 | ); |
| ... | ... | @@ -243,6 +244,65 @@ describe("DesktopShellEnvironment", () => { |
| 243 | 244 | }), |
| 244 | 245 | ); |
| 245 | 246 | |
| 247 | it.effect("prefers login-shell desktop session hints over inherited values on linux", () => | |
| 248 | Effect.gen(function* () { | |
| 249 | const env: NodeJS.ProcessEnv = { | |
| 250 | SHELL: "/bin/zsh", | |
| 251 | PATH: "/usr/bin", | |
| 252 | XDG_CURRENT_DESKTOP: "wrong-launcher", | |
| 253 | XDG_SESSION_DESKTOP: "wrong-launcher", | |
| 254 | }; | |
| 255 | ||
| 256 | yield* runShellEnvironment({ | |
| 257 | env, | |
| 258 | platform: "linux", | |
| 259 | handler: () => | |
| 260 | envOutput({ | |
| 261 | PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin", | |
| 262 | XDG_CURRENT_DESKTOP: "KDE", | |
| 263 | XDG_SESSION_DESKTOP: "KDE", | |
| 264 | XDG_SESSION_TYPE: "wayland", | |
| 265 | }), | |
| 266 | }); | |
| 267 | ||
| 268 | assert.equal(env.XDG_CURRENT_DESKTOP, "KDE"); | |
| 269 | assert.equal(env.XDG_SESSION_DESKTOP, "KDE"); | |
| 270 | assert.equal(env.XDG_SESSION_TYPE, "wayland"); | |
| 271 | }), | |
| 272 | ); | |
| 273 | ||
| 274 | it.effect("overrides stale dbus session addresses from the login shell", () => | |
| 275 | Effect.gen(function* () { | |
| 276 | const env: NodeJS.ProcessEnv = { | |
| 277 | SHELL: "/bin/zsh", | |
| 278 | PATH: "/usr/bin", | |
| 279 | DBUS_SESSION_BUS_ADDRESS: "unix:path=/tmp/stale-bus", | |
| 280 | }; | |
| 281 | ||
| 282 | yield* runShellEnvironment({ | |
| 283 | env, | |
| 284 | platform: "linux", | |
| 285 | handler: () => | |
| 286 | envOutput({ | |
| 287 | PATH: "/usr/bin", | |
| 288 | DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/1000/bus", | |
| 289 | }), | |
| 290 | }); | |
| 291 | ||
| 292 | assert.equal(env.DBUS_SESSION_BUS_ADDRESS, "unix:path=/run/user/1000/bus"); | |
| 293 | }), | |
| 294 | ); | |
| 295 | ||
| 296 | it("resolves dbus runtime dir candidates with existence checks", () => { | |
| 297 | const busPath = DesktopShellEnvironment.resolveDefaultLinuxDbusSessionBusAddress({ | |
| 298 | env: { XDG_RUNTIME_DIR: "/tmp/stale-runtime" }, | |
| 299 | uid: 1000, | |
| 300 | exists: (path) => path === "/run/user/1000/bus", | |
| 301 | }); | |
| 302 | ||
| 303 | assert.equal(busPath, "unix:path=/run/user/1000/bus"); | |
| 304 | }); | |
| 305 | ||
| 246 | 306 | it.effect("logs command failures with safe probe context and the exact cause", () => { |
| 247 | 307 | const env: NodeJS.ProcessEnv = { |
| 248 | 308 | SHELL: "/bin/bash", |
apps/desktop/src/shell/DesktopShellEnvironment.ts+89-2| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import * as Context from "effect/Context"; |
| 2 | 2 | import * as Duration from "effect/Duration"; |
| 3 | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; | |
| 4 | 5 | import * as Layer from "effect/Layer"; |
| 5 | 6 | import * as Option from "effect/Option"; |
| 6 | 7 | import * as Schema from "effect/Schema"; |
| ... | ... | @@ -68,12 +69,19 @@ export class DesktopShellEnvironment extends Context.Service< |
| 68 | 69 | |
| 69 | 70 | const LOGIN_SHELL_ENV_NAMES = [ |
| 70 | 71 | "PATH", |
| 72 | "DBUS_SESSION_BUS_ADDRESS", | |
| 73 | "DISPLAY", | |
| 71 | 74 | "SSH_AUTH_SOCK", |
| 72 | 75 | "HOMEBREW_PREFIX", |
| 73 | 76 | "HOMEBREW_CELLAR", |
| 74 | 77 | "HOMEBREW_REPOSITORY", |
| 75 | 78 | "XDG_CONFIG_HOME", |
| 79 | "XDG_CURRENT_DESKTOP", | |
| 76 | 80 | "XDG_DATA_HOME", |
| 81 | "XDG_RUNTIME_DIR", | |
| 82 | "XDG_SESSION_DESKTOP", | |
| 83 | "XDG_SESSION_TYPE", | |
| 84 | "WAYLAND_DISPLAY", | |
| 77 | 85 | ] as const; |
| 78 | 86 | const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const; |
| 79 | 87 | const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; |
| ... | ... | @@ -92,6 +100,47 @@ const pathDelimiter = (platform: NodeJS.Platform) => (platform === "win32" ? ";" |
| 92 | 100 | const readEnvPath = (env: NodeJS.ProcessEnv): Option.Option<string> => |
| 93 | 101 | trimNonEmpty(env.PATH ?? env.Path ?? env.path); |
| 94 | 102 | |
| 103 | const normalizeRuntimeDir = (value: string): string => value.replace(/\/+$/u, ""); | |
| 104 | ||
| 105 | const linuxRuntimeDirCandidates = ( | |
| 106 | env: NodeJS.ProcessEnv, | |
| 107 | uid: number | undefined, | |
| 108 | ): ReadonlyArray<string> => { | |
| 109 | const candidates: string[] = []; | |
| 110 | const fromEnv = trimNonEmpty(env.XDG_RUNTIME_DIR); | |
| 111 | if (Option.isSome(fromEnv)) { | |
| 112 | candidates.push(normalizeRuntimeDir(fromEnv.value)); | |
| 113 | } | |
| 114 | if (uid !== undefined) { | |
| 115 | candidates.push(`/run/user/${uid}`); | |
| 116 | } | |
| 117 | return candidates.filter((candidate) => candidate.length > 0); | |
| 118 | }; | |
| 119 | ||
| 120 | function resolveDefaultLinuxDbusSessionBusPath(input: { | |
| 121 | readonly env: NodeJS.ProcessEnv; | |
| 122 | readonly uid: number | undefined; | |
| 123 | readonly exists?: (path: string) => boolean; | |
| 124 | }): string | null { | |
| 125 | for (const runtimeDir of linuxRuntimeDirCandidates(input.env, input.uid)) { | |
| 126 | const busPath = `${runtimeDir}/bus`; | |
| 127 | if (input.exists === undefined || input.exists(busPath)) { | |
| 128 | return busPath; | |
| 129 | } | |
| 130 | } | |
| 131 | ||
| 132 | return null; | |
| 133 | } | |
| 134 | ||
| 135 | export function resolveDefaultLinuxDbusSessionBusAddress(input: { | |
| 136 | readonly env: NodeJS.ProcessEnv; | |
| 137 | readonly exists: (path: string) => boolean; | |
| 138 | readonly uid: number | undefined; | |
| 139 | }): string | null { | |
| 140 | const busPath = resolveDefaultLinuxDbusSessionBusPath(input); | |
| 141 | return busPath !== null && input.exists(busPath) ? `unix:path=${busPath}` : null; | |
| 142 | } | |
| 143 | ||
| 95 | 144 | const pathComparisonKey = (entry: string, platform: NodeJS.Platform) => { |
| 96 | 145 | const normalized = entry.trim().replace(/^"+|"+$/g, ""); |
| 97 | 146 | return platform === "win32" ? normalized.toLowerCase() : normalized; |
| ... | ... | @@ -356,7 +405,12 @@ const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWin |
| 356 | 405 | const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosixEnvironment")( |
| 357 | 406 | function* ( |
| 358 | 407 | config: ShellEnvironmentConfig, |
| 359 | ): Effect.fn.Return<void, never, ChildProcessSpawner.ChildProcessSpawner> { | |
| 408 | ): Effect.fn.Return< | |
| 409 | void, | |
| 410 | never, | |
| 411 | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | |
| 412 | > { | |
| 413 | const fileSystem = yield* FileSystem.FileSystem; | |
| 360 | 414 | const shellEnvironment: EnvironmentPatch = {}; |
| 361 | 415 | |
| 362 | 416 | for (const shell of listLoginShellCandidates(config)) { |
| ... | ... | @@ -383,23 +437,54 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix |
| 383 | 437 | config.env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK; |
| 384 | 438 | } |
| 385 | 439 | |
| 440 | const shellPreferredEnvNames = [ | |
| 441 | "DBUS_SESSION_BUS_ADDRESS", | |
| 442 | "XDG_CURRENT_DESKTOP", | |
| 443 | "XDG_SESSION_DESKTOP", | |
| 444 | "XDG_SESSION_TYPE", | |
| 445 | ] as const; | |
| 446 | for (const name of shellPreferredEnvNames) { | |
| 447 | if (shellEnvironment[name]) { | |
| 448 | config.env[name] = shellEnvironment[name]; | |
| 449 | } | |
| 450 | } | |
| 451 | ||
| 386 | 452 | for (const name of [ |
| 453 | "DISPLAY", | |
| 387 | 454 | "HOMEBREW_PREFIX", |
| 388 | 455 | "HOMEBREW_CELLAR", |
| 389 | 456 | "HOMEBREW_REPOSITORY", |
| 390 | 457 | "XDG_CONFIG_HOME", |
| 391 | 458 | "XDG_DATA_HOME", |
| 459 | "XDG_RUNTIME_DIR", | |
| 460 | "WAYLAND_DISPLAY", | |
| 392 | 461 | ] as const) { |
| 393 | 462 | if (!config.env[name] && shellEnvironment[name]) { |
| 394 | 463 | config.env[name] = shellEnvironment[name]; |
| 395 | 464 | } |
| 396 | 465 | } |
| 466 | ||
| 467 | if ( | |
| 468 | config.platform === "linux" && | |
| 469 | Option.isNone(trimNonEmpty(config.env.DBUS_SESSION_BUS_ADDRESS)) | |
| 470 | ) { | |
| 471 | for (const runtimeDir of linuxRuntimeDirCandidates(config.env, process.getuid?.())) { | |
| 472 | const dbusSessionBusPath = `${runtimeDir}/bus`; | |
| 473 | const busExists = yield* fileSystem | |
| 474 | .exists(dbusSessionBusPath) | |
| 475 | .pipe(Effect.orElseSucceed(() => false)); | |
| 476 | if (busExists) { | |
| 477 | config.env.DBUS_SESSION_BUS_ADDRESS = `unix:path=${dbusSessionBusPath}`; | |
| 478 | break; | |
| 479 | } | |
| 480 | } | |
| 481 | } | |
| 397 | 482 | }, |
| 398 | 483 | ); |
| 399 | 484 | |
| 400 | 485 | const installShellEnvironment = ( |
| 401 | 486 | config: ShellEnvironmentConfig, |
| 402 | ): Effect.Effect<void, never, ChildProcessSpawner.ChildProcessSpawner> => { | |
| 487 | ): Effect.Effect<void, never, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem> => { | |
| 403 | 488 | if (config.platform === "win32") { |
| 404 | 489 | return installWindowsEnvironment(config); |
| 405 | 490 | } |
| ... | ... | @@ -411,6 +496,7 @@ const installShellEnvironment = ( |
| 411 | 496 | |
| 412 | 497 | export const make = Effect.gen(function* () { |
| 413 | 498 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 499 | const fileSystem = yield* FileSystem.FileSystem; | |
| 414 | 500 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 415 | 501 | const installIntoProcess: DesktopShellEnvironment["Service"]["installIntoProcess"] = |
| 416 | 502 | installShellEnvironment({ |
| ... | ... | @@ -418,6 +504,7 @@ export const make = Effect.gen(function* () { |
| 418 | 504 | platform: environment.platform, |
| 419 | 505 | userShell: Option.none(), |
| 420 | 506 | }).pipe( |
| 507 | Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 421 | 508 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| 422 | 509 | Effect.withSpan("desktop.shellEnvironment.installIntoProcess"), |
| 423 | 510 | ); |
apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts+1| ... | ... | @@ -43,6 +43,7 @@ function makeElectronAppLayer( |
| 43 | 43 | setDesktopName: () => Effect.void, |
| 44 | 44 | setDockIcon: () => Effect.void, |
| 45 | 45 | appendCommandLineSwitch: () => Effect.void, |
| 46 | removeCommandLineSwitch: () => Effect.void, | |
| 46 | 47 | onBeforeQuitForUpdate: () => Effect.void, |
| 47 | 48 | on: () => Effect.void, |
| 48 | 49 | } satisfies ElectronApp.ElectronApp["Service"]); |
apps/desktop/src/window/DesktopApplicationMenu.test.ts+1| ... | ... | @@ -46,6 +46,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { |
| 46 | 46 | setDockIcon: () => Effect.void, |
| 47 | 47 | appendCommandLineSwitch: () => Effect.void, |
| 48 | 48 | onBeforeQuitForUpdate: () => Effect.void, |
| 49 | removeCommandLineSwitch: () => Effect.void, | |
| 49 | 50 | on: () => Effect.void, |
| 50 | 51 | } satisfies ElectronApp.ElectronApp["Service"]); |
| 51 | 52 |