Skip to content

Commit 85355a3

Browse files
fix(desktop): persist configurable storage paths
1 parent ab4fe39 commit 85355a3

12 files changed

Lines changed: 483 additions & 27 deletions

File tree

apps/desktop/electron-builder.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ win:
5050
target:
5151
- target: nsis
5252
arch: [x64, arm64]
53+
nsis:
54+
oneClick: false
55+
allowToChangeInstallationDirectory: true
5356
linux:
5457
icon: resources/icon.png
5558
target:

apps/desktop/src/main/config.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,20 @@ import {
99
toPersistedV3,
1010
} from '@open-codesign/shared';
1111
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
12+
import { getActiveStorageLocations } from './storage-settings';
1213

1314
const XDG_DEFAULT = join(homedir(), '.config', 'open-codesign');
1415

15-
export function configDir(): string {
16+
export function defaultConfigDir(): string {
1617
const xdg = process.env['XDG_CONFIG_HOME'];
1718
if (xdg && xdg.length > 0) return join(xdg, 'open-codesign');
1819
return XDG_DEFAULT;
1920
}
2021

22+
export function configDir(): string {
23+
return getActiveStorageLocations().configDir ?? defaultConfigDir();
24+
}
25+
2126
export function configPath(): string {
2227
return join(configDir(), 'config.toml');
2328
}

apps/desktop/src/main/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { mkdirSync } from 'node:fs';
12
import { stat } from 'node:fs/promises';
23
import { basename, dirname, join } from 'node:path';
34
import { fileURLToPath } from 'node:url';
@@ -47,6 +48,7 @@ import { preparePromptContext } from './prompt-context';
4748
import { isKeylessProviderAllowed, resolveActiveModel } from './provider-settings';
4849
import { safeInitSnapshotsDb } from './snapshots-db';
4950
import { registerSnapshotsIpc, registerSnapshotsUnavailableIpc } from './snapshots-ipc';
51+
import { initStorageSettings } from './storage-settings';
5052

5153
// ESM shim: package.json "type": "module" means the built bundle is ESM and
5254
// __dirname/__filename don't exist. Derive them from import.meta.url so the
@@ -56,6 +58,13 @@ const __dirname = dirname(__filename);
5658

5759
let mainWindow: ElectronBrowserWindow | null = null;
5860

61+
const defaultUserDataDir = app.getPath('userData');
62+
const storageLocations = initStorageSettings(defaultUserDataDir);
63+
if (storageLocations.dataDir !== undefined) {
64+
mkdirSync(storageLocations.dataDir, { recursive: true });
65+
app.setPath('userData', storageLocations.dataDir);
66+
}
67+
5968
/**
6069
* Workstream B Phase 1 feature flag. When truthy, `codesign:*:generate` routes
6170
* through `generateViaAgent()` (pi-agent-core, zero tools). Default off — any

apps/desktop/src/main/logger.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { join } from 'node:path';
22
import log from 'electron-log/main';
33
import { app } from './electron-runtime';
4+
import { getActiveStorageLocations } from './storage-settings';
45

56
/**
67
* Centralized logger for the main + preload + renderer processes.
@@ -19,11 +20,19 @@ import { app } from './electron-runtime';
1920

2021
let initialized = false;
2122

23+
export function defaultLogsDir(): string {
24+
return app.getPath('logs');
25+
}
26+
27+
export function logsDir(): string {
28+
return getActiveStorageLocations().logsDir ?? defaultLogsDir();
29+
}
30+
2231
export function initLogger(): typeof log {
2332
if (initialized) return log;
2433
initialized = true;
2534

26-
log.transports.file.resolvePathFn = () => join(app.getPath('logs'), 'main.log');
35+
log.transports.file.resolvePathFn = () => getLogPath();
2736
log.transports.file.maxSize = 5 * 1024 * 1024; // 5MB
2837
log.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] {scope} {text}';
2938
log.transports.console.level = app.isPackaged ? 'warn' : 'info';
@@ -59,5 +68,5 @@ export function getLogger(scope: string) {
5968
}
6069

6170
export function getLogPath(): string {
62-
return join(app.getPath('logs'), 'main.log');
71+
return join(logsDir(), 'main.log');
6372
}

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

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ vi.mock('./electron-runtime', () => ({
2121
handlers.set(channel, fn);
2222
},
2323
},
24+
dialog: { showOpenDialog: vi.fn(async () => ({ canceled: true, filePaths: [] })) },
2425
shell: { openPath: vi.fn() },
2526
}));
2627

@@ -50,8 +51,7 @@ vi.mock('electron-log/main', () => ({
5051
}));
5152

5253
vi.mock('./config', () => ({
53-
configPath: () => '/tmp/config.toml',
54-
configDir: () => '/tmp',
54+
defaultConfigDir: () => '/tmp/config',
5555
readConfig: vi.fn(async () => null),
5656
writeConfig: vi.fn(async () => {}),
5757
}));
@@ -82,7 +82,28 @@ vi.mock('./keychain-ux', () => ({
8282
}));
8383

8484
vi.mock('./storage-settings', () => ({
85-
buildAppPaths: vi.fn(() => ({})),
85+
buildAppPathsForLocations: vi.fn(() => ({})),
86+
getDefaultUserDataDir: vi.fn(() => '/tmp/data'),
87+
patchForStorageKind: vi.fn((kind: string, dir: string) => ({ [`${kind}Dir`]: dir })),
88+
readPersistedStorageLocations: vi.fn(async () => ({})),
89+
writeStorageLocations: vi.fn(async () => ({})),
90+
}));
91+
92+
vi.mock('./logger', () => ({
93+
defaultLogsDir: () => '/tmp/logs',
94+
getLogger: () => ({
95+
warn: vi.fn(),
96+
info: vi.fn(),
97+
error: vi.fn(),
98+
}),
99+
}));
100+
101+
vi.mock('./imports/codex-config', () => ({
102+
readCodexConfig: vi.fn(async () => null),
103+
}));
104+
105+
vi.mock('./imports/claude-code-config', () => ({
106+
readClaudeCodeSettings: vi.fn(async () => null),
86107
}));
87108

88109
vi.mock('@open-codesign/providers', () => ({
@@ -99,13 +120,14 @@ describe('registerOnboardingIpc — channel versioning', () => {
99120
expect(registeredChannels).toContain('settings:list-providers');
100121
});
101122

102-
it('registers all eight settings v1 channels', async () => {
123+
it('registers all settings v1 channels', async () => {
103124
const v1Channels = [
104125
'settings:v1:list-providers',
105126
'settings:v1:add-provider',
106127
'settings:v1:delete-provider',
107128
'settings:v1:set-active-provider',
108129
'settings:v1:get-paths',
130+
'settings:v1:choose-storage-folder',
109131
'settings:v1:open-folder',
110132
'settings:v1:reset-onboarding',
111133
'settings:v1:toggle-devtools',
@@ -116,13 +138,14 @@ describe('registerOnboardingIpc — channel versioning', () => {
116138
}
117139
});
118140

119-
it('preserves all eight legacy settings shim channels for backward compat', async () => {
141+
it('preserves all legacy settings shim channels for backward compat', async () => {
120142
const legacyChannels = [
121143
'settings:list-providers',
122144
'settings:add-provider',
123145
'settings:delete-provider',
124146
'settings:set-active-provider',
125147
'settings:get-paths',
148+
'settings:choose-storage-folder',
126149
'settings:open-folder',
127150
'settings:reset-onboarding',
128151
'settings:toggle-devtools',
@@ -240,3 +263,43 @@ describe('getApiKeyForProvider — API key retrieval', () => {
240263
expect(() => getApiKeyForProvider('openai')).toThrow(/PROVIDER_KEY_MISSING|No API key stored/);
241264
});
242265
});
266+
267+
describe('config:v1:import-codex-config empty env handling', () => {
268+
it('imports providers without encrypting empty env secrets', async () => {
269+
const { readCodexConfig } = await import('./imports/codex-config');
270+
const { encryptSecret } = await import('./keychain');
271+
const { writeConfig } = await import('./config');
272+
vi.mocked(encryptSecret).mockClear();
273+
vi.mocked(writeConfig).mockClear();
274+
vi.mocked(readCodexConfig).mockResolvedValueOnce({
275+
providers: [
276+
{
277+
id: 'codex-empty-env',
278+
name: 'Codex (imported)',
279+
builtin: false,
280+
wire: 'openai-chat',
281+
baseUrl: 'https://api.example.com/v1',
282+
defaultModel: 'gpt-test',
283+
envKey: 'OPEN_CODESIGN_EMPTY_ENV_FOR_TEST',
284+
},
285+
],
286+
activeProvider: 'codex-empty-env',
287+
activeModel: 'gpt-test',
288+
envKeyMap: { 'codex-empty-env': 'OPEN_CODESIGN_EMPTY_ENV_FOR_TEST' },
289+
warnings: [],
290+
});
291+
process.env['OPEN_CODESIGN_EMPTY_ENV_FOR_TEST'] = ' ';
292+
293+
const handler = handlers.get('config:v1:import-codex-config');
294+
expect(handler).toBeDefined();
295+
await expect(handler?.({} as unknown)).resolves.toMatchObject({
296+
provider: 'codex-empty-env',
297+
hasKey: true,
298+
});
299+
300+
expect(encryptSecret).not.toHaveBeenCalled();
301+
const written = vi.mocked(writeConfig).mock.calls.at(-1)?.[0];
302+
expect(written?.secrets['codex-empty-env']).toBeUndefined();
303+
delete process.env['OPEN_CODESIGN_EMPTY_ENV_FOR_TEST'];
304+
});
305+
});

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

Lines changed: 64 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@ import {
1414
hydrateConfig,
1515
isSupportedOnboardingProvider,
1616
} from '@open-codesign/shared';
17-
import { configDir, configPath, readConfig, writeConfig } from './config';
18-
import { ipcMain, shell } from './electron-runtime';
17+
import { defaultConfigDir, readConfig, writeConfig } from './config';
18+
import { dialog, ipcMain, shell } from './electron-runtime';
1919
import { type ClaudeCodeImport, readClaudeCodeSettings } from './imports/claude-code-config';
2020
import { type CodexImport, readCodexConfig } from './imports/codex-config';
2121
import { buildSecretRef, decryptSecret, migrateSecretMasks, tryBuildSecretRef } from './keychain';
2222
import { prepareKeychain } from './keychain-ux';
23-
import { getLogPath, getLogger } from './logger';
23+
import { defaultLogsDir, getLogger } from './logger';
2424
import {
2525
type ProviderRow,
2626
assertProviderHasStoredSecret,
@@ -29,7 +29,15 @@ import {
2929
isKeylessProviderAllowed,
3030
toProviderRows,
3131
} from './provider-settings';
32-
import { buildAppPaths } from './storage-settings';
32+
import {
33+
type AppPaths,
34+
type StorageKind,
35+
buildAppPathsForLocations,
36+
getDefaultUserDataDir,
37+
patchForStorageKind,
38+
readPersistedStorageLocations,
39+
writeStorageLocations,
40+
} from './storage-settings';
3341

3442
const logger = getLogger('settings-ipc');
3543

@@ -387,8 +395,42 @@ async function runSetActiveProvider(raw: unknown): Promise<OnboardingState> {
387395
return toState(cachedConfig);
388396
}
389397

390-
function runGetPaths() {
391-
return buildAppPaths(configPath(), getLogPath(), configDir());
398+
function defaultDataDir(): string {
399+
return getDefaultUserDataDir();
400+
}
401+
402+
function getStoragePathDefaults() {
403+
return {
404+
configDir: defaultConfigDir(),
405+
logsDir: defaultLogsDir(),
406+
dataDir: defaultDataDir(),
407+
};
408+
}
409+
410+
async function runGetPaths(): Promise<AppPaths> {
411+
const persisted = await readPersistedStorageLocations();
412+
return buildAppPathsForLocations(persisted, getStoragePathDefaults());
413+
}
414+
415+
function parseStorageKind(raw: unknown): StorageKind {
416+
if (raw === 'config' || raw === 'logs' || raw === 'data') return raw;
417+
throw new CodesignError('storage kind must be "config", "logs", or "data"', 'IPC_BAD_INPUT');
418+
}
419+
420+
async function runChooseStorageFolder(raw: unknown): Promise<AppPaths> {
421+
const kind = parseStorageKind(raw);
422+
const result = await dialog.showOpenDialog({
423+
properties: ['openDirectory', 'createDirectory'],
424+
});
425+
if (result.canceled || result.filePaths.length === 0) {
426+
return runGetPaths();
427+
}
428+
const selected = result.filePaths[0];
429+
if (selected === undefined || selected.trim().length === 0) {
430+
return runGetPaths();
431+
}
432+
await writeStorageLocations(patchForStorageKind(kind, selected));
433+
return runGetPaths();
392434
}
393435

394436
async function runOpenFolder(raw: unknown): Promise<void> {
@@ -636,7 +678,7 @@ async function runImportCodex(imported: CodexImport): Promise<OnboardingState> {
636678
for (const entry of imported.providers) {
637679
nextProviders[entry.id] = entry;
638680
if (entry.envKey !== undefined) {
639-
const envValue = process.env[entry.envKey];
681+
const envValue = process.env[entry.envKey]?.trim();
640682
if (envValue !== undefined && envValue.length > 0) {
641683
const ref = tryBuildSecretRef(envValue);
642684
if (ref !== null) nextSecrets[entry.id] = ref;
@@ -680,8 +722,9 @@ async function runImportClaudeCode(imported: ClaudeCodeImport): Promise<Onboardi
680722
}
681723
}
682724
nextProviders[imported.provider.id] = imported.provider;
683-
if (imported.apiKey !== null) {
684-
const ref = tryBuildSecretRef(imported.apiKey);
725+
const importedApiKey = imported.apiKey?.trim();
726+
if (importedApiKey !== undefined && importedApiKey.length > 0) {
727+
const ref = tryBuildSecretRef(importedApiKey);
685728
if (ref !== null) nextSecrets[imported.provider.id] = ref;
686729
}
687730
const next = hydrateConfig({
@@ -891,7 +934,12 @@ export function registerOnboardingIpc(): void {
891934
async (_e, raw: unknown): Promise<OnboardingState> => runSetActiveProvider(raw),
892935
);
893936

894-
ipcMain.handle('settings:v1:get-paths', () => runGetPaths());
937+
ipcMain.handle('settings:v1:get-paths', async (): Promise<AppPaths> => runGetPaths());
938+
939+
ipcMain.handle(
940+
'settings:v1:choose-storage-folder',
941+
async (_e, raw: unknown): Promise<AppPaths> => runChooseStorageFolder(raw),
942+
);
895943

896944
ipcMain.handle(
897945
'settings:v1:open-folder',
@@ -929,11 +977,16 @@ export function registerOnboardingIpc(): void {
929977
},
930978
);
931979

932-
ipcMain.handle('settings:get-paths', () => {
980+
ipcMain.handle('settings:get-paths', async (): Promise<AppPaths> => {
933981
logger.warn('legacy settings:get-paths channel used, schedule removal next minor');
934982
return runGetPaths();
935983
});
936984

985+
ipcMain.handle('settings:choose-storage-folder', async (_e, raw: unknown): Promise<AppPaths> => {
986+
logger.warn('legacy settings:choose-storage-folder channel used, schedule removal next minor');
987+
return runChooseStorageFolder(raw);
988+
});
989+
937990
ipcMain.handle('settings:open-folder', async (_e, raw: unknown) => {
938991
logger.warn('legacy settings:open-folder channel used, schedule removal next minor');
939992
return runOpenFolder(raw);

0 commit comments

Comments
 (0)