Skip to content

Commit bfc4a7c

Browse files
committed
fix(desktop): select Linux secret storage backend
1 parent d5fce53 commit bfc4a7c

7 files changed

Lines changed: 326 additions & 16 deletions

File tree

apps/desktop/src/desktopSettings.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ describe("desktopSettings", () => {
3535

3636
it("defaults packaged nightly builds to the nightly update channel", () => {
3737
expect(resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1")).toEqual({
38+
linuxPasswordStore: "auto",
3839
serverExposureMode: "local-only",
3940
tailscaleServeEnabled: false,
4041
tailscaleServePort: 443,
@@ -47,6 +48,7 @@ describe("desktopSettings", () => {
4748
const settingsPath = makeSettingsPath();
4849

4950
writeDesktopSettings(settingsPath, {
51+
linuxPasswordStore: "gnome-libsecret",
5052
serverExposureMode: "network-accessible",
5153
tailscaleServeEnabled: true,
5254
tailscaleServePort: 8443,
@@ -55,6 +57,7 @@ describe("desktopSettings", () => {
5557
});
5658

5759
expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual({
60+
linuxPasswordStore: "gnome-libsecret",
5861
serverExposureMode: "network-accessible",
5962
tailscaleServeEnabled: true,
6063
tailscaleServePort: 8443,
@@ -67,6 +70,7 @@ describe("desktopSettings", () => {
6770
expect(
6871
setDesktopServerExposurePreference(
6972
{
73+
linuxPasswordStore: "auto",
7074
serverExposureMode: "local-only",
7175
tailscaleServeEnabled: false,
7276
tailscaleServePort: 443,
@@ -76,6 +80,7 @@ describe("desktopSettings", () => {
7680
"network-accessible",
7781
),
7882
).toEqual({
83+
linuxPasswordStore: "auto",
7984
serverExposureMode: "network-accessible",
8085
tailscaleServeEnabled: false,
8186
tailscaleServePort: 443,
@@ -88,6 +93,7 @@ describe("desktopSettings", () => {
8893
expect(
8994
setDesktopTailscaleServePreference(
9095
{
96+
linuxPasswordStore: "auto",
9197
serverExposureMode: "local-only",
9298
tailscaleServeEnabled: false,
9399
tailscaleServePort: 443,
@@ -97,6 +103,7 @@ describe("desktopSettings", () => {
97103
{ enabled: true, port: 8443 },
98104
),
99105
).toEqual({
106+
linuxPasswordStore: "auto",
100107
serverExposureMode: "local-only",
101108
tailscaleServeEnabled: true,
102109
tailscaleServePort: 8443,
@@ -109,6 +116,7 @@ describe("desktopSettings", () => {
109116
expect(
110117
setDesktopTailscaleServePreference(
111118
{
119+
linuxPasswordStore: "auto",
112120
serverExposureMode: "local-only",
113121
tailscaleServeEnabled: false,
114122
tailscaleServePort: 8443,
@@ -118,6 +126,7 @@ describe("desktopSettings", () => {
118126
{ enabled: true },
119127
),
120128
).toEqual({
129+
linuxPasswordStore: "auto",
121130
serverExposureMode: "local-only",
122131
tailscaleServeEnabled: true,
123132
tailscaleServePort: 8443,
@@ -130,6 +139,7 @@ describe("desktopSettings", () => {
130139
expect(
131140
setDesktopUpdateChannelPreference(
132141
{
142+
linuxPasswordStore: "auto",
133143
serverExposureMode: "local-only",
134144
tailscaleServeEnabled: false,
135145
tailscaleServePort: 443,
@@ -139,6 +149,7 @@ describe("desktopSettings", () => {
139149
"nightly",
140150
),
141151
).toEqual({
152+
linuxPasswordStore: "auto",
142153
serverExposureMode: "local-only",
143154
tailscaleServeEnabled: false,
144155
tailscaleServePort: 443,
@@ -159,6 +170,7 @@ describe("desktopSettings", () => {
159170
fs.writeFileSync(settingsPath, JSON.stringify({ serverExposureMode: "local-only" }), "utf8");
160171

161172
expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({
173+
linuxPasswordStore: "auto",
162174
serverExposureMode: "local-only",
163175
tailscaleServeEnabled: false,
164176
tailscaleServePort: 443,
@@ -179,6 +191,7 @@ describe("desktopSettings", () => {
179191
);
180192

181193
expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({
194+
linuxPasswordStore: "auto",
182195
serverExposureMode: "local-only",
183196
tailscaleServeEnabled: false,
184197
tailscaleServePort: 443,
@@ -200,6 +213,7 @@ describe("desktopSettings", () => {
200213
);
201214

202215
expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({
216+
linuxPasswordStore: "auto",
203217
serverExposureMode: "local-only",
204218
tailscaleServeEnabled: false,
205219
tailscaleServePort: 443,
@@ -220,6 +234,7 @@ describe("desktopSettings", () => {
220234
);
221235

222236
expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual({
237+
linuxPasswordStore: "auto",
223238
serverExposureMode: "local-only",
224239
tailscaleServeEnabled: true,
225240
tailscaleServePort: 443,

apps/desktop/src/desktopSettings.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,15 @@ import * as FS from "node:fs";
22
import * as Path from "node:path";
33
import type { DesktopServerExposureMode, DesktopUpdateChannel } from "@t3tools/contracts";
44

5+
import {
6+
DEFAULT_LINUX_PASSWORD_STORE,
7+
normalizeLinuxPasswordStorePreference,
8+
type LinuxPasswordStorePreference,
9+
} from "./linuxSecretStorage.ts";
510
import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts";
611

712
export interface DesktopSettings {
13+
readonly linuxPasswordStore: LinuxPasswordStorePreference;
814
readonly serverExposureMode: DesktopServerExposureMode;
915
readonly tailscaleServeEnabled: boolean;
1016
readonly tailscaleServePort: number;
@@ -15,6 +21,7 @@ export interface DesktopSettings {
1521
export const DEFAULT_TAILSCALE_SERVE_PORT = 443;
1622

1723
export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
24+
linuxPasswordStore: DEFAULT_LINUX_PASSWORD_STORE,
1825
serverExposureMode: "local-only",
1926
tailscaleServeEnabled: false,
2027
tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT,
@@ -85,6 +92,7 @@ export function readDesktopSettings(settingsPath: string, appVersion: string): D
8592

8693
const raw = FS.readFileSync(settingsPath, "utf8");
8794
const parsed = JSON.parse(raw) as {
95+
readonly linuxPasswordStore?: unknown;
8896
readonly serverExposureMode?: unknown;
8997
readonly tailscaleServeEnabled?: unknown;
9098
readonly tailscaleServePort?: unknown;
@@ -101,6 +109,7 @@ export function readDesktopSettings(settingsPath: string, appVersion: string): D
101109
(isLegacySettings && parsedUpdateChannel === "nightly");
102110

103111
return {
112+
linuxPasswordStore: normalizeLinuxPasswordStorePreference(parsed.linuxPasswordStore),
104113
serverExposureMode:
105114
parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only",
106115
tailscaleServeEnabled: parsed.tailscaleServeEnabled === true,
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import {
4+
normalizeLinuxPasswordStorePreference,
5+
resolveLinuxPasswordStoreSwitch,
6+
resolveLinuxSecretStorageUnavailableMessage,
7+
} from "./linuxSecretStorage.ts";
8+
9+
describe("linuxSecretStorage", () => {
10+
it("preserves explicit supported password-store preferences", () => {
11+
expect(normalizeLinuxPasswordStorePreference("gnome-libsecret")).toBe("gnome-libsecret");
12+
expect(normalizeLinuxPasswordStorePreference("kwallet")).toBe("kwallet");
13+
expect(normalizeLinuxPasswordStorePreference("kwallet5")).toBe("kwallet5");
14+
expect(normalizeLinuxPasswordStorePreference("kwallet6")).toBe("kwallet6");
15+
});
16+
17+
it("falls back to auto for missing or unsupported preferences", () => {
18+
expect(normalizeLinuxPasswordStorePreference(undefined)).toBe("auto");
19+
expect(normalizeLinuxPasswordStorePreference("basic")).toBe("auto");
20+
});
21+
22+
it("does not force a password-store for desktops Electron already recognizes", () => {
23+
expect(
24+
resolveLinuxPasswordStoreSwitch({
25+
preference: "auto",
26+
env: { XDG_CURRENT_DESKTOP: "GNOME" },
27+
}),
28+
).toBeNull();
29+
expect(
30+
resolveLinuxPasswordStoreSwitch({
31+
preference: "auto",
32+
env: { XDG_CURRENT_DESKTOP: "KDE", KDE_SESSION_VERSION: "6" },
33+
}),
34+
).toBeNull();
35+
});
36+
37+
it("forces gnome-libsecret for unrecognized Linux desktop sessions", () => {
38+
expect(
39+
resolveLinuxPasswordStoreSwitch({
40+
preference: "auto",
41+
env: { XDG_CURRENT_DESKTOP: "niri" },
42+
}),
43+
).toBe("gnome-libsecret");
44+
});
45+
46+
it("uses explicit preferences instead of the auto heuristic", () => {
47+
expect(
48+
resolveLinuxPasswordStoreSwitch({
49+
preference: "kwallet6",
50+
env: { XDG_CURRENT_DESKTOP: "niri" },
51+
}),
52+
).toBe("kwallet6");
53+
});
54+
55+
it("uses GNOME Keyring remediation for libsecret and unknown backends", () => {
56+
expect(
57+
resolveLinuxSecretStorageUnavailableMessage({
58+
configuredPreference: "auto",
59+
selectedBackend: "gnome_libsecret",
60+
env: { XDG_CURRENT_DESKTOP: "niri" },
61+
}),
62+
).toContain("GNOME Keyring");
63+
});
64+
65+
it("prefers explicit libsecret selection over KDE desktop heuristics", () => {
66+
expect(
67+
resolveLinuxSecretStorageUnavailableMessage({
68+
configuredPreference: "gnome-libsecret",
69+
selectedBackend: "unknown",
70+
env: { XDG_CURRENT_DESKTOP: "KDE" },
71+
}),
72+
).toContain("GNOME Keyring");
73+
expect(
74+
resolveLinuxSecretStorageUnavailableMessage({
75+
configuredPreference: "auto",
76+
selectedBackend: "gnome_libsecret",
77+
env: { XDG_CURRENT_DESKTOP: "KDE" },
78+
}),
79+
).toContain("GNOME Keyring");
80+
});
81+
82+
it("uses KWallet remediation for KDE desktops and selected backends", () => {
83+
expect(
84+
resolveLinuxSecretStorageUnavailableMessage({
85+
configuredPreference: "auto",
86+
selectedBackend: "kwallet6",
87+
env: {},
88+
}),
89+
).toContain("KWallet");
90+
expect(
91+
resolveLinuxSecretStorageUnavailableMessage({
92+
configuredPreference: "auto",
93+
selectedBackend: "unknown",
94+
env: { XDG_CURRENT_DESKTOP: "KDE" },
95+
}),
96+
).toContain("KWallet");
97+
});
98+
});
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
export type LinuxPasswordStorePreference =
2+
| "auto"
3+
| "gnome-libsecret"
4+
| "kwallet"
5+
| "kwallet5"
6+
| "kwallet6";
7+
export type LinuxPasswordStoreSwitch = Exclude<LinuxPasswordStorePreference, "auto">;
8+
9+
export const DEFAULT_LINUX_PASSWORD_STORE: LinuxPasswordStorePreference = "auto";
10+
11+
const ELECTRON_LIBSECRET_DESKTOPS = new Set([
12+
"deepin",
13+
"gnome",
14+
"pantheon",
15+
"ukui",
16+
"unity",
17+
"x-cinnamon",
18+
"xfce",
19+
]);
20+
21+
const KDE_DESKTOPS = new Set(["kde", "kde4", "kde5", "kde6", "plasma"]);
22+
23+
export function normalizeLinuxPasswordStorePreference(
24+
value: unknown,
25+
): LinuxPasswordStorePreference {
26+
return value === "gnome-libsecret" ||
27+
value === "kwallet" ||
28+
value === "kwallet5" ||
29+
value === "kwallet6"
30+
? value
31+
: DEFAULT_LINUX_PASSWORD_STORE;
32+
}
33+
34+
export function resolveLinuxPasswordStoreSwitch(input: {
35+
readonly preference: LinuxPasswordStorePreference;
36+
readonly env: NodeJS.ProcessEnv;
37+
}): LinuxPasswordStoreSwitch | null {
38+
if (input.preference !== "auto") {
39+
return input.preference;
40+
}
41+
42+
return isElectronKnownLinuxSecretStorageDesktop(input.env) ? null : "gnome-libsecret";
43+
}
44+
45+
export function resolveLinuxSecretStorageUnavailableMessage(input: {
46+
readonly configuredPreference: LinuxPasswordStorePreference;
47+
readonly selectedBackend: string | null;
48+
readonly env: NodeJS.ProcessEnv;
49+
}): string {
50+
const backend = normalizeSelectedStorageBackend(input.selectedBackend);
51+
if (input.configuredPreference === "gnome-libsecret" || backend === "gnome-libsecret") {
52+
return getGnomeKeyringRemediationMessage();
53+
}
54+
55+
if (
56+
input.configuredPreference === "kwallet" ||
57+
input.configuredPreference === "kwallet5" ||
58+
input.configuredPreference === "kwallet6" ||
59+
backend === "kwallet" ||
60+
backend === "kwallet5" ||
61+
backend === "kwallet6" ||
62+
isKdeDesktop(input.env)
63+
) {
64+
return "T3 Code could not access KWallet to save this environment credential. Enable the KDE wallet subsystem in System Settings, then restart T3 Code.";
65+
}
66+
67+
return getGnomeKeyringRemediationMessage();
68+
}
69+
70+
function getGnomeKeyringRemediationMessage(): string {
71+
return "T3 Code could not access GNOME Keyring to save this environment credential. Install and start GNOME Keyring, then restart T3 Code.";
72+
}
73+
74+
function isElectronKnownLinuxSecretStorageDesktop(env: NodeJS.ProcessEnv): boolean {
75+
return resolveLinuxDesktopNames(env).some(
76+
(name) => ELECTRON_LIBSECRET_DESKTOPS.has(name) || KDE_DESKTOPS.has(name),
77+
);
78+
}
79+
80+
function isKdeDesktop(env: NodeJS.ProcessEnv): boolean {
81+
return resolveLinuxDesktopNames(env).some((name) => KDE_DESKTOPS.has(name));
82+
}
83+
84+
function resolveLinuxDesktopNames(env: NodeJS.ProcessEnv): string[] {
85+
return [
86+
...splitDesktopNameList(env.XDG_CURRENT_DESKTOP),
87+
env.DESKTOP_SESSION,
88+
env.GDMSESSION,
89+
env.KDE_SESSION_VERSION ? `kde${env.KDE_SESSION_VERSION}` : undefined,
90+
].flatMap((entry) => {
91+
const normalized = normalizeDesktopName(entry);
92+
return normalized ? [normalized] : [];
93+
});
94+
}
95+
96+
function splitDesktopNameList(value: string | undefined): string[] {
97+
return value?.split(":") ?? [];
98+
}
99+
100+
function normalizeDesktopName(value: string | undefined): string | null {
101+
const normalized = value?.trim().toLowerCase();
102+
return normalized && normalized.length > 0 ? normalized : null;
103+
}
104+
105+
function normalizeSelectedStorageBackend(value: string | null): string | null {
106+
const normalized = value?.trim().toLowerCase().replace(/_/gu, "-");
107+
return normalized && normalized.length > 0 ? normalized : null;
108+
}

0 commit comments

Comments
 (0)