authorgravatar for mwolson@member.fsf.orgMike Olson <mwolson@member.fsf.org> 2026-08-03 16:02:47-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-03 22:02:47+02:00
log6f04a5cffb8fcad95f709af69eb2da2605c4d472
tree629a95783fe41b9ffafca1dfcfac70f9c6f49b78
parent30c96228067bcd3a49e432ec898e52d4acb04297
signature Signed by PGP key B5690EEEBB952194

fix(desktop): Niri/Hyprland - Linux secret storage backend (#2916)

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";
99import * as ElectronApp from "../electron/ElectronApp.ts";
1010import * as ElectronDialog from "../electron/ElectronDialog.ts";
1111import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
12import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
1213import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts";
1314import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
1415import * as DesktopClerk from "./DesktopClerk.ts";
......@@ -19,6 +20,7 @@ import * as DesktopEnvironment from "./DesktopEnvironment.ts";
1920import * as DesktopLifecycle from "./DesktopLifecycle.ts";
2021import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts";
2122import * as DesktopObservability from "./DesktopObservability.ts";
23import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts";
2224import * as DesktopShutdown from "./DesktopShutdown.ts";
2325import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";
2426import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
......@@ -225,17 +227,45 @@ const startup = Effect.gen(function* () {
225227 const clerk = yield* DesktopClerk.DesktopClerk;
226228 const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment;
227229 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
230 const preReadyElectronOptions = yield* DesktopPreReadyPlatform.DesktopPreReadyElectronOptions;
231 const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage;
228232 const updates = yield* DesktopUpdates.DesktopUpdates;
229233 const environment = yield* DesktopEnvironment.DesktopEnvironment;
230234
231235 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 }
232256 const userDataPath = yield* appIdentity.resolveUserDataPath;
233257 yield* electronApp.setPath("userData", userDataPath);
234258 yield* logStartupInfo("runtime logging configured", { logDir: environment.logDir });
235259 yield* desktopSettings.load;
236260
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 });
239269 }
240270
241271 yield* appIdentity.configure;
......@@ -247,6 +277,12 @@ const startup = Effect.gen(function* () {
247277 Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)),
248278 );
249279 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 }
250286 yield* appIdentity.configure;
251287 yield* applicationMenu.configure;
252288 yield* updates.configure;
apps/desktop/src/app/DesktopAppIdentity.test.ts+1
......@@ -64,6 +64,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) =>
6464 }),
6565 appendCommandLineSwitch: () => Effect.void,
6666 onBeforeQuitForUpdate: () => Effect.void,
67 removeCommandLineSwitch: () => Effect.void,
6768 on: () => Effect.void,
6869 } satisfies ElectronApp.ElectronApp["Service"]);
6970
apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts+1
......@@ -39,6 +39,7 @@ function makeSafeStorageLayer(available: boolean, failDecrypt: Ref.Ref<boolean>
3939 return decoded.slice("encrypted:".length);
4040 });
4141 },
42 selectedStorageBackend: Effect.succeed(Option.none()),
4243 } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]);
4344}
4445
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.
2import * as NodePath from "node:path";
3import { assert, describe, it } from "@effect/vitest";
4
5import {
6 resolveEarlyLinuxElectronOptions,
7 resolveEarlyLinuxPasswordStorePreference,
8} from "./DesktopEarlyElectronStartup.ts";
9
10describe("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 @@
1import { fromLenientJson } from "@t3tools/shared/schemaJson";
2import * as Option from "effect/Option";
3import * as Schema from "effect/Schema";
4
5import {
6 DEFAULT_LINUX_PASSWORD_STORE,
7 normalizeLinuxPasswordStorePreference,
8 resolveLinuxPasswordStoreSwitch,
9 type LinuxPasswordStoreSwitch,
10 type LinuxPasswordStorePreference,
11} from "../linuxSecretStorage.ts";
12import {
13 resolveDesktopBaseDir,
14 resolveDesktopStateDir,
15 type JoinPath,
16} from "./DesktopStatePaths.ts";
17
18interface EarlyDesktopSettingsInput {
19 readonly env: NodeJS.ProcessEnv;
20 readonly homeDirectory: string;
21 readonly joinPath: JoinPath;
22 readonly readFileString: (path: string) => string;
23}
24
25type EarlyLinuxElectronOptionsInput = EarlyDesktopSettingsInput;
26
27export interface EarlyLinuxElectronOptions {
28 readonly linuxWmClass: string;
29 readonly passwordStore: LinuxPasswordStoreSwitch | null;
30}
31
32const trimNonEmpty = (value: string | undefined): string | null => {
33 const trimmed = value?.trim();
34 return trimmed && trimmed.length > 0 ? trimmed : null;
35};
36
37const EarlyDesktopSettingsJson = fromLenientJson(
38 Schema.Struct({
39 linuxPasswordStore: Schema.optionalKey(Schema.Unknown),
40 }),
41);
42const decodeEarlyDesktopSettingsJson = Schema.decodeSync(EarlyDesktopSettingsJson);
43
44const isDevelopmentEnvironment = (env: NodeJS.ProcessEnv): boolean =>
45 trimNonEmpty(env.VITE_DEV_SERVER_URL) !== null;
46
47function 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
67export 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
79export 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";
1313
1414import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
1515import * as DesktopConfig from "./DesktopConfig.ts";
16import { resolveDesktopBaseDir, resolveDesktopStateDir } from "./DesktopStatePaths.ts";
1617import { isNightlyDesktopVersion } from "../updates/updateChannels.ts";
1718
1819export interface MakeDesktopEnvironmentInput {
......@@ -149,8 +150,11 @@ const make = Effect.fn("desktop.environment.make")(function* (
149150 : input.platform === "darwin"
150151 ? path.join(homeDirectory, "Library", "Application Support")
151152 : 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 });
154158 const rootDir = path.resolve(input.dirname, "../../..");
155159 const appRoot = input.isPackaged ? input.appPath : rootDir;
156160 const branding = resolveDesktopAppBranding({
......@@ -158,10 +162,12 @@ const make = Effect.fn("desktop.environment.make")(function* (
158162 appVersion: input.appVersion,
159163 });
160164 const displayName = branding.displayName;
161 const stateDir = path.join(
165 const stateDir = resolveDesktopStateDir({
162166 baseDir,
163 isDevelopment && Option.isNone(configuredBaseDir) ? "dev" : "userdata",
164 );
167 isDevelopment,
168 joinPath: path.join,
169 t3Home: config.t3Home,
170 });
165171 const userDataDirName = isDevelopment ? "t3code-dev" : "t3code";
166172 const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
167173 const linuxApplicationsDir = path.join(
apps/desktop/src/app/DesktopLifecycle.test.ts+1
......@@ -35,6 +35,7 @@ describe("DesktopLifecycle", () => {
3535 setDesktopName: () => Effect.void,
3636 setDockIcon: () => Effect.void,
3737 appendCommandLineSwitch: () => Effect.void,
38 removeCommandLineSwitch: () => Effect.void,
3839 onBeforeQuitForUpdate: (listener) =>
3940 Effect.acquireRelease(
4041 Effect.sync(() => {
apps/desktop/src/app/DesktopPreReadyPlatform.test.ts created+130
......@@ -0,0 +1,130 @@
1import { assert, describe, it } from "@effect/vitest";
2import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import { beforeEach, vi } from "vite-plus/test";
7
8const { 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
17vi.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
30import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts";
31
32describe("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.
2import * as NodeFS from "node:fs";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5import * as Context from "effect/Context";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8
9import * as Electron from "electron";
10import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
11
12import * as DesktopEarlyElectronStartup from "./DesktopEarlyElectronStartup.ts";
13import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
14
15export interface DesktopPreReadyCommandLineReader {
16 readonly hasSwitch: (switchName: string) => boolean;
17 readonly getSwitchValue: (switchName: string) => string;
18}
19
20export 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
32export 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
41export 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
49export 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.
71export 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 @@
1import * as Option from "effect/Option";
2
3export type JoinPath = (first: string, ...segments: string[]) => string;
4
5function 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
13export 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
23export 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 {
1414 quitMock,
1515 relaunchMock,
1616 removeListenerMock,
17 removeSwitchMock,
1718 setAboutPanelOptionsMock,
1819 setAppUserModelIdMock,
1920 setAsDefaultProtocolClientMock,
......@@ -34,6 +35,7 @@ const {
3435 quitMock: vi.fn(),
3536 relaunchMock: vi.fn(),
3637 removeListenerMock: vi.fn(),
38 removeSwitchMock: vi.fn(),
3739 setAboutPanelOptionsMock: vi.fn(),
3840 setAppUserModelIdMock: vi.fn(),
3941 setAsDefaultProtocolClientMock: vi.fn(() => true),
......@@ -52,6 +54,7 @@ vi.mock("electron", () => ({
5254 app: {
5355 commandLine: {
5456 appendSwitch: appendSwitchMock,
57 removeSwitch: removeSwitchMock,
5558 },
5659 dock: {
5760 setIcon: setDockIconMock,
......@@ -89,6 +92,7 @@ describe("ElectronApp", () => {
8992 quitMock.mockClear();
9093 relaunchMock.mockClear();
9194 removeListenerMock.mockClear();
95 removeSwitchMock.mockClear();
9296 setPathMock.mockClear();
9397 });
9498
......@@ -178,4 +182,13 @@ describe("ElectronApp", () => {
178182 ]);
179183 }).pipe(Effect.provide(ElectronApp.layer)),
180184 );
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 );
181194});
apps/desktop/src/electron/ElectronApp.ts+5
......@@ -69,6 +69,7 @@ export class ElectronApp extends Context.Service<
6969 readonly onBeforeQuitForUpdate: (
7070 listener: () => void,
7171 ) => Effect.Effect<void, never, Scope.Scope>;
72 readonly removeCommandLineSwitch: (switchName: string) => Effect.Effect<void>;
7273 readonly on: <Args extends ReadonlyArray<unknown>>(
7374 eventName: string,
7475 listener: (...args: Args) => void,
......@@ -191,6 +192,10 @@ export const make = ElectronApp.of({
191192 Electron.autoUpdater.removeListener("before-quit-for-update", listener);
192193 }),
193194 ).pipe(Effect.asVoid),
195 removeCommandLineSwitch: (switchName) =>
196 Effect.sync(() => {
197 Electron.app.commandLine.removeSwitch(switchName);
198 }),
194199 on: addScopedAppListener,
195200});
196201
apps/desktop/src/electron/ElectronProtocol.ts+32
......@@ -105,6 +105,38 @@ function withContentSecurityPolicy(response: Response, policy: string): Response
105105 });
106106}
107107
108/**
109 * Must run synchronously during process bootstrap, before Electron emits `ready`.
110 */
111export 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
134const registerDesktopSchemePrivileges = Effect.sync(registerDesktopSchemePrivilegesSync).pipe(
135 Effect.withSpan("desktop.electron.protocol.registerSchemePrivileges"),
136);
137
138export const layerSchemePrivileges = Layer.effectDiscard(registerDesktopSchemePrivileges);
139
108140async function proxyRequest(
109141 request: Request,
110142 targetOrigin: URL,
apps/desktop/src/electron/ElectronSafeStorage.ts+31-14
......@@ -1,9 +1,11 @@
11import * as Context from "effect/Context";
22import * as Effect from "effect/Effect";
33import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";
45import * as Schema from "effect/Schema";
56
67import * as Electron from "electron";
8import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
79
810const electronSafeStorageErrorFields = {
911 cause: Schema.Defect(),
......@@ -60,24 +62,39 @@ export class ElectronSafeStorage extends Context.Service<
6062 readonly decryptString: (
6163 value: Uint8Array,
6264 ) => Effect.Effect<string, ElectronSafeStorageDecryptError>;
65 readonly selectedStorageBackend: Effect.Effect<Option.Option<string>>;
6366 }
6467>()("@t3tools/desktop/electron/ElectronSafeStorage") {}
6568
66export 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 }),
69export 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 }),
7576 }),
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 }
8096 }),
97 });
8198});
8299
83export const layer = Layer.succeed(ElectronSafeStorage, make);
100export const layer = Layer.effect(ElectronSafeStorage, make);
apps/desktop/src/linuxSecretStorage.test.ts created+203
......@@ -0,0 +1,203 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 normalizeLinuxPasswordStorePreference,
5 resolveLinuxPasswordStoreSwitch,
6 resolveLinuxSecretStorageUnavailableMessage,
7} from "./linuxSecretStorage.ts";
8
9const autoSwitch = (env: NodeJS.ProcessEnv) =>
10 resolveLinuxPasswordStoreSwitch({ preference: "auto", env });
11
12describe("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 @@
1export type LinuxPasswordStorePreference =
2 | "auto"
3 | "gnome-libsecret"
4 | "kwallet"
5 | "kwallet5"
6 | "kwallet6";
7export type LinuxPasswordStoreSwitch = Exclude<LinuxPasswordStorePreference, "auto">;
8
9export 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.
14const 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.
24const ELECTRON_KDE_DESKTOP = "KDE";
25// Chromium recognizes LXQt and still selects basic text for it, so it does need a forced backend.
26const ELECTRON_UNPROTECTED_DESKTOPS = new Set(["LXQt"]);
27
28const KDE_NAME_PREFIXES = ["kde", "plasma"];
29const NEGATIVE_FLAG_VALUES = new Set(["0", "false", "no", "off"]);
30
31export 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.
47export 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.
63function 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
80export 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
114function 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
118function 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.
125function 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
139function isKdeDesktopName(name: string): boolean {
140 return KDE_NAME_PREFIXES.some((prefix) => name.startsWith(prefix));
141}
142
143function 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
150function nonEmptyDesktopNames(value: string | undefined): string[] {
151 return splitDesktopNameList(value).flatMap((entry) => {
152 const normalized = normalizeDesktopName(entry);
153 return normalized ? [normalized] : [];
154 });
155}
156
157function isSet(value: string | undefined): boolean {
158 return Boolean(value?.trim());
159}
160
161function isAffirmativeFlag(value: string | undefined): boolean {
162 const normalized = value?.trim().toLowerCase();
163 return normalized ? !NEGATIVE_FLAG_VALUES.has(normalized) : false;
164}
165
166function splitDesktopNameList(value: string | undefined): string[] {
167 return value?.split(":") ?? [];
168}
169
170function normalizeDesktopName(value: string | undefined): string | null {
171 const normalized = value?.trim().toLowerCase();
172 return normalized && normalized.length > 0 ? normalized : null;
173}
174
175function 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";
5050import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts";
5151import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts";
5252import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts";
53import * as DesktopPreReadyPlatform from "./app/DesktopPreReadyPlatform.ts";
5354import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts";
5455import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts";
5556import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts";
......@@ -197,16 +198,20 @@ const desktopClerkLayer = DesktopClerk.layer.pipe(
197198 Layer.provideMerge(ElectronApp.layer),
198199);
199200
201const 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.
200210const desktopRuntimeLayer = desktopClerkLayer.pipe(
201211 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))),
209213 ),
214 Layer.provideMerge(DesktopPreReadyPlatform.layer),
210215);
211216
212217DesktopApp.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";
1111import * as DesktopAppSettings from "./DesktopAppSettings.ts";
1212
1313const DesktopSettingsPatch = Schema.Struct({
14 linuxPasswordStore: Schema.optionalKey(
15 Schema.Literals(["auto", "gnome-libsecret", "kwallet", "kwallet5", "kwallet6"]),
16 ),
1417 mainWindowBounds: Schema.optionalKey(
1518 Schema.NullOr(
1619 Schema.Struct({
......@@ -102,6 +105,7 @@ describe("DesktopSettings", () => {
102105 assert.deepEqual(
103106 DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"),
104107 {
108 linuxPasswordStore: "auto",
105109 mainWindowBounds: null,
106110 mainWindowMaximized: false,
107111 serverExposureMode: "local-only",
......@@ -121,6 +125,7 @@ describe("DesktopSettings", () => {
121125 Effect.gen(function* () {
122126 const settings = yield* DesktopAppSettings.DesktopAppSettings;
123127 yield* writeSettingsPatch({
128 linuxPasswordStore: "gnome-libsecret",
124129 serverExposureMode: "network-accessible",
125130 tailscaleServeEnabled: true,
126131 tailscaleServePort: 8443,
......@@ -129,6 +134,7 @@ describe("DesktopSettings", () => {
129134 });
130135
131136 assert.deepEqual(yield* settings.load, {
137 linuxPasswordStore: "gnome-libsecret",
132138 mainWindowBounds: null,
133139 mainWindowMaximized: false,
134140 serverExposureMode: "network-accessible",
......@@ -235,6 +241,7 @@ describe("DesktopSettings", () => {
235241 );
236242
237243 assert.deepEqual(yield* settings.load, {
244 linuxPasswordStore: "auto",
238245 mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 },
239246 mainWindowMaximized: false,
240247 serverExposureMode: "network-accessible",
......@@ -268,6 +275,44 @@ describe("DesktopSettings", () => {
268275 ),
269276 );
270277
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
271316 it.effect("persists sparse desktop settings documents", () =>
272317 withSettings(
273318 Effect.gen(function* () {
......@@ -300,6 +345,7 @@ describe("DesktopSettings", () => {
300345 });
301346
302347 assert.deepEqual(yield* settings.load, {
348 linuxPasswordStore: "auto",
303349 mainWindowBounds: null,
304350 mainWindowMaximized: false,
305351 serverExposureMode: "local-only",
......@@ -327,6 +373,7 @@ describe("DesktopSettings", () => {
327373 });
328374
329375 assert.deepEqual(yield* settings.load, {
376 linuxPasswordStore: "auto",
330377 mainWindowBounds: null,
331378 mainWindowMaximized: false,
332379 serverExposureMode: "local-only",
......@@ -353,6 +400,7 @@ describe("DesktopSettings", () => {
353400 });
354401
355402 assert.deepEqual(yield* settings.load, {
403 linuxPasswordStore: "auto",
356404 mainWindowBounds: null,
357405 mainWindowMaximized: false,
358406 serverExposureMode: "local-only",
apps/desktop/src/settings/DesktopAppSettings.ts+12
......@@ -16,10 +16,16 @@ import * as Schema from "effect/Schema";
1616import * as SynchronizedRef from "effect/SynchronizedRef";
1717
1818import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
19import {
20 DEFAULT_LINUX_PASSWORD_STORE,
21 normalizeLinuxPasswordStorePreference,
22 type LinuxPasswordStorePreference,
23} from "../linuxSecretStorage.ts";
1924import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts";
2025import { isValidDistroName } from "../wsl/wslPathParsing.ts";
2126
2227export interface DesktopSettings {
28 readonly linuxPasswordStore: LinuxPasswordStorePreference;
2329 readonly mainWindowBounds: DesktopWindowBounds | null;
2430 readonly mainWindowMaximized: boolean;
2531 readonly serverExposureMode: DesktopServerExposureMode;
......@@ -67,6 +73,7 @@ export const DEFAULT_MAIN_WINDOW_SIZE = {
6773} as const;
6874
6975export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
76 linuxPasswordStore: DEFAULT_LINUX_PASSWORD_STORE,
7077 mainWindowBounds: null,
7178 mainWindowMaximized: false,
7279 serverExposureMode: "local-only",
......@@ -87,6 +94,7 @@ const DesktopWindowBoundsDocument = Schema.Struct({
8794});
8895
8996const DesktopSettingsDocument = Schema.Struct({
97 linuxPasswordStore: Schema.optionalKey(Schema.Unknown),
9098 mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)),
9199 mainWindowMaximized: Schema.optionalKey(Schema.Boolean),
92100 serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema),
......@@ -216,6 +224,7 @@ function normalizeDesktopSettingsDocument(
216224 (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl");
217225
218226 return {
227 linuxPasswordStore: normalizeLinuxPasswordStorePreference(parsed.linuxPasswordStore),
219228 mainWindowBounds,
220229 mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true,
221230 serverExposureMode:
......@@ -238,6 +247,9 @@ function toDesktopSettingsDocument(
238247): DesktopSettingsDocument {
239248 const document: Mutable<DesktopSettingsDocument> = {};
240249
250 if (settings.linuxPasswordStore !== defaults.linuxPasswordStore) {
251 document.linuxPasswordStore = settings.linuxPasswordStore;
252 }
241253 if (settings.mainWindowBounds !== null) {
242254 document.mainWindowBounds = settings.mainWindowBounds;
243255 }
apps/desktop/src/settings/DesktopSavedEnvironments.test.ts+1
......@@ -86,6 +86,7 @@ function makeSafeStorageLayer(input: {
8686 }
8787 return Effect.succeed(decoded.slice("enc:".length));
8888 },
89 selectedStorageBackend: Effect.succeed(Option.none()),
8990 } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]);
9091}
9192
apps/desktop/src/shell/DesktopShellEnvironment.test.ts+61-1
......@@ -1,3 +1,4 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
12import { assert, describe, it } from "@effect/vitest";
23import * as Effect from "effect/Effect";
34import * as Layer from "effect/Layer";
......@@ -90,7 +91,7 @@ function runShellEnvironment(input: {
9091 }).pipe(
9192 Effect.provide(
9293 DesktopShellEnvironment.layer.pipe(
93 Layer.provide(Layer.mergeAll(environmentLayer, spawnerLayer)),
94 Layer.provide(Layer.mergeAll(environmentLayer, NodeServices.layer, spawnerLayer)),
9495 ),
9596 ),
9697 );
......@@ -243,6 +244,65 @@ describe("DesktopShellEnvironment", () => {
243244 }),
244245 );
245246
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
246306 it.effect("logs command failures with safe probe context and the exact cause", () => {
247307 const env: NodeJS.ProcessEnv = {
248308 SHELL: "/bin/bash",
apps/desktop/src/shell/DesktopShellEnvironment.ts+89-2
......@@ -1,6 +1,7 @@
11import * as Context from "effect/Context";
22import * as Duration from "effect/Duration";
33import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
45import * as Layer from "effect/Layer";
56import * as Option from "effect/Option";
67import * as Schema from "effect/Schema";
......@@ -68,12 +69,19 @@ export class DesktopShellEnvironment extends Context.Service<
6869
6970const LOGIN_SHELL_ENV_NAMES = [
7071 "PATH",
72 "DBUS_SESSION_BUS_ADDRESS",
73 "DISPLAY",
7174 "SSH_AUTH_SOCK",
7275 "HOMEBREW_PREFIX",
7376 "HOMEBREW_CELLAR",
7477 "HOMEBREW_REPOSITORY",
7578 "XDG_CONFIG_HOME",
79 "XDG_CURRENT_DESKTOP",
7680 "XDG_DATA_HOME",
81 "XDG_RUNTIME_DIR",
82 "XDG_SESSION_DESKTOP",
83 "XDG_SESSION_TYPE",
84 "WAYLAND_DISPLAY",
7785] as const;
7886const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const;
7987const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const;
......@@ -92,6 +100,47 @@ const pathDelimiter = (platform: NodeJS.Platform) => (platform === "win32" ? ";"
92100const readEnvPath = (env: NodeJS.ProcessEnv): Option.Option<string> =>
93101 trimNonEmpty(env.PATH ?? env.Path ?? env.path);
94102
103const normalizeRuntimeDir = (value: string): string => value.replace(/\/+$/u, "");
104
105const 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
120function 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
135export 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
95144const pathComparisonKey = (entry: string, platform: NodeJS.Platform) => {
96145 const normalized = entry.trim().replace(/^"+|"+$/g, "");
97146 return platform === "win32" ? normalized.toLowerCase() : normalized;
......@@ -356,7 +405,12 @@ const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWin
356405const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosixEnvironment")(
357406 function* (
358407 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;
360414 const shellEnvironment: EnvironmentPatch = {};
361415
362416 for (const shell of listLoginShellCandidates(config)) {
......@@ -383,23 +437,54 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix
383437 config.env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK;
384438 }
385439
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
386452 for (const name of [
453 "DISPLAY",
387454 "HOMEBREW_PREFIX",
388455 "HOMEBREW_CELLAR",
389456 "HOMEBREW_REPOSITORY",
390457 "XDG_CONFIG_HOME",
391458 "XDG_DATA_HOME",
459 "XDG_RUNTIME_DIR",
460 "WAYLAND_DISPLAY",
392461 ] as const) {
393462 if (!config.env[name] && shellEnvironment[name]) {
394463 config.env[name] = shellEnvironment[name];
395464 }
396465 }
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 }
397482 },
398483);
399484
400485const installShellEnvironment = (
401486 config: ShellEnvironmentConfig,
402): Effect.Effect<void, never, ChildProcessSpawner.ChildProcessSpawner> => {
487): Effect.Effect<void, never, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem> => {
403488 if (config.platform === "win32") {
404489 return installWindowsEnvironment(config);
405490 }
......@@ -411,6 +496,7 @@ const installShellEnvironment = (
411496
412497export const make = Effect.gen(function* () {
413498 const environment = yield* DesktopEnvironment.DesktopEnvironment;
499 const fileSystem = yield* FileSystem.FileSystem;
414500 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
415501 const installIntoProcess: DesktopShellEnvironment["Service"]["installIntoProcess"] =
416502 installShellEnvironment({
......@@ -418,6 +504,7 @@ export const make = Effect.gen(function* () {
418504 platform: environment.platform,
419505 userShell: Option.none(),
420506 }).pipe(
507 Effect.provideService(FileSystem.FileSystem, fileSystem),
421508 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
422509 Effect.withSpan("desktop.shellEnvironment.installIntoProcess"),
423510 );
apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts+1
......@@ -43,6 +43,7 @@ function makeElectronAppLayer(
4343 setDesktopName: () => Effect.void,
4444 setDockIcon: () => Effect.void,
4545 appendCommandLineSwitch: () => Effect.void,
46 removeCommandLineSwitch: () => Effect.void,
4647 onBeforeQuitForUpdate: () => Effect.void,
4748 on: () => Effect.void,
4849 } satisfies ElectronApp.ElectronApp["Service"]);
apps/desktop/src/window/DesktopApplicationMenu.test.ts+1
......@@ -46,6 +46,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
4646 setDockIcon: () => Effect.void,
4747 appendCommandLineSwitch: () => Effect.void,
4848 onBeforeQuitForUpdate: () => Effect.void,
49 removeCommandLineSwitch: () => Effect.void,
4950 on: () => Effect.void,
5051} satisfies ElectronApp.ElectronApp["Service"]);
5152