From bf1e711198c1d67a20498278a13b6f9bc2a7f026 Mon Sep 17 00:00:00 2001 From: Tiege Bentley Date: Sat, 23 May 2026 23:12:29 +0000 Subject: [PATCH 1/5] chore(desktop): add standalone vite config for browser-only dev Enables running just the renderer at :5175 without Electron, useful for headless Linux dev (no $DISPLAY). Defines __APP_VERSION__ so TopBar renders without the electron-vite pipeline. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/desktop/vite.browser.config.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 apps/desktop/vite.browser.config.ts diff --git a/apps/desktop/vite.browser.config.ts b/apps/desktop/vite.browser.config.ts new file mode 100644 index 00000000..a8bf9359 --- /dev/null +++ b/apps/desktop/vite.browser.config.ts @@ -0,0 +1,10 @@ +import { resolve } from 'node:path'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + root: resolve(__dirname, 'src/renderer'), + define: { __APP_VERSION__: JSON.stringify('0.2.0') }, + plugins: [react()], + server: { host: '127.0.0.1', port: 5175 }, +}); From 9d84d3ebde7f4c57851b7ee64ceca870270777ef Mon Sep 17 00:00:00 2001 From: Tiege Bentley Date: Sun, 24 May 2026 03:51:16 +0000 Subject: [PATCH 2/5] feat(desktop): add test-connection button for image generation providers Resolves credentials for all three paths (inherited, custom encrypted, ChatGPT OAuth) and probes GET /models on the image baseUrl. Includes UI button with loading/success/error toasts, 5 unit tests, and i18n strings for en/es/zh-CN. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/desktop/src/main/connection-ipc.ts | 35 ++++++++ .../main/image-generation-settings.test.ts | 87 +++++++++++++++++++ .../src/main/image-generation-settings.ts | 65 ++++++++++++++ apps/desktop/src/preload/index.ts | 4 + .../settings/ImageGenerationTab.tsx | 50 ++++++++++- packages/i18n/src/locales/en.json | 6 +- packages/i18n/src/locales/es.json | 6 +- packages/i18n/src/locales/zh-CN.json | 6 +- 8 files changed, 255 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/connection-ipc.ts b/apps/desktop/src/main/connection-ipc.ts index 1041c9d0..fb024ef0 100644 --- a/apps/desktop/src/main/connection-ipc.ts +++ b/apps/desktop/src/main/connection-ipc.ts @@ -16,6 +16,7 @@ import { import { buildAuthHeaders, buildAuthHeadersForWire } from './auth-headers'; import { getCodexTokenStore } from './codex-oauth-ipc'; import { ipcMain } from './electron-runtime'; +import { resolveImageGenerationTestCredentials } from './image-generation-settings'; import { getApiKeyForProvider, getCachedConfig, hasApiKeyForProvider } from './onboarding-ipc'; import { isKeylessProviderAllowed } from './provider-settings'; import { withTlsBypass } from './tls-override'; @@ -806,6 +807,10 @@ export function registerConnectionIpc(): void { handleConnectionV1TestProvider(raw), ); + // Tests the currently configured image-generation provider using its own + // (possibly separate) key + baseUrl. No payload — resolved from settings. + ipcMain.handle('connection:v1:test-image-provider', () => handleConnectionV1TestImageProvider()); + // Fetch available models for a stored provider by ID — credentials resolved // from the encrypted config so the renderer never touches plaintext keys. ipcMain.handle('models:v1:list-for-provider', (_e, raw: unknown) => @@ -916,6 +921,36 @@ async function handleConnectionV1TestProvider(raw: unknown): Promise { + const cfg = getCachedConfig(); + const resolved = await resolveImageGenerationTestCredentials(cfg); + if (!resolved.ok) { + return { + ok: false, + code: 'IPC_BAD_INPUT', + message: resolved.message, + hint: + resolved.code === 'IMAGE_GEN_DISABLED' + ? 'Configure an image generation provider in Settings → Image generation first.' + : 'Add an API key for the image generation provider in Settings → Image generation, or sign in to ChatGPT.', + }; + } + const wire: WireApi = + resolved.provider === 'chatgpt-codex' ? 'openai-codex-responses' : 'openai-chat'; + return runProviderTest({ + provider: resolved.provider, + wire, + apiKey: resolved.apiKey, + baseUrl: resolved.baseUrl, + builtin: true, + }); +} + type ResolvedProviderForListing = { providerId: string; entry: ProviderEntry }; function resolveProviderForListing( diff --git a/apps/desktop/src/main/image-generation-settings.test.ts b/apps/desktop/src/main/image-generation-settings.test.ts index 1bf504bf..58d48878 100644 --- a/apps/desktop/src/main/image-generation-settings.test.ts +++ b/apps/desktop/src/main/image-generation-settings.test.ts @@ -13,6 +13,7 @@ import { isGenerateImageAssetEnabled, parseImageGenerationUpdate, resolveImageGenerationConfig, + resolveImageGenerationTestCredentials, updateImageGenerationSettings, } from './image-generation-settings'; @@ -406,3 +407,89 @@ describe('image generation enablement', () => { }); }); }); + +describe('resolveImageGenerationTestCredentials', () => { + afterEach(() => { + mocks.cachedConfig = null; + getApiKeyForProviderMock.mockReset(); + mocks.codexGetValidAccessToken.mockReset(); + }); + + it('returns IMAGE_GEN_DISABLED when no imageGeneration config exists', async () => { + const result = await resolveImageGenerationTestCredentials(null); + expect(result).toMatchObject({ ok: false, code: 'IMAGE_GEN_DISABLED' }); + }); + + it('resolves inherited credentials even when image generation is disabled', async () => { + getApiKeyForProviderMock.mockReturnValue('sk-openai'); + const cfg = makeConfig(false); + const result = await resolveImageGenerationTestCredentials(cfg); + expect(result).toMatchObject({ + ok: true, + provider: 'openai', + apiKey: 'sk-openai', + baseUrl: 'https://api.openai.com/v1', + }); + }); + + it('returns PROVIDER_KEY_MISSING when inherited credential cannot be read', async () => { + getApiKeyForProviderMock.mockImplementation(() => { + throw new CodesignError('missing key', ERROR_CODES.PROVIDER_KEY_MISSING); + }); + const cfg = makeConfig(true); + const result = await resolveImageGenerationTestCredentials(cfg); + expect(result).toMatchObject({ ok: false, code: 'PROVIDER_KEY_MISSING' }); + }); + + it('decrypts custom-mode key when one is stored', async () => { + const baseCfg = makeConfig(true); + const cfg = hydrateConfig({ + version: 3, + activeProvider: baseCfg.activeProvider, + activeModel: baseCfg.activeModel, + providers: baseCfg.providers, + secrets: baseCfg.secrets, + imageGeneration: { + schemaVersion: IMAGE_GENERATION_SCHEMA_VERSION, + enabled: true, + provider: 'openai', + credentialMode: 'custom', + model: 'gpt-image-2', + quality: 'high', + size: '1536x1024', + outputFormat: 'png', + apiKey: { ciphertext: 'sk-custom-image', mask: 'sk-…age' }, + }, + }); + const result = await resolveImageGenerationTestCredentials(cfg); + expect(result).toMatchObject({ ok: true, provider: 'openai', apiKey: 'sk-custom-image' }); + }); + + it('uses ChatGPT OAuth access token for chatgpt-codex provider', async () => { + mocks.codexGetValidAccessToken.mockResolvedValue('codex-token-abc'); + const baseCfg = makeConfig(true); + const cfg = hydrateConfig({ + version: 3, + activeProvider: baseCfg.activeProvider, + activeModel: baseCfg.activeModel, + providers: baseCfg.providers, + secrets: baseCfg.secrets, + imageGeneration: { + schemaVersion: IMAGE_GENERATION_SCHEMA_VERSION, + enabled: true, + provider: 'chatgpt-codex', + credentialMode: 'inherit', + model: 'gpt-5.5', + quality: 'high', + size: '1536x1024', + outputFormat: 'png', + }, + }); + const result = await resolveImageGenerationTestCredentials(cfg); + expect(result).toMatchObject({ + ok: true, + provider: 'chatgpt-codex', + apiKey: 'codex-token-abc', + }); + }); +}); diff --git a/apps/desktop/src/main/image-generation-settings.ts b/apps/desktop/src/main/image-generation-settings.ts index f15ccf43..678edd4c 100644 --- a/apps/desktop/src/main/image-generation-settings.ts +++ b/apps/desktop/src/main/image-generation-settings.ts @@ -246,6 +246,71 @@ export async function resolveImageGenerationConfig( }; } +export type ResolvedImageGenerationTestCredentials = + | { + ok: true; + provider: ImageGenerationProvider; + apiKey: string; + baseUrl: string; + } + | { ok: false; code: 'IMAGE_GEN_DISABLED' | 'PROVIDER_KEY_MISSING'; message: string }; + +export async function resolveImageGenerationTestCredentials( + cfg: Config | null, +): Promise { + if (cfg === null || cfg.imageGeneration === undefined) { + return { + ok: false, + code: 'IMAGE_GEN_DISABLED', + message: 'Image generation is not configured.', + }; + } + const parsed = ImageGenerationSettingsSchema.parse(cfg.imageGeneration); + const baseUrl = parsed.baseUrl ?? defaultImageBaseUrl(parsed.provider); + + if (parsed.provider === CHATGPT_CODEX_PROVIDER_ID) { + let apiKey: string; + try { + apiKey = await getCodexTokenStore().getValidAccessToken(); + } catch (err) { + return { + ok: false, + code: 'PROVIDER_KEY_MISSING', + message: err instanceof Error ? err.message : String(err), + }; + } + return { ok: true, provider: parsed.provider, apiKey, baseUrl }; + } + + if (parsed.credentialMode === 'custom') { + if (parsed.apiKey === undefined) { + return { + ok: false, + code: 'PROVIDER_KEY_MISSING', + message: `No custom image API key stored for "${parsed.provider}".`, + }; + } + return { + ok: true, + provider: parsed.provider, + apiKey: decryptSecret(parsed.apiKey.ciphertext), + baseUrl, + }; + } + + let apiKey: string; + try { + apiKey = getApiKeyForProvider(parsed.provider); + } catch (err) { + return { + ok: false, + code: 'PROVIDER_KEY_MISSING', + message: err instanceof Error ? err.message : String(err), + }; + } + return { ok: true, provider: parsed.provider, apiKey, baseUrl }; +} + export async function isGenerateImageAssetEnabled(cfg: Config): Promise { return (await resolveImageGenerationConfig(cfg)) !== null; } diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 31003fe6..4daa3b12 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -618,6 +618,10 @@ const api = { ipcRenderer.invoke('connection:v1:test-provider', providerId) as Promise< ConnectionTestResult | ConnectionTestError >, + testImageProvider: () => + ipcRenderer.invoke('connection:v1:test-image-provider') as Promise< + ConnectionTestResult | ConnectionTestError + >, }, models: { list: (input: { provider: SupportedOnboardingProvider; apiKey: string; baseUrl: string }) => diff --git a/apps/desktop/src/renderer/src/components/settings/ImageGenerationTab.tsx b/apps/desktop/src/renderer/src/components/settings/ImageGenerationTab.tsx index 10956b9a..815d2a6d 100644 --- a/apps/desktop/src/renderer/src/components/settings/ImageGenerationTab.tsx +++ b/apps/desktop/src/renderer/src/components/settings/ImageGenerationTab.tsx @@ -20,8 +20,10 @@ function defaultImageBaseUrlFor(provider: ImageGenerationSettingsView['provider' function ImageGenerationPanel() { const t = useT(); const pushToast = useCodesignStore((s) => s.pushToast); + const reportableErrorToast = useCodesignStore((s) => s.reportableErrorToast); const [settings, setSettings] = useState(null); const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); const [apiKey, setApiKey] = useState(''); const [model, setModel] = useState(''); const [baseUrl, setBaseUrl] = useState(''); @@ -46,6 +48,42 @@ function ImageGenerationPanel() { }); }, [pushToast, t]); + async function handleTestConnection() { + if (!window.codesign?.connection) return; + setTesting(true); + try { + const res = await window.codesign.connection.testImageProvider(); + if (res.ok) { + pushToast({ + variant: 'success', + title: t('settings.imageGen.toast.connectionOk', { + defaultValue: 'Image provider connection OK', + }), + }); + } else { + reportableErrorToast({ + code: 'CONNECTION_TEST_FAILED', + scope: 'settings', + title: t('settings.imageGen.toast.connectionFailed', { + defaultValue: 'Image provider connection failed', + }), + description: res.hint || res.message, + }); + } + } catch (err) { + reportableErrorToast({ + code: 'CONNECTION_TEST_FAILED', + scope: 'settings', + title: t('settings.imageGen.toast.connectionFailed', { + defaultValue: 'Image provider connection failed', + }), + description: err instanceof Error ? err.message : t('settings.common.unknownError'), + }); + } finally { + setTesting(false); + } + } + async function save(patch: Partial & { apiKey?: string }) { if (!window.codesign?.imageGeneration) return; setSaving(true); @@ -252,7 +290,17 @@ function ImageGenerationPanel() { -
+
+