Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@iarna/toml": "^2.2.5",
"@open-codesign/artifacts": "workspace:*",
"@open-codesign/core": "workspace:*",
"@open-codesign/providers": "workspace:*",
Expand Down
67 changes: 67 additions & 0 deletions apps/desktop/src/main/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import * as TOML from '@iarna/toml';
import { CodesignError, type Config, ConfigSchema } from '@open-codesign/shared';

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

export function configDir(): string {
const xdg = process.env['XDG_CONFIG_HOME'];
if (xdg && xdg.length > 0) return join(xdg, 'open-codesign');
return XDG_DEFAULT;
}

export function configPath(): string {
return join(configDir(), 'config.toml');
}

export async function readConfig(): Promise<Config | null> {
const path = configPath();
let raw: string;
try {
raw = await readFile(path, 'utf8');
} catch (err) {
if (isNotFound(err)) return null;
throw new CodesignError(`Failed to read config at ${path}`, 'CONFIG_READ_FAILED', {
cause: err,
});
}

let parsed: unknown;
try {
parsed = TOML.parse(raw);
} catch (err) {
throw new CodesignError(`Config at ${path} is not valid TOML`, 'CONFIG_PARSE_FAILED', {
cause: err,
});
}

const validated = ConfigSchema.safeParse(parsed);
if (!validated.success) {
throw new CodesignError(
`Config at ${path} does not match the expected schema: ${validated.error.message}`,
'CONFIG_SCHEMA_INVALID',
{ cause: validated.error },
);
}
return validated.data;
}

export async function writeConfig(config: Config): Promise<void> {
const validated = ConfigSchema.parse(config);
const dir = configDir();
await mkdir(dir, { recursive: true });
const path = configPath();
const body = TOML.stringify(validated as unknown as TOML.JsonMap);
await writeFile(path, body, { encoding: 'utf8', mode: 0o600 });
}

function isNotFound(err: unknown): boolean {
return (
typeof err === 'object' &&
err !== null &&
'code' in err &&
(err as { code?: unknown }).code === 'ENOENT'
);
}
8 changes: 6 additions & 2 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { detectProviderFromKey } from '@open-codesign/providers';
import { BRAND, CodesignError, GeneratePayload } from '@open-codesign/shared';
import { BrowserWindow, app, ipcMain, shell } from 'electron';
import { autoUpdater } from 'electron-updater';
import { getApiKeyForProvider, loadConfigOnBoot, registerOnboardingIpc } from './onboarding-ipc';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Expand Down Expand Up @@ -52,11 +53,12 @@ function registerIpcHandlers(): void {

ipcMain.handle('codesign:generate', async (_e, raw: unknown) => {
const payload = GeneratePayload.parse(raw);
const apiKey = getApiKeyForProvider(payload.model.provider);
return generate({
prompt: payload.prompt,
history: payload.history,
model: payload.model,
apiKey: payload.apiKey,
apiKey,
...(payload.baseUrl !== undefined ? { baseUrl: payload.baseUrl } : {}),
});
});
Expand All @@ -76,8 +78,10 @@ function setupAutoUpdater(): void {
ipcMain.handle('codesign:install-update', () => autoUpdater.quitAndInstall());
}

void app.whenReady().then(() => {
void app.whenReady().then(async () => {
await loadConfigOnBoot();
registerIpcHandlers();
registerOnboardingIpc();
setupAutoUpdater();
createWindow();

Expand Down
29 changes: 29 additions & 0 deletions apps/desktop/src/main/keychain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { CodesignError } from '@open-codesign/shared';
import { safeStorage } from 'electron';

export function ensureKeychainAvailable(): void {
if (!safeStorage.isEncryptionAvailable()) {
throw new CodesignError(
'OS keychain (safeStorage) is not available. Cannot persist API keys securely.',
'KEYCHAIN_UNAVAILABLE',
);
}
}

export function encryptSecret(plaintext: string): string {
ensureKeychainAvailable();
if (plaintext.length === 0) {
throw new CodesignError('Cannot encrypt empty secret', 'KEYCHAIN_EMPTY_INPUT');
}
const buf = safeStorage.encryptString(plaintext);
return buf.toString('base64');
}

export function decryptSecret(ciphertextBase64: string): string {
ensureKeychainAvailable();
if (ciphertextBase64.length === 0) {
throw new CodesignError('Cannot decrypt empty ciphertext', 'KEYCHAIN_EMPTY_INPUT');
}
const buf = Buffer.from(ciphertextBase64, 'base64');
return safeStorage.decryptString(buf);
}
157 changes: 157 additions & 0 deletions apps/desktop/src/main/onboarding-ipc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { type ValidateResult, pingProvider } from '@open-codesign/providers';
import {
CodesignError,
type Config,
type OnboardingState,
type SupportedOnboardingProvider,
isSupportedOnboardingProvider,
} from '@open-codesign/shared';
import { ipcMain } from 'electron';
import { readConfig, writeConfig } from './config';
import { decryptSecret, encryptSecret } from './keychain';

interface SaveKeyInput {
provider: SupportedOnboardingProvider;
apiKey: string;
modelPrimary: string;
modelFast: string;
}

interface ValidateKeyInput {
provider: SupportedOnboardingProvider;
apiKey: string;
baseUrl?: string;
}

let cachedConfig: Config | null = null;
let configLoaded = false;

export async function loadConfigOnBoot(): Promise<void> {
cachedConfig = await readConfig();
configLoaded = true;
}

export function getCachedConfig(): Config | null {
if (!configLoaded) {
throw new CodesignError('getCachedConfig called before loadConfigOnBoot', 'CONFIG_NOT_LOADED');
}
return cachedConfig;
}

export function getApiKeyForProvider(provider: string): string {
const cfg = getCachedConfig();
if (cfg === null) {
throw new CodesignError('No configuration found. Complete onboarding first.', 'CONFIG_MISSING');
}
const ref = cfg.secrets[provider as keyof typeof cfg.secrets];
if (ref === undefined) {
throw new CodesignError(
`No API key stored for provider "${provider}". Re-run onboarding to add one.`,
'PROVIDER_KEY_MISSING',
);
}
return decryptSecret(ref.ciphertext);
}

function toState(cfg: Config | null): OnboardingState {
if (cfg === null) {
return { hasKey: false, provider: null, modelPrimary: null, modelFast: null };
}
if (!isSupportedOnboardingProvider(cfg.provider)) {
return { hasKey: false, provider: null, modelPrimary: null, modelFast: null };
}
const ref = cfg.secrets[cfg.provider];
if (ref === undefined) {
return { hasKey: false, provider: cfg.provider, modelPrimary: null, modelFast: null };
}
return {
hasKey: true,
provider: cfg.provider,
modelPrimary: cfg.modelPrimary,
modelFast: cfg.modelFast,
};
}

function parseSaveKey(raw: unknown): SaveKeyInput {
if (typeof raw !== 'object' || raw === null) {
throw new CodesignError('save-key expects an object payload', 'IPC_BAD_INPUT');
}
const r = raw as Record<string, unknown>;
const provider = r['provider'];
const apiKey = r['apiKey'];
const modelPrimary = r['modelPrimary'];
const modelFast = r['modelFast'];
if (typeof provider !== 'string' || !isSupportedOnboardingProvider(provider)) {
throw new CodesignError(
`Provider "${String(provider)}" is not supported in v0.1.`,
'PROVIDER_NOT_SUPPORTED',
);
}
if (typeof apiKey !== 'string' || apiKey.trim().length === 0) {
throw new CodesignError('apiKey must be a non-empty string', 'IPC_BAD_INPUT');
}
if (typeof modelPrimary !== 'string' || modelPrimary.trim().length === 0) {
throw new CodesignError('modelPrimary must be a non-empty string', 'IPC_BAD_INPUT');
}
if (typeof modelFast !== 'string' || modelFast.trim().length === 0) {
throw new CodesignError('modelFast must be a non-empty string', 'IPC_BAD_INPUT');
}
return { provider, apiKey, modelPrimary, modelFast };
}

function parseValidateKey(raw: unknown): ValidateKeyInput {
if (typeof raw !== 'object' || raw === null) {
throw new CodesignError('validate-key expects an object payload', 'IPC_BAD_INPUT');
}
const r = raw as Record<string, unknown>;
const provider = r['provider'];
const apiKey = r['apiKey'];
const baseUrl = r['baseUrl'];
if (typeof provider !== 'string') {
throw new CodesignError('provider must be a string', 'IPC_BAD_INPUT');
}
if (typeof apiKey !== 'string' || apiKey.trim().length === 0) {
throw new CodesignError('apiKey must be a non-empty string', 'IPC_BAD_INPUT');
}
if (!isSupportedOnboardingProvider(provider)) {
throw new CodesignError(
`Provider "${provider}" is not supported in v0.1. Only anthropic, openai, openrouter.`,
'PROVIDER_NOT_SUPPORTED',
);
}
const out: ValidateKeyInput = { provider, apiKey };
if (typeof baseUrl === 'string' && baseUrl.length > 0) out.baseUrl = baseUrl;
return out;
}

export function registerOnboardingIpc(): void {
ipcMain.handle('onboarding:get-state', (): OnboardingState => toState(getCachedConfig()));

ipcMain.handle('onboarding:validate-key', async (_e, raw: unknown): Promise<ValidateResult> => {
const input = parseValidateKey(raw);
return pingProvider(input.provider, input.apiKey, input.baseUrl);
});

ipcMain.handle('onboarding:save-key', async (_e, raw: unknown): Promise<OnboardingState> => {
const input = parseSaveKey(raw);
const ciphertext = encryptSecret(input.apiKey);
const next: Config = {
version: 1,
provider: input.provider,
modelPrimary: input.modelPrimary,
modelFast: input.modelFast,
secrets: {
...(cachedConfig?.secrets ?? {}),
[input.provider]: { ciphertext },
},
};
await writeConfig(next);
cachedConfig = next;
configLoaded = true;
return toState(cachedConfig);
});

ipcMain.handle('onboarding:skip', async (): Promise<OnboardingState> => {
return toState(cachedConfig);
});
}
36 changes: 34 additions & 2 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,28 @@
import type { ChatMessage, ModelRef } from '@open-codesign/shared';
import type {
ChatMessage,
ModelRef,
OnboardingState,
SupportedOnboardingProvider,
} from '@open-codesign/shared';
import { contextBridge, ipcRenderer } from 'electron';

export interface ValidateKeyResult {
ok: true;
modelCount: number;
}
export interface ValidateKeyError {
ok: false;
code: '401' | '402' | '429' | 'network';
message: string;
}

const api = {
detectProvider: (key: string) =>
ipcRenderer.invoke('codesign:detect-provider', key) as Promise<string | null>,
generate: (payload: {
prompt: string;
history: ChatMessage[];
model: ModelRef;
apiKey: string;
baseUrl?: string;
}) => ipcRenderer.invoke('codesign:generate', payload),
checkForUpdates: () => ipcRenderer.invoke('codesign:check-for-updates'),
Expand All @@ -19,6 +33,24 @@ const api = {
ipcRenderer.on('codesign:update-available', listener);
return () => ipcRenderer.removeListener('codesign:update-available', listener);
},
onboarding: {
getState: () => ipcRenderer.invoke('onboarding:get-state') as Promise<OnboardingState>,
validateKey: (input: {
provider: SupportedOnboardingProvider;
apiKey: string;
baseUrl?: string;
}) =>
ipcRenderer.invoke('onboarding:validate-key', input) as Promise<
ValidateKeyResult | ValidateKeyError
>,
saveKey: (input: {
provider: SupportedOnboardingProvider;
apiKey: string;
modelPrimary: string;
modelFast: string;
}) => ipcRenderer.invoke('onboarding:save-key', input) as Promise<OnboardingState>,
skip: () => ipcRenderer.invoke('onboarding:skip') as Promise<OnboardingState>,
},
};

contextBridge.exposeInMainWorld('codesign', api);
Expand Down
22 changes: 21 additions & 1 deletion apps/desktop/src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,43 @@ import { buildSrcdoc } from '@open-codesign/runtime';
import { BUILTIN_DEMOS } from '@open-codesign/templates';
import { Button } from '@open-codesign/ui';
import { Send, Sparkles } from 'lucide-react';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Onboarding } from './onboarding';
import { useCodesignStore } from './store';

export function App() {
const messages = useCodesignStore((s) => s.messages);
const previewHtml = useCodesignStore((s) => s.previewHtml);
const isGenerating = useCodesignStore((s) => s.isGenerating);
const sendPrompt = useCodesignStore((s) => s.sendPrompt);
const config = useCodesignStore((s) => s.config);
const configLoaded = useCodesignStore((s) => s.configLoaded);
const loadConfig = useCodesignStore((s) => s.loadConfig);
const [prompt, setPrompt] = useState('');

useEffect(() => {
void loadConfig();
}, [loadConfig]);

function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!prompt.trim() || isGenerating) return;
void sendPrompt(prompt);
setPrompt('');
}

if (!configLoaded) {
return (
<div className="h-full flex items-center justify-center bg-[var(--color-background)] text-sm text-[var(--color-text-muted)]">
Loading…
</div>
);
}

if (config === null || !config.hasKey) {
return <Onboarding />;
}

return (
<div className="h-full grid grid-cols-[380px_1fr] bg-[var(--color-background)]">
<aside className="flex flex-col border-r border-[var(--color-border)] bg-[var(--color-background-secondary)]">
Expand Down
Loading
Loading