Skip to content

Commit a9d45f7

Browse files
author
t
committed
feat: move desktop runtime behind app server
1 parent 801b4de commit a9d45f7

38 files changed

Lines changed: 836 additions & 1036 deletions

apps/desktop/README.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,13 @@ src/ renderer(React + Vite,无 Tailwind,手写设计系统
1616
Plugins / Repl / Sessions / Settings / Skills
1717
components/ Sidebar / InspectorRail / ToolCard / UpdateBanner …
1818
lib/ tauri-api(renderer↔Rust IPC 封装)· protocol-client ·
19-
mac-agent(实验期 fallback)· repl-stream · updater …
19+
protocol-agent · repl-stream · updater …
2020
src-tauri/ Rust 主进程
2121
src/app_server.rs bundled runtime 启停、stdio 与 crash event
2222
src/commands.rs #[tauri::command] —— renderer 通过 invoke() 调用
23-
src/credentials.rs 凭据读写(原子写入)
23+
src/credentials.rs 凭据保存与无密钥状态查询
2424
src/settings.rs 设置持久化
25-
src/tools.rs 工具实现
25+
src/tools.rs legacy native helpers(renderer 仅暴露只读 file read)
2626
src/lib.rs Tauri builder / 插件注册
2727
tauri.conf.json 窗口 + 构建 + 打包配置
2828
capabilities/ 权限能力声明
@@ -32,10 +32,10 @@ src-tauri/ Rust 主进程
3232
renderer ↔ Rust 的 IPC 边界由 `src/lib/tauri-api.ts` 封装,契约测试见
3333
`src/lib/tauri-api.test.ts`#84)。
3434

35-
实验 app-server 由 Tauri 作为 target-specific sidecar 监督。`apps/server` 会被打成单个
35+
app-server 由 Tauri 作为 target-specific sidecar 监督。`apps/server` 会被打成单个
3636
`app-server.cjs` resource,Node runtime 通过 `bundle.externalBin` 进入 `.app`;renderer 只能通过
37-
Rust commands 与版本化协议通信,不能直接使用 shell plugin。现有 `mac-agent` 在迁移期保留为显式
38-
fallback,不能作为长期双架构
37+
Rust commands 与版本化协议通信,不能直接使用 shell plugin。provider、agent loop、tools、权限、
38+
session materialization 和凭证明文都只存在于 sidecar;renderer 不再带有第二套运行时
3939

4040
## 开发
4141

apps/desktop/src-tauri/src/commands.rs

Lines changed: 4 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ pub fn get_app_info() -> AppInfo {
2525
}
2626

2727
#[tauri::command]
28-
pub fn read_credentials() -> Result<Credentials, String> {
29-
credentials::read()
28+
pub fn credential_status() -> Result<credentials::CredentialStatus, String> {
29+
credentials::status()
3030
}
3131

3232
#[tauri::command]
@@ -116,71 +116,10 @@ pub fn append_allow_matcher(matcher: String) -> Result<(), String> {
116116
settings::write_user(&value)
117117
}
118118

119-
/// Create a new session JSONL with a metadata header line. Returns the
120-
/// generated session id. The id format matches what @deepcode/core's
121-
/// SessionManager produces: `YYYY-MM-DD-<random>`.
122-
#[tauri::command]
123-
pub fn session_create(cwd: String) -> Result<String, String> {
124-
let Some(home) = dirs::home_dir() else {
125-
return Err("no home directory".into());
126-
};
127-
let now = std::time::SystemTime::now();
128-
let secs = now
129-
.duration_since(std::time::UNIX_EPOCH)
130-
.map_err(|e| e.to_string())?
131-
.as_secs();
132-
let date = format_date(secs);
133-
// Lightweight unique suffix from time-nanos — no extra crate dep
134-
let nanos = now
135-
.duration_since(std::time::UNIX_EPOCH)
136-
.map_err(|e| e.to_string())?
137-
.subsec_nanos();
138-
let rand_id = format!("{:08x}", nanos);
139-
let id = format!("{}-{}", date, rand_id);
140-
let dir = home.join(".deepcode").join("sessions");
141-
std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir {}: {}", dir.display(), e))?;
142-
let path = dir.join(format!("{}.v1.jsonl", id));
143-
let header = serde_json::json!({
144-
"type": "session_meta",
145-
"schema_version": 1,
146-
"id": id,
147-
"cwd": cwd,
148-
"created_at": secs,
149-
"client": "desktop"
150-
});
151-
let line = format!("{}\n", header);
152-
std::fs::write(&path, line).map_err(|e| format!("write {}: {}", path.display(), e))?;
153-
Ok(id)
154-
}
155-
156-
/// Append a single JSON line to a session's JSONL file.
157-
#[tauri::command]
158-
pub fn session_append(id: String, message: serde_json::Value) -> Result<(), String> {
159-
safe_session_id(&id)?;
160-
let Some(home) = dirs::home_dir() else {
161-
return Err("no home directory".into());
162-
};
163-
let dir = home.join(".deepcode").join("sessions");
164-
std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir {}: {}", dir.display(), e))?;
165-
let _lock = SessionWriterLock::acquire(&dir, &id)?;
166-
let path = ensure_canonical_session(&dir, &id)?;
167-
let mut normalized = message;
168-
normalized["type"] = serde_json::Value::String("message".to_string());
169-
normalized["schema_version"] = serde_json::Value::Number(1.into());
170-
let line = format!("{}\n", normalized);
171-
let mut f = std::fs::OpenOptions::new()
172-
.create(true)
173-
.append(true)
174-
.open(&path)
175-
.map_err(|e| format!("open {}: {}", path.display(), e))?;
176-
f.write_all(line.as_bytes())
177-
.map_err(|e| format!("write {}: {}", path.display(), e))
178-
}
179-
180119
/// Read a session's JSONL and return its message lines (skipping the
181120
/// `session_meta` header and any unparseable lines). Each returned value is the
182-
/// stored message object as written by session_append: `{ type, role, content,
183-
/// timestamp }`. Returns an empty vec if the file doesn't exist.
121+
/// canonical `{ type, role, content, timestamp }` object. Returns an empty vec
122+
/// if the file doesn't exist.
184123
#[tauri::command]
185124
pub fn session_read(id: String) -> Result<Vec<serde_json::Value>, String> {
186125
safe_session_id(&id)?;
@@ -318,24 +257,6 @@ fn parse_session_messages(text: &str) -> Result<Vec<serde_json::Value>, String>
318257
Ok(out)
319258
}
320259

321-
fn format_date(secs: u64) -> String {
322-
// Simple YYYY-MM-DD; days since epoch math is enough for filename use.
323-
let days = secs / 86_400;
324-
// Reference: 1970-01-01 was a Thursday; we compute YMD via the
325-
// standard "civil_from_days" algorithm by Howard Hinnant.
326-
let z = days as i64 + 719_468;
327-
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
328-
let doe = (z - era * 146_097) as u64;
329-
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
330-
let y = yoe as i64 + era * 400;
331-
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
332-
let mp = (5 * doy + 2) / 153;
333-
let d = doy - (153 * mp + 2) / 5 + 1;
334-
let m = if mp < 10 { mp + 3 } else { mp - 9 };
335-
let y = if m <= 2 { y + 1 } else { y };
336-
format!("{:04}-{:02}-{:02}", y, m, d)
337-
}
338-
339260
/// List session files under ~/.deepcode/sessions/. Returns just metadata.
340261
#[derive(Serialize)]
341262
pub struct SessionMeta {

apps/desktop/src-tauri/src/credentials.rs

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ pub struct Credentials {
1414
pub base_url: Option<String>,
1515
}
1616

17+
#[derive(Debug, Serialize, Clone)]
18+
#[serde(rename_all = "camelCase")]
19+
pub struct CredentialStatus {
20+
pub has_key: bool,
21+
#[serde(skip_serializing_if = "Option::is_none")]
22+
pub base_url: Option<String>,
23+
}
24+
1725
pub fn credentials_path() -> Option<PathBuf> {
1826
let home = dirs::home_dir()?;
1927
Some(home.join(".deepcode").join("credentials.json"))
@@ -30,6 +38,21 @@ pub fn read() -> Result<Credentials, String> {
3038
}
3139
}
3240

41+
pub fn status() -> Result<CredentialStatus, String> {
42+
let credentials = read()?;
43+
Ok(CredentialStatus {
44+
has_key: credentials
45+
.api_key
46+
.as_ref()
47+
.is_some_and(|value| !value.is_empty())
48+
|| credentials
49+
.auth_token
50+
.as_ref()
51+
.is_some_and(|value| !value.is_empty()),
52+
base_url: credentials.base_url,
53+
})
54+
}
55+
3356
pub fn write(creds: &Credentials) -> Result<(), String> {
3457
let Some(path) = credentials_path() else {
3558
return Err("no home directory".into());
@@ -49,9 +72,8 @@ pub fn write(creds: &Credentials) -> Result<(), String> {
4972
}
5073

5174
// ── Serde contract ─────────────────────────────────────────────────────
52-
// tauri-api.ts#readCredentials reads `api_key`/`auth_token`/`base_url` (snake)
53-
// and maps them to camelCase itself. Lock that shape + the skip-if-None omission
54-
// the TS side relies on (missing field → undefined). See HANDOFF §8a.
75+
// Credentials remain backend-only. The renderer receives CredentialStatus,
76+
// while this shape stays compatible with the CLI's credentials.json.
5577
#[cfg(test)]
5678
mod contract_tests {
5779
use super::*;
@@ -76,4 +98,17 @@ mod contract_tests {
7698
let v = serde_json::to_value(Credentials::default()).unwrap();
7799
assert_eq!(v.as_object().unwrap().len(), 0, "None fields must be skipped: {v}");
78100
}
101+
102+
#[test]
103+
fn status_never_serializes_credentials() {
104+
let value = serde_json::to_value(CredentialStatus {
105+
has_key: true,
106+
base_url: Some("https://host/v1".into()),
107+
})
108+
.unwrap();
109+
assert_eq!(value["hasKey"], true);
110+
assert_eq!(value["baseUrl"], "https://host/v1");
111+
assert!(value.get("api_key").is_none());
112+
assert!(value.get("auth_token").is_none());
113+
}
79114
}

apps/desktop/src-tauri/src/lib.rs

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,17 @@
33
//
44
// Architecture: most of DeepCode's logic lives in @deepcode/core (TypeScript).
55
// The Tauri backend's job is to host the webview and expose a few native
6-
// commands that the frontend can't do (file dialogs, credentials read/write,
7-
// settings file IO, child-process spawn for CLI integration).
8-
//
9-
// During the protocol rollout, Rust also supervises the bundled app-server
10-
// sidecar. The renderer loop remains only as an explicit compatibility path.
6+
// commands that the frontend can't do (file dialogs, credential save/status,
7+
// settings/session index IO, and read-only file previews). Rust supervises the
8+
// bundled app-server sidecar; runtime/tool execution never runs in the webview.
119

1210
mod app_server;
1311
mod commands;
1412
mod credentials;
1513
mod settings;
14+
#[allow(dead_code)] // mutation-only snapshot helpers remain for compatibility tests
1615
mod snapshots;
16+
#[allow(dead_code)] // legacy native mutation helpers are no longer renderer commands
1717
mod tools;
1818
mod voice;
1919

@@ -22,15 +22,13 @@ use app_server::{
2222
};
2323
use commands::{
2424
append_allow_matcher, cli_path, get_app_info, get_settings_path, list_plugins, list_sessions,
25-
list_skills, load_keybindings, load_settings_file, open_url, read_credentials,
26-
save_credentials, save_keybindings, save_settings_file, session_append, session_archive,
27-
session_create, session_delete, session_read, session_set_title,
25+
credential_status, list_skills, load_keybindings, load_settings_file, open_url,
26+
save_credentials, save_keybindings, save_settings_file, session_archive, session_delete,
27+
session_read, session_set_title,
2828
};
2929
use snapshots::session_snapshots;
3030
use tauri::Manager;
31-
use tools::{
32-
tool_bash, tool_bash_cancel, tool_edit, tool_glob, tool_grep, tool_read, tool_write, BashState,
33-
};
31+
use tools::tool_read;
3432
use voice::{voice_cancel, voice_start, voice_status, voice_stop, VoiceState};
3533

3634
#[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -43,24 +41,21 @@ pub fn run() {
4341
.plugin(tauri_plugin_updater::Builder::new().build())
4442
.plugin(tauri_plugin_process::init())
4543
.manage(VoiceState::default())
46-
.manage(BashState::default())
4744
.manage(AppServerState::default())
4845
.invoke_handler(tauri::generate_handler![
4946
get_app_info,
5047
app_server_start,
5148
app_server_send,
5249
app_server_stop,
5350
app_server_status,
54-
read_credentials,
51+
credential_status,
5552
save_credentials,
5653
load_settings_file,
5754
save_settings_file,
5855
get_settings_path,
5956
append_allow_matcher,
6057
load_keybindings,
6158
save_keybindings,
62-
session_create,
63-
session_append,
6459
session_read,
6560
session_set_title,
6661
session_delete,
@@ -71,12 +66,6 @@ pub fn run() {
7166
cli_path,
7267
open_url,
7368
tool_read,
74-
tool_write,
75-
tool_edit,
76-
tool_bash,
77-
tool_bash_cancel,
78-
tool_glob,
79-
tool_grep,
8069
session_snapshots,
8170
voice_status,
8271
voice_start,

apps/desktop/src-tauri/src/tools.rs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,16 @@ pub async fn tool_read(
8787
offset: Option<usize>,
8888
limit: Option<usize>,
8989
) -> Result<ReadOk, String> {
90-
let raw = tokio::fs::read_to_string(&file_path)
90+
let resolved = tokio::fs::canonicalize(&file_path)
91+
.await
92+
.map_err(|e| format!("read {}: {}", file_path, e))?;
93+
let credentials_path = if let Some(path) = crate::credentials::credentials_path() {
94+
tokio::fs::canonicalize(path).await.ok()
95+
} else {
96+
None
97+
};
98+
reject_credentials_path(&resolved, credentials_path.as_deref())?;
99+
let raw = tokio::fs::read_to_string(&resolved)
91100
.await
92101
.map_err(|e| format!("read {}: {}", file_path, e))?;
93102
let lines: Vec<&str> = raw.split('\n').collect();
@@ -129,6 +138,14 @@ pub async fn tool_read(
129138
})
130139
}
131140

141+
fn reject_credentials_path(resolved: &Path, credentials_path: Option<&Path>) -> Result<(), String> {
142+
if credentials_path.is_some_and(|path| resolved == path) {
143+
Err("credential files are backend-only".to_string())
144+
} else {
145+
Ok(())
146+
}
147+
}
148+
132149
// ──────────────────────────────────────────────────────────────────────────
133150
// Write
134151
// ──────────────────────────────────────────────────────────────────────────
@@ -528,6 +545,13 @@ mod casing_tests {
528545
);
529546
}
530547

548+
#[test]
549+
fn renderer_read_rejects_backend_credentials() {
550+
let credential = Path::new("/home/user/.deepcode/credentials.json");
551+
assert!(reject_credentials_path(credential, Some(credential)).is_err());
552+
assert!(reject_credentials_path(Path::new("/workspace/src.ts"), Some(credential)).is_ok());
553+
}
554+
531555
#[test]
532556
fn edit_ok_serializes_camel_case() {
533557
let v = serde_json::to_value(EditOk {

apps/desktop/src/App.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// Milestone: 0.1.2 — adds project-folder flow + inspector wiring + session refresh.
44

55
import { useCallback, useEffect, useState } from 'react';
6-
import { contextWindowFor } from '@deepcode/core/dist/providers/deepseek.js';
6+
import { contextWindowFor } from '@deepcode/core/dist/providers/model-metadata.js';
77
import { FilePanel } from './components/FilePanel.js';
88
import { InspectorPanel } from './components/InspectorPanel.js';
99
import { InspectorRail } from './components/InspectorRail.js';
@@ -12,7 +12,7 @@ import { SETTINGS_FAMILY, SettingsLayout } from './components/SettingsLayout.js'
1212
import { Sidebar } from './components/Sidebar.js';
1313
import { UpdateBanner } from './components/UpdateBanner.js';
1414
import { registerShortcut } from './lib/keyboard.js';
15-
import { clearHistory as clearAgentHistory } from './lib/mac-agent.js';
15+
import { clearProtocolThread as clearAgentHistory } from './lib/protocol-agent.js';
1616
import { loadProjectPath, saveProjectPath } from './lib/project.js';
1717
import { storedToMsgs, type Msg } from './lib/repl-stream.js';
1818
import { onUpdateDownloaded, startUpdaterPolling } from './lib/updater.js';
@@ -243,6 +243,7 @@ export function App(): JSX.Element {
243243
setScreen,
244244
projectPath,
245245
() => setSessionEpoch((k) => k + 1),
246+
setActiveSessionId,
246247
handleInspector,
247248
resumedMessages,
248249
openFile,
@@ -290,6 +291,7 @@ function renderScreen(
290291
setScreen: (s: ScreenName) => void,
291292
projectPath: string,
292293
onTurnComplete: () => void,
294+
onSessionStarted: (sessionId: string) => void,
293295
onInspector: (patch: Partial<InspectorData>) => void,
294296
initialMessages?: Msg[],
295297
onOpenFile?: (path: string) => void,
@@ -301,6 +303,7 @@ function renderScreen(
301303
<ReplScreen
302304
projectPath={projectPath}
303305
onTurnComplete={onTurnComplete}
306+
onSessionStarted={onSessionStarted}
304307
initialMessages={initialMessages}
305308
onInspector={onInspector}
306309
onOpenFile={onOpenFile}
@@ -327,6 +330,7 @@ function renderScreen(
327330
<ReplScreen
328331
projectPath={projectPath}
329332
onTurnComplete={onTurnComplete}
333+
onSessionStarted={onSessionStarted}
330334
initialMessages={initialMessages}
331335
onInspector={onInspector}
332336
onOpenFile={onOpenFile}

apps/desktop/src/components/InspectorPanel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
// empty state rather than a placeholder — per HANDOFF: no fake sections.
1212

1313
import { useEffect, useRef } from 'react';
14-
import { contextWindowFor } from '@deepcode/core/dist/providers/deepseek.js';
14+
import { contextWindowFor } from '@deepcode/core/dist/providers/model-metadata.js';
1515
import { projectName } from '../lib/project.js';
1616
import type { InspectorData, InspectorSection } from '../types/inspector.js';
1717

0 commit comments

Comments
 (0)