Skip to content

Commit bc32b4b

Browse files
committed
feat(desktop): persist mask + friendly keychain UX
Three changes end the "macOS asks for keychain password every time you open Settings" complaint on unsigned builds: 1. Mask persistence — the Settings page was decrypting every stored secret just to render "sk-ant-…xyz9". Each decrypt triggers a keychain prompt on unsigned macOS builds, so users saw N prompts (one per provider) on every launch. Now the mask is computed at save time and stored alongside the ciphertext as `SecretRef.mask`. Settings reads it straight off disk, zero safeStorage calls. Legacy configs missing the field run a one-shot migration on first Settings open: decrypt once per provider to populate the mask, then persist. 2. First-run explainer (keychain-ux.ts) — before the first safeStorage prompt of the app's lifetime, show an in-app dialog explaining that macOS is about to ask for the login password, recommend "Always Allow", and reassure the user their key stays on-device. Flag persisted as a zero-byte file in userData so it only shows once ever. 3. Graceful degradation — when `safeStorage.isEncryptionAvailable()` returns false (most common cause: app running from a read-only DMG mount instead of /Applications), show a helpful dialog with actionable next steps and an "Open Applications folder" button, rather than throwing a bare "KEYCHAIN_UNAVAILABLE" to the renderer. Shown once per session to avoid modal spam. Import paths use a non-throwing `tryBuildSecretRef` so a broken keychain doesn't abort a multi-provider import — the rest of the config still lands, the user re-adds keys by hand. All IPC handlers that touch safeStorage now go through a `withKeychain` gate that calls `prepareKeychain` first; if encryption is unavailable, the handler returns a user-friendly error instead of the bare throw. - packages/shared/src/config.ts: SecretRef.mask?: string - apps/desktop/src/main/keychain.ts: maskSecret, buildSecretRef, tryBuildSecretRef, migrateSecretMasks - apps/desktop/src/main/provider-settings.ts: prefer stored mask - apps/desktop/src/main/keychain-ux.ts: explainer + unavailable dialogs - apps/desktop/src/main/onboarding-ipc.ts: withKeychain on 9 handlers
1 parent 2d38967 commit bc32b4b

6 files changed

Lines changed: 333 additions & 38 deletions

File tree

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { existsSync, writeFileSync } from 'node:fs';
2+
import { join } from 'node:path';
3+
import { app, dialog, safeStorage, shell } from './electron-runtime';
4+
import { getLogger } from './logger';
5+
6+
/**
7+
* Keychain UX layer. Wraps the raw `safeStorage` calls with two one-time
8+
* dialogs to turn the default macOS experience (opaque "password required"
9+
* modal appearing with no context) into something a non-technical user can
10+
* reason about.
11+
*
12+
* (C) Friendly explainer — before the first keychain prompt of the app's
13+
* lifetime, show an in-app message telling the user macOS is about
14+
* to ask for their login password and recommending they click
15+
* "Always Allow" so it doesn't repeat. Flag stored as a zero-byte
16+
* file in userData so it persists across launches.
17+
*
18+
* (E) Graceful degradation — when `safeStorage.isEncryptionAvailable()`
19+
* returns false (most commonly the app is running from a read-only
20+
* DMG mount instead of /Applications, so Electron can't establish a
21+
* keychain trust entry), surface a helpful dialog with a button to
22+
* open the Applications folder and quit. Shown at most once per
23+
* session — subsequent calls re-throw silently so we don't spam the
24+
* user with modals if they dismiss it.
25+
*/
26+
27+
const log = getLogger('keychain-ux');
28+
29+
const EXPLAINER_FLAG_FILE = 'keychain-explainer-seen';
30+
31+
let unavailableDialogShownThisSession = false;
32+
33+
function explainerFlagPath(): string {
34+
return join(app.getPath('userData'), EXPLAINER_FLAG_FILE);
35+
}
36+
37+
function hasSeenExplainer(): boolean {
38+
try {
39+
return existsSync(explainerFlagPath());
40+
} catch {
41+
return false;
42+
}
43+
}
44+
45+
function markExplainerSeen(): void {
46+
try {
47+
writeFileSync(explainerFlagPath(), '', { flag: 'w' });
48+
} catch (err) {
49+
log.warn('[keychain-ux] failed to persist explainer flag', err);
50+
}
51+
}
52+
53+
/**
54+
* Show the first-time keychain explainer. Blocks until dismissed. Safe to
55+
* call unconditionally — returns immediately if the user has already seen
56+
* it on any prior launch.
57+
*/
58+
export async function maybeShowKeychainExplainer(): Promise<void> {
59+
if (hasSeenExplainer()) return;
60+
// Only relevant when safeStorage will actually prompt (macOS / Linux
61+
// with a working keychain). If encryption isn't even available, the
62+
// unavailable dialog handles the UX — skip the explainer.
63+
if (!safeStorage.isEncryptionAvailable()) return;
64+
65+
try {
66+
await dialog.showMessageBox({
67+
type: 'info',
68+
title: 'Open CoDesign 需要使用系统钥匙串',
69+
message: '你的 API key 将会加密存在你自己的电脑上',
70+
detail: [
71+
'macOS 接下来会弹出一个系统窗口,问你是否允许 Open CoDesign 访问钥匙串。',
72+
'',
73+
'请点「始终允许」(Always Allow)— 之后 Open CoDesign 就不会再问了。',
74+
'',
75+
'你的 key 只在你本机保存,不会上传到任何云服务。',
76+
].join('\n'),
77+
buttons: ['我知道了'],
78+
defaultId: 0,
79+
});
80+
} catch (err) {
81+
log.warn('[keychain-ux] explainer dialog failed', err);
82+
}
83+
markExplainerSeen();
84+
}
85+
86+
/**
87+
* Show the "keychain unavailable" dialog with actionable next steps. Shown
88+
* at most once per session to avoid modal spam. Callers should treat this
89+
* as advisory and still surface the underlying error to the user so
90+
* failing IPC handlers return something the renderer can render.
91+
*/
92+
export async function maybeShowKeychainUnavailableDialog(): Promise<void> {
93+
if (unavailableDialogShownThisSession) return;
94+
unavailableDialogShownThisSession = true;
95+
96+
const fromDmg = process.execPath.includes('/Volumes/');
97+
try {
98+
const response = await dialog.showMessageBox({
99+
type: 'warning',
100+
title: '系统钥匙串不可用',
101+
message: '无法加密保存你的 API key',
102+
detail: [
103+
fromDmg
104+
? '最常见原因:你直接从 DMG 运行了 Open CoDesign,没有拖到「应用程序」文件夹。'
105+
: '系统钥匙串当前无法访问。可能是 macOS 权限未授权,或者 Open CoDesign 不在「应用程序」文件夹中。',
106+
'',
107+
'解决办法:',
108+
'1. 退出 Open CoDesign(Cmd+Q)',
109+
'2. 把 Open CoDesign.app 拖到「应用程序」文件夹',
110+
'3. 从「应用程序」启动',
111+
'',
112+
'之后第一次启动 macOS 会请求权限,请选「始终允许」。',
113+
].join('\n'),
114+
buttons: ['打开「应用程序」文件夹', '先不管,继续使用'],
115+
defaultId: 0,
116+
cancelId: 1,
117+
});
118+
if (response.response === 0) {
119+
await shell.openPath('/Applications');
120+
}
121+
} catch (err) {
122+
log.warn('[keychain-ux] unavailable dialog failed', err);
123+
}
124+
}
125+
126+
/**
127+
* Preflight for any IPC handler that is about to encrypt or decrypt. Returns
128+
* `true` when keychain ops should proceed, `false` when the caller should
129+
* abort the operation (unavailable dialog will have been shown).
130+
*/
131+
export async function prepareKeychain(): Promise<boolean> {
132+
if (!safeStorage.isEncryptionAvailable()) {
133+
await maybeShowKeychainUnavailableDialog();
134+
return false;
135+
}
136+
await maybeShowKeychainExplainer();
137+
return true;
138+
}

apps/desktop/src/main/keychain.ts

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { CodesignError } from '@open-codesign/shared';
1+
import { CodesignError, type Config, type SecretRef } from '@open-codesign/shared';
22
import { safeStorage } from './electron-runtime';
33

44
export function ensureKeychainAvailable(): void {
@@ -27,3 +27,86 @@ export function decryptSecret(ciphertextBase64: string): string {
2727
const buf = Buffer.from(ciphertextBase64, 'base64');
2828
return safeStorage.decryptString(buf);
2929
}
30+
31+
/**
32+
* Derive a display-safe mask for a secret — e.g. "sk-ant-***xyz9". Stored
33+
* alongside the ciphertext so Settings can render the provider row without
34+
* invoking `safeStorage.decryptString` (which prompts for the keychain
35+
* password on unsigned macOS builds).
36+
*/
37+
export function maskSecret(plaintext: string): string {
38+
if (plaintext.length <= 8) return '***';
39+
const prefix = plaintext.startsWith('sk-') ? 'sk-' : plaintext.slice(0, 4);
40+
const suffix = plaintext.slice(-4);
41+
return `${prefix}***${suffix}`;
42+
}
43+
44+
/**
45+
* Convenience wrapper: encrypt a plaintext API key and return the full
46+
* `SecretRef` (ciphertext + mask) in one shot. Use this at every save site
47+
* so mask metadata is always written. Older configs missing `mask` are
48+
* migrated by `migrateSecretMasks` on first read.
49+
*/
50+
export function buildSecretRef(plaintext: string): SecretRef {
51+
return {
52+
ciphertext: encryptSecret(plaintext),
53+
mask: maskSecret(plaintext),
54+
};
55+
}
56+
57+
/**
58+
* Non-throwing variant of `buildSecretRef` — returns `null` when safeStorage
59+
* is unavailable (unsigned macOS without keychain entitlements, Linux
60+
* without a secret-service daemon, etc.). Callers should skip persisting
61+
* the secret and surface a warning so the REST of the imported config
62+
* still lands; the user can re-add the key by hand once keychain is fixed.
63+
*
64+
* Only `KEYCHAIN_UNAVAILABLE` is caught — real unexpected errors still
65+
* propagate so they're not silently swallowed.
66+
*/
67+
export function tryBuildSecretRef(plaintext: string): SecretRef | null {
68+
try {
69+
return buildSecretRef(plaintext);
70+
} catch (err) {
71+
if (err instanceof CodesignError && err.code === 'KEYCHAIN_UNAVAILABLE') {
72+
return null;
73+
}
74+
throw err;
75+
}
76+
}
77+
78+
/**
79+
* One-shot migration: for any secret missing the `mask` field, decrypt it
80+
* once and populate the mask. Designed to run on config load — after this
81+
* pass, `toProviderRows` never touches safeStorage again. Returns the
82+
* migrated config plus a flag indicating whether anything changed (so the
83+
* caller can decide whether to persist).
84+
*
85+
* Each decrypt-for-migration triggers exactly one keychain prompt per
86+
* provider on unsigned macOS builds — unavoidable since we have to read
87+
* the plaintext once to derive the mask. This is a one-time cost; all
88+
* future launches read masks directly from disk.
89+
*
90+
* Robust to partial failures: if a single provider fails to decrypt (e.g.
91+
* keychain revoked access mid-migration), we leave that row's mask
92+
* unset and carry on with the rest.
93+
*/
94+
export function migrateSecretMasks(cfg: Config): { config: Config; changed: boolean } {
95+
const secrets = cfg.secrets ?? {};
96+
const entries = Object.entries(secrets);
97+
const needs = entries.filter(([, ref]) => ref.mask === undefined || ref.mask.length === 0);
98+
if (needs.length === 0) return { config: cfg, changed: false };
99+
100+
const nextSecrets: Record<string, SecretRef> = { ...secrets };
101+
let changed = false;
102+
for (const [provider, ref] of needs) {
103+
try {
104+
const plain = decryptSecret(ref.ciphertext);
105+
nextSecrets[provider] = { ...ref, mask: maskSecret(plain) };
106+
changed = true;
107+
} catch {
108+
/* skip — row will retry on next boot or when user edits it */
109+
}
110+
}
111+
return { config: { ...cfg, secrets: nextSecrets }, changed };
112+
}

apps/desktop/src/main/onboarding-ipc.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,26 @@ vi.mock('./config', () => ({
5959
vi.mock('./keychain', () => ({
6060
encryptSecret: vi.fn((s: string) => `enc:${s}`),
6161
decryptSecret: vi.fn((s: string) => s.replace('enc:', '')),
62+
maskSecret: vi.fn((s: string) => (s.length > 8 ? `${s.slice(0, 4)}***${s.slice(-4)}` : '***')),
63+
buildSecretRef: vi.fn((s: string) => ({
64+
ciphertext: `enc:${s}`,
65+
mask: s.length > 8 ? `${s.slice(0, 4)}***${s.slice(-4)}` : '***',
66+
})),
67+
tryBuildSecretRef: vi.fn((s: string) => ({
68+
ciphertext: `enc:${s}`,
69+
mask: s.length > 8 ? `${s.slice(0, 4)}***${s.slice(-4)}` : '***',
70+
})),
71+
migrateSecretMasks: vi.fn((cfg: { secrets?: Record<string, unknown> }) => ({
72+
config: cfg,
73+
changed: false,
74+
})),
75+
ensureKeychainAvailable: vi.fn(() => {}),
76+
}));
77+
78+
vi.mock('./keychain-ux', () => ({
79+
prepareKeychain: vi.fn(async () => true),
80+
maybeShowKeychainExplainer: vi.fn(async () => {}),
81+
maybeShowKeychainUnavailableDialog: vi.fn(async () => {}),
6282
}));
6383

6484
vi.mock('./storage-settings', () => ({

0 commit comments

Comments
 (0)