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";...@@ -9,6 +9,7 @@ import * as Crypto from "effect/Crypto";
9import * as ElectronApp from "../electron/ElectronApp.ts";9import * as ElectronApp from "../electron/ElectronApp.ts";
10import * as ElectronDialog from "../electron/ElectronDialog.ts";10import * as ElectronDialog from "../electron/ElectronDialog.ts";
11import * as ElectronProtocol from "../electron/ElectronProtocol.ts";11import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
12import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
12import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts";13import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts";
13import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";14import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
14import * as DesktopClerk from "./DesktopClerk.ts";15import * as DesktopClerk from "./DesktopClerk.ts";
...@@ -19,6 +20,7 @@ import * as DesktopEnvironment from "./DesktopEnvironment.ts";...@@ -19,6 +20,7 @@ import * as DesktopEnvironment from "./DesktopEnvironment.ts";
19import * as DesktopLifecycle from "./DesktopLifecycle.ts";20import * as DesktopLifecycle from "./DesktopLifecycle.ts";
20import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts";21import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts";
21import * as DesktopObservability from "./DesktopObservability.ts";22import * as DesktopObservability from "./DesktopObservability.ts";
23import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts";
22import * as DesktopShutdown from "./DesktopShutdown.ts";24import * as DesktopShutdown from "./DesktopShutdown.ts";
23import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";25import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";
24import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";26import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
...@@ -225,17 +227,45 @@ const startup = Effect.gen(function* () {...@@ -225,17 +227,45 @@ const startup = Effect.gen(function* () {
225 const clerk = yield* DesktopClerk.DesktopClerk;227 const clerk = yield* DesktopClerk.DesktopClerk;
226 const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment;228 const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment;
227 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;229 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
230 const preReadyElectronOptions = yield* DesktopPreReadyPlatform.DesktopPreReadyElectronOptions;
231 const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage;
228 const updates = yield* DesktopUpdates.DesktopUpdates;232 const updates = yield* DesktopUpdates.DesktopUpdates;
229 const environment = yield* DesktopEnvironment.DesktopEnvironment;233 const environment = yield* DesktopEnvironment.DesktopEnvironment;
230234
231 yield* shellEnvironment.installIntoProcess;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 const userDataPath = yield* appIdentity.resolveUserDataPath;256 const userDataPath = yield* appIdentity.resolveUserDataPath;
233 yield* electronApp.setPath("userData", userDataPath);257 yield* electronApp.setPath("userData", userDataPath);
234 yield* logStartupInfo("runtime logging configured", { logDir: environment.logDir });258 yield* logStartupInfo("runtime logging configured", { logDir: environment.logDir });
235 yield* desktopSettings.load;259 yield* desktopSettings.load;
236260
237 if (environment.platform === "linux") {261 if (linuxElectronOptions !== null) {
238 yield* electronApp.appendCommandLineSwitch("class", environment.linuxWmClass);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 }
240270
241 yield* appIdentity.configure;271 yield* appIdentity.configure;
...@@ -247,6 +277,12 @@ const startup = Effect.gen(function* () {...@@ -247,6 +277,12 @@ const startup = Effect.gen(function* () {
247 Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)),277 Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)),
248 );278 );
249 yield* logStartupInfo("app ready");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 yield* appIdentity.configure;286 yield* appIdentity.configure;
251 yield* applicationMenu.configure;287 yield* applicationMenu.configure;
252 yield* updates.configure;288 yield* updates.configure;
apps/desktop/src/app/DesktopAppIdentity.test.ts+1
...@@ -64,6 +64,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) =>...@@ -64,6 +64,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) =>
64 }),64 }),
65 appendCommandLineSwitch: () => Effect.void,65 appendCommandLineSwitch: () => Effect.void,
66 onBeforeQuitForUpdate: () => Effect.void,66 onBeforeQuitForUpdate: () => Effect.void,
67 removeCommandLineSwitch: () => Effect.void,
67 on: () => Effect.void,68 on: () => Effect.void,
68 } satisfies ElectronApp.ElectronApp["Service"]);69 } 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>...@@ -39,6 +39,7 @@ function makeSafeStorageLayer(available: boolean, failDecrypt: Ref.Ref<boolean>
39 return decoded.slice("encrypted:".length);39 return decoded.slice("encrypted:".length);
40 });40 });
41 },41 },
42 selectedStorageBackend: Effect.succeed(Option.none()),
42 } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]);43 } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]);
43}44}
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";...@@ -13,6 +13,7 @@ import * as Path from "effect/Path";
1313
14import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";14import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
15import * as DesktopConfig from "./DesktopConfig.ts";15import * as DesktopConfig from "./DesktopConfig.ts";
16import { resolveDesktopBaseDir, resolveDesktopStateDir } from "./DesktopStatePaths.ts";
16import { isNightlyDesktopVersion } from "../updates/updateChannels.ts";17import { isNightlyDesktopVersion } from "../updates/updateChannels.ts";
1718
18export interface MakeDesktopEnvironmentInput {19export interface MakeDesktopEnvironmentInput {
...@@ -149,8 +150,11 @@ const make = Effect.fn("desktop.environment.make")(function* (...@@ -149,8 +150,11 @@ const make = Effect.fn("desktop.environment.make")(function* (
149 : input.platform === "darwin"150 : input.platform === "darwin"
150 ? path.join(homeDirectory, "Library", "Application Support")151 ? path.join(homeDirectory, "Library", "Application Support")
151 : Option.getOrElse(config.xdgConfigHome, () => path.join(homeDirectory, ".config"));152 : Option.getOrElse(config.xdgConfigHome, () => path.join(homeDirectory, ".config"));
152 const configuredBaseDir = config.t3Home;153 const baseDir = resolveDesktopBaseDir({
153 const baseDir = Option.getOrElse(configuredBaseDir, () => path.join(homeDirectory, ".t3"));154 homeDirectory,
155 joinPath: path.join,
156 t3Home: config.t3Home,
157 });
154 const rootDir = path.resolve(input.dirname, "../../..");158 const rootDir = path.resolve(input.dirname, "../../..");
155 const appRoot = input.isPackaged ? input.appPath : rootDir;159 const appRoot = input.isPackaged ? input.appPath : rootDir;
156 const branding = resolveDesktopAppBranding({160 const branding = resolveDesktopAppBranding({
...@@ -158,10 +162,12 @@ const make = Effect.fn("desktop.environment.make")(function* (...@@ -158,10 +162,12 @@ const make = Effect.fn("desktop.environment.make")(function* (
158 appVersion: input.appVersion,162 appVersion: input.appVersion,
159 });163 });
160 const displayName = branding.displayName;164 const displayName = branding.displayName;
161 const stateDir = path.join(165 const stateDir = resolveDesktopStateDir({
162 baseDir,166 baseDir,
163 isDevelopment && Option.isNone(configuredBaseDir) ? "dev" : "userdata",167 isDevelopment,
164 );168 joinPath: path.join,
169 t3Home: config.t3Home,
170 });
165 const userDataDirName = isDevelopment ? "t3code-dev" : "t3code";171 const userDataDirName = isDevelopment ? "t3code-dev" : "t3code";
166 const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";172 const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
167 const linuxApplicationsDir = path.join(173 const linuxApplicationsDir = path.join(
apps/desktop/src/app/DesktopLifecycle.test.ts+1
...@@ -35,6 +35,7 @@ describe("DesktopLifecycle", () => {...@@ -35,6 +35,7 @@ describe("DesktopLifecycle", () => {
35 setDesktopName: () => Effect.void,35 setDesktopName: () => Effect.void,
36 setDockIcon: () => Effect.void,36 setDockIcon: () => Effect.void,
37 appendCommandLineSwitch: () => Effect.void,37 appendCommandLineSwitch: () => Effect.void,
38 removeCommandLineSwitch: () => Effect.void,
38 onBeforeQuitForUpdate: (listener) =>39 onBeforeQuitForUpdate: (listener) =>
39 Effect.acquireRelease(40 Effect.acquireRelease(
40 Effect.sync(() => {41 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 {...@@ -14,6 +14,7 @@ const {
14 quitMock,14 quitMock,
15 relaunchMock,15 relaunchMock,
16 removeListenerMock,16 removeListenerMock,
17 removeSwitchMock,
17 setAboutPanelOptionsMock,18 setAboutPanelOptionsMock,
18 setAppUserModelIdMock,19 setAppUserModelIdMock,
19 setAsDefaultProtocolClientMock,20 setAsDefaultProtocolClientMock,
...@@ -34,6 +35,7 @@ const {...@@ -34,6 +35,7 @@ const {
34 quitMock: vi.fn(),35 quitMock: vi.fn(),
35 relaunchMock: vi.fn(),36 relaunchMock: vi.fn(),
36 removeListenerMock: vi.fn(),37 removeListenerMock: vi.fn(),
38 removeSwitchMock: vi.fn(),
37 setAboutPanelOptionsMock: vi.fn(),39 setAboutPanelOptionsMock: vi.fn(),
38 setAppUserModelIdMock: vi.fn(),40 setAppUserModelIdMock: vi.fn(),
39 setAsDefaultProtocolClientMock: vi.fn(() => true),41 setAsDefaultProtocolClientMock: vi.fn(() => true),
...@@ -52,6 +54,7 @@ vi.mock("electron", () => ({...@@ -52,6 +54,7 @@ vi.mock("electron", () => ({
52 app: {54 app: {
53 commandLine: {55 commandLine: {
54 appendSwitch: appendSwitchMock,56 appendSwitch: appendSwitchMock,
57 removeSwitch: removeSwitchMock,
55 },58 },
56 dock: {59 dock: {
57 setIcon: setDockIconMock,60 setIcon: setDockIconMock,
...@@ -89,6 +92,7 @@ describe("ElectronApp", () => {...@@ -89,6 +92,7 @@ describe("ElectronApp", () => {
89 quitMock.mockClear();92 quitMock.mockClear();
90 relaunchMock.mockClear();93 relaunchMock.mockClear();
91 removeListenerMock.mockClear();94 removeListenerMock.mockClear();
95 removeSwitchMock.mockClear();
92 setPathMock.mockClear();96 setPathMock.mockClear();
93 });97 });
9498
...@@ -178,4 +182,13 @@ describe("ElectronApp", () => {...@@ -178,4 +182,13 @@ describe("ElectronApp", () => {
178 ]);182 ]);
179 }).pipe(Effect.provide(ElectronApp.layer)),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,6 +69,7 @@ export class ElectronApp extends Context.Service<
69 readonly onBeforeQuitForUpdate: (69 readonly onBeforeQuitForUpdate: (
70 listener: () => void,70 listener: () => void,
71 ) => Effect.Effect<void, never, Scope.Scope>;71 ) => Effect.Effect<void, never, Scope.Scope>;
72 readonly removeCommandLineSwitch: (switchName: string) => Effect.Effect<void>;
72 readonly on: <Args extends ReadonlyArray<unknown>>(73 readonly on: <Args extends ReadonlyArray<unknown>>(
73 eventName: string,74 eventName: string,
74 listener: (...args: Args) => void,75 listener: (...args: Args) => void,
...@@ -191,6 +192,10 @@ export const make = ElectronApp.of({...@@ -191,6 +192,10 @@ export const make = ElectronApp.of({
191 Electron.autoUpdater.removeListener("before-quit-for-update", listener);192 Electron.autoUpdater.removeListener("before-quit-for-update", listener);
192 }),193 }),
193 ).pipe(Effect.asVoid),194 ).pipe(Effect.asVoid),
195 removeCommandLineSwitch: (switchName) =>
196 Effect.sync(() => {
197 Electron.app.commandLine.removeSwitch(switchName);
198 }),
194 on: addScopedAppListener,199 on: addScopedAppListener,
195});200});
196201
apps/desktop/src/electron/ElectronProtocol.ts+32
...@@ -105,6 +105,38 @@ function withContentSecurityPolicy(response: Response, policy: string): Response...@@ -105,6 +105,38 @@ function withContentSecurityPolicy(response: Response, policy: string): Response
105 });105 });
106}106}
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
108async function proxyRequest(140async function proxyRequest(
109 request: Request,141 request: Request,
110 targetOrigin: URL,142 targetOrigin: URL,
apps/desktop/src/electron/ElectronSafeStorage.ts+31-14
...@@ -1,9 +1,11 @@...@@ -1,9 +1,11 @@
1import * as Context from "effect/Context";1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";3import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";
4import * as Schema from "effect/Schema";5import * as Schema from "effect/Schema";
56
6import * as Electron from "electron";7import * as Electron from "electron";
8import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
79
8const electronSafeStorageErrorFields = {10const electronSafeStorageErrorFields = {
9 cause: Schema.Defect(),11 cause: Schema.Defect(),
...@@ -60,24 +62,39 @@ export class ElectronSafeStorage extends Context.Service<...@@ -60,24 +62,39 @@ export class ElectronSafeStorage extends Context.Service<
60 readonly decryptString: (62 readonly decryptString: (
61 value: Uint8Array,63 value: Uint8Array,
62 ) => Effect.Effect<string, ElectronSafeStorageDecryptError>;64 ) => Effect.Effect<string, ElectronSafeStorageDecryptError>;
65 readonly selectedStorageBackend: Effect.Effect<Option.Option<string>>;
63 }66 }
64>()("@t3tools/desktop/electron/ElectronSafeStorage") {}67>()("@t3tools/desktop/electron/ElectronSafeStorage") {}
6568
66export const make = ElectronSafeStorage.of({69export const make = Effect.gen(function* () {
67 isEncryptionAvailable: Effect.try({70 const platform = yield* HostProcessPlatform;
68 try: () => Electron.safeStorage.isEncryptionAvailable(),71
69 catch: (cause) => new ElectronSafeStorageAvailabilityError({ cause }),72 return ElectronSafeStorage.of({
70 }),73 isEncryptionAvailable: Effect.try({
71 encryptString: (value) =>74 try: () => Electron.safeStorage.isEncryptionAvailable(),
72 Effect.try({75 catch: (cause) => new ElectronSafeStorageAvailabilityError({ cause }),
73 try: () => Electron.safeStorage.encryptString(value),
74 catch: (cause) => new ElectronSafeStorageEncryptError({ cause }),
75 }),76 }),
76 decryptString: (value) =>77 encryptString: (value) =>
77 Effect.try({78 Effect.try({
78 try: () => Electron.safeStorage.decryptString(Buffer.from(value)),79 try: () => Electron.safeStorage.encryptString(value),
79 catch: (cause) => new ElectronSafeStorageDecryptError({ cause }),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});
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";...@@ -50,6 +50,7 @@ import * as DesktopServerExposure from "./backend/DesktopServerExposure.ts";
50import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts";50import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts";
51import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts";51import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts";
52import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts";52import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts";
53import * as DesktopPreReadyPlatform from "./app/DesktopPreReadyPlatform.ts";
53import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts";54import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts";
54import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts";55import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts";
55import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts";56import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts";
...@@ -197,16 +198,20 @@ const desktopClerkLayer = DesktopClerk.layer.pipe(...@@ -197,16 +198,20 @@ const desktopClerkLayer = DesktopClerk.layer.pipe(
197 Layer.provideMerge(ElectronApp.layer),198 Layer.provideMerge(ElectronApp.layer),
198);199);
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.
200const desktopRuntimeLayer = desktopClerkLayer.pipe(210const desktopRuntimeLayer = desktopClerkLayer.pipe(
201 Layer.flatMap((clerkContext) =>211 Layer.flatMap((clerkContext) =>
202 desktopApplicationLayer.pipe(212 desktopApplicationRuntimeLayer.pipe(Layer.provideMerge(Layer.succeedContext(clerkContext))),
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 ),
209 ),213 ),
214 Layer.provideMerge(DesktopPreReadyPlatform.layer),
210);215);
211216
212DesktopApp.program.pipe(Effect.provide(desktopRuntimeLayer), NodeRuntime.runMain);217DesktopApp.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,6 +11,9 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
11import * as DesktopAppSettings from "./DesktopAppSettings.ts";11import * as DesktopAppSettings from "./DesktopAppSettings.ts";
1212
13const DesktopSettingsPatch = Schema.Struct({13const DesktopSettingsPatch = Schema.Struct({
14 linuxPasswordStore: Schema.optionalKey(
15 Schema.Literals(["auto", "gnome-libsecret", "kwallet", "kwallet5", "kwallet6"]),
16 ),
14 mainWindowBounds: Schema.optionalKey(17 mainWindowBounds: Schema.optionalKey(
15 Schema.NullOr(18 Schema.NullOr(
16 Schema.Struct({19 Schema.Struct({
...@@ -102,6 +105,7 @@ describe("DesktopSettings", () => {...@@ -102,6 +105,7 @@ describe("DesktopSettings", () => {
102 assert.deepEqual(105 assert.deepEqual(
103 DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"),106 DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"),
104 {107 {
108 linuxPasswordStore: "auto",
105 mainWindowBounds: null,109 mainWindowBounds: null,
106 mainWindowMaximized: false,110 mainWindowMaximized: false,
107 serverExposureMode: "local-only",111 serverExposureMode: "local-only",
...@@ -121,6 +125,7 @@ describe("DesktopSettings", () => {...@@ -121,6 +125,7 @@ describe("DesktopSettings", () => {
121 Effect.gen(function* () {125 Effect.gen(function* () {
122 const settings = yield* DesktopAppSettings.DesktopAppSettings;126 const settings = yield* DesktopAppSettings.DesktopAppSettings;
123 yield* writeSettingsPatch({127 yield* writeSettingsPatch({
128 linuxPasswordStore: "gnome-libsecret",
124 serverExposureMode: "network-accessible",129 serverExposureMode: "network-accessible",
125 tailscaleServeEnabled: true,130 tailscaleServeEnabled: true,
126 tailscaleServePort: 8443,131 tailscaleServePort: 8443,
...@@ -129,6 +134,7 @@ describe("DesktopSettings", () => {...@@ -129,6 +134,7 @@ describe("DesktopSettings", () => {
129 });134 });
130135
131 assert.deepEqual(yield* settings.load, {136 assert.deepEqual(yield* settings.load, {
137 linuxPasswordStore: "gnome-libsecret",
132 mainWindowBounds: null,138 mainWindowBounds: null,
133 mainWindowMaximized: false,139 mainWindowMaximized: false,
134 serverExposureMode: "network-accessible",140 serverExposureMode: "network-accessible",
...@@ -235,6 +241,7 @@ describe("DesktopSettings", () => {...@@ -235,6 +241,7 @@ describe("DesktopSettings", () => {
235 );241 );
236242
237 assert.deepEqual(yield* settings.load, {243 assert.deepEqual(yield* settings.load, {
244 linuxPasswordStore: "auto",
238 mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 },245 mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 },
239 mainWindowMaximized: false,246 mainWindowMaximized: false,
240 serverExposureMode: "network-accessible",247 serverExposureMode: "network-accessible",
...@@ -268,6 +275,44 @@ describe("DesktopSettings", () => {...@@ -268,6 +275,44 @@ describe("DesktopSettings", () => {
268 ),275 ),
269 );276 );
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
271 it.effect("persists sparse desktop settings documents", () =>316 it.effect("persists sparse desktop settings documents", () =>
272 withSettings(317 withSettings(
273 Effect.gen(function* () {318 Effect.gen(function* () {
...@@ -300,6 +345,7 @@ describe("DesktopSettings", () => {...@@ -300,6 +345,7 @@ describe("DesktopSettings", () => {
300 });345 });
301346
302 assert.deepEqual(yield* settings.load, {347 assert.deepEqual(yield* settings.load, {
348 linuxPasswordStore: "auto",
303 mainWindowBounds: null,349 mainWindowBounds: null,
304 mainWindowMaximized: false,350 mainWindowMaximized: false,
305 serverExposureMode: "local-only",351 serverExposureMode: "local-only",
...@@ -327,6 +373,7 @@ describe("DesktopSettings", () => {...@@ -327,6 +373,7 @@ describe("DesktopSettings", () => {
327 });373 });
328374
329 assert.deepEqual(yield* settings.load, {375 assert.deepEqual(yield* settings.load, {
376 linuxPasswordStore: "auto",
330 mainWindowBounds: null,377 mainWindowBounds: null,
331 mainWindowMaximized: false,378 mainWindowMaximized: false,
332 serverExposureMode: "local-only",379 serverExposureMode: "local-only",
...@@ -353,6 +400,7 @@ describe("DesktopSettings", () => {...@@ -353,6 +400,7 @@ describe("DesktopSettings", () => {
353 });400 });
354401
355 assert.deepEqual(yield* settings.load, {402 assert.deepEqual(yield* settings.load, {
403 linuxPasswordStore: "auto",
356 mainWindowBounds: null,404 mainWindowBounds: null,
357 mainWindowMaximized: false,405 mainWindowMaximized: false,
358 serverExposureMode: "local-only",406 serverExposureMode: "local-only",
apps/desktop/src/settings/DesktopAppSettings.ts+12
...@@ -16,10 +16,16 @@ import * as Schema from "effect/Schema";...@@ -16,10 +16,16 @@ import * as Schema from "effect/Schema";
16import * as SynchronizedRef from "effect/SynchronizedRef";16import * as SynchronizedRef from "effect/SynchronizedRef";
1717
18import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";18import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
19import {
20 DEFAULT_LINUX_PASSWORD_STORE,
21 normalizeLinuxPasswordStorePreference,
22 type LinuxPasswordStorePreference,
23} from "../linuxSecretStorage.ts";
19import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts";24import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts";
20import { isValidDistroName } from "../wsl/wslPathParsing.ts";25import { isValidDistroName } from "../wsl/wslPathParsing.ts";
2126
22export interface DesktopSettings {27export interface DesktopSettings {
28 readonly linuxPasswordStore: LinuxPasswordStorePreference;
23 readonly mainWindowBounds: DesktopWindowBounds | null;29 readonly mainWindowBounds: DesktopWindowBounds | null;
24 readonly mainWindowMaximized: boolean;30 readonly mainWindowMaximized: boolean;
25 readonly serverExposureMode: DesktopServerExposureMode;31 readonly serverExposureMode: DesktopServerExposureMode;
...@@ -67,6 +73,7 @@ export const DEFAULT_MAIN_WINDOW_SIZE = {...@@ -67,6 +73,7 @@ export const DEFAULT_MAIN_WINDOW_SIZE = {
67} as const;73} as const;
6874
69export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {75export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
76 linuxPasswordStore: DEFAULT_LINUX_PASSWORD_STORE,
70 mainWindowBounds: null,77 mainWindowBounds: null,
71 mainWindowMaximized: false,78 mainWindowMaximized: false,
72 serverExposureMode: "local-only",79 serverExposureMode: "local-only",
...@@ -87,6 +94,7 @@ const DesktopWindowBoundsDocument = Schema.Struct({...@@ -87,6 +94,7 @@ const DesktopWindowBoundsDocument = Schema.Struct({
87});94});
8895
89const DesktopSettingsDocument = Schema.Struct({96const DesktopSettingsDocument = Schema.Struct({
97 linuxPasswordStore: Schema.optionalKey(Schema.Unknown),
90 mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)),98 mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)),
91 mainWindowMaximized: Schema.optionalKey(Schema.Boolean),99 mainWindowMaximized: Schema.optionalKey(Schema.Boolean),
92 serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema),100 serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema),
...@@ -216,6 +224,7 @@ function normalizeDesktopSettingsDocument(...@@ -216,6 +224,7 @@ function normalizeDesktopSettingsDocument(
216 (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl");224 (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl");
217225
218 return {226 return {
227 linuxPasswordStore: normalizeLinuxPasswordStorePreference(parsed.linuxPasswordStore),
219 mainWindowBounds,228 mainWindowBounds,
220 mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true,229 mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true,
221 serverExposureMode:230 serverExposureMode:
...@@ -238,6 +247,9 @@ function toDesktopSettingsDocument(...@@ -238,6 +247,9 @@ function toDesktopSettingsDocument(
238): DesktopSettingsDocument {247): DesktopSettingsDocument {
239 const document: Mutable<DesktopSettingsDocument> = {};248 const document: Mutable<DesktopSettingsDocument> = {};
240249
250 if (settings.linuxPasswordStore !== defaults.linuxPasswordStore) {
251 document.linuxPasswordStore = settings.linuxPasswordStore;
252 }
241 if (settings.mainWindowBounds !== null) {253 if (settings.mainWindowBounds !== null) {
242 document.mainWindowBounds = settings.mainWindowBounds;254 document.mainWindowBounds = settings.mainWindowBounds;
243 }255 }
apps/desktop/src/settings/DesktopSavedEnvironments.test.ts+1
...@@ -86,6 +86,7 @@ function makeSafeStorageLayer(input: {...@@ -86,6 +86,7 @@ function makeSafeStorageLayer(input: {
86 }86 }
87 return Effect.succeed(decoded.slice("enc:".length));87 return Effect.succeed(decoded.slice("enc:".length));
88 },88 },
89 selectedStorageBackend: Effect.succeed(Option.none()),
89 } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]);90 } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]);
90}91}
9192
apps/desktop/src/shell/DesktopShellEnvironment.test.ts+61-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
1import { assert, describe, it } from "@effect/vitest";2import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
...@@ -90,7 +91,7 @@ function runShellEnvironment(input: {...@@ -90,7 +91,7 @@ function runShellEnvironment(input: {
90 }).pipe(91 }).pipe(
91 Effect.provide(92 Effect.provide(
92 DesktopShellEnvironment.layer.pipe(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,6 +244,65 @@ describe("DesktopShellEnvironment", () => {
243 }),244 }),
244 );245 );
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
246 it.effect("logs command failures with safe probe context and the exact cause", () => {306 it.effect("logs command failures with safe probe context and the exact cause", () => {
247 const env: NodeJS.ProcessEnv = {307 const env: NodeJS.ProcessEnv = {
248 SHELL: "/bin/bash",308 SHELL: "/bin/bash",
apps/desktop/src/shell/DesktopShellEnvironment.ts+89-2
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import * as Context from "effect/Context";1import * as Context from "effect/Context";
2import * as Duration from "effect/Duration";2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";6import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";7import * as Schema from "effect/Schema";
...@@ -68,12 +69,19 @@ export class DesktopShellEnvironment extends Context.Service<...@@ -68,12 +69,19 @@ export class DesktopShellEnvironment extends Context.Service<
6869
69const LOGIN_SHELL_ENV_NAMES = [70const LOGIN_SHELL_ENV_NAMES = [
70 "PATH",71 "PATH",
72 "DBUS_SESSION_BUS_ADDRESS",
73 "DISPLAY",
71 "SSH_AUTH_SOCK",74 "SSH_AUTH_SOCK",
72 "HOMEBREW_PREFIX",75 "HOMEBREW_PREFIX",
73 "HOMEBREW_CELLAR",76 "HOMEBREW_CELLAR",
74 "HOMEBREW_REPOSITORY",77 "HOMEBREW_REPOSITORY",
75 "XDG_CONFIG_HOME",78 "XDG_CONFIG_HOME",
79 "XDG_CURRENT_DESKTOP",
76 "XDG_DATA_HOME",80 "XDG_DATA_HOME",
81 "XDG_RUNTIME_DIR",
82 "XDG_SESSION_DESKTOP",
83 "XDG_SESSION_TYPE",
84 "WAYLAND_DISPLAY",
77] as const;85] as const;
78const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const;86const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const;
79const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const;87const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const;
...@@ -92,6 +100,47 @@ const pathDelimiter = (platform: NodeJS.Platform) => (platform === "win32" ? ";"...@@ -92,6 +100,47 @@ const pathDelimiter = (platform: NodeJS.Platform) => (platform === "win32" ? ";"
92const readEnvPath = (env: NodeJS.ProcessEnv): Option.Option<string> =>100const readEnvPath = (env: NodeJS.ProcessEnv): Option.Option<string> =>
93 trimNonEmpty(env.PATH ?? env.Path ?? env.path);101 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
95const pathComparisonKey = (entry: string, platform: NodeJS.Platform) => {144const pathComparisonKey = (entry: string, platform: NodeJS.Platform) => {
96 const normalized = entry.trim().replace(/^"+|"+$/g, "");145 const normalized = entry.trim().replace(/^"+|"+$/g, "");
97 return platform === "win32" ? normalized.toLowerCase() : normalized;146 return platform === "win32" ? normalized.toLowerCase() : normalized;
...@@ -356,7 +405,12 @@ const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWin...@@ -356,7 +405,12 @@ const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWin
356const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosixEnvironment")(405const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosixEnvironment")(
357 function* (406 function* (
358 config: ShellEnvironmentConfig,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 const shellEnvironment: EnvironmentPatch = {};414 const shellEnvironment: EnvironmentPatch = {};
361415
362 for (const shell of listLoginShellCandidates(config)) {416 for (const shell of listLoginShellCandidates(config)) {
...@@ -383,23 +437,54 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix...@@ -383,23 +437,54 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix
383 config.env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK;437 config.env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK;
384 }438 }
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
386 for (const name of [452 for (const name of [
453 "DISPLAY",
387 "HOMEBREW_PREFIX",454 "HOMEBREW_PREFIX",
388 "HOMEBREW_CELLAR",455 "HOMEBREW_CELLAR",
389 "HOMEBREW_REPOSITORY",456 "HOMEBREW_REPOSITORY",
390 "XDG_CONFIG_HOME",457 "XDG_CONFIG_HOME",
391 "XDG_DATA_HOME",458 "XDG_DATA_HOME",
459 "XDG_RUNTIME_DIR",
460 "WAYLAND_DISPLAY",
392 ] as const) {461 ] as const) {
393 if (!config.env[name] && shellEnvironment[name]) {462 if (!config.env[name] && shellEnvironment[name]) {
394 config.env[name] = shellEnvironment[name];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);
399484
400const installShellEnvironment = (485const installShellEnvironment = (
401 config: ShellEnvironmentConfig,486 config: ShellEnvironmentConfig,
402): Effect.Effect<void, never, ChildProcessSpawner.ChildProcessSpawner> => {487): Effect.Effect<void, never, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem> => {
403 if (config.platform === "win32") {488 if (config.platform === "win32") {
404 return installWindowsEnvironment(config);489 return installWindowsEnvironment(config);
405 }490 }
...@@ -411,6 +496,7 @@ const installShellEnvironment = (...@@ -411,6 +496,7 @@ const installShellEnvironment = (
411496
412export const make = Effect.gen(function* () {497export const make = Effect.gen(function* () {
413 const environment = yield* DesktopEnvironment.DesktopEnvironment;498 const environment = yield* DesktopEnvironment.DesktopEnvironment;
499 const fileSystem = yield* FileSystem.FileSystem;
414 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;500 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
415 const installIntoProcess: DesktopShellEnvironment["Service"]["installIntoProcess"] =501 const installIntoProcess: DesktopShellEnvironment["Service"]["installIntoProcess"] =
416 installShellEnvironment({502 installShellEnvironment({
...@@ -418,6 +504,7 @@ export const make = Effect.gen(function* () {...@@ -418,6 +504,7 @@ export const make = Effect.gen(function* () {
418 platform: environment.platform,504 platform: environment.platform,
419 userShell: Option.none(),505 userShell: Option.none(),
420 }).pipe(506 }).pipe(
507 Effect.provideService(FileSystem.FileSystem, fileSystem),
421 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),508 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
422 Effect.withSpan("desktop.shellEnvironment.installIntoProcess"),509 Effect.withSpan("desktop.shellEnvironment.installIntoProcess"),
423 );510 );
apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts+1
...@@ -43,6 +43,7 @@ function makeElectronAppLayer(...@@ -43,6 +43,7 @@ function makeElectronAppLayer(
43 setDesktopName: () => Effect.void,43 setDesktopName: () => Effect.void,
44 setDockIcon: () => Effect.void,44 setDockIcon: () => Effect.void,
45 appendCommandLineSwitch: () => Effect.void,45 appendCommandLineSwitch: () => Effect.void,
46 removeCommandLineSwitch: () => Effect.void,
46 onBeforeQuitForUpdate: () => Effect.void,47 onBeforeQuitForUpdate: () => Effect.void,
47 on: () => Effect.void,48 on: () => Effect.void,
48 } satisfies ElectronApp.ElectronApp["Service"]);49 } satisfies ElectronApp.ElectronApp["Service"]);
apps/desktop/src/window/DesktopApplicationMenu.test.ts+1
...@@ -46,6 +46,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {...@@ -46,6 +46,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
46 setDockIcon: () => Effect.void,46 setDockIcon: () => Effect.void,
47 appendCommandLineSwitch: () => Effect.void,47 appendCommandLineSwitch: () => Effect.void,
48 onBeforeQuitForUpdate: () => Effect.void,48 onBeforeQuitForUpdate: () => Effect.void,
49 removeCommandLineSwitch: () => Effect.void,
49 on: () => Effect.void,50 on: () => Effect.void,
50} satisfies ElectronApp.ElectronApp["Service"]);51} satisfies ElectronApp.ElectronApp["Service"]);
5152