From 7f30adf5b6a04bd12d53b3f40033b65ce2e1e77a Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Sat, 25 Jul 2026 10:01:41 +0800 Subject: [PATCH] feat: add localized agent capture setup --- apps/desktop/e2e/workbench.spec.ts | 23 + apps/desktop/src-tauri/Cargo.lock | 10 +- apps/desktop/src-tauri/Cargo.toml | 1 + apps/desktop/src-tauri/src/agent_config.rs | 795 +++++++++++++++++++++ apps/desktop/src-tauri/src/lib.rs | 496 ++++++++++++- apps/desktop/src-tauri/tauri.conf.json | 6 + apps/desktop/src/App.test.tsx | 140 ++++ apps/desktop/src/App.tsx | 237 +++--- apps/desktop/src/CompareView.tsx | 40 +- apps/desktop/src/SettingsDialog.tsx | 420 ++++++++--- apps/desktop/src/i18n.tsx | 455 ++++++++++++ apps/desktop/src/styles.css | 38 + apps/desktop/src/workspace.ts | 233 +++++- docs/agent-connections.html | 61 ++ docs/development-plan.html | 6 +- docs/experience.html | 2 +- docs/index.html | 3 +- docs/internationalization.html | 60 ++ docs/progress.html | 14 +- 19 files changed, 2780 insertions(+), 260 deletions(-) create mode 100644 apps/desktop/src-tauri/src/agent_config.rs create mode 100644 apps/desktop/src/i18n.tsx create mode 100644 docs/agent-connections.html create mode 100644 docs/internationalization.html diff --git a/apps/desktop/e2e/workbench.spec.ts b/apps/desktop/e2e/workbench.spec.ts index 7f1eab7..96cdcba 100644 --- a/apps/desktop/e2e/workbench.spec.ts +++ b/apps/desktop/e2e/workbench.spec.ts @@ -98,6 +98,29 @@ test.describe("desktop workbench quality baseline", () => { } }); + test("switches the minimum workspace and settings to Chinese", async ({ page }) => { + await page.setViewportSize(MINIMUM_VIEWPORT); + await page.addInitScript(() => { + localStorage.setItem("codeischeap.locale", "zh-CN"); + }); + await page.goto("/"); + + await expect(page.getByRole("heading", { name: "请求" })).toBeVisible(); + await expect(page.getByRole("button", { name: "切换为英文" })).toBeVisible(); + await page.getByRole("button", { name: "设置" }).click(); + const dialog = page.getByRole("dialog", { name: "设置与诊断" }); + await expect(dialog.getByRole("tab", { name: "连接" })).toBeVisible(); + await expect(dialog.getByRole("tab", { name: "配置档" })).toBeVisible(); + await expect(dialog.getByRole("tab", { name: "诊断" })).toBeVisible(); + + const layout = await page.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + })); + expect(layout.scrollWidth).toBe(layout.clientWidth); + expect(await page.evaluate(() => document.documentElement.lang)).toBe("zh-CN"); + }); + test("keeps signed update controls usable at minimum size", async ({ page }) => { await page.setViewportSize(MINIMUM_VIEWPORT); await page.goto("/"); diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 3f5b967..31d5635 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -557,6 +557,7 @@ dependencies = [ "tauri-plugin-updater", "tempfile", "tokio", + "toml_edit 0.25.11+spec-1.1.0", "url", ] @@ -565,6 +566,7 @@ name = "codeischeap-desktop-api" version = "0.1.0" dependencies = [ "codeischeap-capture-ipc", + "codeischeap-capture-policy", "codeischeap-prompt-ir", "codeischeap-storage", "regex", @@ -573,6 +575,7 @@ dependencies = [ "serde_json", "sha2", "ts-rs", + "url", ] [[package]] @@ -2885,7 +2888,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.13+spec-1.1.0", + "toml_edit 0.25.11+spec-1.1.0", ] [[package]] @@ -4584,13 +4587,14 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.13+spec-1.1.0" +version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", + "toml_writer", "winnow 1.0.4", ] diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 8125209..d853255 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -31,6 +31,7 @@ tauri = { version = "2.11.5", features = ["tray-icon"] } tauri-plugin-dialog = "2" tauri-plugin-updater = "2.10.1" tokio = { version = "1.45", features = ["io-util", "macros", "net", "rt-multi-thread", "sync", "time"] } +toml_edit = "=0.25.11" url = "2.5" [dev-dependencies] diff --git a/apps/desktop/src-tauri/src/agent_config.rs b/apps/desktop/src-tauri/src/agent_config.rs new file mode 100644 index 0000000..e904d2f --- /dev/null +++ b/apps/desktop/src-tauri/src/agent_config.rs @@ -0,0 +1,795 @@ +use std::env; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use codeischeap_desktop_api::CaptureProfile; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use toml_edit::{DocumentMut, Item, Table, value}; +use url::Url; + +const CONNECTION_VERSION: &str = "0.1"; +const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1"; +const DEFAULT_ANTHROPIC_BASE_URL: &str = "https://api.anthropic.com"; +const DEFAULT_GEMINI_BASE_URL: &str = "https://generativelanguage.googleapis.com"; +const GEMINI_BASE_URL_KEY: &str = "GOOGLE_GEMINI_BASE_URL"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentKind { + Codex, + ClaudeCode, + GeminiCli, + UniversalProxy, +} + +impl AgentKind { + pub const ALL: [Self; 4] = [ + Self::Codex, + Self::ClaudeCode, + Self::GeminiCli, + Self::UniversalProxy, + ]; + + pub const fn id(self) -> &'static str { + match self { + Self::Codex => "codex", + Self::ClaudeCode => "claude_code", + Self::GeminiCli => "gemini_cli", + Self::UniversalProxy => "universal_proxy", + } + } + + pub const fn display_name(self) -> &'static str { + match self { + Self::Codex => "Codex", + Self::ClaudeCode => "Claude Code", + Self::GeminiCli => "Gemini CLI", + Self::UniversalProxy => "Universal proxy", + } + } + + pub const fn is_native(self) -> bool { + !matches!(self, Self::UniversalProxy) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentConnectionRecord { + pub version: String, + pub kind: AgentKind, + pub active: bool, + pub config_path: String, + #[serde(default)] + pub config_existed: bool, + pub target: String, + pub original_base_url: Option, + #[serde(default)] + pub original_entry: Option, + pub managed_base_url: String, + #[serde(default)] + pub container_existed: bool, + pub profile_name: String, + pub previous_profile: Option, + #[serde(default)] + pub ca_trust_installed: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentConnectionStatus { + pub kind: AgentKind, + pub name: String, + pub detected: bool, + pub connected: bool, + pub can_connect: bool, + pub config_path: String, + pub detail: String, + pub profile_name: Option, +} + +pub fn connection_setting_key(kind: AgentKind) -> String { + format!("agent.connection.{}.v0.1", kind.id()) +} + +pub fn config_path(kind: AgentKind) -> Result { + let home = env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }) + .map(PathBuf::from) + .ok_or_else(|| "the user home directory is unavailable".to_owned())?; + Ok(match kind { + AgentKind::Codex => home.join(".codex").join("config.toml"), + AgentKind::ClaudeCode => home.join(".claude").join("settings.json"), + AgentKind::GeminiCli => home.join(".gemini").join(".env"), + AgentKind::UniversalProxy => { + return Err("universal proxy does not use an agent configuration file".to_owned()); + } + }) +} + +pub fn status( + kind: AgentKind, + record: Option<&AgentConnectionRecord>, +) -> Result { + if !kind.is_native() { + return Err("universal proxy status is provided by the capture runtime".to_owned()); + } + let path = config_path(kind)?; + let detected = path.is_file(); + let connected = record + .filter(|record| record.active) + .is_some_and(|record| current_value_matches(record).unwrap_or(false)); + let detail = if connected { + "Managed by CodeIsCheap; restart the agent to apply the local Gateway.".to_owned() + } else if record.is_some_and(|record| record.active) { + "The agent configuration changed after CodeIsCheap connected it; reconnect or restore after reviewing the file.".to_owned() + } else if detected { + "Ready to connect. Only the API base URL field will be changed.".to_owned() + } else { + "Ready to connect. CodeIsCheap will create the configuration file and only manage the API base URL field.".to_owned() + }; + Ok(AgentConnectionStatus { + kind, + name: kind.display_name().to_owned(), + detected, + connected, + can_connect: true, + config_path: path.to_string_lossy().into_owned(), + detail, + profile_name: record + .filter(|record| record.active) + .map(|record| record.profile_name.clone()), + }) +} + +pub fn connect( + kind: AgentKind, + gateway_origin: &str, +) -> Result<(CaptureProfile, AgentConnectionRecord), String> { + let path = config_path(kind)?; + connect_at(kind, &path, gateway_origin) +} + +pub fn restore(record: &AgentConnectionRecord) -> Result<(), String> { + if record.version != CONNECTION_VERSION { + return Err(format!( + "agent connection version {} is unsupported", + record.version + )); + } + let expected = config_path(record.kind)?; + if Path::new(&record.config_path) != expected { + return Err( + "agent connection path does not match the current user configuration".to_owned(), + ); + } + restore_at(record, &expected) +} + +fn connect_at( + kind: AgentKind, + path: &Path, + gateway_origin: &str, +) -> Result<(CaptureProfile, AgentConnectionRecord), String> { + match kind { + AgentKind::Codex => connect_codex(path, gateway_origin), + AgentKind::ClaudeCode => connect_claude(path, gateway_origin), + AgentKind::GeminiCli => connect_gemini(path, gateway_origin), + AgentKind::UniversalProxy => { + Err("universal proxy is managed by the capture runtime".to_owned()) + } + } +} + +fn connect_codex( + path: &Path, + gateway_origin: &str, +) -> Result<(CaptureProfile, AgentConnectionRecord), String> { + let config_existed = path.is_file(); + let mut document = if config_existed { + fs::read_to_string(path) + .map_err(|error| format!("Codex config could not be read: {error}"))? + .parse::() + .map_err(|error| format!("Codex config TOML is invalid: {error}"))? + } else { + DocumentMut::new() + }; + let provider = document + .get("model_provider") + .and_then(Item::as_str) + .unwrap_or("openai") + .to_owned(); + let (target, original) = if provider == "openai" { + ( + "openai_base_url".to_owned(), + document + .get("openai_base_url") + .and_then(Item::as_str) + .map(str::to_owned), + ) + } else { + let original = document + .get("model_providers") + .and_then(Item::as_table) + .and_then(|providers| providers.get(&provider)) + .and_then(Item::as_table) + .and_then(|provider| provider.get("base_url")) + .and_then(Item::as_str) + .map(str::to_owned) + .ok_or_else(|| format!("Codex provider {provider} does not define base_url"))?; + ( + format!("model_providers.{provider}.base_url"), + Some(original), + ) + }; + let effective_original = original.as_deref().unwrap_or(DEFAULT_OPENAI_BASE_URL); + let (profile, managed) = profile_and_managed_url("Codex", effective_original, gateway_origin)?; + if provider == "openai" { + document["openai_base_url"] = value(&managed); + } else { + let providers = document["model_providers"].or_insert(Item::Table(Table::new())); + let providers = providers + .as_table_mut() + .ok_or_else(|| "Codex model_providers must be a TOML table".to_owned())?; + let provider_item = providers + .entry(&provider) + .or_insert(Item::Table(Table::new())); + let provider_table = provider_item + .as_table_mut() + .ok_or_else(|| format!("Codex provider {provider} must be a TOML table"))?; + provider_table["base_url"] = value(&managed); + } + write_config(path, document.to_string().as_bytes())?; + Ok(( + profile.clone(), + AgentConnectionRecord { + version: CONNECTION_VERSION.to_owned(), + kind: AgentKind::Codex, + active: true, + config_path: path.to_string_lossy().into_owned(), + config_existed, + target, + original_base_url: original, + original_entry: None, + managed_base_url: managed, + container_existed: config_existed, + profile_name: profile.name.clone(), + previous_profile: None, + ca_trust_installed: false, + }, + )) +} + +fn connect_claude( + path: &Path, + gateway_origin: &str, +) -> Result<(CaptureProfile, AgentConnectionRecord), String> { + let (mut document, existed) = if path.is_file() { + let encoded = fs::read_to_string(path) + .map_err(|error| format!("Claude Code settings could not be read: {error}"))?; + let value = serde_json::from_str::(&encoded) + .map_err(|error| format!("Claude Code settings JSON is invalid: {error}"))?; + (value, true) + } else { + (Value::Object(Map::new()), false) + }; + let root = document + .as_object_mut() + .ok_or_else(|| "Claude Code settings must contain a JSON object".to_owned())?; + let env_existed = root.get("env").is_some(); + let env = root + .entry("env") + .or_insert_with(|| Value::Object(Map::new())); + let env = env + .as_object_mut() + .ok_or_else(|| "Claude Code settings env must be a JSON object".to_owned())?; + let original = env + .get("ANTHROPIC_BASE_URL") + .and_then(Value::as_str) + .map(str::to_owned); + let effective_original = original.as_deref().unwrap_or(DEFAULT_ANTHROPIC_BASE_URL); + let (profile, managed) = + profile_and_managed_url("Claude Code", effective_original, gateway_origin)?; + env.insert( + "ANTHROPIC_BASE_URL".to_owned(), + Value::String(managed.clone()), + ); + let encoded = serde_json::to_vec_pretty(&document) + .map_err(|error| format!("Claude Code settings could not be encoded: {error}"))?; + write_config(path, &encoded)?; + Ok(( + profile.clone(), + AgentConnectionRecord { + version: CONNECTION_VERSION.to_owned(), + kind: AgentKind::ClaudeCode, + active: true, + config_path: path.to_string_lossy().into_owned(), + config_existed: existed, + target: "env.ANTHROPIC_BASE_URL".to_owned(), + original_base_url: original, + original_entry: None, + managed_base_url: managed, + container_existed: existed && env_existed, + profile_name: profile.name.clone(), + previous_profile: None, + ca_trust_installed: false, + }, + )) +} + +fn connect_gemini( + path: &Path, + gateway_origin: &str, +) -> Result<(CaptureProfile, AgentConnectionRecord), String> { + let config_existed = path.is_file(); + let encoded = if config_existed { + fs::read_to_string(path) + .map_err(|error| format!("Gemini CLI environment file could not be read: {error}"))? + } else { + String::new() + }; + let entry = find_env_entry(&encoded, GEMINI_BASE_URL_KEY)?; + let original = entry.as_ref().map(|entry| entry.value.clone()); + let effective_original = original.as_deref().unwrap_or(DEFAULT_GEMINI_BASE_URL); + let (profile, managed) = + profile_and_managed_url("Gemini CLI", effective_original, gateway_origin)?; + let updated = replace_env_entry( + &encoded, + entry.as_ref().map(|entry| entry.index), + &format!("{GEMINI_BASE_URL_KEY}={managed}"), + ); + write_config(path, updated.as_bytes())?; + Ok(( + profile.clone(), + AgentConnectionRecord { + version: CONNECTION_VERSION.to_owned(), + kind: AgentKind::GeminiCli, + active: true, + config_path: path.to_string_lossy().into_owned(), + config_existed, + target: GEMINI_BASE_URL_KEY.to_owned(), + original_base_url: original, + original_entry: entry.map(|entry| entry.raw), + managed_base_url: managed, + container_existed: config_existed, + profile_name: profile.name.clone(), + previous_profile: None, + ca_trust_installed: false, + }, + )) +} + +fn restore_at(record: &AgentConnectionRecord, path: &Path) -> Result<(), String> { + match record.kind { + AgentKind::Codex => restore_codex(record, path), + AgentKind::ClaudeCode => restore_claude(record, path), + AgentKind::GeminiCli => restore_gemini(record, path), + AgentKind::UniversalProxy => { + Err("universal proxy is restored by the capture runtime".to_owned()) + } + } +} + +fn restore_codex(record: &AgentConnectionRecord, path: &Path) -> Result<(), String> { + let encoded = fs::read_to_string(path) + .map_err(|error| format!("Codex config could not be read for restore: {error}"))?; + let mut document = encoded + .parse::() + .map_err(|error| format!("Codex config TOML is invalid: {error}"))?; + let current = codex_target_value(&document, &record.target)?; + if current.as_deref() != Some(record.managed_base_url.as_str()) { + return Err("Codex base URL changed after connection; refusing to overwrite it".to_owned()); + } + if record.target == "openai_base_url" { + match &record.original_base_url { + Some(original) => document["openai_base_url"] = value(original), + None => { + document.remove("openai_base_url"); + } + } + } else { + let provider = record + .target + .strip_prefix("model_providers.") + .and_then(|value| value.strip_suffix(".base_url")) + .ok_or_else(|| "Codex restore target is invalid".to_owned())?; + let table = document["model_providers"][provider] + .as_table_mut() + .ok_or_else(|| format!("Codex provider {provider} is unavailable"))?; + table["base_url"] = value( + record + .original_base_url + .as_deref() + .ok_or_else(|| "Codex original base URL is unavailable".to_owned())?, + ); + } + if !record.config_existed && document.as_table().is_empty() { + fs::remove_file(path) + .map_err(|error| format!("generated Codex config could not be removed: {error}")) + } else { + write_config(path, document.to_string().as_bytes()) + } +} + +fn restore_claude(record: &AgentConnectionRecord, path: &Path) -> Result<(), String> { + let encoded = fs::read_to_string(path) + .map_err(|error| format!("Claude Code settings could not be read for restore: {error}"))?; + let mut document = serde_json::from_str::(&encoded) + .map_err(|error| format!("Claude Code settings JSON is invalid: {error}"))?; + let root = document + .as_object_mut() + .ok_or_else(|| "Claude Code settings must contain a JSON object".to_owned())?; + let env = root + .get_mut("env") + .and_then(Value::as_object_mut) + .ok_or_else(|| "Claude Code settings env is unavailable".to_owned())?; + let current = env.get("ANTHROPIC_BASE_URL").and_then(Value::as_str); + if current != Some(record.managed_base_url.as_str()) { + return Err( + "Claude Code base URL changed after connection; refusing to overwrite it".to_owned(), + ); + } + match &record.original_base_url { + Some(original) => { + env.insert( + "ANTHROPIC_BASE_URL".to_owned(), + Value::String(original.clone()), + ); + } + None => { + env.remove("ANTHROPIC_BASE_URL"); + if !record.container_existed && env.is_empty() { + root.remove("env"); + } + } + } + if !record.config_existed && root.is_empty() { + fs::remove_file(path).map_err(|error| { + format!("generated Claude Code settings could not be removed: {error}") + }) + } else { + let encoded = serde_json::to_vec_pretty(&document) + .map_err(|error| format!("Claude Code settings could not be encoded: {error}"))?; + write_config(path, &encoded) + } +} + +fn restore_gemini(record: &AgentConnectionRecord, path: &Path) -> Result<(), String> { + let encoded = fs::read_to_string(path) + .map_err(|error| format!("Gemini CLI environment file could not be read: {error}"))?; + let entry = find_env_entry(&encoded, GEMINI_BASE_URL_KEY)? + .ok_or_else(|| "Gemini CLI base URL is unavailable".to_owned())?; + if entry.value != record.managed_base_url { + return Err( + "Gemini CLI base URL changed after connection; refusing to overwrite it".to_owned(), + ); + } + let updated = match record.original_entry.as_deref() { + Some(original) => replace_env_entry(&encoded, Some(entry.index), original), + None => remove_env_entry(&encoded, entry.index), + }; + if !record.config_existed && updated.trim().is_empty() { + fs::remove_file(path).map_err(|error| { + format!("generated Gemini CLI environment file could not be removed: {error}") + }) + } else { + write_config(path, updated.as_bytes()) + } +} + +fn current_value_matches(record: &AgentConnectionRecord) -> Result { + let path = Path::new(&record.config_path); + if !path.is_file() { + return Ok(false); + } + match record.kind { + AgentKind::Codex => { + let document = fs::read_to_string(path) + .map_err(|error| format!("Codex config could not be read: {error}"))? + .parse::() + .map_err(|error| format!("Codex config TOML is invalid: {error}"))?; + Ok(codex_target_value(&document, &record.target)?.as_deref() + == Some(record.managed_base_url.as_str())) + } + AgentKind::ClaudeCode => { + let document = serde_json::from_str::( + &fs::read_to_string(path) + .map_err(|error| format!("Claude Code settings could not be read: {error}"))?, + ) + .map_err(|error| format!("Claude Code settings JSON is invalid: {error}"))?; + Ok(document + .get("env") + .and_then(|env| env.get("ANTHROPIC_BASE_URL")) + .and_then(Value::as_str) + == Some(record.managed_base_url.as_str())) + } + AgentKind::GeminiCli => { + let encoded = fs::read_to_string(path).map_err(|error| { + format!("Gemini CLI environment file could not be read: {error}") + })?; + Ok(find_env_entry(&encoded, GEMINI_BASE_URL_KEY)? + .is_some_and(|entry| entry.value == record.managed_base_url)) + } + AgentKind::UniversalProxy => Ok(false), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct EnvEntry { + index: usize, + raw: String, + value: String, +} + +fn find_env_entry(encoded: &str, key: &str) -> Result, String> { + let mut found = None; + for (index, raw) in encoded.lines().enumerate() { + let Some(value) = parse_env_assignment(raw, key) else { + continue; + }; + if found.is_some() { + return Err(format!( + "Gemini CLI environment file defines {key} more than once" + )); + } + found = Some(EnvEntry { + index, + raw: raw.to_owned(), + value, + }); + } + Ok(found) +} + +fn parse_env_assignment(line: &str, key: &str) -> Option { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return None; + } + let line = line.strip_prefix("export ").unwrap_or(line).trim_start(); + let (name, value) = line.split_once('=')?; + if name.trim() != key { + return None; + } + let value = value.trim(); + let value = if value.len() >= 2 + && ((value.starts_with('"') && value.ends_with('"')) + || (value.starts_with('\'') && value.ends_with('\''))) + { + &value[1..value.len() - 1] + } else { + value + }; + Some(value.to_owned()) +} + +fn replace_env_entry(encoded: &str, index: Option, replacement: &str) -> String { + let newline = if encoded.contains("\r\n") { + "\r\n" + } else { + "\n" + }; + let had_trailing_newline = encoded.ends_with('\n'); + let mut lines = encoded.lines().map(str::to_owned).collect::>(); + match index { + Some(index) => lines[index] = replacement.to_owned(), + None => lines.push(replacement.to_owned()), + } + let mut updated = lines.join(newline); + if had_trailing_newline || encoded.is_empty() { + updated.push_str(newline); + } + updated +} + +fn remove_env_entry(encoded: &str, index: usize) -> String { + let newline = if encoded.contains("\r\n") { + "\r\n" + } else { + "\n" + }; + let had_trailing_newline = encoded.ends_with('\n'); + let mut lines = encoded.lines().map(str::to_owned).collect::>(); + lines.remove(index); + let mut updated = lines.join(newline); + if had_trailing_newline && !updated.is_empty() { + updated.push_str(newline); + } + updated +} + +fn codex_target_value(document: &DocumentMut, target: &str) -> Result, String> { + if target == "openai_base_url" { + return Ok(document + .get("openai_base_url") + .and_then(Item::as_str) + .map(str::to_owned)); + } + let provider = target + .strip_prefix("model_providers.") + .and_then(|value| value.strip_suffix(".base_url")) + .ok_or_else(|| "Codex connection target is invalid".to_owned())?; + Ok(document + .get("model_providers") + .and_then(Item::as_table) + .and_then(|providers| providers.get(provider)) + .and_then(Item::as_table) + .and_then(|provider| provider.get("base_url")) + .and_then(Item::as_str) + .map(str::to_owned)) +} + +fn profile_and_managed_url( + agent_name: &str, + upstream_base_url: &str, + gateway_origin: &str, +) -> Result<(CaptureProfile, String), String> { + let upstream = Url::parse(upstream_base_url) + .map_err(|error| format!("{agent_name} base URL is invalid: {error}"))?; + if !matches!(upstream.scheme(), "http" | "https") + || upstream.host_str().is_none() + || !upstream.username().is_empty() + || upstream.password().is_some() + || upstream.query().is_some() + || upstream.fragment().is_some() + { + return Err(format!( + "{agent_name} base URL must be a credential-free HTTP(S) URL" + )); + } + let gateway = Url::parse(gateway_origin) + .map_err(|error| format!("CodeIsCheap Gateway URL is invalid: {error}"))?; + let path = upstream.path().trim_end_matches('/'); + let managed = if path.is_empty() { + gateway.origin().ascii_serialization() + } else { + format!("{}{}", gateway.origin().ascii_serialization(), path) + }; + let host = upstream.host_str().unwrap_or_default(); + let profile = CaptureProfile { + version: "0.1".to_owned(), + name: format!("{agent_name} · {host}"), + gateway_upstream: upstream.origin().ascii_serialization(), + additional_hosts: Vec::new(), + } + .validated() + .map_err(|error| error.to_string())?; + Ok((profile, managed)) +} + +fn write_config(path: &Path, content: &[u8]) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("agent config directory could not be created: {error}"))?; + } + let mut file = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(path) + .map_err(|error| format!("agent config could not be opened for writing: {error}"))?; + file.write_all(content) + .map_err(|error| format!("agent config could not be written: {error}"))?; + file.sync_all() + .map_err(|error| format!("agent config could not be synced: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn codex_connection_preserves_other_toml_and_restores_base_url() { + let directory = tempdir().expect("tempdir"); + let path = directory.path().join("config.toml"); + fs::write( + &path, + "model_provider = \"custom\"\nmodel = \"gpt-test\"\n\n[model_providers.custom]\nwire_api = \"responses\"\nbase_url = \"https://proxy.example/v1\"\n", + ) + .expect("write config"); + let (profile, record) = + connect_at(AgentKind::Codex, &path, "http://127.0.0.1:8787").expect("connect Codex"); + assert_eq!(profile.gateway_upstream, "https://proxy.example"); + let connected = fs::read_to_string(&path).expect("connected config"); + assert!(connected.contains("model = \"gpt-test\"")); + assert!(connected.contains("base_url = \"http://127.0.0.1:8787/v1\"")); + restore_at(&record, &path).expect("restore Codex"); + assert!( + fs::read_to_string(&path) + .expect("restored config") + .contains("base_url = \"https://proxy.example/v1\"") + ); + } + + #[test] + fn claude_connection_only_changes_base_url_and_removes_new_env_on_restore() { + let directory = tempdir().expect("tempdir"); + let path = directory.path().join("settings.json"); + fs::write(&path, r#"{"permissions":{"allow":["Read"]}}"#).expect("write settings"); + let (profile, record) = connect_at(AgentKind::ClaudeCode, &path, "http://127.0.0.1:8787") + .expect("connect Claude"); + assert_eq!(profile.gateway_upstream, "https://api.anthropic.com"); + let connected: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!( + connected["env"]["ANTHROPIC_BASE_URL"], + "http://127.0.0.1:8787" + ); + assert_eq!(connected["permissions"]["allow"][0], "Read"); + restore_at(&record, &path).expect("restore Claude"); + let restored: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + assert!(restored.get("env").is_none()); + assert_eq!(restored["permissions"]["allow"][0], "Read"); + } + + #[test] + fn restore_refuses_to_overwrite_a_later_user_change() { + let directory = tempdir().expect("tempdir"); + let path = directory.path().join("settings.json"); + fs::write(&path, "{}").expect("write settings"); + let (_, record) = connect_at(AgentKind::ClaudeCode, &path, "http://127.0.0.1:8787") + .expect("connect Claude"); + fs::write( + &path, + r#"{"env":{"ANTHROPIC_BASE_URL":"https://user.example"}}"#, + ) + .expect("edit settings"); + assert!(restore_at(&record, &path).is_err()); + } + + #[test] + fn gemini_connection_preserves_env_file_and_restores_original_entry() { + let directory = tempdir().expect("tempdir"); + let path = directory.path().join(".env"); + fs::write( + &path, + "# Gemini CLI\nGEMINI_API_KEY=not-read-by-test\nexport GOOGLE_GEMINI_BASE_URL=\"https://gemini.example\"\n", + ) + .expect("write environment file"); + let (profile, record) = connect_at(AgentKind::GeminiCli, &path, "http://127.0.0.1:8787") + .expect("connect Gemini CLI"); + assert_eq!(profile.gateway_upstream, "https://gemini.example"); + let connected = fs::read_to_string(&path).expect("connected environment file"); + assert!(connected.contains("GEMINI_API_KEY=not-read-by-test")); + assert!(connected.contains("GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:8787")); + restore_at(&record, &path).expect("restore Gemini CLI"); + let restored = fs::read_to_string(&path).expect("restored environment file"); + assert!(restored.contains("GEMINI_API_KEY=not-read-by-test")); + assert!(restored.contains("export GOOGLE_GEMINI_BASE_URL=\"https://gemini.example\"")); + } + + #[test] + fn gemini_restore_refuses_later_changes_and_generated_file_is_removed() { + let directory = tempdir().expect("tempdir"); + let path = directory.path().join(".env"); + let (_, record) = connect_at(AgentKind::GeminiCli, &path, "http://127.0.0.1:8787") + .expect("generate Gemini CLI environment file"); + fs::write(&path, "GOOGLE_GEMINI_BASE_URL=https://user.example\n") + .expect("edit environment file"); + assert!(restore_at(&record, &path).is_err()); + fs::write(&path, "GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:8787\n") + .expect("restore managed value"); + restore_at(&record, &path).expect("restore generated Gemini CLI environment file"); + assert!(!path.exists()); + } + + #[test] + fn generated_agent_configs_are_removed_on_restore() { + let directory = tempdir().expect("tempdir"); + let codex_path = directory.path().join("codex.toml"); + let (_, codex_record) = connect_at(AgentKind::Codex, &codex_path, "http://127.0.0.1:8787") + .expect("generate Codex config"); + assert!(codex_path.is_file()); + restore_at(&codex_record, &codex_path).expect("restore generated Codex config"); + assert!(!codex_path.exists()); + + let claude_path = directory.path().join("settings.json"); + let (_, claude_record) = + connect_at(AgentKind::ClaudeCode, &claude_path, "http://127.0.0.1:8787") + .expect("generate Claude Code config"); + assert!(claude_path.is_file()); + restore_at(&claude_record, &claude_path).expect("restore generated Claude Code config"); + assert!(!claude_path.exists()); + } +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 16171f6..e1fc62c 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,5 +1,7 @@ +mod agent_config; mod beta_metrics; +use agent_config::{AgentConnectionRecord, AgentConnectionStatus, AgentKind}; use beta_metrics::BetaMetricsTracker; use std::collections::{BTreeSet, HashMap, VecDeque}; @@ -88,6 +90,7 @@ const KEY_SERVICE: &str = "com.codeischeap.desktop"; const KEY_ACCOUNT: &str = "capture-database-v1"; const DEFAULT_GATEWAY_ADDRESS: &str = "127.0.0.1:8787"; const CAPTURE_PROFILE_SETTING_KEY: &str = "capture.profile"; +const CAPTURE_PROFILES_SETTING_KEY: &str = "capture.profiles.v0.1"; const CAPTURE_UPDATED_EVENT: &str = "capture-updated"; const CAPTURE_RUNTIME_ERROR_EVENT: &str = "capture-runtime-error"; const UPDATE_DOWNLOAD_PROGRESS_EVENT: &str = "update-download-progress"; @@ -441,31 +444,300 @@ async fn update_capture_profile( ) -> Result { initialize_store(&app, &state)?; ensure_store_writable(&state.store)?; - let profile = profile.validated().map_err(|error| error.to_string())?; - if state.capture_active.load(Ordering::Acquire) { - return Err("pause capture before changing the active Profile".to_owned()); + apply_capture_profile(&app, &state, profile).await?; + load_runtime_workspace(&app, &state).await +} + +#[tauri::command] +fn list_capture_profiles( + app: AppHandle, + state: State<'_, DesktopState>, +) -> Result, String> { + initialize_store(&app, &state)?; + let active = active_capture_profile(&state)?; + load_capture_profiles(&state.store, &active) +} + +#[tauri::command] +async fn activate_capture_profile( + name: String, + app: AppHandle, + state: State<'_, DesktopState>, +) -> Result { + initialize_store(&app, &state)?; + ensure_store_writable(&state.store)?; + let active = active_capture_profile(&state)?; + let profile = load_capture_profiles(&state.store, &active)? + .into_iter() + .find(|profile| profile.name.eq_ignore_ascii_case(name.trim())) + .ok_or_else(|| format!("capture Profile {name} was not found"))?; + let resume_capture = prepare_agent_profile_change(&app, &state).await?; + let operation = apply_capture_profile(&app, &state, profile).await; + finish_agent_profile_change(&state, resume_capture, operation).await?; + load_runtime_workspace(&app, &state).await +} + +#[tauri::command] +fn delete_capture_profile( + name: String, + app: AppHandle, + state: State<'_, DesktopState>, +) -> Result, String> { + initialize_store(&app, &state)?; + ensure_store_writable(&state.store)?; + let active = active_capture_profile(&state)?; + if active.name.eq_ignore_ascii_case(name.trim()) { + return Err("the active capture Profile cannot be deleted".to_owned()); } - if *state.mode.lock().await != CaptureMode::Gateway { - return Err("return capture to Gateway mode before changing the active Profile".to_owned()); + let mut profiles = load_capture_profiles(&state.store, &active)?; + let before = profiles.len(); + profiles.retain(|profile| !profile.name.eq_ignore_ascii_case(name.trim())); + if profiles.len() == before { + return Err(format!("capture Profile {name} was not found")); } - let previous = active_capture_profile(&state)?; - if previous == profile { - return load_runtime_workspace(&app, &state).await; + persist_capture_profiles(&state.store, &profiles)?; + Ok(profiles) +} + +#[tauri::command] +async fn list_agent_connections( + app: AppHandle, + state: State<'_, DesktopState>, +) -> Result, String> { + initialize_store(&app, &state)?; + let workspace = load_runtime_workspace(&app, &state).await?; + AgentKind::ALL + .into_iter() + .map(|kind| { + let record = load_agent_connection(&state.store, kind)?; + if kind.is_native() { + agent_config::status(kind, record.as_ref()) + } else { + let active = record.as_ref().is_some_and(|record| record.active); + let proxy_mode = workspace.capture.mode == CaptureMode::Proxy; + let trusted = workspace.capture.certificate_authority.trust + == CertificateTrust::Trusted; + Ok(AgentConnectionStatus { + kind, + name: kind.display_name().to_owned(), + detected: true, + connected: active && proxy_mode && trusted, + can_connect: workspace.capture.can_control + && workspace.capture.proxy_available, + config_path: "System proxy + CodeIsCheap local CA".to_owned(), + detail: if active && proxy_mode && trusted { + "System proxy capture is active for supported desktop and CLI agents." + .to_owned() + } else if proxy_mode && !trusted { + "Proxy capture is running, but the local CA is not trusted.".to_owned() + } else { + "Covers agents without a stable base URL setting by configuring the system proxy and local CA." + .to_owned() + }, + profile_name: None, + }) + } + }) + .collect() +} + +#[tauri::command] +async fn connect_agent( + kind: AgentKind, + app: AppHandle, + state: State<'_, DesktopState>, +) -> Result { + initialize_store(&app, &state)?; + ensure_store_writable(&state.store)?; + if !kind.is_native() { + return connect_universal_proxy(&app, &state).await; + } + if let Some(record) = load_agent_connection(&state.store, kind)? { + if record.active && agent_config::status(kind, Some(&record))?.connected { + return Err(format!( + "{} is already connected by CodeIsCheap", + kind.display_name() + )); + } + } + let resume_capture = prepare_agent_profile_change(&app, &state).await?; + let operation = async { + let previous = active_capture_profile(&state)?; + let gateway_origin = state + .gateway + .lock() + .await + .as_ref() + .map(|runtime| runtime.endpoint.clone()) + .ok_or_else(|| "local AI gateway has not started".to_owned())?; + let (profile, mut record) = agent_config::connect(kind, &gateway_origin)?; + record.previous_profile = Some(previous.clone()); + if let Err(error) = apply_capture_profile(&app, &state, profile).await { + let restore = agent_config::restore(&record); + return Err(combine_profile_update_error(error, restore)); + } + if let Err(error) = persist_agent_connection(&state.store, &record) { + let restore_config = agent_config::restore(&record); + let restore_profile = restore_capture_profile(&app, &state, previous).await; + return Err(format!( + "agent connection could not be recorded: {error}; config restore: {}; Profile restore: {}", + result_label(restore_config), + result_label(restore_profile) + )); + } + Ok(()) } + .await; + finish_agent_profile_change(&state, resume_capture, operation).await?; + load_runtime_workspace(&app, &state).await +} - stop_gateway(&state).await?; - replace_capture_profile(&state, profile.clone())?; - if let Err(error) = ensure_gateway(&app, &state).await { - let rollback = restore_capture_profile(&app, &state, previous).await; - return Err(combine_profile_update_error(error, rollback)); +#[tauri::command] +async fn restore_agent_connection( + kind: AgentKind, + app: AppHandle, + state: State<'_, DesktopState>, +) -> Result { + initialize_store(&app, &state)?; + ensure_store_writable(&state.store)?; + if !kind.is_native() { + return restore_universal_proxy(&app, &state).await; } - if let Err(error) = persist_capture_profile(&state.store, &profile) { - let rollback = restore_capture_profile(&app, &state, previous).await; - return Err(combine_profile_update_error(error, rollback)); + let resume_capture = prepare_agent_profile_change(&app, &state).await?; + let operation = async { + let mut record = load_agent_connection(&state.store, kind)? + .filter(|record| record.active) + .ok_or_else(|| format!("{} is not connected by CodeIsCheap", kind.display_name()))?; + agent_config::restore(&record)?; + record.active = false; + persist_agent_connection(&state.store, &record)?; + if let Some(previous) = record.previous_profile.clone() { + apply_capture_profile(&app, &state, previous).await?; + } + Ok(()) } + .await; + finish_agent_profile_change(&state, resume_capture, operation).await?; load_runtime_workspace(&app, &state).await } +async fn connect_universal_proxy( + app: &AppHandle, + state: &DesktopState, +) -> Result { + let before = load_runtime_workspace(app, state).await?; + if load_agent_connection(&state.store, AgentKind::UniversalProxy)? + .is_some_and(|record| record.active) + && before.capture.mode == CaptureMode::Proxy + && before.capture.certificate_authority.trust == CertificateTrust::Trusted + { + return Err("universal proxy capture is already connected".to_owned()); + } + let ca_was_trusted = before.capture.certificate_authority.trust == CertificateTrust::Trusted; + let confdir = application_certificate_confdir(app)?; + switch_capture_mode(app, state, CaptureMode::Proxy).await?; + let mut ca_trust_installed = false; + if !ca_was_trusted { + let install_confdir = confdir.clone(); + let installed = match tokio::task::spawn_blocking(move || install_ca_trust(install_confdir)) + .await + .map_err(|error| format!("certificate trust task failed: {error}"))? + .map_err(|error| error.to_string()) + { + Ok(installed) => installed, + Err(error) => { + let rollback = switch_capture_mode(app, state, CaptureMode::Gateway).await; + return Err(combine_profile_update_error(error, rollback)); + } + }; + ca_trust_installed = installed; + let activation = { + let mut runtime = state.proxy.lock().await; + match runtime.as_mut() { + Some(runtime) => activate_system_proxy(app, runtime).await, + None => Err("explicit proxy runtime is unavailable".to_owned()), + } + }; + if let Err(error) = activation { + let proxy_restore = switch_capture_mode(app, state, CaptureMode::Gateway).await; + let ca_restore = if ca_trust_installed { + let restore_confdir = confdir.clone(); + tokio::task::spawn_blocking(move || uninstall_ca_trust(restore_confdir)) + .await + .map_err(|error| format!("certificate trust task failed: {error}"))? + .map(|_| ()) + .map_err(|error| error.to_string()) + } else { + Ok(()) + }; + return Err(format!( + "system proxy activation failed: {error}; proxy restore: {}; CA restore: {}", + result_label(proxy_restore), + result_label(ca_restore) + )); + } + } + let record = AgentConnectionRecord { + version: "0.1".to_owned(), + kind: AgentKind::UniversalProxy, + active: true, + config_path: String::new(), + config_existed: false, + target: "system_proxy".to_owned(), + original_base_url: None, + original_entry: None, + managed_base_url: state + .proxy + .lock() + .await + .as_ref() + .map(|runtime| runtime.endpoint.clone()) + .unwrap_or_default(), + container_existed: false, + profile_name: "Universal proxy".to_owned(), + previous_profile: None, + ca_trust_installed, + }; + if let Err(error) = persist_agent_connection(&state.store, &record) { + let proxy_restore = switch_capture_mode(app, state, CaptureMode::Gateway).await; + let ca_restore = if ca_trust_installed { + tokio::task::spawn_blocking(move || uninstall_ca_trust(confdir)) + .await + .map_err(|error| format!("certificate trust task failed: {error}"))? + .map(|_| ()) + .map_err(|error| error.to_string()) + } else { + Ok(()) + }; + return Err(format!( + "universal proxy connection could not be recorded: {error}; proxy restore: {}; CA restore: {}", + result_label(proxy_restore), + result_label(ca_restore) + )); + } + load_runtime_workspace(app, state).await +} + +async fn restore_universal_proxy( + app: &AppHandle, + state: &DesktopState, +) -> Result { + let mut record = load_agent_connection(&state.store, AgentKind::UniversalProxy)? + .filter(|record| record.active) + .ok_or_else(|| "universal proxy capture is not connected by CodeIsCheap".to_owned())?; + switch_capture_mode(app, state, CaptureMode::Gateway).await?; + if record.ca_trust_installed { + let confdir = application_certificate_confdir(app)?; + tokio::task::spawn_blocking(move || uninstall_ca_trust(confdir)) + .await + .map_err(|error| format!("certificate trust task failed: {error}"))? + .map_err(|error| error.to_string())?; + } + record.active = false; + persist_agent_connection(&state.store, &record)?; + load_runtime_workspace(app, state).await +} + #[tauri::command] async fn install_certificate_authority_trust( app: AppHandle, @@ -847,14 +1119,20 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ bootstrap_workspace, + activate_capture_profile, check_for_update, + connect_agent, + delete_capture_profile, install_certificate_authority_trust, install_update, + list_agent_connections, + list_capture_profiles, preview_batch_capture_export, preview_beta_metrics, preview_capture_export, preview_support_bundle, search_workspace, + restore_agent_connection, set_capture_active, set_capture_mode, update_capture_profile, @@ -1166,6 +1444,192 @@ fn persist_capture_profile(store: &SharedStore, profile: &CaptureProfile) -> Res .map_err(|error| error.to_string()) } +fn load_capture_profiles( + store: &SharedStore, + active: &CaptureProfile, +) -> Result, String> { + let encoded = store + .lock() + .map_err(|_| "encrypted workspace is temporarily unavailable".to_owned())? + .as_ref() + .ok_or_else(|| "encrypted workspace has not initialized".to_owned())? + .get_setting_json(CAPTURE_PROFILES_SETTING_KEY) + .map_err(|error| error.to_string())?; + let mut profiles = match encoded { + Some(encoded) => serde_json::from_str::>(&encoded) + .map_err(|error| format!("capture Profile catalog is invalid: {error}"))?, + None => Vec::new(), + }; + let mut validated = Vec::with_capacity(profiles.len() + 1); + for profile in profiles.drain(..) { + let profile = profile.validated().map_err(|error| error.to_string())?; + if !validated + .iter() + .any(|saved: &CaptureProfile| saved.name.eq_ignore_ascii_case(&profile.name)) + { + validated.push(profile); + } + } + upsert_profile(&mut validated, active.clone()); + validated.sort_by(|left, right| left.name.to_lowercase().cmp(&right.name.to_lowercase())); + Ok(validated) +} + +fn persist_capture_profiles( + store: &SharedStore, + profiles: &[CaptureProfile], +) -> Result<(), String> { + let encoded = serde_json::to_string(profiles).map_err(|error| error.to_string())?; + store + .lock() + .map_err(|_| "encrypted workspace is temporarily unavailable".to_owned())? + .as_mut() + .ok_or_else(|| "encrypted workspace has not initialized".to_owned())? + .set_setting_json(CAPTURE_PROFILES_SETTING_KEY, &encoded, current_unix_ms()?) + .map_err(|error| error.to_string()) +} + +fn upsert_profile(profiles: &mut Vec, profile: CaptureProfile) { + if let Some(saved) = profiles + .iter_mut() + .find(|saved| saved.name.eq_ignore_ascii_case(&profile.name)) + { + *saved = profile; + } else { + profiles.push(profile); + } +} + +async fn ensure_profile_change_ready(state: &DesktopState) -> Result<(), String> { + if state.capture_active.load(Ordering::Acquire) { + return Err("pause capture before changing the active Profile".to_owned()); + } + if *state.mode.lock().await != CaptureMode::Gateway { + return Err("return capture to Gateway mode before changing the active Profile".to_owned()); + } + Ok(()) +} + +async fn prepare_agent_profile_change( + app: &AppHandle, + state: &DesktopState, +) -> Result { + let proxy_mode = { *state.mode.lock().await == CaptureMode::Proxy }; + if proxy_mode { + switch_capture_mode(app, state, CaptureMode::Gateway).await?; + } + ensure_gateway(app, state).await?; + let resume_capture = state.capture_active.load(Ordering::Acquire); + if resume_capture { + let gateway = state.gateway.lock().await; + let runtime = gateway + .as_ref() + .ok_or_else(|| "local AI gateway has not started".to_owned())?; + runtime.capture.set_enabled(false); + state.capture_active.store(false, Ordering::Release); + } + Ok(resume_capture) +} + +async fn restore_gateway_capture(state: &DesktopState, active: bool) -> Result<(), String> { + if active { + let gateway = state.gateway.lock().await; + let runtime = gateway + .as_ref() + .ok_or_else(|| "local AI gateway has not started".to_owned())?; + runtime.capture.set_enabled(true); + state.capture_active.store(true, Ordering::Release); + } + Ok(()) +} + +async fn finish_agent_profile_change( + state: &DesktopState, + resume_capture: bool, + operation: Result<(), String>, +) -> Result<(), String> { + let resume = restore_gateway_capture(state, resume_capture).await; + match (operation, resume) { + (Ok(()), Ok(())) => Ok(()), + (Err(error), Ok(())) => Err(error), + (Ok(()), Err(resume_error)) => Err(resume_error), + (Err(error), Err(resume_error)) => Err(format!( + "{error}; capture state could not be restored: {resume_error}" + )), + } +} + +async fn apply_capture_profile( + app: &AppHandle, + state: &DesktopState, + profile: CaptureProfile, +) -> Result<(), String> { + let profile = profile.validated().map_err(|error| error.to_string())?; + ensure_profile_change_ready(state).await?; + let previous = active_capture_profile(state)?; + let mut profiles = load_capture_profiles(&state.store, &previous)?; + upsert_profile(&mut profiles, profile.clone()); + persist_capture_profiles(&state.store, &profiles)?; + if previous == profile { + return persist_capture_profile(&state.store, &profile); + } + stop_gateway(state).await?; + replace_capture_profile(state, profile.clone())?; + if let Err(error) = ensure_gateway(app, state).await { + let rollback = restore_capture_profile(app, state, previous).await; + return Err(combine_profile_update_error(error, rollback)); + } + if let Err(error) = persist_capture_profile(&state.store, &profile) { + let rollback = restore_capture_profile(app, state, previous).await; + return Err(combine_profile_update_error(error, rollback)); + } + Ok(()) +} + +fn load_agent_connection( + store: &SharedStore, + kind: AgentKind, +) -> Result, String> { + let encoded = store + .lock() + .map_err(|_| "encrypted workspace is temporarily unavailable".to_owned())? + .as_ref() + .ok_or_else(|| "encrypted workspace has not initialized".to_owned())? + .get_setting_json(&agent_config::connection_setting_key(kind)) + .map_err(|error| error.to_string())?; + encoded + .map(|encoded| { + serde_json::from_str(&encoded) + .map_err(|error| format!("agent connection record is invalid: {error}")) + }) + .transpose() +} + +fn persist_agent_connection( + store: &SharedStore, + record: &AgentConnectionRecord, +) -> Result<(), String> { + let encoded = serde_json::to_string(record).map_err(|error| error.to_string())?; + store + .lock() + .map_err(|_| "encrypted workspace is temporarily unavailable".to_owned())? + .as_mut() + .ok_or_else(|| "encrypted workspace has not initialized".to_owned())? + .set_setting_json( + &agent_config::connection_setting_key(record.kind), + &encoded, + current_unix_ms()?, + ) + .map_err(|error| error.to_string()) +} + +fn result_label(result: Result<(), String>) -> String { + match result { + Ok(()) => "ok".to_owned(), + Err(error) => error, + } +} + fn active_capture_profile(state: &DesktopState) -> Result { state .capture_profile diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index d517930..7148ab0 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -26,6 +26,12 @@ "csp": "default-src 'self'; img-src 'self' asset: data:; style-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost" } }, + "plugins": { + "updater": { + "pubkey": "", + "endpoints": [] + } + }, "bundle": { "active": true, "targets": "all", diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 8fc1c3a..4a297f4 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -49,6 +49,22 @@ describe("request workbench", () => { vi.mocked(save).mockReset(); }); + it("switches between Chinese and English and persists the locale", async () => { + const user = userEvent.setup(); + localStorage.setItem("codeischeap.locale", "zh-CN"); + + render(); + + expect(await screen.findByRole("heading", { name: "请求" })).toBeInTheDocument(); + expect(document.documentElement.lang).toBe("zh-CN"); + await user.click(screen.getByRole("button", { name: "切换为英文" })); + + expect(screen.getByRole("heading", { name: "Requests" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Switch to Chinese" })).toBeInTheDocument(); + expect(localStorage.getItem("codeischeap.locale")).toBe("en"); + expect(document.documentElement.lang).toBe("en"); + }); + it("filters requests without losing the inspector workflow", async () => { const user = userEvent.setup(); render(); @@ -128,6 +144,9 @@ describe("request workbench", () => { expect(within(dialog).getByRole("tab", { name: "Profiles" })) .toHaveAttribute("aria-selected", "true"); await user.keyboard("{ArrowRight}"); + expect(within(dialog).getByRole("tab", { name: "Agents" })) + .toHaveAttribute("aria-selected", "true"); + await user.keyboard("{ArrowRight}"); expect(within(dialog).getByRole("tab", { name: "Metrics" })) .toHaveAttribute("aria-selected", "true"); await user.keyboard("{ArrowRight}"); @@ -434,6 +453,7 @@ describe("request workbench", () => { }; vi.mocked(invoke).mockImplementation(async (command, args) => { if (command === "bootstrap_workspace") return structuredClone(workspace); + if (command === "list_capture_profiles") return [structuredClone(workspace.captureProfile)]; if (command === "set_capture_active" && args?.active === false) { workspace = { ...workspace, @@ -501,6 +521,126 @@ describe("request workbench", () => { expect(gateway).toHaveValue("https://gateway.example.test"); }); + it("activates and deletes saved Profiles and manages Codex connection", async () => { + const user = userEvent.setup(); + window.__TAURI_INTERNALS__ = {}; + let workspace = structuredClone(fixture) as unknown as WorkspaceBootstrap; + workspace.capture = { ...workspace.capture, canControl: true }; + const originalProfile = structuredClone(workspace.captureProfile); + const labProfile: CaptureProfile = { + version: "0.1", + name: "Private lab", + gatewayUpstream: "https://gateway.example.test", + additionalHosts: [], + }; + let profiles = [originalProfile, labProfile]; + let codexConnected = false; + let universalConnected = false; + const agentStatuses = () => [{ + kind: "codex", + name: "Codex", + detected: true, + connected: codexConnected, + canConnect: true, + configPath: "C:\\Users\\tester\\.codex\\config.toml", + detail: codexConnected + ? "Managed by CodeIsCheap; restart the agent to apply the local Gateway." + : "Ready to connect. Only the API base URL field will be changed.", + profileName: codexConnected ? "Codex · api.openai.com" : null, + }, { + kind: "claude_code", + name: "Claude Code", + detected: false, + connected: false, + canConnect: true, + configPath: "C:\\Users\\tester\\.claude\\settings.json", + detail: "Ready to connect. CodeIsCheap will create the configuration file and only manage the API base URL field.", + profileName: null, + }, { + kind: "gemini_cli", + name: "Gemini CLI", + detected: true, + connected: false, + canConnect: true, + configPath: "C:\\Users\\tester\\.gemini\\.env", + detail: "Ready to connect. Only the API base URL field will be changed.", + profileName: null, + }, { + kind: "universal_proxy", + name: "Universal proxy", + detected: true, + connected: universalConnected, + canConnect: true, + configPath: "System proxy + CodeIsCheap local CA", + detail: "Covers agents without a stable base URL setting by configuring the system proxy and local CA.", + profileName: null, + }]; + vi.mocked(invoke).mockImplementation(async (command, args) => { + if (command === "bootstrap_workspace") return structuredClone(workspace); + if (command === "list_capture_profiles") return structuredClone(profiles); + if (command === "activate_capture_profile") { + const profile = profiles.find((candidate) => candidate.name === args?.name); + if (!profile) throw new Error("Profile not found"); + workspace = { + ...workspace, + captureProfile: structuredClone(profile), + capture: { ...workspace.capture, profile: `${profile.name} · Local Gateway` }, + }; + return structuredClone(workspace); + } + if (command === "delete_capture_profile") { + profiles = profiles.filter((profile) => profile.name !== args?.name); + return structuredClone(profiles); + } + if (command === "list_agent_connections") return agentStatuses(); + if (command === "connect_agent") { + if (args?.kind === "codex") codexConnected = true; + if (args?.kind === "universal_proxy") universalConnected = true; + return structuredClone(workspace); + } + if (command === "restore_agent_connection") { + if (args?.kind === "codex") codexConnected = false; + if (args?.kind === "universal_proxy") universalConnected = false; + return structuredClone(workspace); + } + throw new Error(`Unexpected command: ${command}`); + }); + + render(); + await user.click(await screen.findByRole("button", { name: "Settings" })); + const dialog = screen.getByRole("dialog", { name: "Settings & diagnostics" }); + await user.click(within(dialog).getByRole("tab", { name: "Profiles" })); + const labRow = (await within(dialog).findByText("Private lab")).closest(".profile-catalog-row"); + expect(labRow).not.toBeNull(); + await user.click(within(labRow as HTMLElement).getByRole("button", { name: "Activate" })); + expect(invoke).toHaveBeenCalledWith("activate_capture_profile", { name: "Private lab" }); + await waitFor(() => expect( + within(labRow as HTMLElement).getByRole("button", { name: "Active" }), + ).toBeDisabled()); + await user.click(within(dialog).getByRole("button", { + name: `Delete Profile ${originalProfile.name}`, + })); + expect(invoke).toHaveBeenCalledWith("delete_capture_profile", { name: originalProfile.name }); + + await user.click(within(dialog).getByRole("tab", { name: "Agents" })); + const codexRow = (await within(dialog).findByText("Codex")).closest(".agent-row"); + expect(codexRow).not.toBeNull(); + expect(within(dialog).queryByRole("textbox")).not.toBeInTheDocument(); + await user.click(within(codexRow as HTMLElement).getByRole("button", { name: "Connect" })); + expect(invoke).toHaveBeenCalledWith("connect_agent", { kind: "codex" }); + await user.click(await within(codexRow as HTMLElement).findByRole("button", { name: "Restore" })); + expect(invoke).toHaveBeenCalledWith("restore_agent_connection", { kind: "codex" }); + + const universalRow = within(dialog).getByText("Universal proxy").closest(".agent-row"); + expect(universalRow).not.toBeNull(); + expect(within(universalRow as HTMLElement).getByText("Cursor")).toBeInTheDocument(); + expect(within(universalRow as HTMLElement).getByText("OpenCode")).toBeInTheDocument(); + await user.click(within(universalRow as HTMLElement).getByRole("button", { name: "Connect" })); + expect(invoke).toHaveBeenCalledWith("connect_agent", { kind: "universal_proxy" }); + await user.click(await within(universalRow as HTMLElement).findByRole("button", { name: "Restore" })); + expect(invoke).toHaveBeenCalledWith("restore_agent_connection", { kind: "universal_proxy" }); + }); + it("returns an active proxy workspace to the safe Gateway from settings", async () => { const user = userEvent.setup(); window.__TAURI_INTERNALS__ = {}; diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 93e8fee..84bf8f2 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -13,6 +13,7 @@ import { Filter, GitCompareArrows, LocateFixed, + Languages, LoaderCircle, Moon, Network, @@ -61,14 +62,8 @@ import { import { formatRawJson, resolveEvidenceLocator, resolveEvidencePointer } from "./raw-evidence"; import type { ResolvedRawEvidence } from "./raw-evidence"; import { handleTabListKeyDown, useModalDialog } from "./accessibility"; +import { I18nProvider, localeTag, useI18n, type Translate } from "./i18n"; -const number = new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }); -const clock = new Intl.DateTimeFormat(undefined, { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, -}); const REQUEST_ROW_HEIGHT = 116; const SIDEBAR_MIN_WIDTH = 184; const SIDEBAR_MAX_WIDTH = 292; @@ -83,6 +78,11 @@ interface PaneWidths { } export function App() { + return ; +} + +function AppContent() { + const { t } = useI18n(); const [workspace, setWorkspace] = useState(null); const [loadError, setLoadError] = useState(""); const [reloadToken, setReloadToken] = useState(0); @@ -145,7 +145,7 @@ export function App() { .catch((error: unknown) => { if (cancelled) return; setWorkspace(null); - setLoadError(error instanceof Error ? error.message : "The encrypted workspace could not be opened."); + setLoadError(error instanceof Error ? error.message : t("The encrypted workspace could not be opened.")); }); return () => { cancelled = true; }; }, [reloadToken]); @@ -182,7 +182,7 @@ export function App() { .catch((error: unknown) => { if (!disposed) { setCaptureError( - error instanceof Error ? error.message : "Capture events are unavailable.", + error instanceof Error ? error.message : t("Capture events are unavailable."), ); } }); @@ -223,7 +223,7 @@ export function App() { }) .catch((error: unknown) => { if (cancelled) return; - setSearchError(error instanceof Error ? error.message : "Full-text search failed."); + setSearchError(error instanceof Error ? error.message : t("Full-text search failed.")); }); }, 180); return () => { @@ -299,7 +299,7 @@ export function App() { setWorkspace(nextWorkspace); }) .catch((error: unknown) => { - setCaptureError(error instanceof Error ? error.message : "Capture state could not change."); + setCaptureError(error instanceof Error ? error.message : t("Capture state could not change.")); }); }; @@ -314,7 +314,7 @@ export function App() { setCaptureError(""); }) .catch((error: unknown) => { - setCaptureError(error instanceof Error ? error.message : "Capture mode could not change."); + setCaptureError(error instanceof Error ? error.message : t("Capture mode could not change.")); }) .finally(() => setModeChanging(false)); }; @@ -334,18 +334,22 @@ export function App() { }) .catch((error: unknown) => { setCertificateError( - error instanceof Error ? error.message : "Certificate trust could not change.", + error instanceof Error ? error.message : t("Certificate trust could not change."), ); }) .finally(() => setCertificateChanging(false)); }; - const changeCaptureProfile = async (profile: CaptureProfile) => { - const nextWorkspace = await persistCaptureProfile(profile); + const applyWorkspaceChange = (nextWorkspace: WorkspaceBootstrap) => { setWorkspace(nextWorkspace); setCaptureActive(nextWorkspace.capture.active); setCaptureMode(nextWorkspace.capture.mode); setCaptureError(""); + }; + + const changeCaptureProfile = async (profile: CaptureProfile) => { + const nextWorkspace = await persistCaptureProfile(profile); + applyWorkspaceChange(nextWorkspace); return nextWorkspace.captureProfile; }; @@ -354,7 +358,7 @@ export function App() { } if (!workspace) { - return
CLoading workspace
; + return
C{t("Loading workspace")}
; } const recoveryMode = workspace.source === "recovery_backup"; @@ -370,9 +374,9 @@ export function App() { onToggleTheme={() => setTheme((value) => value === "light" ? "dark" : "light")} onOpenSettings={() => setSettingsOpen(true)} /> - {recoveryMode &&
+ {recoveryMode &&
-
Read-only recovery modeThe primary workspace could not open after an update. This validated encrypted backup remains searchable and exportable; capture and system changes are disabled.
+
{t("Read-only recovery mode")}{t("The primary workspace could not open after an update. This validated encrypted backup remains searchable and exportable; capture and system changes are disabled.")}
}
- setPaneWidths((widths) => resizePane("sidebar", widths, delta, viewportWidth))} /> + setPaneWidths((widths) => resizePane("sidebar", widths, delta, viewportWidth))} /> setCompareBase(null)} onExportVisible={() => setExportSelection({ requests, batch: true })} /> - setPaneWidths((widths) => resizePane("requestList", widths, delta, viewportWidth))} /> + setPaneWidths((widths) => resizePane("requestList", widths, delta, viewportWidth))} /> {compareBase && compareTarget ? } @@ -468,22 +473,25 @@ function Titlebar({ active, canControl, source, theme, onToggleCapture, onToggle onToggleTheme: () => void; onOpenSettings: () => void; }) { + const { locale, t, toggleLocale } = useI18n(); const recoveryMode = source === "recovery_backup"; const sourceLabel = source === "encrypted_local" - ? "Encrypted local workspace" - : recoveryMode ? "Recovery backup" : "Synthetic workspace"; + ? t("Encrypted local workspace") + : recoveryMode ? t("Recovery backup") : t("Synthetic workspace"); + const languageLabel = locale === "en" ? t("Switch to Chinese") : t("Switch to English"); return (
CCodeIsCheap{sourceLabel}
- {recoveryMode ? "Read-only" : active ? "Capturing" : "Paused"} - - - + +
); @@ -506,6 +514,7 @@ function CaptureSidebar({ workspace, active, canControl, proxyAvailable, mode, m onToolsOnly: (value: boolean) => void; onErrorsOnly: (value: boolean) => void; }) { + const { locale, t } = useI18n(); const certificate = workspace.capture.certificateAuthority; const showCertificate = proxyAvailable || certificate.state !== "missing"; const canChangeTrust = certificate.canManageTrust @@ -513,43 +522,43 @@ function CaptureSidebar({ workspace, active, canControl, proxyAvailable, mode, m && (certificate.trust === "trusted" || (certificate.state === "ready" && certificate.trust === "not_trusted")); return ( -