diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000..45a2adc --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,20 @@ +# First-party plugins + +JuCode Desktop uses a small built-in plugin registry. It is not a third-party JavaScript runtime: plugins are TypeScript modules shipped with the app, and native capabilities can be exposed through narrowly scoped Tauri commands. + +Each manifest in `src/lib/plugins/registry.ts` declares: + +- `id` and human-readable `name` +- the plugin-owned `commands` +- an optional required `bin` +- whether the plugin is enabled by default + +Users can enable or disable plugins in **Settings → Extensions → Plugins**. The choice is stored locally. Disabled plugins do not initialize or show their GitPanel controls. + +## GitHub Pull Requests + +`src/lib/plugins/github-pr.ts` owns GitHub CLI detection, authentication checks, PR lookup, and PR creation. Its optional binary is `gh`. + +The native bridge is isolated in `src-tauri/src/plugins/github_pr.rs`. It runs without prompts and allows only the arguments required by the plugin (`--version`, `auth status`, `pr view`, and `pr create`). It does not provide arbitrary shell or GitHub CLI execution. + +To add another first-party plugin, add its manifest and module to the registry, keep its UI behind the enabled setting, and add a focused native allowlist only when the feature needs OS-level access. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bcf9a24..a788648 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,6 +12,7 @@ mod browser; mod capture; mod claude_history; mod installer; +mod plugins; mod shell_env; use backend::BackendKind; @@ -620,55 +621,285 @@ fn fetch_deepseek_balance() -> Result { .map_err(|e| e.to_string()) } -/// 语音转写:调用小米 MiMo ASR(OpenAI 兼容 chat/completions 端点,模型 -/// mimo-v2.5-asr),API key 存于 auth.json 的 providers.mimo。音频为 base64 -/// 编码的 WAV/MP3(编码后 ≤10MB),返回转写文本。 +#[derive(Clone, Copy, Debug, PartialEq)] +enum AsrProtocol { + Mimo, + OpenAiWhisper, + Deepgram, +} + +#[derive(Clone, Copy, Debug)] +struct AsrProvider { + id: &'static str, + name: &'static str, + base_url: &'static str, + model: &'static str, + auth_key: &'static str, + protocol: AsrProtocol, +} + +const ASR_PROVIDERS: &[AsrProvider] = &[ + AsrProvider { + id: "mimo", + name: "Xiaomi MiMo", + base_url: "https://api.xiaomimimo.com/v1", + model: "mimo-v2.5-asr", + auth_key: "mimo", + protocol: AsrProtocol::Mimo, + }, + AsrProvider { + id: "openai", + name: "OpenAI-compatible Whisper", + base_url: "https://api.openai.com/v1", + model: "whisper-1", + auth_key: "asr-openai", + protocol: AsrProtocol::OpenAiWhisper, + }, + AsrProvider { + id: "groq", + name: "Groq Whisper", + base_url: "https://api.groq.com/openai/v1", + model: "whisper-large-v3-turbo", + auth_key: "asr-groq", + protocol: AsrProtocol::OpenAiWhisper, + }, + AsrProvider { + id: "deepgram", + name: "Deepgram", + base_url: "https://api.deepgram.com/v1", + model: "nova-3", + auth_key: "asr-deepgram", + protocol: AsrProtocol::Deepgram, + }, +]; + +#[derive(Debug)] +struct AsrConfig { + provider: &'static AsrProvider, + base_url: String, + model: String, +} + +#[derive(Debug)] +struct AsrHttpRequest { + url: String, + headers: Vec<(&'static str, String)>, + body: Vec, +} + +fn asr_provider(id: &str) -> Option<&'static AsrProvider> { + ASR_PROVIDERS.iter().find(|provider| provider.id == id) +} + +fn resolve_asr_config(config: &serde_json::Value) -> Result { + let raw = config.get("asr").and_then(|value| value.as_object()); + let id = raw + .and_then(|value| value.get("provider")) + .and_then(|value| value.as_str()) + .unwrap_or("mimo"); + let provider = asr_provider(id).ok_or_else(|| format!("Unsupported ASR provider: {id}"))?; + let base_url = raw + .and_then(|value| value.get("base_url")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(provider.base_url) + .trim_end_matches('/') + .to_string(); + if !(base_url.starts_with("https://") || base_url.starts_with("http://")) { + return Err("ASR base URL must start with http:// or https://".to_string()); + } + let model = raw + .and_then(|value| value.get("model")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(provider.model) + .to_string(); + Ok(AsrConfig { provider, base_url, model }) +} + +fn append_multipart_field(body: &mut Vec, boundary: &str, name: &str, value: &str) { + body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); + body.extend_from_slice( + format!("Content-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n").as_bytes(), + ); +} + +fn query_component(value: &str) -> String { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + let mut out = String::new(); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { + out.push(byte as char); + } else { + out.push('%'); + out.push(HEX[(byte >> 4) as usize] as char); + out.push(HEX[(byte & 0xf) as usize] as char); + } + } + out +} + +fn build_asr_request( + config: &AsrConfig, + key: &str, + audio: &[u8], + mime: &str, + language: &str, + boundary: &str, +) -> Result { + match config.provider.protocol { + AsrProtocol::Mimo => { + let body = serde_json::to_vec(&serde_json::json!({ + "model": config.model, + "messages": [{ + "role": "user", + "content": [{ + "type": "input_audio", + "input_audio": { + "data": format!( + "data:{mime};base64,{}", + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + audio + ) + ) + } + }] + }], + "asr_options": { "language": language } + })) + .map_err(|error| error.to_string())?; + Ok(AsrHttpRequest { + url: format!("{}/chat/completions", config.base_url), + headers: vec![ + ("api-key", key.to_string()), + ("Content-Type", "application/json".to_string()), + ], + body, + }) + } + AsrProtocol::OpenAiWhisper => { + let mut body = Vec::new(); + append_multipart_field(&mut body, boundary, "model", &config.model); + if language != "auto" && !language.is_empty() { + append_multipart_field(&mut body, boundary, "language", language); + } + body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); + body.extend_from_slice( + format!( + "Content-Disposition: form-data; name=\"file\"; filename=\"recording.wav\"\r\nContent-Type: {mime}\r\n\r\n" + ) + .as_bytes(), + ); + body.extend_from_slice(audio); + body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); + Ok(AsrHttpRequest { + url: format!("{}/audio/transcriptions", config.base_url), + headers: vec![ + ("Authorization", format!("Bearer {key}")), + ("Content-Type", format!("multipart/form-data; boundary={boundary}")), + ], + body, + }) + } + AsrProtocol::Deepgram => { + let mut url = format!( + "{}/listen?model={}&smart_format=true", + config.base_url, + query_component(&config.model) + ); + if language != "auto" && !language.is_empty() { + url.push_str("&language="); + url.push_str(&query_component(language)); + } + Ok(AsrHttpRequest { + url, + headers: vec![ + ("Authorization", format!("Token {key}")), + ("Content-Type", mime.to_string()), + ], + body: audio.to_vec(), + }) + } + } +} + +fn parse_asr_response(protocol: AsrProtocol, response: &serde_json::Value) -> Option { + let text = match protocol { + AsrProtocol::Mimo => response + .get("choices") + .and_then(|value| value.get(0)) + .and_then(|value| value.get("message")) + .and_then(|value| value.get("content")), + AsrProtocol::OpenAiWhisper => response.get("text"), + AsrProtocol::Deepgram => response + .get("results") + .and_then(|value| value.get("channels")) + .and_then(|value| value.get(0)) + .and_then(|value| value.get("alternatives")) + .and_then(|value| value.get(0)) + .and_then(|value| value.get("transcript")), + }; + text.and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +/// Transcribes the composer's WAV payload using the ASR provider selected in +/// config.json. Provider API keys remain in auth.json. #[tauri::command(async)] fn transcribe_audio( audio_base64: String, mime: Option, language: Option, ) -> Result { + use base64::Engine as _; + + if audio_base64.len() > 14_000_000 { + return Err("Audio recording is larger than 10 MB".to_string()); + } + let config = resolve_asr_config(&read_json(&jucode_dir().join("config.json")))?; let key = read_json(&jucode_dir().join("auth.json")) .get("providers") - .and_then(|p| p.get("mimo")) - .and_then(|v| v.as_str()) - .map(|s| s.trim().to_string()) - .filter(|k| !k.is_empty()) - .ok_or_else(|| "未配置 MiMo API key(设置 → 账户 → 语音识别)".to_string())?; + .and_then(|providers| providers.get(config.provider.auth_key)) + .and_then(|value| value.as_str()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!( + "No API key configured for {} (Settings → Account → Speech recognition)", + config.provider.name + ))?; + let audio = base64::engine::general_purpose::STANDARD + .decode(audio_base64) + .map_err(|error| format!("Invalid base64 audio: {error}"))?; + if audio.len() > 10 * 1024 * 1024 { + return Err("Audio recording is larger than 10 MB".to_string()); + } let mime = mime.unwrap_or_else(|| "audio/wav".to_string()); let language = language.unwrap_or_else(|| "auto".to_string()); - let body = serde_json::json!({ - "model": "mimo-v2.5-asr", - "messages": [{ - "role": "user", - "content": [{ - "type": "input_audio", - "input_audio": { "data": format!("data:{mime};base64,{audio_base64}") } - }] - }], - "asr_options": { "language": language } - }); - let resp = ureq::post("https://api.xiaomimimo.com/v1/chat/completions") - .timeout(std::time::Duration::from_secs(120)) - .set("api-key", &key) - .send_json(body) + let boundary = format!("jucode-asr-{}", std::process::id()); + let request = build_asr_request(&config, &key, &audio, &mime, &language, &boundary)?; + let mut http = ureq::post(&request.url).timeout(std::time::Duration::from_secs(120)); + for (name, value) in &request.headers { + http = http.set(name, value); + } + let response = http + .send_bytes(&request.body) .map_err(|e| match e { ureq::Error::Status(code, r) => format!( - "MiMo ASR 请求失败(HTTP {code}):{}", + "{} transcription failed (HTTP {code}): {}", + config.provider.name, r.into_string().unwrap_or_default() ), other => other.to_string(), })? .into_json::() .map_err(|e| e.to_string())?; - resp.get("choices") - .and_then(|c| c.get(0)) - .and_then(|c| c.get("message")) - .and_then(|m| m.get("content")) - .and_then(|v| v.as_str()) - .map(|s| s.trim().to_string()) - .ok_or_else(|| format!("无法解析转写结果:{resp}")) + parse_asr_response(config.provider.protocol, &response) + .ok_or_else(|| format!("Could not parse transcription response: {response}")) } /// One-shot LLM text generation (no agent, no chat pollution) — used for AI @@ -2030,120 +2261,6 @@ fn git_checkpoint_restore(cwd: String, checkpoint: String) -> Result PathBuf { - if let Some(found) = which("gh") { - return found; - } - for candidate in ["/opt/homebrew/bin/gh", "/usr/local/bin/gh", "/usr/bin/gh"] { - let p = PathBuf::from(candidate); - if p.is_file() { - return p; - } - } - PathBuf::from("gh") -} - -/// gh CLI 桥的参数白名单:只放行 PR 工作流需要的四种调用 —— -/// `--version`(可用性检测)、`auth status`(登录检测)、 -/// `pr view --json …`(查询当前分支已有 PR)、`pr create`(创建 PR)。 -fn validate_gh_args(args: &[String]) -> Result<(), String> { - match args.first().map(String::as_str) { - Some("--version") if args.len() == 1 => Ok(()), - Some("auth") if args.len() == 2 && args[1] == "status" => Ok(()), - Some("pr") => validate_gh_pr_args(&args[1..]), - _ => Err(format!("gh arguments not allowed: {}", args.join(" "))), - } -} - -fn validate_gh_pr_args(rest: &[String]) -> Result<(), String> { - match rest.first().map(String::as_str) { - // gh pr view --json url,title,state,isDraft - Some("view") => { - let mut i = 1; - while i < rest.len() { - if rest[i] != "--json" { - return Err(format!("gh argument not allowed: {}", rest[i])); - } - let v = rest - .get(i + 1) - .ok_or_else(|| "--json requires a value".to_string())?; - if v.is_empty() || !v.chars().all(|c| c.is_ascii_alphanumeric() || c == ',') { - return Err(format!("gh --json fields not allowed: {v}")); - } - i += 2; - } - Ok(()) - } - // gh pr create --title … --body … [--base ] [--draft] - Some("create") => { - let mut i = 1; - let mut has_title = false; - while i < rest.len() { - match rest[i].as_str() { - "--title" | "--body" => { - // 值是自由文本(作为独立 argv 传给 gh,无 shell 解释)。 - if rest.get(i + 1).is_none() { - return Err(format!("{} requires a value", rest[i])); - } - has_title |= rest[i] == "--title"; - i += 2; - } - "--base" | "--head" => { - let v = rest - .get(i + 1) - .ok_or_else(|| format!("{} requires a value", rest[i]))?; - if !is_valid_ref_name(v) { - return Err(format!("invalid ref name: {v}")); - } - i += 2; - } - "--draft" => i += 1, - other => return Err(format!("gh argument not allowed: {other}")), - } - } - if !has_title { - return Err("gh pr create requires --title".to_string()); - } - Ok(()) - } - _ => Err(format!("gh pr subcommand not allowed: {}", rest.join(" "))), - } -} - -/// GitHub CLI bridge (separate from the git whitelist). Fully non-interactive: -/// prompts are disabled so a missing login fails fast with gh's stderr, and the -/// whole call is killed after a bounded timeout. -#[tauri::command(async)] -fn gh(args: Vec, cwd: Option) -> Result { - validate_gh_args(&args)?; - let dir = cwd.map(PathBuf::from).unwrap_or_else(resolve_cwd); - let mut cmd = Command::new(resolve_gh()); - no_window(&mut cmd); - // gh 的登录态/配置常依赖终端环境(GH_CONFIG_DIR、代理等)。 - shell_env::merge_into(&mut cmd); - cmd.args(&args) - .current_dir(dir) - // 全程非交互:未登录 / 缺配置时立即报错返回,绝不挂起等输入。 - .env("GH_PROMPT_DISABLED", "1") - .env("GH_NO_UPDATE_NOTIFIER", "1") - .env("GH_PAGER", "cat") - .env("NO_COLOR", "1") - .env("GIT_TERMINAL_PROMPT", "0"); - let output = run_with_timeout(cmd, REMOTE_OP_TIMEOUT)?; - if output.status.success() { - Ok(String::from_utf8_lossy(&output.stdout).into_owned()) - } else { - let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); - if stderr.trim().is_empty() { - Err(String::from_utf8_lossy(&output.stdout).into_owned()) - } else { - Err(stderr) - } - } -} - // --- terminal (real PTY) --- struct Pty { @@ -2468,7 +2585,7 @@ pub fn run() { check_dependencies, run_install, git, - gh, + plugins::github_pr::gh, worktree_base, claude_history::claude_sessions, claude_history::claude_session_transcript, @@ -2516,6 +2633,81 @@ mod tests { assert_eq!(read_json_strict(&p).unwrap(), serde_json::json!({})); } + #[test] + fn asr_provider_switch_uses_provider_defaults_and_overrides() { + use super::{resolve_asr_config, AsrProtocol}; + + let mimo = resolve_asr_config(&serde_json::json!({})).unwrap(); + assert_eq!(mimo.provider.id, "mimo"); + assert_eq!(mimo.provider.protocol, AsrProtocol::Mimo); + assert_eq!(mimo.model, "mimo-v2.5-asr"); + + let groq = resolve_asr_config( + &serde_json::json!({ "asr": { "provider": "groq" } }) + ).unwrap(); + assert_eq!(groq.provider.protocol, AsrProtocol::OpenAiWhisper); + assert_eq!(groq.base_url, "https://api.groq.com/openai/v1"); + assert_eq!(groq.model, "whisper-large-v3-turbo"); + assert_eq!(groq.provider.auth_key, "asr-groq"); + + let deepgram = resolve_asr_config(&serde_json::json!({ + "asr": { + "provider": "deepgram", + "base_url": "https://speech.example/v1/", + "model": "custom-model" + } + })).unwrap(); + assert_eq!(deepgram.provider.protocol, AsrProtocol::Deepgram); + assert_eq!(deepgram.base_url, "https://speech.example/v1"); + assert_eq!(deepgram.model, "custom-model"); + } + + #[test] + fn asr_request_assembly_matches_each_protocol() { + use super::{build_asr_request, resolve_asr_config}; + + let audio = b"wav bytes"; + let mimo = resolve_asr_config(&serde_json::json!({})).unwrap(); + let request = build_asr_request( + &mimo, "mimo-key", audio, "audio/wav", "auto", "boundary" + ).unwrap(); + assert_eq!(request.url, "https://api.xiaomimimo.com/v1/chat/completions"); + assert!(request.headers.contains(&("api-key", "mimo-key".to_string()))); + let json: serde_json::Value = serde_json::from_slice(&request.body).unwrap(); + assert_eq!(json["model"], "mimo-v2.5-asr"); + assert!(json["messages"][0]["content"][0]["input_audio"]["data"] + .as_str().unwrap().starts_with("data:audio/wav;base64,")); + + let openai = resolve_asr_config( + &serde_json::json!({ "asr": { "provider": "openai" } }) + ).unwrap(); + let request = build_asr_request( + &openai, "openai-key", audio, "audio/wav", "en", "boundary" + ).unwrap(); + assert_eq!(request.url, "https://api.openai.com/v1/audio/transcriptions"); + assert!(request.headers.contains( + &("Authorization", "Bearer openai-key".to_string()) + )); + let body = String::from_utf8_lossy(&request.body); + assert!(body.contains("name=\"model\"\r\n\r\nwhisper-1")); + assert!(body.contains("name=\"language\"\r\n\r\nen")); + assert!(body.contains("filename=\"recording.wav\"")); + assert!(request.body.windows(audio.len()).any(|window| window == audio)); + + let deepgram = resolve_asr_config( + &serde_json::json!({ "asr": { "provider": "deepgram" } }) + ).unwrap(); + let request = build_asr_request( + &deepgram, "dg-key", audio, "audio/wav", "zh", "boundary" + ).unwrap(); + assert_eq!( + request.url, + "https://api.deepgram.com/v1/listen?model=nova-3&smart_format=true&language=zh" + ); + assert!(request.headers.contains(&("Authorization", "Token dg-key".to_string()))); + assert_eq!(request.body, audio); + } + // On Windows, npm installs a binary as both an extensionless POSIX-shell shim // and a `.cmd`. Only the `.cmd` is launchable, so resolution must prefer it and // never return the extensionless file (which CreateProcess cannot execute). @@ -2664,7 +2856,7 @@ mod tests { // --- git bridge argument validation --- - use super::{is_valid_ref_name, is_valid_remote_name, validate_gh_args, validate_git_args}; + use super::{is_valid_ref_name, is_valid_remote_name, validate_git_args}; fn args(v: &[&str]) -> Vec { v.iter().map(|s| s.to_string()).collect() @@ -2945,23 +3137,4 @@ mod tests { assert!(validate_git_args(&args(&["worktree", "list", "--porcelain"])).is_err()); } - #[test] - fn gh_whitelist_allows_pr_workflow_only() { - assert!(validate_gh_args(&args(&["--version"])).is_ok()); - assert!(validate_gh_args(&args(&["auth", "status"])).is_ok()); - assert!(validate_gh_args(&args(&["pr", "view", "--json", "url,title,state,isDraft"])).is_ok()); - assert!(validate_gh_args(&args(&[ - "pr", "create", "--title", "feat: x", "--body", "", "--base", "main", "--draft" - ])) - .is_ok()); - - assert!(validate_gh_args(&args(&["repo", "clone", "x/y"])).is_err()); - assert!(validate_gh_args(&args(&["auth", "login"])).is_err()); - assert!(validate_gh_args(&args(&["pr", "merge"])).is_err()); - assert!(validate_gh_args(&args(&["pr", "create", "--body", "no title"])).is_err()); - assert!(validate_gh_args(&args(&["pr", "create", "--title", "t", "--base", "-evil"])).is_err()); - assert!(validate_gh_args(&args(&["pr", "create", "--title", "t", "--web"])).is_err()); - assert!(validate_gh_args(&args(&["pr", "view", "--json", "url;rm -rf"])).is_err()); - assert!(validate_gh_args(&args(&["--version", "extra"])).is_err()); - } } diff --git a/src-tauri/src/plugins/github_pr.rs b/src-tauri/src/plugins/github_pr.rs new file mode 100644 index 0000000..72c7b15 --- /dev/null +++ b/src-tauri/src/plugins/github_pr.rs @@ -0,0 +1,148 @@ +use std::path::PathBuf; +use std::process::Command; + +use super::super::{ + is_valid_ref_name, no_window, resolve_cwd, run_with_timeout, shell_env, which, + REMOTE_OP_TIMEOUT, +}; + +/// Resolves the plugin's optional `gh` binary. Packaged apps inherit a minimal +/// PATH, so common install locations are checked after the captured shell PATH. +fn resolve_gh() -> PathBuf { + if let Some(found) = which("gh") { + return found; + } + for candidate in ["/opt/homebrew/bin/gh", "/usr/local/bin/gh", "/usr/bin/gh"] { + let path = PathBuf::from(candidate); + if path.is_file() { + return path; + } + } + PathBuf::from("gh") +} + +/// Rust-side command allowlist for the first-party GitHub PR plugin. +pub(crate) fn validate_args(args: &[String]) -> Result<(), String> { + match args.first().map(String::as_str) { + Some("--version") if args.len() == 1 => Ok(()), + Some("auth") if args.len() == 2 && args[1] == "status" => Ok(()), + Some("pr") => validate_pr_args(&args[1..]), + _ => Err(format!("gh arguments not allowed: {}", args.join(" "))), + } +} + +fn validate_pr_args(rest: &[String]) -> Result<(), String> { + match rest.first().map(String::as_str) { + Some("view") => { + let mut index = 1; + while index < rest.len() { + if rest[index] != "--json" { + return Err(format!("gh argument not allowed: {}", rest[index])); + } + let value = rest + .get(index + 1) + .ok_or_else(|| "--json requires a value".to_string())?; + if value.is_empty() + || !value + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == ',') + { + return Err(format!("gh --json fields not allowed: {value}")); + } + index += 2; + } + Ok(()) + } + Some("create") => { + let mut index = 1; + let mut has_title = false; + while index < rest.len() { + match rest[index].as_str() { + "--title" | "--body" => { + if rest.get(index + 1).is_none() { + return Err(format!("{} requires a value", rest[index])); + } + has_title |= rest[index] == "--title"; + index += 2; + } + "--base" | "--head" => { + let value = rest + .get(index + 1) + .ok_or_else(|| format!("{} requires a value", rest[index]))?; + if !is_valid_ref_name(value) { + return Err(format!("invalid ref name: {value}")); + } + index += 2; + } + "--draft" => index += 1, + other => return Err(format!("gh argument not allowed: {other}")), + } + } + if !has_title { + return Err("gh pr create requires --title".to_string()); + } + Ok(()) + } + _ => Err(format!("gh pr subcommand not allowed: {}", rest.join(" "))), + } +} + +/// Non-interactive bridge used only by the GitHub PR plugin. The allowlist +/// above keeps the exposed Tauri command narrower than arbitrary `gh` access. +#[tauri::command(async)] +pub(crate) fn gh(args: Vec, cwd: Option) -> Result { + validate_args(&args)?; + let dir = cwd.map(PathBuf::from).unwrap_or_else(resolve_cwd); + let mut command = Command::new(resolve_gh()); + no_window(&mut command); + shell_env::merge_into(&mut command); + command + .args(&args) + .current_dir(dir) + .env("GH_PROMPT_DISABLED", "1") + .env("GH_NO_UPDATE_NOTIFIER", "1") + .env("GH_PAGER", "cat") + .env("NO_COLOR", "1") + .env("GIT_TERMINAL_PROMPT", "0"); + let output = run_with_timeout(command, REMOTE_OP_TIMEOUT)?; + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + if stderr.trim().is_empty() { + Err(String::from_utf8_lossy(&output.stdout).into_owned()) + } else { + Err(stderr) + } + } +} + +#[cfg(test)] +mod tests { + use super::validate_args; + + fn args(values: &[&str]) -> Vec { + values.iter().map(|value| value.to_string()).collect() + } + + #[test] + fn allowlist_matches_manifest_commands() { + assert!(validate_args(&args(&["--version"])).is_ok()); + assert!(validate_args(&args(&["auth", "status"])).is_ok()); + assert!(validate_args(&args(&["pr", "view", "--json", "url,title,state,isDraft"])).is_ok()); + assert!(validate_args(&args(&[ + "pr", "create", "--title", "feat: x", "--body", "", "--base", "main", "--draft" + ])) + .is_ok()); + assert!(validate_args(&args(&["repo", "clone", "x/y"])).is_err()); + assert!(validate_args(&args(&["auth", "login"])).is_err()); + assert!(validate_args(&args(&["pr", "merge"])).is_err()); + assert!(validate_args(&args(&["pr", "create", "--body", "no title"])).is_err()); + assert!( + validate_args(&args(&["pr", "create", "--title", "t", "--base", "-evil"])).is_err() + ); + assert!(validate_args(&args(&["pr", "create", "--title", "t", "--web"])).is_err()); + assert!(validate_args(&args(&["pr", "view", "--json", "url;rm -rf"])).is_err()); + assert!(validate_args(&args(&["--version", "extra"])).is_err()); + } +} diff --git a/src-tauri/src/plugins/mod.rs b/src-tauri/src/plugins/mod.rs new file mode 100644 index 0000000..5a96923 --- /dev/null +++ b/src-tauri/src/plugins/mod.rs @@ -0,0 +1 @@ +pub(crate) mod github_pr; diff --git a/src/lib/GitPanel.svelte b/src/lib/GitPanel.svelte index 25a54ae..60d85c2 100644 --- a/src/lib/GitPanel.svelte +++ b/src/lib/GitPanel.svelte @@ -5,21 +5,24 @@ import { openUrl } from '@tauri-apps/plugin-opener'; import IconButton from '$lib/ui/IconButton.svelte'; import Button from '$lib/ui/Button.svelte'; - import { git, gh, generateText } from '$lib/protocol'; + import { git, generateText } from '$lib/protocol'; import { isValidBranchName, parseBranches, parseSyncStatus, - parseGhVersion, - hasGitHubRemote, - parsePrView, - extractPrUrl, defaultBaseBranch, parseNumstat, type SyncInfo, - type PrInfo, type RangeFile } from '$lib/gitops'; + import { + checkGitHubPr, + createGitHubPr, + viewGitHubPr, + type GitHubPrState, + type PrInfo + } from '$lib/plugins/github-pr'; + import { isPluginEnabled, PLUGIN_SETTINGS_EVENT } from '$lib/plugins/registry'; import { t } from '$lib/i18n'; import ParallelTasks from '$lib/ParallelTasks.svelte'; import type { WorktreeMeta } from '$lib/types'; @@ -59,8 +62,8 @@ let syncBusy = $state<'' | 'pull' | 'push' | 'fetch'>(''); // GitHub PR(通过 gh CLI) - type GhState = 'checking' | 'missing' | 'unauthed' | 'noRemote' | 'ready'; - let ghState = $state('checking'); + let pluginEnabled = $state(isPluginEnabled('github-pr')); + let ghState = $state('checking'); let pr = $state(null); let prForm = $state(false); let prTitle = $state(''); @@ -107,7 +110,20 @@ } onMount(() => { refresh(); - checkGh(); + if (pluginEnabled) checkGh(); + const updatePlugin = () => { + const enabled = isPluginEnabled('github-pr'); + if (enabled === pluginEnabled) return; + pluginEnabled = enabled; + pr = null; + prForm = false; + if (enabled) { + ghState = 'checking'; + checkGh(); + } + }; + window.addEventListener(PLUGIN_SETTINGS_EVENT, updatePlugin); + return () => window.removeEventListener(PLUGIN_SETTINGS_EVENT, updatePlugin); }); async function run(args: string[]) { @@ -281,39 +297,11 @@ // --- GitHub PR --- async function checkGh() { - try { - if (!parseGhVersion(await gh(['--version'], dir()))) { - ghState = 'missing'; - return; - } - } catch { - ghState = 'missing'; - return; - } - try { - if (!hasGitHubRemote(await git(['remote', '-v'], dir()))) { - ghState = 'noRemote'; - return; - } - } catch { - ghState = 'noRemote'; - return; - } - try { - await gh(['auth', 'status'], dir()); - } catch { - ghState = 'unauthed'; - return; - } - ghState = 'ready'; - await loadPr(); + ghState = await checkGitHubPr(dir()); + if (ghState === 'ready') await loadPr(); } async function loadPr() { - try { - pr = parsePrView(await gh(['pr', 'view', '--json', 'url,title,state,isDraft'], dir())); - } catch { - pr = null; // 当前分支还没有 PR - } + pr = await viewGitHubPr(dir()); } async function openPrForm() { prError = ''; @@ -332,12 +320,10 @@ prBusy = true; prError = ''; try { - const args = ['pr', 'create', '--title', prTitle.trim(), '--body', prBody]; - if (prBase) args.push('--base', prBase); - if (prDraft) args.push('--draft'); - const out = await gh(args, dir()); - const url = extractPrUrl(out); - const created: PrInfo | null = url ? { url, title: prTitle.trim(), state: 'OPEN', isDraft: prDraft } : null; + const created = await createGitHubPr( + { title: prTitle, body: prBody, base: prBase || undefined, draft: prDraft }, + dir() + ); prForm = false; await loadPr(); if (!pr && created) pr = created; @@ -468,37 +454,39 @@ {#if compareLoaded && compareFiles.length === 0 && !compareBusy}
{t('dock.git.reviewEmpty')}
{/if} {/if} -
{t('dock.git.pr')}
- {#if ghState === 'checking'} -
{t('dock.git.ghChecking')}
- {:else if ghState === 'missing'} -
- {t('dock.git.ghMissing')} - -
- {:else if ghState === 'unauthed'} -
- {t('dock.git.ghUnauthed')} - -
- {:else if ghState === 'noRemote'} -
{t('dock.git.noGithubRemote')}
- {:else if pr} -
- {pr.isDraft ? 'DRAFT' : pr.state} - -
- {:else} -
- -
+ {#if pluginEnabled} +
{t('dock.git.pr')}
+ {#if ghState === 'checking'} +
{t('dock.git.ghChecking')}
+ {:else if ghState === 'missing'} +
+ {t('dock.git.ghMissing')} + +
+ {:else if ghState === 'unauthed'} +
+ {t('dock.git.ghUnauthed')} + +
+ {:else if ghState === 'noRemote'} +
{t('dock.git.noGithubRemote')}
+ {:else if pr} +
+ {pr.isDraft ? 'DRAFT' : pr.state} + +
+ {:else} +
+ +
+ {/if} {/if} {#if !worktree} diff --git a/src/lib/Settings.svelte b/src/lib/Settings.svelte index d35cb51..c8cf06a 100644 --- a/src/lib/Settings.svelte +++ b/src/lib/Settings.svelte @@ -22,6 +22,8 @@ import Segmented from '$lib/ui/Segmented.svelte'; import { focusTrap } from '$lib/focusTrap'; import { t, setLocale, getLocale, LOCALES, LOCALE_LABELS } from '$lib/i18n'; + import { ASR_PROVIDERS, asrProvider, resolveAsrSettings, type AsrSettings } from '$lib/audio'; + import { PLUGINS, loadPluginSettings, setPluginEnabled } from '$lib/plugins/registry'; let { sessionId, @@ -66,6 +68,9 @@ let builtin = $state<{ id: string; base_url: string; protocol: string; models: ModelCfg[] }[]>([]); let custom = $state([]); let saved = $state(false); + let asr = $state(resolveAsrSettings(null)); + let asrKey = $state(''); + let pluginSettings = $state(loadPluginSettings()); // Capture the opening section once; the prop doesn't change during the modal's life. let section = $state<'overview' | 'account' | 'behavior' | 'extensions'>(untrack(() => initialSection)); @@ -124,6 +129,7 @@ onMount(async () => { cfg = await readConfig(); + asr = resolveAsrSettings(cfg.asr); if (cfg.compaction_threshold_percent == null) cfg.compaction_threshold_percent = 75; keyed = await readAuthProviders(); loadBalances(); @@ -216,7 +222,8 @@ retry_attempts: Number(cfg.retry_attempts) || 0, connect_timeout_seconds: Number(cfg.connect_timeout_seconds) || 0, read_timeout_seconds: Number(cfg.read_timeout_seconds) || 0, - include_project_instructions: !!cfg.include_project_instructions + include_project_instructions: !!cfg.include_project_instructions, + asr }); saved = true; setTimeout(() => (saved = false), 1500); @@ -261,15 +268,24 @@ // Card click: not-logged-in jucode kicks off OAuth directly (no expand); other // (key-based) providers expand to reveal the key input. Logged-in cards expand // to show details. - // MiMo ASR key for composer voice input (stored as providers.mimo — a plain - // keyed provider from auth.json's perspective, but not a chat provider, so - // it gets its own group instead of a provider card). - let mimoKey = $state(''); - async function saveMimoKey() { - if (!mimoKey.trim()) return; - await setAuthKey('mimo', mimoKey.trim()); + // ASR keys are separate from chat-provider keys, except the historical MiMo + // key which stays at providers.mimo for backward compatibility. + const selectedAsr = $derived(asrProvider(asr.provider)); + const asrOptions = ASR_PROVIDERS.map((provider) => ({ value: provider.id, label: provider.name })); + function selectAsr(id: string) { + const provider = asrProvider(id); + asr = { provider: provider.id, base_url: provider.baseUrl, model: provider.model }; + asrKey = ''; + } + async function saveAsrKey() { + if (!asrKey.trim()) return; + await setAuthKey(selectedAsr.authKey, asrKey.trim()); keyed = await readAuthProviders(); - mimoKey = ''; + asrKey = ''; + } + function togglePlugin(id: string, enabled: boolean) { + pluginSettings = { ...pluginSettings, [id]: enabled }; + setPluginEnabled(id, enabled); } function cardClick(p: Provider, authed: boolean) { @@ -361,16 +377,52 @@
{t('settings.voice.groupLabel')}

{t('settings.voice.hint')}

+
+