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
31 changes: 27 additions & 4 deletions apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,34 @@

DeepCode Mac 客户端(Electron + React + Tailwind + xterm + monaco)。

## 当前状态
## 当前状态 — M6 skeleton

M0 骨架 — 仅占位结构。实际实现
骨架已落地(type-check 通过)

- **M6**:onboarding / chat / sessions / settings / MCPManager / plugins 管理 / skills 管理 / xterm + monaco 嵌入 / **自动更新(electron-updater + GitHub Releases)**
- **M7**:右侧文件面板 / 完整 composer / approval UI / 收起态 inspector / rewind UX
- `electron/main.ts` — BrowserWindow + IPC 处理(version / creds / settings)+
electron-updater 钩子(懒加载,没装也不崩)
- `electron/preload.ts` — `contextBridge` 暴露 `window.deepcode` 给 renderer
- `src/main.tsx` + `src/App.tsx` — React 入口 + Onboarding gate + 更新 banner
- `src/screens/Onboarding.tsx` — 首次运行的 API key 收集表单
- `src/screens/Repl.tsx` — 对话占位 UI
- `src/components/UpdateBanner.tsx` — "Relaunch to update vX.Y.Z" 提示
- `tsconfig.electron.json` — 等装了 `electron` 之后用这个编译 main/preload

## 还没做(M6-rest,多个 PR)

- 装 `electron` / `electron-builder` / `vite` / `tailwindcss` 实际依赖(约 250 MB node_modules)
- Vite dev server + HMR
- Tailwind PostCSS 流水线
- xterm.js 终端嵌入
- Monaco 编辑器嵌入
- electron-builder universal dmg 打包
- Apple Developer ID + codesign + notarize
- 11 个屏幕剩余 9 个(Chat / Sessions / Settings / MCPManager / FilePanel 等)
- Renderer ↔ main process 的 agent loop 流式桥

## 为什么 skeleton 故意留小

Electron 二进制装下来 ~250 MB,CI 装包会变慢。把这个负担留给真正开始
做 M6-rest 的 PR,这样到 M6-skeleton 为止的 monorepo 仍然轻。

详见 `docs/DEVELOPMENT_PLAN.md` §4 + §4a + §4b。
124 changes: 121 additions & 3 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,124 @@
// Electron main process entry.
// Milestone: M6
// Spec: docs/DEVELOPMENT_PLAN.md §4 + §4b (auto-update)
// Status: placeholder
// Milestone: M6 — skeleton: window creation, IPC bridge, electron-updater stub
//
// This file is intentionally minimal for the skeleton PR. It wires:
// · Single BrowserWindow with the renderer's HTML
// · IPC channels for credentials / settings / agent control
// · electron-updater hook (lazy-loaded; gracefully no-ops if pkg not present)
//
// Full feature wiring (terminal, file panel, 11 screens) lands in subsequent
// M6-rest PRs.

export {};
import { app, BrowserWindow, ipcMain } from 'electron';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { homedir } from 'node:os';
import {
CredentialsStore,
loadSettings,
resolveCredentials,
VERSION,
} from '@deepcode/core';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const isDev = !app.isPackaged;
let mainWindow: BrowserWindow | null = null;

async function createWindow(): Promise<void> {
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 900,
minHeight: 600,
backgroundColor: '#0e0e10',
titleBarStyle: 'hiddenInset',
webPreferences: {
preload: join(__dirname, 'preload.cjs'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});

if (isDev) {
// Vite dev server (configured to run on 5173 — see scripts/run-dev.sh)
await mainWindow.loadURL('http://localhost:5173');
mainWindow.webContents.openDevTools({ mode: 'detach' });
} else {
await mainWindow.loadFile(join(__dirname, '..', 'dist', 'index.html'));
}

mainWindow.on('closed', () => {
mainWindow = null;
});
}

// ──────────────────────────────────────────────────────────────────────────
// IPC handlers — renderer asks main for things it can't do (fs, creds, etc.)
// ──────────────────────────────────────────────────────────────────────────

ipcMain.handle('app:version', () => VERSION);

ipcMain.handle('creds:load', async () => {
const store = new CredentialsStore();
const creds = await resolveCredentials({ store });
return {
hasKey: !!(creds.apiKey || creds.authToken),
baseURL: creds.baseURL,
};
});

ipcMain.handle('creds:save', async (_event, args: { apiKey: string; baseURL?: string }) => {
const store = new CredentialsStore();
await store.save({ apiKey: args.apiKey, baseURL: args.baseURL });
return true;
});

ipcMain.handle('settings:load', async () => {
const { merged } = await loadSettings({ cwd: process.cwd(), home: homedir() });
return merged;
});

// ──────────────────────────────────────────────────────────────────────────
// electron-updater — lazy import so the skeleton works without the dep
// ──────────────────────────────────────────────────────────────────────────

async function setupAutoUpdater(): Promise<void> {
if (isDev) return;
try {
const mod = await import('electron-updater').catch(() => null);
if (!mod) return;
const { autoUpdater } = mod;
autoUpdater.checkForUpdatesAndNotify().catch(() => {
/* silent: no releases yet / offline */
});
autoUpdater.on('update-downloaded', (info) => {
mainWindow?.webContents.send('updater:update-downloaded', {
version: info.version,
releaseNotes: info.releaseNotes,
});
});
} catch {
/* electron-updater not installed yet — fine for skeleton */
}
}

// ──────────────────────────────────────────────────────────────────────────
// App lifecycle
// ──────────────────────────────────────────────────────────────────────────

app.whenReady().then(async () => {
await createWindow();
await setupAutoUpdater();

app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) void createWindow();
});
});

app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
33 changes: 29 additions & 4 deletions apps/desktop/electron/preload.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,31 @@
// Electron preload script — bridges renderer to @deepcode/core via contextBridge.
// Milestone: M6
// Electron preload — bridges renderer to the trusted main process via
// contextBridge. The renderer can ONLY call these exposed APIs; raw `require`
// and Node globals are disabled.
// Spec: docs/DEVELOPMENT_PLAN.md §4
// Status: placeholder
// Milestone: M6

import { contextBridge, ipcRenderer } from 'electron';

const api = {
version: (): Promise<string> => ipcRenderer.invoke('app:version'),
creds: {
load: (): Promise<{ hasKey: boolean; baseURL?: string }> =>
ipcRenderer.invoke('creds:load'),
save: (args: { apiKey: string; baseURL?: string }): Promise<boolean> =>
ipcRenderer.invoke('creds:save', args),
},
settings: {
load: (): Promise<Record<string, unknown>> => ipcRenderer.invoke('settings:load'),
},
/** Subscribe to "update downloaded" events from the auto-updater. */
onUpdateDownloaded: (cb: (info: { version: string; releaseNotes?: string }) => void): (() => void) => {
const listener = (_e: unknown, info: { version: string; releaseNotes?: string }) => cb(info);
ipcRenderer.on('updater:update-downloaded', listener);
return () => ipcRenderer.removeListener('updater:update-downloaded', listener);
},
};

contextBridge.exposeInMainWorld('deepcode', api);

export {};
// Type declaration for the renderer (mirrored manually in src/types/global.d.ts)
export type DeepCodeRendererAPI = typeof api;
13 changes: 10 additions & 3 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,27 @@
"description": "DeepCode Mac desktop client (Electron + React)",
"license": "MIT",
"type": "module",
"main": "dist-electron/main.cjs",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "echo 'desktop tests in M6' && exit 0",
"test": "vitest run --passWithNoTests",
"lint": "echo 'lint: configured in M1' && exit 0",
"clean": "rm -rf dist dist-electron release *.tsbuildinfo"
"clean": "rm -rf dist dist-electron release *.tsbuildinfo",
"// notes": "M6-rest will add: dev (vite + concurrent electron), pack (electron-builder), dist (dmg)"
},
"dependencies": {
"@deepcode/core": "workspace:*",
"@deepcode/shared-ui": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.10.0",
"typescript": "^5.7.0"
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"typescript": "^5.7.0",
"vitest": "^2.1.9"
},
"engines": {
"node": ">=22"
Expand Down
48 changes: 45 additions & 3 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,47 @@
// Top-level React component for desktop client.
// Milestone: M6
// Status: placeholder
// Spec: docs/VISUAL_DESIGN.html
// Milestone: M6 skeleton — onboarding + REPL placeholder + update banner

export {};
import { useEffect, useState } from 'react';
import { OnboardingScreen } from './screens/Onboarding.js';
import { ReplScreen } from './screens/Repl.js';
import { UpdateBanner } from './components/UpdateBanner.js';
import type { UpdateInfo } from './types/global.js';

export function App(): JSX.Element {
const [version, setVersion] = useState<string>('');
const [hasKey, setHasKey] = useState<boolean | null>(null);
const [update, setUpdate] = useState<UpdateInfo | null>(null);

useEffect(() => {
void window.deepcode.version().then(setVersion);
void window.deepcode.creds.load().then((c) => setHasKey(c.hasKey));
const off = window.deepcode.onUpdateDownloaded((info) => setUpdate(info));
return () => off();
}, []);

if (hasKey === null) {
return (
<div className="flex h-screen items-center justify-center bg-bg text-fg">
Loading…
</div>
);
}

return (
<div className="flex h-screen flex-col bg-bg text-fg">
{update && <UpdateBanner info={update} />}
<header className="flex items-center justify-between border-b border-border px-4 py-2 text-sm">
<span className="font-semibold">DeepCode</span>
<span className="text-muted">v{version}</span>
</header>
<main className="flex-1 overflow-hidden">
{!hasKey ? (
<OnboardingScreen onComplete={() => setHasKey(true)} />
) : (
<ReplScreen />
)}
</main>
</div>
);
}
41 changes: 41 additions & 0 deletions apps/desktop/src/components/UpdateBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// "Relaunch to update vX.Y.Z" banner — fires when electron-updater has
// downloaded a new release. Click → app.relaunch() (host wires this in M6-rest).
// Spec: docs/VISUAL_DESIGN.html screen #11
// Milestone: M6 skeleton

import { useState } from 'react';
import type { UpdateInfo } from '../types/global.js';

interface BannerProps {
info: UpdateInfo;
}

export function UpdateBanner({ info }: BannerProps): JSX.Element | null {
const [dismissed, setDismissed] = useState(false);
if (dismissed) return null;
return (
<div className="flex items-center justify-between border-b border-border bg-accent/10 px-4 py-2 text-sm">
<span>
DeepCode v{info.version} is ready to install. Relaunch to update.
</span>
<div className="flex gap-2">
<button
className="rounded bg-accent px-3 py-1 text-xs font-medium text-bg"
onClick={() => {
// The renderer can't relaunch directly — main process listens for
// this and calls app.relaunch(). Wiring in M6-rest.
window.location.reload();
}}
>
Relaunch now
</button>
<button
className="rounded px-3 py-1 text-xs text-muted"
onClick={() => setDismissed(true)}
>
Later
</button>
</div>
</div>
);
}
Loading
Loading