Skip to main content

codex_mobile_bridge/
manage.rs

1use std::collections::HashMap;
2use std::env;
3use std::fs;
4#[cfg(unix)]
5use std::os::unix::fs::{PermissionsExt, symlink};
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9use anyhow::{Context, Result, bail};
10use clap::{Args, Parser, Subcommand, ValueEnum};
11use serde::{Deserialize, Serialize};
12use serde_json::json;
13use sha2::{Digest, Sha256};
14
15use crate::{BRIDGE_BUILD_HASH, BRIDGE_PROTOCOL_VERSION, BRIDGE_VERSION};
16
17const CRATE_NAME: &str = env!("CARGO_PKG_NAME");
18const BINARY_NAME: &str = "codex-mobile-bridge";
19const SERVICE_NAME: &str = "codex-mobile-bridge.service";
20const DEFAULT_LISTEN_ADDR: &str = "0.0.0.0:8787";
21const DEFAULT_RUNTIME_LIMIT: usize = 4;
22
23#[derive(Debug, Parser)]
24pub struct ManageCli {
25    #[command(subcommand)]
26    command: ManageCommand,
27}
28
29#[derive(Debug, Subcommand)]
30enum ManageCommand {
31    Activate(ActivateArgs),
32    SelfUpdate(SelfUpdateArgs),
33    Rollback(RollbackArgs),
34    Repair(RepairArgs),
35    Metadata,
36}
37
38#[derive(Debug, Clone, Args, Default)]
39struct EnvOverrides {
40    #[arg(long)]
41    bridge_token: Option<String>,
42
43    #[arg(long)]
44    listen_addr: Option<String>,
45
46    #[arg(long)]
47    runtime_limit: Option<usize>,
48
49    #[arg(long)]
50    db_path: Option<PathBuf>,
51
52    #[arg(long)]
53    codex_home: Option<PathBuf>,
54
55    #[arg(long)]
56    codex_binary: Option<String>,
57
58    #[arg(long)]
59    launch_path: Option<String>,
60}
61
62#[derive(Debug, Clone, Args)]
63pub struct ActivateArgs {
64    #[command(flatten)]
65    env: EnvOverrides,
66
67    #[arg(long, value_enum, default_value_t = ActivateOperation::Install)]
68    operation: ActivateOperation,
69}
70
71#[derive(Debug, Clone, Copy, Eq, PartialEq, ValueEnum)]
72pub enum ActivateOperation {
73    Install,
74    Update,
75    Repair,
76}
77
78impl ActivateOperation {
79    fn as_str(self) -> &'static str {
80        match self {
81            Self::Install => "install",
82            Self::Update => "update",
83            Self::Repair => "repair",
84        }
85    }
86}
87
88#[derive(Debug, Clone, Args)]
89pub struct SelfUpdateArgs {
90    #[command(flatten)]
91    env: EnvOverrides,
92
93    #[arg(long)]
94    target_version: String,
95
96    #[arg(long, default_value = "cargo")]
97    cargo_binary: String,
98
99    #[arg(long, default_value = "crates-io")]
100    registry: String,
101}
102
103#[derive(Debug, Clone, Args)]
104pub struct RollbackArgs {
105    #[command(flatten)]
106    env: EnvOverrides,
107
108    #[arg(long, default_value = "cargo")]
109    cargo_binary: String,
110
111    #[arg(long, default_value = "crates-io")]
112    registry: String,
113}
114
115#[derive(Debug, Clone, Args)]
116pub struct RepairArgs {
117    #[command(flatten)]
118    env: EnvOverrides,
119
120    #[arg(long, default_value = "cargo")]
121    cargo_binary: String,
122
123    #[arg(long, default_value = "crates-io")]
124    registry: String,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
128struct BridgeEnvValues {
129    bridge_token: String,
130    listen_addr: String,
131    runtime_limit: usize,
132    db_path: PathBuf,
133    codex_home: Option<PathBuf>,
134    codex_binary: String,
135    launch_path: String,
136}
137
138#[derive(Debug, Clone)]
139struct ManagedPaths {
140    share_dir: PathBuf,
141    releases_dir: PathBuf,
142    current_link: PathBuf,
143    config_dir: PathBuf,
144    systemd_user_dir: PathBuf,
145    state_dir: PathBuf,
146    env_file: PathBuf,
147    unit_file: PathBuf,
148    install_record_file: PathBuf,
149    bridge_db_path: PathBuf,
150}
151
152impl ManagedPaths {
153    fn new(home_dir: PathBuf) -> Self {
154        let share_dir = home_dir.join(".local/share/codex-mobile");
155        let config_dir = home_dir.join(".config/codex-mobile");
156        let systemd_user_dir = home_dir.join(".config/systemd/user");
157        let state_dir = home_dir.join(".local/state/codex-mobile");
158        Self {
159            current_link: share_dir.join("current"),
160            releases_dir: share_dir.join("releases"),
161            env_file: config_dir.join("bridge.env"),
162            unit_file: systemd_user_dir.join(SERVICE_NAME),
163            install_record_file: state_dir.join("install.json"),
164            bridge_db_path: state_dir.join("bridge.db"),
165            share_dir,
166            config_dir,
167            systemd_user_dir,
168            state_dir,
169        }
170    }
171
172    fn release_root_for_version(&self, version: &str) -> PathBuf {
173        self.releases_dir.join(format!("{CRATE_NAME}-{version}"))
174    }
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize, Default)]
178#[serde(rename_all = "camelCase")]
179#[serde(default)]
180struct InstallRecord {
181    install_state: String,
182    current_artifact_id: Option<String>,
183    current_version: Option<String>,
184    current_build_hash: Option<String>,
185    current_sha256: Option<String>,
186    current_protocol_version: Option<u32>,
187    current_release_path: Option<String>,
188    previous_artifact_id: Option<String>,
189    previous_version: Option<String>,
190    previous_build_hash: Option<String>,
191    previous_sha256: Option<String>,
192    previous_protocol_version: Option<u32>,
193    previous_release_path: Option<String>,
194    last_operation: Option<String>,
195    last_operation_status: Option<String>,
196    last_operation_at_ms: i64,
197    installed_at_ms: i64,
198    updated_at_ms: i64,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
202#[serde(rename_all = "camelCase")]
203struct ReleaseMetadata {
204    artifact_id: String,
205    version: String,
206    build_hash: String,
207    sha256: String,
208    protocol_version: u32,
209    release_root: String,
210    executable_path: String,
211}
212
213pub fn run(cli: ManageCli) -> Result<()> {
214    match cli.command {
215        ManageCommand::Activate(args) => run_activate(args),
216        ManageCommand::SelfUpdate(args) => run_self_update(args),
217        ManageCommand::Rollback(args) => run_rollback(args),
218        ManageCommand::Repair(args) => run_repair(args),
219        ManageCommand::Metadata => print_metadata(),
220    }
221}
222
223fn run_activate(args: ActivateArgs) -> Result<()> {
224    let paths = managed_paths()?;
225    let release_root = resolve_current_release_root()?;
226    let current_metadata = current_release_metadata(&release_root)?;
227    let existing_env = read_env_file(&paths)?;
228    let env_values = merge_env_values(&paths, &existing_env, &args.env)?;
229    let existing_record = read_install_record(&paths)?;
230    activate_release(
231        &paths,
232        &current_metadata,
233        &env_values,
234        existing_record.as_ref(),
235        args.operation.as_str(),
236    )?;
237    println!(
238        "{}",
239        json!({
240            "operation": args.operation.as_str(),
241            "version": current_metadata.version,
242            "releasePath": current_metadata.release_root,
243        })
244    );
245    Ok(())
246}
247
248fn run_self_update(args: SelfUpdateArgs) -> Result<()> {
249    let paths = managed_paths()?;
250    let existing_record =
251        read_install_record(&paths)?.context("缺少 install record,无法执行自更新")?;
252    let existing_env = read_env_file(&paths)?;
253    let env_values = merge_env_values(&paths, &existing_env, &args.env)?;
254    let release_root = install_release(
255        &paths,
256        &args.target_version,
257        &args.cargo_binary,
258        &args.registry,
259    )?;
260    let metadata = load_release_metadata(&release_root)?;
261    activate_release(
262        &paths,
263        &metadata,
264        &env_values,
265        Some(&existing_record),
266        "update",
267    )?;
268    println!(
269        "{}",
270        json!({
271            "operation": "self-update",
272            "version": metadata.version,
273            "releasePath": metadata.release_root,
274        })
275    );
276    Ok(())
277}
278
279fn run_rollback(args: RollbackArgs) -> Result<()> {
280    let paths = managed_paths()?;
281    let existing_record = read_install_record(&paths)?.context("缺少 install record,无法回滚")?;
282    let previous_version = existing_record
283        .previous_version
284        .as_deref()
285        .filter(|value| !value.trim().is_empty())
286        .context("当前没有可回滚的上一版")?;
287    let existing_env = read_env_file(&paths)?;
288    let env_values = merge_env_values(&paths, &existing_env, &args.env)?;
289
290    let target_release_root = existing_record
291        .previous_release_path
292        .as_deref()
293        .map(PathBuf::from)
294        .filter(|path| release_binary_path(path).is_some())
295        .unwrap_or_else(|| paths.release_root_for_version(previous_version));
296    let target_release_root = if release_binary_path(&target_release_root).is_some() {
297        target_release_root
298    } else {
299        install_release(&paths, previous_version, &args.cargo_binary, &args.registry)?
300    };
301    let metadata = release_metadata_from_previous_record(&existing_record, &target_release_root)
302        .unwrap_or(load_release_metadata(&target_release_root)?);
303
304    rollback_release(&paths, &metadata, &env_values, &existing_record)?;
305    println!(
306        "{}",
307        json!({
308            "operation": "rollback",
309            "version": metadata.version,
310            "releasePath": metadata.release_root,
311        })
312    );
313    Ok(())
314}
315
316fn run_repair(args: RepairArgs) -> Result<()> {
317    let paths = managed_paths()?;
318    let existing_record = read_install_record(&paths)?;
319    let existing_env = read_env_file(&paths)?;
320    let env_values = merge_env_values(&paths, &existing_env, &args.env)?;
321
322    let metadata = resolve_repair_metadata(&paths, existing_record.as_ref(), &args)?;
323    activate_release(
324        &paths,
325        &metadata,
326        &env_values,
327        existing_record.as_ref(),
328        "repair",
329    )?;
330    println!(
331        "{}",
332        json!({
333            "operation": "repair",
334            "version": metadata.version,
335            "releasePath": metadata.release_root,
336        })
337    );
338    Ok(())
339}
340
341fn print_metadata() -> Result<()> {
342    let release_root = resolve_current_release_root()?;
343    let metadata = current_release_metadata(&release_root)?;
344    println!("{}", serde_json::to_string(&metadata)?);
345    Ok(())
346}
347
348fn resolve_repair_metadata(
349    paths: &ManagedPaths,
350    existing_record: Option<&InstallRecord>,
351    args: &RepairArgs,
352) -> Result<ReleaseMetadata> {
353    if let Some(record) = existing_record {
354        let current_release_root = record
355            .current_release_path
356            .as_deref()
357            .map(PathBuf::from)
358            .unwrap_or_else(|| {
359                record
360                    .current_version
361                    .as_deref()
362                    .map(|value| paths.release_root_for_version(value))
363                    .unwrap_or_else(|| paths.current_link.clone())
364            });
365        if release_binary_path(&current_release_root).is_some() {
366            if let Some(metadata) =
367                release_metadata_from_current_record(record, &current_release_root)
368            {
369                return Ok(metadata);
370            }
371            return load_release_metadata(&current_release_root);
372        }
373        if let Some(current_version) = record.current_version.as_deref() {
374            let release_root =
375                install_release(paths, current_version, &args.cargo_binary, &args.registry)?;
376            return load_release_metadata(&release_root);
377        }
378    }
379
380    let release_root = resolve_current_release_root()?;
381    current_release_metadata(&release_root)
382}
383
384fn activate_release(
385    paths: &ManagedPaths,
386    metadata: &ReleaseMetadata,
387    env_values: &BridgeEnvValues,
388    existing_record: Option<&InstallRecord>,
389    operation: &str,
390) -> Result<()> {
391    ensure_managed_directories(paths)?;
392    let release_root = PathBuf::from(&metadata.release_root);
393    ensure_release_binary_link(&release_root)?;
394    write_managed_env(&paths.env_file, env_values)?;
395    write_user_service(&paths.unit_file)?;
396    point_current_release(&paths.current_link, &release_root)?;
397    daemon_reload()?;
398    ensure_service_started()?;
399    let next_record = build_activate_record(existing_record, metadata, operation);
400    write_install_record(&paths.install_record_file, &next_record)?;
401    Ok(())
402}
403
404fn rollback_release(
405    paths: &ManagedPaths,
406    metadata: &ReleaseMetadata,
407    env_values: &BridgeEnvValues,
408    existing_record: &InstallRecord,
409) -> Result<()> {
410    ensure_managed_directories(paths)?;
411    let release_root = PathBuf::from(&metadata.release_root);
412    ensure_release_binary_link(&release_root)?;
413    write_managed_env(&paths.env_file, env_values)?;
414    write_user_service(&paths.unit_file)?;
415    point_current_release(&paths.current_link, &release_root)?;
416    daemon_reload()?;
417    ensure_service_started()?;
418    let next_record = build_rollback_record(existing_record, metadata);
419    write_install_record(&paths.install_record_file, &next_record)?;
420    Ok(())
421}
422
423fn install_release(
424    paths: &ManagedPaths,
425    version: &str,
426    cargo_binary: &str,
427    registry: &str,
428) -> Result<PathBuf> {
429    let release_root = paths.release_root_for_version(version);
430    fs::create_dir_all(&release_root)
431        .with_context(|| format!("创建 release 目录失败: {}", release_root.display()))?;
432
433    let output = Command::new(cargo_binary)
434        .arg("install")
435        .arg("--locked")
436        .arg("--force")
437        .arg("--registry")
438        .arg(registry)
439        .arg("--root")
440        .arg(&release_root)
441        .arg("--version")
442        .arg(version)
443        .arg("--bin")
444        .arg(BINARY_NAME)
445        .arg(CRATE_NAME)
446        .output()
447        .with_context(|| format!("执行 cargo install 失败: {cargo_binary}"))?;
448    if !output.status.success() {
449        bail!(
450            "cargo install 失败(version={version}, registry={registry}): stdout={}; stderr={}",
451            String::from_utf8_lossy(&output.stdout).trim(),
452            String::from_utf8_lossy(&output.stderr).trim(),
453        );
454    }
455    ensure_release_binary_link(&release_root)?;
456    Ok(release_root)
457}
458
459fn managed_paths() -> Result<ManagedPaths> {
460    let home_dir = env::var_os("HOME")
461        .map(PathBuf::from)
462        .context("未找到 HOME 环境变量")?;
463    Ok(ManagedPaths::new(home_dir))
464}
465
466fn resolve_current_release_root() -> Result<PathBuf> {
467    let current_exe = env::current_exe().context("读取当前可执行文件路径失败")?;
468    let canonical = current_exe
469        .canonicalize()
470        .with_context(|| format!("解析当前可执行文件路径失败: {}", current_exe.display()))?;
471    let parent = canonical.parent().context("当前可执行文件路径缺少父目录")?;
472    if parent.file_name().and_then(|value| value.to_str()) == Some("bin") {
473        return parent
474            .parent()
475            .map(Path::to_path_buf)
476            .context("无法解析 release 根目录");
477    }
478    Ok(parent.to_path_buf())
479}
480
481fn current_release_metadata(release_root: &Path) -> Result<ReleaseMetadata> {
482    let executable_path =
483        release_binary_path(release_root).context("release 缺少 bridge 可执行文件")?;
484    Ok(ReleaseMetadata {
485        artifact_id: release_root
486            .file_name()
487            .and_then(|value| value.to_str())
488            .unwrap_or(CRATE_NAME)
489            .to_string(),
490        version: BRIDGE_VERSION.to_string(),
491        build_hash: BRIDGE_BUILD_HASH.to_string(),
492        sha256: sha256_file(&executable_path)?,
493        protocol_version: BRIDGE_PROTOCOL_VERSION,
494        release_root: release_root.to_string_lossy().to_string(),
495        executable_path: executable_path.to_string_lossy().to_string(),
496    })
497}
498
499fn load_release_metadata(release_root: &Path) -> Result<ReleaseMetadata> {
500    let current_exe = env::current_exe()
501        .context("读取当前可执行文件路径失败")?
502        .canonicalize()
503        .context("解析当前可执行文件路径失败")?;
504    let release_binary =
505        release_binary_path(release_root).context("release 缺少 bridge 可执行文件")?;
506    let canonical_release_binary = release_binary
507        .canonicalize()
508        .with_context(|| format!("解析 release 可执行文件失败: {}", release_binary.display()))?;
509    if canonical_release_binary == current_exe {
510        return current_release_metadata(release_root);
511    }
512
513    let output = Command::new(&release_binary)
514        .arg("manage")
515        .arg("metadata")
516        .output()
517        .with_context(|| format!("执行 metadata 命令失败: {}", release_binary.display()))?;
518    if !output.status.success() {
519        bail!(
520            "读取 release metadata 失败: stdout={}; stderr={}",
521            String::from_utf8_lossy(&output.stdout).trim(),
522            String::from_utf8_lossy(&output.stderr).trim(),
523        );
524    }
525    let mut metadata: ReleaseMetadata =
526        serde_json::from_slice(&output.stdout).context("解析 release metadata 失败")?;
527    metadata.release_root = release_root.to_string_lossy().to_string();
528    metadata.artifact_id = release_root
529        .file_name()
530        .and_then(|value| value.to_str())
531        .unwrap_or(CRATE_NAME)
532        .to_string();
533    metadata.executable_path = release_binary.to_string_lossy().to_string();
534    Ok(metadata)
535}
536
537fn release_metadata_from_current_record(
538    record: &InstallRecord,
539    release_root: &Path,
540) -> Option<ReleaseMetadata> {
541    metadata_from_record(
542        release_root,
543        record.current_artifact_id.clone(),
544        record.current_version.clone(),
545        record.current_build_hash.clone(),
546        record.current_sha256.clone(),
547        record.current_protocol_version,
548    )
549}
550
551fn release_metadata_from_previous_record(
552    record: &InstallRecord,
553    release_root: &Path,
554) -> Option<ReleaseMetadata> {
555    metadata_from_record(
556        release_root,
557        record.previous_artifact_id.clone(),
558        record.previous_version.clone(),
559        record.previous_build_hash.clone(),
560        record.previous_sha256.clone(),
561        record.previous_protocol_version,
562    )
563}
564
565fn metadata_from_record(
566    release_root: &Path,
567    artifact_id: Option<String>,
568    version: Option<String>,
569    build_hash: Option<String>,
570    sha256: Option<String>,
571    protocol_version: Option<u32>,
572) -> Option<ReleaseMetadata> {
573    let executable_path = release_binary_path(release_root)?;
574    Some(ReleaseMetadata {
575        artifact_id: artifact_id.unwrap_or_else(|| {
576            release_root
577                .file_name()
578                .and_then(|value| value.to_str())
579                .unwrap_or(CRATE_NAME)
580                .to_string()
581        }),
582        version: version?,
583        build_hash: build_hash?,
584        sha256: sha256?,
585        protocol_version: protocol_version?,
586        release_root: release_root.to_string_lossy().to_string(),
587        executable_path: executable_path.to_string_lossy().to_string(),
588    })
589}
590
591fn ensure_managed_directories(paths: &ManagedPaths) -> Result<()> {
592    for path in [
593        &paths.share_dir,
594        &paths.releases_dir,
595        &paths.config_dir,
596        &paths.systemd_user_dir,
597        &paths.state_dir,
598    ] {
599        fs::create_dir_all(path)
600            .with_context(|| format!("创建受管目录失败: {}", path.display()))?;
601    }
602    Ok(())
603}
604
605fn release_binary_path(release_root: &Path) -> Option<PathBuf> {
606    let bin_path = release_root.join("bin").join(BINARY_NAME);
607    if bin_path.is_file() {
608        return Some(bin_path);
609    }
610    let root_path = release_root.join(BINARY_NAME);
611    if root_path.is_file() {
612        return Some(root_path);
613    }
614    None
615}
616
617fn ensure_release_binary_link(release_root: &Path) -> Result<()> {
618    let binary_path = release_root.join("bin").join(BINARY_NAME);
619    if !binary_path.is_file() {
620        let fallback_binary = release_root.join(BINARY_NAME);
621        if fallback_binary.is_file() {
622            return Ok(());
623        }
624        bail!("release 缺少 bridge 二进制: {}", release_root.display());
625    }
626
627    let link_path = release_root.join(BINARY_NAME);
628    if link_path.exists() || fs::symlink_metadata(&link_path).is_ok() {
629        let metadata = fs::symlink_metadata(&link_path)
630            .with_context(|| format!("读取 binary link 信息失败: {}", link_path.display()))?;
631        if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() {
632            bail!("binary link 路径被目录占用: {}", link_path.display());
633        }
634        fs::remove_file(&link_path)
635            .with_context(|| format!("移除旧 binary link 失败: {}", link_path.display()))?;
636    }
637
638    #[cfg(unix)]
639    {
640        symlink(Path::new("bin").join(BINARY_NAME), &link_path)
641            .with_context(|| format!("创建 binary link 失败: {}", link_path.display()))?;
642    }
643
644    #[cfg(not(unix))]
645    {
646        bail!("当前平台不支持创建 bridge 可执行文件软链接");
647    }
648
649    Ok(())
650}
651
652fn point_current_release(current_link: &Path, release_root: &Path) -> Result<()> {
653    if let Ok(metadata) = fs::symlink_metadata(current_link) {
654        if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() {
655            bail!("current 路径被非预期目录占用: {}", current_link.display());
656        }
657        fs::remove_file(current_link)
658            .with_context(|| format!("移除 current 链接失败: {}", current_link.display()))?;
659    }
660
661    #[cfg(unix)]
662    {
663        symlink(release_root, current_link)
664            .with_context(|| format!("创建 current 链接失败: {}", current_link.display()))?;
665    }
666
667    #[cfg(not(unix))]
668    {
669        bail!("当前平台不支持创建 current 软链接");
670    }
671
672    Ok(())
673}
674
675fn write_managed_env(path: &Path, values: &BridgeEnvValues) -> Result<()> {
676    let content = build_managed_env(values);
677    write_text_file(path, &content, 0o600)
678}
679
680fn write_user_service(path: &Path) -> Result<()> {
681    write_text_file(path, &build_user_service(), 0o644)
682}
683
684fn write_text_file(path: &Path, content: &str, mode: u32) -> Result<()> {
685    if let Some(parent) = path.parent() {
686        fs::create_dir_all(parent)
687            .with_context(|| format!("创建文件父目录失败: {}", parent.display()))?;
688    }
689    fs::write(path, content).with_context(|| format!("写入文件失败: {}", path.display()))?;
690    #[cfg(unix)]
691    {
692        let permissions = fs::Permissions::from_mode(mode);
693        fs::set_permissions(path, permissions)
694            .with_context(|| format!("设置文件权限失败: {}", path.display()))?;
695    }
696    Ok(())
697}
698
699fn build_managed_env(values: &BridgeEnvValues) -> String {
700    let mut lines = vec![
701        format!(
702            "CODEX_MOBILE_TOKEN={}",
703            shell_quote_value(&values.bridge_token)
704        ),
705        format!(
706            "CODEX_MOBILE_LISTEN_ADDR={}",
707            shell_quote_value(&values.listen_addr)
708        ),
709        format!("CODEX_MOBILE_RUNTIME_LIMIT={}", values.runtime_limit),
710        format!(
711            "CODEX_MOBILE_DB_PATH={}",
712            shell_quote_value(&values.db_path.to_string_lossy())
713        ),
714        format!("CODEX_BINARY={}", shell_quote_value(&values.codex_binary)),
715        format!("PATH={}", shell_quote_value(&values.launch_path)),
716    ];
717    if let Some(codex_home) = values.codex_home.as_ref() {
718        lines.push(format!(
719            "CODEX_HOME={}",
720            shell_quote_value(&codex_home.to_string_lossy())
721        ));
722    }
723    lines.join("\n") + "\n"
724}
725
726fn build_user_service() -> String {
727    [
728        "[Unit]",
729        "Description=Codex Mobile Bridge",
730        "After=network-online.target",
731        "Wants=network-online.target",
732        "StartLimitIntervalSec=0",
733        "",
734        "[Service]",
735        "Type=simple",
736        "EnvironmentFile=%h/.config/codex-mobile/bridge.env",
737        "ExecStart=%h/.local/share/codex-mobile/current/codex-mobile-bridge",
738        "WorkingDirectory=%h",
739        "Restart=always",
740        "RestartSec=3",
741        "KillMode=mixed",
742        "TimeoutStopSec=20",
743        "NoNewPrivileges=yes",
744        "",
745        "[Install]",
746        "WantedBy=default.target",
747        "",
748    ]
749    .join("\n")
750}
751
752fn merge_env_values(
753    paths: &ManagedPaths,
754    existing_values: &HashMap<String, String>,
755    overrides: &EnvOverrides,
756) -> Result<BridgeEnvValues> {
757    let bridge_token = overrides
758        .bridge_token
759        .as_deref()
760        .map(str::trim)
761        .filter(|value| !value.is_empty())
762        .map(ToOwned::to_owned)
763        .or_else(|| non_empty_map_value(existing_values, "CODEX_MOBILE_TOKEN"))
764        .context("缺少 bridge token,请先提供 --bridge-token 或已有 bridge.env")?;
765    let listen_addr = overrides
766        .listen_addr
767        .as_deref()
768        .map(str::trim)
769        .filter(|value| !value.is_empty())
770        .map(ToOwned::to_owned)
771        .or_else(|| non_empty_map_value(existing_values, "CODEX_MOBILE_LISTEN_ADDR"))
772        .unwrap_or_else(|| DEFAULT_LISTEN_ADDR.to_string());
773    let runtime_limit = overrides
774        .runtime_limit
775        .filter(|value| *value > 0)
776        .or_else(|| {
777            existing_values
778                .get("CODEX_MOBILE_RUNTIME_LIMIT")
779                .and_then(|value| value.trim().parse::<usize>().ok())
780                .filter(|value| *value > 0)
781        })
782        .unwrap_or(DEFAULT_RUNTIME_LIMIT);
783    let db_path = overrides
784        .db_path
785        .clone()
786        .or_else(|| non_empty_map_value(existing_values, "CODEX_MOBILE_DB_PATH").map(PathBuf::from))
787        .unwrap_or_else(|| paths.bridge_db_path.clone());
788    let codex_home = overrides
789        .codex_home
790        .clone()
791        .or_else(|| non_empty_map_value(existing_values, "CODEX_HOME").map(PathBuf::from));
792    let codex_binary = overrides
793        .codex_binary
794        .as_deref()
795        .map(str::trim)
796        .filter(|value| !value.is_empty())
797        .map(ToOwned::to_owned)
798        .or_else(|| non_empty_map_value(existing_values, "CODEX_BINARY"))
799        .or_else(|| {
800            env::var("CODEX_BINARY")
801                .ok()
802                .map(|value| value.trim().to_string())
803        })
804        .filter(|value| !value.trim().is_empty())
805        .context("缺少 CODEX_BINARY,请先传入 --codex-binary")?;
806    let launch_path = overrides
807        .launch_path
808        .as_deref()
809        .map(str::trim)
810        .filter(|value| !value.is_empty())
811        .map(ToOwned::to_owned)
812        .or_else(|| non_empty_map_value(existing_values, "PATH"))
813        .or_else(|| env::var("PATH").ok().map(|value| value.trim().to_string()))
814        .filter(|value| !value.trim().is_empty())
815        .context("缺少 PATH,请先传入 --launch-path")?;
816
817    Ok(BridgeEnvValues {
818        bridge_token,
819        listen_addr,
820        runtime_limit,
821        db_path,
822        codex_home,
823        codex_binary,
824        launch_path,
825    })
826}
827
828fn non_empty_map_value(values: &HashMap<String, String>, key: &str) -> Option<String> {
829    values
830        .get(key)
831        .map(|value| value.trim())
832        .filter(|value| !value.is_empty())
833        .map(ToOwned::to_owned)
834}
835
836fn shell_quote_value(value: &str) -> String {
837    format!("'{}'", value.replace('\'', "'\"'\"'"))
838}
839
840fn read_env_file(paths: &ManagedPaths) -> Result<HashMap<String, String>> {
841    if !paths.env_file.is_file() {
842        return Ok(HashMap::new());
843    }
844    let raw = fs::read_to_string(&paths.env_file)
845        .with_context(|| format!("读取 bridge.env 失败: {}", paths.env_file.display()))?;
846    Ok(parse_env_lines(&raw))
847}
848
849fn parse_env_lines(raw: &str) -> HashMap<String, String> {
850    raw.lines()
851        .map(str::trim)
852        .filter(|line| !line.is_empty() && !line.starts_with('#') && line.contains('='))
853        .filter_map(|line| {
854            let (key, value) = line.split_once('=')?;
855            Some((key.trim().to_string(), decode_env_value(value.trim())))
856        })
857        .collect()
858}
859
860fn decode_env_value(raw: &str) -> String {
861    if raw.starts_with('\'') && raw.ends_with('\'') && raw.len() >= 2 {
862        return raw[1..raw.len() - 1].replace("'\"'\"'", "'");
863    }
864    raw.to_string()
865}
866
867fn read_install_record(path_set: &ManagedPaths) -> Result<Option<InstallRecord>> {
868    if !path_set.install_record_file.is_file() {
869        return Ok(None);
870    }
871    let raw = fs::read_to_string(&path_set.install_record_file).with_context(|| {
872        format!(
873            "读取 install record 失败: {}",
874            path_set.install_record_file.display()
875        )
876    })?;
877    let parsed = serde_json::from_str(&raw).context("解析 install record 失败")?;
878    Ok(Some(parsed))
879}
880
881fn write_install_record(path: &Path, record: &InstallRecord) -> Result<()> {
882    let content = serde_json::to_string(record).context("序列化 install record 失败")?;
883    write_text_file(path, &content, 0o600)
884}
885
886fn daemon_reload() -> Result<()> {
887    run_shell("systemctl --user daemon-reload")
888}
889
890fn ensure_service_started() -> Result<()> {
891    run_shell(&format!(
892        "systemctl --user enable {SERVICE_NAME} >/dev/null && if systemctl --user is-active --quiet {SERVICE_NAME}; then systemctl --user restart {SERVICE_NAME}; else systemctl --user start {SERVICE_NAME}; fi"
893    ))
894}
895
896fn run_shell(command: &str) -> Result<()> {
897    let output = Command::new("bash")
898        .arg("-lc")
899        .arg(format!(
900            "uid=\"$(id -u)\"; export XDG_RUNTIME_DIR=\"/run/user/$uid\"; export DBUS_SESSION_BUS_ADDRESS=\"unix:path=$XDG_RUNTIME_DIR/bus\"; {command}"
901        ))
902        .output()
903        .context("执行 shell 命令失败")?;
904    if !output.status.success() {
905        bail!(
906            "shell 命令失败: stdout={}; stderr={}",
907            String::from_utf8_lossy(&output.stdout).trim(),
908            String::from_utf8_lossy(&output.stderr).trim(),
909        );
910    }
911    Ok(())
912}
913
914fn sha256_file(path: &Path) -> Result<String> {
915    let bytes = fs::read(path).with_context(|| format!("读取文件失败: {}", path.display()))?;
916    let mut hasher = Sha256::new();
917    hasher.update(&bytes);
918    Ok(hasher
919        .finalize()
920        .iter()
921        .map(|byte| format!("{byte:02x}"))
922        .collect())
923}
924
925fn build_activate_record(
926    existing_record: Option<&InstallRecord>,
927    metadata: &ReleaseMetadata,
928    operation: &str,
929) -> InstallRecord {
930    let now = now_millis();
931    let promote_current = existing_record
932        .and_then(|record| record.current_artifact_id.as_ref())
933        .is_some_and(|artifact_id| artifact_id != &metadata.artifact_id);
934    let promoted_record = existing_record.filter(|_| promote_current);
935
936    InstallRecord {
937        install_state: "installed".to_string(),
938        current_artifact_id: Some(metadata.artifact_id.clone()),
939        current_version: Some(metadata.version.clone()),
940        current_build_hash: Some(metadata.build_hash.clone()),
941        current_sha256: Some(metadata.sha256.clone()),
942        current_protocol_version: Some(metadata.protocol_version),
943        current_release_path: Some(metadata.release_root.clone()),
944        previous_artifact_id: promoted_record
945            .and_then(|record| record.current_artifact_id.clone())
946            .or_else(|| existing_record.and_then(|record| record.previous_artifact_id.clone())),
947        previous_version: promoted_record
948            .and_then(|record| record.current_version.clone())
949            .or_else(|| existing_record.and_then(|record| record.previous_version.clone())),
950        previous_build_hash: promoted_record
951            .and_then(|record| record.current_build_hash.clone())
952            .or_else(|| existing_record.and_then(|record| record.previous_build_hash.clone())),
953        previous_sha256: promoted_record
954            .and_then(|record| record.current_sha256.clone())
955            .or_else(|| existing_record.and_then(|record| record.previous_sha256.clone())),
956        previous_protocol_version: promoted_record
957            .and_then(|record| record.current_protocol_version)
958            .or_else(|| existing_record.and_then(|record| record.previous_protocol_version)),
959        previous_release_path: promoted_record
960            .and_then(|record| record.current_release_path.clone())
961            .or_else(|| existing_record.and_then(|record| record.previous_release_path.clone())),
962        last_operation: Some(operation.to_string()),
963        last_operation_status: Some("success".to_string()),
964        last_operation_at_ms: now,
965        installed_at_ms: existing_record
966            .map(|record| record.installed_at_ms)
967            .filter(|value| *value > 0)
968            .unwrap_or(now),
969        updated_at_ms: now,
970    }
971}
972
973fn build_rollback_record(
974    existing_record: &InstallRecord,
975    metadata: &ReleaseMetadata,
976) -> InstallRecord {
977    let now = now_millis();
978    InstallRecord {
979        install_state: "installed".to_string(),
980        current_artifact_id: Some(metadata.artifact_id.clone()),
981        current_version: Some(metadata.version.clone()),
982        current_build_hash: Some(metadata.build_hash.clone()),
983        current_sha256: Some(metadata.sha256.clone()),
984        current_protocol_version: Some(metadata.protocol_version),
985        current_release_path: Some(metadata.release_root.clone()),
986        previous_artifact_id: existing_record.current_artifact_id.clone(),
987        previous_version: existing_record.current_version.clone(),
988        previous_build_hash: existing_record.current_build_hash.clone(),
989        previous_sha256: existing_record.current_sha256.clone(),
990        previous_protocol_version: existing_record.current_protocol_version,
991        previous_release_path: existing_record.current_release_path.clone(),
992        last_operation: Some("rollback".to_string()),
993        last_operation_status: Some("success".to_string()),
994        last_operation_at_ms: now,
995        installed_at_ms: existing_record.installed_at_ms,
996        updated_at_ms: now,
997    }
998}
999
1000fn now_millis() -> i64 {
1001    use std::time::{SystemTime, UNIX_EPOCH};
1002
1003    SystemTime::now()
1004        .duration_since(UNIX_EPOCH)
1005        .unwrap_or_default()
1006        .as_millis() as i64
1007}
1008
1009#[cfg(test)]
1010mod tests {
1011    use super::*;
1012
1013    #[test]
1014    fn decode_env_value_restores_single_quotes() {
1015        let raw = "'a'\"'\"'b'";
1016        assert_eq!(decode_env_value(raw), "a'b");
1017    }
1018
1019    #[test]
1020    fn merge_env_values_prefers_overrides_and_defaults() {
1021        let home_dir = PathBuf::from("/home/demo");
1022        let paths = ManagedPaths::new(home_dir);
1023        let existing = HashMap::from([
1024            (
1025                "CODEX_MOBILE_TOKEN".to_string(),
1026                "persisted-token".to_string(),
1027            ),
1028            ("CODEX_BINARY".to_string(), "/usr/bin/codex".to_string()),
1029            ("PATH".to_string(), "/usr/bin:/bin".to_string()),
1030        ]);
1031        let overrides = EnvOverrides {
1032            bridge_token: Some("override-token".to_string()),
1033            runtime_limit: Some(9),
1034            db_path: Some(PathBuf::from("/tmp/bridge.db")),
1035            ..EnvOverrides::default()
1036        };
1037
1038        let merged = merge_env_values(&paths, &existing, &overrides).expect("merge failed");
1039
1040        assert_eq!(
1041            merged,
1042            BridgeEnvValues {
1043                bridge_token: "override-token".to_string(),
1044                listen_addr: DEFAULT_LISTEN_ADDR.to_string(),
1045                runtime_limit: 9,
1046                db_path: PathBuf::from("/tmp/bridge.db"),
1047                codex_home: None,
1048                codex_binary: "/usr/bin/codex".to_string(),
1049                launch_path: "/usr/bin:/bin".to_string(),
1050            }
1051        );
1052    }
1053
1054    #[test]
1055    fn build_activate_record_promotes_previous_release() {
1056        let existing = InstallRecord {
1057            install_state: "installed".to_string(),
1058            current_artifact_id: Some("codex-mobile-bridge-0.1.0".to_string()),
1059            current_version: Some("0.1.0".to_string()),
1060            current_build_hash: Some("build-1".to_string()),
1061            current_sha256: Some("sha-1".to_string()),
1062            current_protocol_version: Some(1),
1063            current_release_path: Some("/releases/0.1.0".to_string()),
1064            installed_at_ms: 10,
1065            ..InstallRecord::default()
1066        };
1067        let metadata = ReleaseMetadata {
1068            artifact_id: "codex-mobile-bridge-0.2.0".to_string(),
1069            version: "0.2.0".to_string(),
1070            build_hash: "build-2".to_string(),
1071            sha256: "sha-2".to_string(),
1072            protocol_version: 2,
1073            release_root: "/releases/0.2.0".to_string(),
1074            executable_path: "/releases/0.2.0/bin/codex-mobile-bridge".to_string(),
1075        };
1076
1077        let next = build_activate_record(Some(&existing), &metadata, "update");
1078
1079        assert_eq!(next.current_version.as_deref(), Some("0.2.0"));
1080        assert_eq!(next.previous_version.as_deref(), Some("0.1.0"));
1081        assert_eq!(
1082            next.previous_release_path.as_deref(),
1083            Some("/releases/0.1.0")
1084        );
1085        assert_eq!(next.installed_at_ms, 10);
1086        assert_eq!(next.last_operation.as_deref(), Some("update"));
1087    }
1088
1089    #[test]
1090    fn build_rollback_record_swaps_current_and_previous() {
1091        let existing = InstallRecord {
1092            install_state: "installed".to_string(),
1093            current_artifact_id: Some("codex-mobile-bridge-0.2.0".to_string()),
1094            current_version: Some("0.2.0".to_string()),
1095            current_build_hash: Some("build-2".to_string()),
1096            current_sha256: Some("sha-2".to_string()),
1097            current_protocol_version: Some(2),
1098            current_release_path: Some("/releases/0.2.0".to_string()),
1099            previous_artifact_id: Some("codex-mobile-bridge-0.1.0".to_string()),
1100            previous_version: Some("0.1.0".to_string()),
1101            previous_build_hash: Some("build-1".to_string()),
1102            previous_sha256: Some("sha-1".to_string()),
1103            previous_protocol_version: Some(1),
1104            previous_release_path: Some("/releases/0.1.0".to_string()),
1105            installed_at_ms: 10,
1106            ..InstallRecord::default()
1107        };
1108        let metadata = ReleaseMetadata {
1109            artifact_id: "codex-mobile-bridge-0.1.0".to_string(),
1110            version: "0.1.0".to_string(),
1111            build_hash: "build-1".to_string(),
1112            sha256: "sha-1".to_string(),
1113            protocol_version: 1,
1114            release_root: "/releases/0.1.0".to_string(),
1115            executable_path: "/releases/0.1.0/bin/codex-mobile-bridge".to_string(),
1116        };
1117
1118        let next = build_rollback_record(&existing, &metadata);
1119
1120        assert_eq!(next.current_version.as_deref(), Some("0.1.0"));
1121        assert_eq!(next.previous_version.as_deref(), Some("0.2.0"));
1122        assert_eq!(next.last_operation.as_deref(), Some("rollback"));
1123    }
1124}