Skip to content

Commit b8f7dae

Browse files
committed
fix(desktop): Select Linux secret storage backend
Address Bugbot review: authoritative desktop hints and shell DBUS override
1 parent a4964b3 commit b8f7dae

21 files changed

Lines changed: 1009 additions & 22 deletions

apps/desktop/src/app/DesktopApp.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import * as Crypto from "effect/Crypto";
99
import * as ElectronApp from "../electron/ElectronApp.ts";
1010
import * as ElectronDialog from "../electron/ElectronDialog.ts";
1111
import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
12+
import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
1213
import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts";
1314
import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
1415
import * as DesktopClerk from "./DesktopClerk.ts";
@@ -17,6 +18,7 @@ import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts";
1718
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
1819
import * as DesktopLifecycle from "./DesktopLifecycle.ts";
1920
import * as DesktopObservability from "./DesktopObservability.ts";
21+
import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts";
2022
import * as DesktopShutdown from "./DesktopShutdown.ts";
2123
import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";
2224
import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
@@ -206,17 +208,45 @@ const startup = Effect.gen(function* () {
206208
const clerk = yield* DesktopClerk.DesktopClerk;
207209
const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment;
208210
const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
211+
const preReadyElectronOptions = yield* DesktopPreReadyPlatform.DesktopPreReadyElectronOptions;
212+
const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage;
209213
const updates = yield* DesktopUpdates.DesktopUpdates;
210214
const environment = yield* DesktopEnvironment.DesktopEnvironment;
211215

212216
yield* shellEnvironment.installIntoProcess;
217+
const hasCommandLinePasswordStore =
218+
preReadyElectronOptions.linuxPasswordStoreCommandLine !== null;
219+
const linuxElectronOptions =
220+
environment.platform === "linux" && !hasCommandLinePasswordStore
221+
? DesktopPreReadyPlatform.resolveEarlyLinuxElectronOptionsFromProcess()
222+
: preReadyElectronOptions.linux;
223+
if (linuxElectronOptions !== null && !hasCommandLinePasswordStore) {
224+
if (
225+
linuxElectronOptions.passwordStore !== null ||
226+
preReadyElectronOptions.linux?.passwordStore !== null
227+
) {
228+
yield* electronApp.removeCommandLineSwitch("password-store");
229+
}
230+
if (linuxElectronOptions.passwordStore !== null) {
231+
yield* electronApp.appendCommandLineSwitch(
232+
"password-store",
233+
linuxElectronOptions.passwordStore,
234+
);
235+
}
236+
}
213237
const userDataPath = yield* appIdentity.resolveUserDataPath;
214238
yield* electronApp.setPath("userData", userDataPath);
215239
yield* logStartupInfo("runtime logging configured", { logDir: environment.logDir });
216240
yield* desktopSettings.load;
217241

218-
if (environment.platform === "linux") {
219-
yield* electronApp.appendCommandLineSwitch("class", environment.linuxWmClass);
242+
if (linuxElectronOptions !== null) {
243+
yield* logStartupInfo("linux password store configured", {
244+
passwordStore: hasCommandLinePasswordStore
245+
? "command-line"
246+
: (linuxElectronOptions.passwordStore ?? "electron-default"),
247+
xdgCurrentDesktop: process.env.XDG_CURRENT_DESKTOP ?? null,
248+
xdgSessionDesktop: process.env.XDG_SESSION_DESKTOP ?? null,
249+
});
220250
}
221251

222252
yield* appIdentity.configure;
@@ -228,6 +258,12 @@ const startup = Effect.gen(function* () {
228258
Effect.catchCause((cause) => fatalStartupCause("whenReady", cause)),
229259
);
230260
yield* logStartupInfo("app ready");
261+
if (environment.platform === "linux") {
262+
const selectedBackend = yield* safeStorage.selectedStorageBackend;
263+
yield* logStartupInfo("safe storage ready", {
264+
backend: Option.getOrElse(selectedBackend, () => "unknown"),
265+
});
266+
}
231267
yield* appIdentity.configure;
232268
yield* applicationMenu.configure;
233269
yield* updates.configure;

apps/desktop/src/app/DesktopAppIdentity.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) =>
6363
calls.setDockIcon.push(iconPath);
6464
}),
6565
appendCommandLineSwitch: () => Effect.void,
66+
removeCommandLineSwitch: () => Effect.void,
6667
on: () => Effect.void,
6768
} satisfies ElectronApp.ElectronApp["Service"]);
6869

apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// @effect-diagnostics nodeBuiltinImport:off - tests use POSIX path joining to match the Linux startup boundary.
2+
import * as NodePath from "node:path";
3+
import { assert, describe, it } from "@effect/vitest";
4+
5+
import {
6+
resolveEarlyLinuxElectronOptions,
7+
resolveEarlyLinuxPasswordStorePreference,
8+
} from "./DesktopEarlyElectronStartup.ts";
9+
10+
describe("DesktopEarlyElectronStartup", () => {
11+
const joinPath = NodePath.posix.join;
12+
13+
it("reads the persisted linux password-store preference before Electron is ready", () => {
14+
const preference = resolveEarlyLinuxPasswordStorePreference({
15+
env: { T3CODE_HOME: "/home/user/.t3-test" },
16+
homeDirectory: "/home/user",
17+
joinPath,
18+
readFileString: (path) => {
19+
assert.equal(path, "/home/user/.t3-test/userdata/desktop-settings.json");
20+
return JSON.stringify({ linuxPasswordStore: "kwallet6" });
21+
},
22+
});
23+
24+
assert.equal(preference, "kwallet6");
25+
});
26+
27+
it("accepts JSONC in the early desktop settings file", () => {
28+
const preference = resolveEarlyLinuxPasswordStorePreference({
29+
env: { T3CODE_HOME: "/home/user/.t3-test" },
30+
homeDirectory: "/home/user",
31+
joinPath,
32+
readFileString: () => `{
33+
// manually edited setting
34+
"linuxPasswordStore": "gnome-libsecret",
35+
}`,
36+
});
37+
38+
assert.equal(preference, "gnome-libsecret");
39+
});
40+
41+
it("falls back to auto when the early settings document is missing or invalid", () => {
42+
const preference = resolveEarlyLinuxPasswordStorePreference({
43+
env: {},
44+
homeDirectory: "/home/user",
45+
joinPath,
46+
readFileString: () => {
47+
throw new Error("missing");
48+
},
49+
});
50+
51+
assert.equal(preference, "auto");
52+
});
53+
54+
it("preserves absolute root paths when resolving early settings", () => {
55+
const preference = resolveEarlyLinuxPasswordStorePreference({
56+
env: { T3CODE_HOME: "/" },
57+
homeDirectory: "/home/user",
58+
joinPath,
59+
readFileString: (path) => {
60+
assert.equal(path, "/userdata/desktop-settings.json");
61+
return JSON.stringify({ linuxPasswordStore: "kwallet6" });
62+
},
63+
});
64+
65+
assert.equal(preference, "kwallet6");
66+
});
67+
68+
it("resolves the early linux Electron switches", () => {
69+
const options = resolveEarlyLinuxElectronOptions({
70+
env: {
71+
T3CODE_HOME: "/home/user/.t3-test",
72+
XDG_CURRENT_DESKTOP: "niri",
73+
VITE_DEV_SERVER_URL: "http://127.0.0.1:5173",
74+
},
75+
homeDirectory: "/home/user",
76+
joinPath,
77+
readFileString: (path) => {
78+
assert.equal(path, "/home/user/.t3-test/dev/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+
});
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { fromLenientJson } from "@t3tools/shared/schemaJson";
2+
import * as Option from "effect/Option";
3+
import * as Schema from "effect/Schema";
4+
5+
import {
6+
DEFAULT_LINUX_PASSWORD_STORE,
7+
normalizeLinuxPasswordStorePreference,
8+
resolveLinuxPasswordStoreSwitch,
9+
type LinuxPasswordStoreSwitch,
10+
type LinuxPasswordStorePreference,
11+
} from "../linuxSecretStorage.ts";
12+
import {
13+
resolveDesktopBaseDir,
14+
resolveDesktopStateDir,
15+
type JoinPath,
16+
} from "./DesktopStatePaths.ts";
17+
18+
interface EarlyDesktopSettingsInput {
19+
readonly env: NodeJS.ProcessEnv;
20+
readonly homeDirectory: string;
21+
readonly joinPath: JoinPath;
22+
readonly readFileString: (path: string) => string;
23+
}
24+
25+
type EarlyLinuxElectronOptionsInput = EarlyDesktopSettingsInput;
26+
27+
export interface EarlyLinuxElectronOptions {
28+
readonly linuxWmClass: string;
29+
readonly passwordStore: LinuxPasswordStoreSwitch | null;
30+
}
31+
32+
const trimNonEmpty = (value: string | undefined): string | null => {
33+
const trimmed = value?.trim();
34+
return trimmed && trimmed.length > 0 ? trimmed : null;
35+
};
36+
37+
const EarlyDesktopSettingsJson = fromLenientJson(
38+
Schema.Struct({
39+
linuxPasswordStore: Schema.optionalKey(Schema.Unknown),
40+
}),
41+
);
42+
const decodeEarlyDesktopSettingsJson = Schema.decodeSync(EarlyDesktopSettingsJson);
43+
44+
const isDevelopmentEnvironment = (env: NodeJS.ProcessEnv): boolean =>
45+
trimNonEmpty(env.VITE_DEV_SERVER_URL) !== null;
46+
47+
function resolveEarlyDesktopSettingsPath(input: {
48+
readonly env: NodeJS.ProcessEnv;
49+
readonly homeDirectory: string;
50+
readonly joinPath: JoinPath;
51+
}): string {
52+
const baseDir = resolveDesktopBaseDir({
53+
homeDirectory: input.homeDirectory,
54+
joinPath: input.joinPath,
55+
t3Home: Option.fromUndefinedOr(input.env.T3CODE_HOME),
56+
});
57+
const stateDir = resolveDesktopStateDir({
58+
baseDir,
59+
isDevelopment: isDevelopmentEnvironment(input.env),
60+
joinPath: input.joinPath,
61+
});
62+
return input.joinPath(stateDir, "desktop-settings.json");
63+
}
64+
65+
export function resolveEarlyLinuxPasswordStorePreference(
66+
input: EarlyDesktopSettingsInput,
67+
): LinuxPasswordStorePreference {
68+
const settingsPath = resolveEarlyDesktopSettingsPath(input);
69+
try {
70+
const parsed = decodeEarlyDesktopSettingsJson(input.readFileString(settingsPath));
71+
return normalizeLinuxPasswordStorePreference(parsed.linuxPasswordStore);
72+
} catch {
73+
return DEFAULT_LINUX_PASSWORD_STORE;
74+
}
75+
}
76+
77+
export function resolveEarlyLinuxElectronOptions(
78+
input: EarlyLinuxElectronOptionsInput,
79+
): EarlyLinuxElectronOptions {
80+
const preference = resolveEarlyLinuxPasswordStorePreference(input);
81+
return {
82+
linuxWmClass: isDevelopmentEnvironment(input.env) ? "t3code-dev" : "t3code",
83+
passwordStore: resolveLinuxPasswordStoreSwitch({
84+
preference,
85+
env: input.env,
86+
}),
87+
};
88+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { assert, describe, it } from "@effect/vitest";
2+
import * as Deferred from "effect/Deferred";
3+
import * as Effect from "effect/Effect";
4+
import * as Layer from "effect/Layer";
5+
import * as Option from "effect/Option";
6+
7+
import {
8+
DesktopPreReadyElectronOptions,
9+
makeDesktopElectronPreReadyLayer,
10+
readCommandLineSwitchValue,
11+
} from "./DesktopPreReadyPlatform.ts";
12+
13+
describe("DesktopPreReadyPlatform", () => {
14+
it("reads an explicit Electron command-line switch value", () => {
15+
const value = readCommandLineSwitchValue(
16+
{
17+
hasSwitch: (switchName) => switchName === "password-store",
18+
getSwitchValue: (switchName) => {
19+
assert.equal(switchName, "password-store");
20+
return "basic";
21+
},
22+
},
23+
"password-store",
24+
);
25+
26+
assert.equal(value, "basic");
27+
});
28+
29+
it("treats valueless Electron command-line switches as absent", () => {
30+
const value = readCommandLineSwitchValue(
31+
{
32+
hasSwitch: () => true,
33+
getSwitchValue: () => "",
34+
},
35+
"password-store",
36+
);
37+
38+
assert.isNull(value);
39+
});
40+
41+
it("returns null for missing Electron command-line switches", () => {
42+
const value = readCommandLineSwitchValue(
43+
{
44+
hasSwitch: () => false,
45+
getSwitchValue: () => {
46+
throw new Error("Unexpected switch value read.");
47+
},
48+
},
49+
"password-store",
50+
);
51+
52+
assert.isNull(value);
53+
});
54+
55+
it.effect("builds scheme privileges and command-line setup as sibling pre-ready effects", () =>
56+
Effect.gen(function* () {
57+
const schemeStarted = yield* Deferred.make<void>();
58+
const configureStarted = yield* Deferred.make<void>();
59+
60+
const layer = makeDesktopElectronPreReadyLayer({
61+
schemePrivilegesLayer: Layer.effectDiscard(
62+
Deferred.succeed(schemeStarted, undefined).pipe(
63+
Effect.andThen(Deferred.await(configureStarted)),
64+
),
65+
),
66+
configureElectronBeforeReady: Deferred.succeed(configureStarted, undefined).pipe(
67+
Effect.andThen(Deferred.await(schemeStarted)),
68+
Effect.as({
69+
linux: null,
70+
linuxPasswordStoreCommandLine: null,
71+
}),
72+
),
73+
});
74+
75+
const options = yield* DesktopPreReadyElectronOptions.pipe(
76+
Effect.provide(layer),
77+
Effect.timeoutOption("50 millis"),
78+
);
79+
80+
assert.deepEqual(Option.getOrNull(options), {
81+
linux: null,
82+
linuxPasswordStoreCommandLine: null,
83+
});
84+
}),
85+
);
86+
});

0 commit comments

Comments
 (0)