@@ -7,8 +7,13 @@ use std::sync::{Arc, Mutex};
77use serde:: Serialize ;
88use tauri:: { AppHandle , Emitter , Manager } ;
99
10+ mod backend;
1011mod browser;
1112mod capture;
13+ mod claude_history;
14+ mod shell_env;
15+
16+ use backend:: BackendKind ;
1217
1318/// One `jucode serve` child process backing a single GUI session.
1419struct Session {
@@ -30,62 +35,13 @@ struct EventPayload {
3035}
3136
3237/// Resolves the `jucode` binary: `JUCODE_BIN` override, then the system-installed
33- /// CLI on PATH, then a sibling `JuCode-CLI` checkout / in-tree build (dev
34- /// convenience). The desktop app no longer bundles the engine — it drives
35- /// whatever `jucode` the user has installed.
38+ /// CLI on PATH, then well-known install dirs, then a sibling `JuCode-CLI`
39+ /// checkout / in-tree build (dev convenience). The desktop app no longer
40+ /// bundles the engine — it drives whatever `jucode` the user has installed.
41+ /// (Resolution now lives in `backend::resolve_backend_bin`, shared with the
42+ /// codex / claude backends.)
3643fn resolve_bin ( ) -> PathBuf {
37- // On Windows the engine binary carries an .exe suffix.
38- let exe = if cfg ! ( windows) { "jucode.exe" } else { "jucode" } ;
39- if let Ok ( path) = std:: env:: var ( "JUCODE_BIN" ) {
40- return PathBuf :: from ( path) ;
41- }
42- // System install (PATH). A packaged app inherits a minimal PATH (launchd on
43- // macOS, the desktop session elsewhere), so also probe the usual install
44- // locations directly.
45- if let Some ( found) = which ( "jucode" ) {
46- return found;
47- }
48- // HOME on unix; USERPROFILE on Windows.
49- let home = std:: env:: var_os ( "HOME" )
50- . or_else ( || std:: env:: var_os ( "USERPROFILE" ) )
51- . map ( PathBuf :: from)
52- . unwrap_or_default ( ) ;
53- let mut well_known: Vec < PathBuf > = Vec :: new ( ) ;
54- if cfg ! ( windows) {
55- // Per-user installer dir and the npm global prefix.
56- if let Some ( la) = std:: env:: var_os ( "LOCALAPPDATA" ) {
57- well_known. push ( PathBuf :: from ( la) . join ( "Programs" ) . join ( "jucode" ) . join ( exe) ) ;
58- }
59- if let Some ( ad) = std:: env:: var_os ( "APPDATA" ) {
60- well_known. push ( PathBuf :: from ( ad) . join ( "npm" ) . join ( exe) ) ;
61- }
62- well_known. push ( home. join ( ".cargo" ) . join ( "bin" ) . join ( exe) ) ;
63- } else {
64- well_known. push ( PathBuf :: from ( "/opt/homebrew/bin/jucode" ) ) ; // macOS (arm64 Homebrew)
65- well_known. push ( PathBuf :: from ( "/usr/local/bin/jucode" ) ) ; // macOS (intel) / Linux
66- well_known. push ( home. join ( ".cargo/bin/jucode" ) ) ;
67- well_known. push ( home. join ( ".local/bin/jucode" ) ) ; // Linux per-user installs
68- }
69- for candidate in well_known {
70- if candidate. is_file ( ) {
71- return candidate;
72- }
73- }
74- // Dev fallback: the freshly-built engine from the sibling checkout.
75- let manifest = PathBuf :: from ( env ! ( "CARGO_MANIFEST_DIR" ) ) ; // <repo>/src-tauri
76- let candidates = [
77- format ! ( "../../JuCode-CLI/target/debug/{exe}" ) ,
78- format ! ( "../../JuCode-CLI/target/release/{exe}" ) ,
79- format ! ( "../../target/debug/{exe}" ) ,
80- format ! ( "../../target/release/{exe}" ) ,
81- ] ;
82- for rel in candidates {
83- let candidate = manifest. join ( rel) ;
84- if candidate. exists ( ) {
85- return candidate;
86- }
87- }
88- PathBuf :: from ( "jucode" )
44+ backend:: resolve_backend_bin ( BackendKind :: Jucode , None )
8945}
9046
9147/// Working directory the agent operates in. `JUCODE_CWD` override, else the
@@ -140,27 +96,55 @@ fn confine_to_root(path: &Path, root: Option<&Path>) -> Result<PathBuf, String>
14096
14197/// Spawns a new engine process for `session`. The frontend generates the id and
14298/// registers its event listener before calling this, so no startup event is lost.
99+ ///
100+ /// `backend` selects which agent CLI backs the session (default `"jucode"`,
101+ /// which keeps the historical behavior exactly); `backend_opts` is validated
102+ /// against that backend's fixed option allowlist (see `backend::validate_opts`)
103+ /// — the frontend can never pass raw argv.
143104#[ tauri:: command]
144105fn create_session (
145106 session : String ,
146107 cwd : Option < String > ,
108+ backend : Option < String > ,
109+ backend_opts : Option < serde_json:: Value > ,
147110 app : AppHandle ,
148111 engines : tauri:: State < Engines > ,
149112) -> Result < ( ) , String > {
113+ let kind = BackendKind :: parse ( backend. as_deref ( ) . unwrap_or ( "jucode" ) ) ?;
114+ let opts = backend:: validate_opts ( kind, backend_opts. as_ref ( ) ) ?;
115+ let bin = backend:: resolve_backend_bin ( kind, opts. bin_override . as_deref ( ) ) ;
116+ let args = backend:: build_args ( kind, & opts) ;
150117 let dir = cwd
151118 . map ( PathBuf :: from)
152119 . filter ( |p| p. is_dir ( ) )
153120 . unwrap_or_else ( resolve_cwd) ;
154- let mut child = Command :: new ( resolve_bin ( ) )
155- . arg ( "serve" )
156- // Lets the engine enable desktop-only tools (e.g. browser_open).
157- . env ( "JUCODE_DESKTOP" , "1" )
121+ let mut cmd = Command :: new ( bin) ;
122+ cmd. args ( & args)
158123 . current_dir ( dir)
159124 . stdin ( Stdio :: piped ( ) )
160- . stdout ( Stdio :: piped ( ) )
161- . stderr ( Stdio :: inherit ( ) )
162- . spawn ( )
163- . map_err ( |error| format ! ( "failed to start jucode serve: {error}" ) ) ?;
125+ . stdout ( Stdio :: piped ( ) ) ;
126+ if kind == BackendKind :: Jucode {
127+ // jucode's stderr stays inherited (visible in the app's own stderr),
128+ // exactly as before multi-backend support.
129+ cmd. stderr ( Stdio :: inherit ( ) ) ;
130+ } else {
131+ // codex / claude diagnostics matter to their adapters — pipe stderr and
132+ // forward each line to the webview as a distinct `{__stderr: …}` payload.
133+ cmd. stderr ( Stdio :: piped ( ) ) ;
134+ }
135+ // 终端等价环境:快照可用则从零重建子进程环境(见 shell_env.rs),
136+ // JUCODE_DESKTOP 让引擎启用桌面专属工具(如 browser_open),协议关键、
137+ // 最后断言不可被用户自定义覆盖。
138+ let explicit: & [ ( & str , & str ) ] = if kind == BackendKind :: Jucode {
139+ & [ ( "JUCODE_DESKTOP" , "1" ) ]
140+ } else {
141+ & [ ]
142+ } ;
143+ shell_env:: apply_to_command ( & mut cmd, opts. use_shell_env , explicit, & opts. env ) ;
144+ let mut child = cmd. spawn ( ) . map_err ( |error| match kind {
145+ BackendKind :: Jucode => format ! ( "failed to start jucode serve: {error}" ) ,
146+ _ => format ! ( "failed to start {} backend: {error}" , kind. bin_name( ) ) ,
147+ } ) ?;
164148
165149 let stdout = child
166150 . stdout
@@ -171,6 +155,32 @@ fn create_session(
171155 . take ( )
172156 . ok_or_else ( || "failed to capture child stdin" . to_string ( ) ) ?;
173157
158+ // Piped stderr (codex / claude): forward lines as {"__stderr": "<line>"}
159+ // agent-event payloads so adapters can surface diagnostics.
160+ if let Some ( stderr) = child. stderr . take ( ) {
161+ let id = session. clone ( ) ;
162+ let handle = app. clone ( ) ;
163+ std:: thread:: spawn ( move || {
164+ let reader = BufReader :: new ( stderr) ;
165+ for line in reader. lines ( ) {
166+ match line {
167+ Ok ( line) if !line. trim ( ) . is_empty ( ) => {
168+ let data = serde_json:: json!( { "__stderr" : line } ) . to_string ( ) ;
169+ let _ = handle. emit (
170+ "agent-event" ,
171+ EventPayload {
172+ session : id. clone ( ) ,
173+ data,
174+ } ,
175+ ) ;
176+ }
177+ Ok ( _) => { }
178+ Err ( _) => break ,
179+ }
180+ }
181+ } ) ;
182+ }
183+
174184 let id = session. clone ( ) ;
175185 let handle = app. clone ( ) ;
176186 std:: thread:: spawn ( move || {
@@ -207,20 +217,15 @@ fn create_session(
207217 Ok ( ( ) )
208218}
209219
210- #[ tauri:: command]
211- fn send_op (
212- session : String ,
213- op : serde_json:: Value ,
214- engines : tauri:: State < Engines > ,
215- ) -> Result < ( ) , String > {
220+ /// Writes one raw line (a single protocol frame) to a session child's stdin.
221+ fn write_line ( engines : & Engines , session : & str , line : & str ) -> Result < ( ) , String > {
216222 let target = engines
217223 . sessions
218224 . lock ( )
219225 . map_err ( |e| format ! ( "lock poisoned: {e}" ) ) ?
220- . get ( & session)
226+ . get ( session)
221227 . cloned ( )
222228 . ok_or_else ( || format ! ( "unknown session: {session}" ) ) ?;
223- let line = serde_json:: to_string ( & op) . map_err ( |error| error. to_string ( ) ) ?;
224229 let mut stdin = target. stdin . lock ( ) . map_err ( |error| error. to_string ( ) ) ?;
225230 stdin
226231 . write_all ( line. as_bytes ( ) )
@@ -229,6 +234,76 @@ fn send_op(
229234 . map_err ( |error| error. to_string ( ) )
230235}
231236
237+ #[ tauri:: command]
238+ fn send_op (
239+ session : String ,
240+ op : serde_json:: Value ,
241+ engines : tauri:: State < Engines > ,
242+ ) -> Result < ( ) , String > {
243+ let line = serde_json:: to_string ( & op) . map_err ( |error| error. to_string ( ) ) ?;
244+ write_line ( & engines, & session, & line)
245+ }
246+
247+ /// Raw stdin write for non-jucode backends: the frontend adapter composes its
248+ /// own protocol frame (JSON-RPC for codex, stream-json for claude) and sends it
249+ /// as one line. Embedded newlines are rejected — one call, one frame.
250+ #[ tauri:: command]
251+ fn send_line (
252+ session : String ,
253+ line : String ,
254+ engines : tauri:: State < Engines > ,
255+ ) -> Result < ( ) , String > {
256+ if line. contains ( '\n' ) || line. contains ( '\r' ) {
257+ return Err ( "line must be a single frame (no embedded newlines)" . to_string ( ) ) ;
258+ }
259+ write_line ( & engines, & session, & line)
260+ }
261+
262+ /// Availability report for one backend binary (settings / new-session UI).
263+ #[ derive( Serialize ) ]
264+ struct BackendStatus {
265+ found : bool ,
266+ path : Option < String > ,
267+ version : Option < String > ,
268+ }
269+
270+ /// Probes a backend binary: resolves it (honoring `bin_override`) and runs
271+ /// `<bin> --version` with a short timeout. `found` reflects the binary's
272+ /// presence; `version` is best-effort.
273+ #[ tauri:: command( async ) ]
274+ fn check_backend ( backend : String , bin_override : Option < String > ) -> Result < BackendStatus , String > {
275+ let kind = BackendKind :: parse ( & backend) ?;
276+ let bin = backend:: resolve_backend_bin ( kind, bin_override. as_deref ( ) ) ;
277+ // A bare name means "nothing found, hope PATH has it at spawn time" —
278+ // resolve it through PATH for the report (None when truly absent).
279+ let path = if bin. components ( ) . count ( ) == 1 {
280+ which ( & bin. to_string_lossy ( ) )
281+ } else if bin. is_file ( ) {
282+ Some ( bin)
283+ } else {
284+ None
285+ } ;
286+ let Some ( path) = path else {
287+ return Ok ( BackendStatus {
288+ found : false ,
289+ path : None ,
290+ version : None ,
291+ } ) ;
292+ } ;
293+ let mut cmd = Command :: new ( & path) ;
294+ cmd. arg ( "--version" ) ;
295+ let version = run_with_timeout ( cmd, std:: time:: Duration :: from_secs ( 15 ) )
296+ . ok ( )
297+ . filter ( |out| out. status . success ( ) )
298+ . map ( |out| String :: from_utf8_lossy ( & out. stdout ) . trim ( ) . to_string ( ) )
299+ . filter ( |v| !v. is_empty ( ) ) ;
300+ Ok ( BackendStatus {
301+ found : true ,
302+ path : Some ( path. display ( ) . to_string ( ) ) ,
303+ version,
304+ } )
305+ }
306+
232307fn jucode_dir ( ) -> PathBuf {
233308 let home = std:: env:: var_os ( "USERPROFILE" ) . or_else ( || std:: env:: var_os ( "HOME" ) ) ;
234309 PathBuf :: from ( home. unwrap_or_default ( ) ) . join ( ".jucode" )
@@ -609,8 +684,18 @@ fn project_root() -> String {
609684}
610685
611686/// Scans PATH for an executable named `cmd`, returning its full path.
687+ /// 终端环境快照可用时优先用快照 PATH(GUI 进程的 PATH 往往缺用户目录),
688+ /// 再回退进程自身 PATH。
612689pub ( crate ) fn which ( cmd : & str ) -> Option < PathBuf > {
613- let path = std:: env:: var_os ( "PATH" ) ?;
690+ if let Some ( snap) = shell_env:: snapshot_path ( ) {
691+ if let Some ( found) = which_in ( cmd, std:: ffi:: OsString :: from ( snap) ) {
692+ return Some ( found) ;
693+ }
694+ }
695+ which_in ( cmd, std:: env:: var_os ( "PATH" ) ?)
696+ }
697+
698+ fn which_in ( cmd : & str , path : std:: ffi:: OsString ) -> Option < PathBuf > {
614699 for dir in std:: env:: split_paths ( & path) {
615700 let candidate = dir. join ( cmd) ;
616701 if candidate. is_file ( ) {
@@ -1291,10 +1376,8 @@ fn validate_git_args(args: &[String]) -> Result<(), String> {
12911376 }
12921377 }
12931378 // remote 只用于列出(remote -v),不放行 add/set-url 等子操作。
1294- "remote" => {
1295- if !positionals. is_empty ( ) {
1296- return Err ( "git remote only supports listing (-v)" . to_string ( ) ) ;
1297- }
1379+ "remote" if !positionals. is_empty ( ) => {
1380+ return Err ( "git remote only supports listing (-v)" . to_string ( ) ) ;
12981381 }
12991382 _ => { }
13001383 }
@@ -1532,6 +1615,9 @@ fn git(args: Vec<String>, cwd: Option<String>) -> Result<String, String> {
15321615 . first ( )
15331616 . is_some_and ( |s| GIT_REMOTE_SUBCOMMANDS . contains ( & s. as_str ( ) ) ) ;
15341617 let mut cmd = Command :: new ( "git" ) ;
1618+ // 远程操作需要终端环境(SSH agent、凭据助手的 PATH 等)——合并快照但
1619+ // 不清空,协议性变量随后显式覆盖。
1620+ shell_env:: merge_into ( & mut cmd) ;
15351621 cmd. args ( & args)
15361622 . current_dir ( dir)
15371623 // 永不弹终端凭据提示:缺凭据直接失败,stderr 会带回前端展示。
@@ -1648,6 +1734,8 @@ fn gh(args: Vec<String>, cwd: Option<String>) -> Result<String, String> {
16481734 validate_gh_args ( & args) ?;
16491735 let dir = cwd. map ( PathBuf :: from) . unwrap_or_else ( resolve_cwd) ;
16501736 let mut cmd = Command :: new ( resolve_gh ( ) ) ;
1737+ // gh 的登录态/配置常依赖终端环境(GH_CONFIG_DIR、代理等)。
1738+ shell_env:: merge_into ( & mut cmd) ;
16511739 cmd. args ( & args)
16521740 . current_dir ( dir)
16531741 // 全程非交互:未登录 / 缺配置时立即报错返回,绝不挂起等输入。
@@ -1916,6 +2004,8 @@ pub fn run() {
19162004 . plugin ( tauri_plugin_notification:: init ( ) )
19172005 . plugin ( tauri_plugin_window_state:: Builder :: default ( ) . build ( ) )
19182006 . setup ( |app| {
2007+ // 异步捕获登录 shell 环境快照(不阻塞启动;见 shell_env.rs)。
2008+ shell_env:: init_async ( ) ;
19192009 #[ cfg( desktop) ]
19202010 {
19212011 use tauri_plugin_deep_link:: DeepLinkExt ;
@@ -1946,6 +2036,10 @@ pub fn run() {
19462036 . invoke_handler ( tauri:: generate_handler![
19472037 create_session,
19482038 send_op,
2039+ send_line,
2040+ check_backend,
2041+ shell_env:: shell_env_status,
2042+ shell_env:: refresh_shell_env,
19492043 close_session,
19502044 read_config,
19512045 write_config,
@@ -1972,6 +2066,8 @@ pub fn run() {
19722066 git,
19732067 gh,
19742068 worktree_base,
2069+ claude_history:: claude_sessions,
2070+ claude_history:: claude_session_transcript,
19752071 pty_open,
19762072 pty_write,
19772073 pty_resize,
0 commit comments