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 ¤t_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(¤t_release_root).is_some() {
366 if let Some(metadata) =
367 release_metadata_from_current_record(record, ¤t_release_root)
368 {
369 return Ok(metadata);
370 }
371 return load_release_metadata(¤t_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}