use std::collections::HashMap;
use std::env;
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::{PermissionsExt, symlink};
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result, bail};
use clap::{Args, Parser, Subcommand, ValueEnum};
use serde::{Deserialize, Serialize};
use serde_json::json;
use sha2::{Digest, Sha256};
use crate::{BRIDGE_BUILD_HASH, BRIDGE_PROTOCOL_VERSION, BRIDGE_VERSION};
const CRATE_NAME: &str = env!("CARGO_PKG_NAME");
const BINARY_NAME: &str = "codex-mobile-bridge";
const SERVICE_NAME: &str = "codex-mobile-bridge.service";
const DEFAULT_LISTEN_ADDR: &str = "0.0.0.0:8787";
const DEFAULT_RUNTIME_LIMIT: usize = 4;
#[derive(Debug, Parser)]
pub struct ManageCli {
#[command(subcommand)]
command: ManageCommand,
}
#[derive(Debug, Subcommand)]
enum ManageCommand {
Activate(ActivateArgs),
SelfUpdate(SelfUpdateArgs),
Rollback(RollbackArgs),
Repair(RepairArgs),
Metadata,
}
#[derive(Debug, Clone, Args, Default)]
struct EnvOverrides {
#[arg(long)]
bridge_token: Option<String>,
#[arg(long)]
listen_addr: Option<String>,
#[arg(long)]
runtime_limit: Option<usize>,
#[arg(long)]
db_path: Option<PathBuf>,
#[arg(long)]
codex_home: Option<PathBuf>,
#[arg(long)]
codex_binary: Option<String>,
#[arg(long)]
launch_path: Option<String>,
}
#[derive(Debug, Clone, Args)]
pub struct ActivateArgs {
#[command(flatten)]
env: EnvOverrides,
#[arg(long, value_enum, default_value_t = ActivateOperation::Install)]
operation: ActivateOperation,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, ValueEnum)]
pub enum ActivateOperation {
Install,
Update,
Repair,
}
impl ActivateOperation {
fn as_str(self) -> &'static str {
match self {
Self::Install => "install",
Self::Update => "update",
Self::Repair => "repair",
}
}
}
#[derive(Debug, Clone, Args)]
pub struct SelfUpdateArgs {
#[command(flatten)]
env: EnvOverrides,
#[arg(long)]
target_version: String,
#[arg(long, default_value = "cargo")]
cargo_binary: String,
#[arg(long, default_value = "crates-io")]
registry: String,
}
#[derive(Debug, Clone, Args)]
pub struct RollbackArgs {
#[command(flatten)]
env: EnvOverrides,
#[arg(long, default_value = "cargo")]
cargo_binary: String,
#[arg(long, default_value = "crates-io")]
registry: String,
}
#[derive(Debug, Clone, Args)]
pub struct RepairArgs {
#[command(flatten)]
env: EnvOverrides,
#[arg(long, default_value = "cargo")]
cargo_binary: String,
#[arg(long, default_value = "crates-io")]
registry: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct BridgeEnvValues {
bridge_token: String,
listen_addr: String,
runtime_limit: usize,
db_path: PathBuf,
codex_home: Option<PathBuf>,
codex_binary: String,
launch_path: String,
}
#[derive(Debug, Clone)]
struct ManagedPaths {
share_dir: PathBuf,
releases_dir: PathBuf,
current_link: PathBuf,
config_dir: PathBuf,
systemd_user_dir: PathBuf,
state_dir: PathBuf,
env_file: PathBuf,
unit_file: PathBuf,
install_record_file: PathBuf,
bridge_db_path: PathBuf,
}
impl ManagedPaths {
fn new(home_dir: PathBuf) -> Self {
let share_dir = home_dir.join(".local/share/codex-mobile");
let config_dir = home_dir.join(".config/codex-mobile");
let systemd_user_dir = home_dir.join(".config/systemd/user");
let state_dir = home_dir.join(".local/state/codex-mobile");
Self {
current_link: share_dir.join("current"),
releases_dir: share_dir.join("releases"),
env_file: config_dir.join("bridge.env"),
unit_file: systemd_user_dir.join(SERVICE_NAME),
install_record_file: state_dir.join("install.json"),
bridge_db_path: state_dir.join("bridge.db"),
share_dir,
config_dir,
systemd_user_dir,
state_dir,
}
}
fn release_root_for_version(&self, version: &str) -> PathBuf {
self.releases_dir.join(format!("{CRATE_NAME}-{version}"))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
#[serde(default)]
struct InstallRecord {
install_state: String,
current_artifact_id: Option<String>,
current_version: Option<String>,
current_build_hash: Option<String>,
current_sha256: Option<String>,
current_protocol_version: Option<u32>,
current_release_path: Option<String>,
previous_artifact_id: Option<String>,
previous_version: Option<String>,
previous_build_hash: Option<String>,
previous_sha256: Option<String>,
previous_protocol_version: Option<u32>,
previous_release_path: Option<String>,
last_operation: Option<String>,
last_operation_status: Option<String>,
last_operation_at_ms: i64,
installed_at_ms: i64,
updated_at_ms: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReleaseMetadata {
artifact_id: String,
version: String,
build_hash: String,
sha256: String,
protocol_version: u32,
release_root: String,
executable_path: String,
}
pub fn run(cli: ManageCli) -> Result<()> {
match cli.command {
ManageCommand::Activate(args) => run_activate(args),
ManageCommand::SelfUpdate(args) => run_self_update(args),
ManageCommand::Rollback(args) => run_rollback(args),
ManageCommand::Repair(args) => run_repair(args),
ManageCommand::Metadata => print_metadata(),
}
}
fn run_activate(args: ActivateArgs) -> Result<()> {
let paths = managed_paths()?;
let release_root = resolve_current_release_root()?;
let current_metadata = current_release_metadata(&release_root)?;
let existing_env = read_env_file(&paths)?;
let env_values = merge_env_values(&paths, &existing_env, &args.env)?;
let existing_record = read_install_record(&paths)?;
activate_release(
&paths,
¤t_metadata,
&env_values,
existing_record.as_ref(),
args.operation.as_str(),
)?;
println!(
"{}",
json!({
"operation": args.operation.as_str(),
"version": current_metadata.version,
"releasePath": current_metadata.release_root,
})
);
Ok(())
}
fn run_self_update(args: SelfUpdateArgs) -> Result<()> {
let paths = managed_paths()?;
let existing_record =
read_install_record(&paths)?.context("缺少 install record,无法执行自更新")?;
let existing_env = read_env_file(&paths)?;
let env_values = merge_env_values(&paths, &existing_env, &args.env)?;
let release_root = install_release(
&paths,
&args.target_version,
&args.cargo_binary,
&args.registry,
)?;
let metadata = load_release_metadata(&release_root)?;
activate_release(
&paths,
&metadata,
&env_values,
Some(&existing_record),
"update",
)?;
println!(
"{}",
json!({
"operation": "self-update",
"version": metadata.version,
"releasePath": metadata.release_root,
})
);
Ok(())
}
fn run_rollback(args: RollbackArgs) -> Result<()> {
let paths = managed_paths()?;
let existing_record = read_install_record(&paths)?.context("缺少 install record,无法回滚")?;
let previous_version = existing_record
.previous_version
.as_deref()
.filter(|value| !value.trim().is_empty())
.context("当前没有可回滚的上一版")?;
let existing_env = read_env_file(&paths)?;
let env_values = merge_env_values(&paths, &existing_env, &args.env)?;
let target_release_root = existing_record
.previous_release_path
.as_deref()
.map(PathBuf::from)
.filter(|path| release_binary_path(path).is_some())
.unwrap_or_else(|| paths.release_root_for_version(previous_version));
let target_release_root = if release_binary_path(&target_release_root).is_some() {
target_release_root
} else {
install_release(&paths, previous_version, &args.cargo_binary, &args.registry)?
};
let metadata = release_metadata_from_previous_record(&existing_record, &target_release_root)
.unwrap_or(load_release_metadata(&target_release_root)?);
rollback_release(&paths, &metadata, &env_values, &existing_record)?;
println!(
"{}",
json!({
"operation": "rollback",
"version": metadata.version,
"releasePath": metadata.release_root,
})
);
Ok(())
}
fn run_repair(args: RepairArgs) -> Result<()> {
let paths = managed_paths()?;
let existing_record = read_install_record(&paths)?;
let existing_env = read_env_file(&paths)?;
let env_values = merge_env_values(&paths, &existing_env, &args.env)?;
let metadata = resolve_repair_metadata(&paths, existing_record.as_ref(), &args)?;
activate_release(
&paths,
&metadata,
&env_values,
existing_record.as_ref(),
"repair",
)?;
println!(
"{}",
json!({
"operation": "repair",
"version": metadata.version,
"releasePath": metadata.release_root,
})
);
Ok(())
}
fn print_metadata() -> Result<()> {
let release_root = resolve_current_release_root()?;
let metadata = current_release_metadata(&release_root)?;
println!("{}", serde_json::to_string(&metadata)?);
Ok(())
}
fn resolve_repair_metadata(
paths: &ManagedPaths,
existing_record: Option<&InstallRecord>,
args: &RepairArgs,
) -> Result<ReleaseMetadata> {
if let Some(record) = existing_record {
let current_release_root = record
.current_release_path
.as_deref()
.map(PathBuf::from)
.unwrap_or_else(|| {
record
.current_version
.as_deref()
.map(|value| paths.release_root_for_version(value))
.unwrap_or_else(|| paths.current_link.clone())
});
if release_binary_path(¤t_release_root).is_some() {
if let Some(metadata) =
release_metadata_from_current_record(record, ¤t_release_root)
{
return Ok(metadata);
}
return load_release_metadata(¤t_release_root);
}
if let Some(current_version) = record.current_version.as_deref() {
let release_root =
install_release(paths, current_version, &args.cargo_binary, &args.registry)?;
return load_release_metadata(&release_root);
}
}
let release_root = resolve_current_release_root()?;
current_release_metadata(&release_root)
}
fn activate_release(
paths: &ManagedPaths,
metadata: &ReleaseMetadata,
env_values: &BridgeEnvValues,
existing_record: Option<&InstallRecord>,
operation: &str,
) -> Result<()> {
ensure_managed_directories(paths)?;
let release_root = PathBuf::from(&metadata.release_root);
ensure_release_binary_link(&release_root)?;
write_managed_env(&paths.env_file, env_values)?;
write_user_service(&paths.unit_file)?;
point_current_release(&paths.current_link, &release_root)?;
daemon_reload()?;
ensure_service_started()?;
let next_record = build_activate_record(existing_record, metadata, operation);
write_install_record(&paths.install_record_file, &next_record)?;
Ok(())
}
fn rollback_release(
paths: &ManagedPaths,
metadata: &ReleaseMetadata,
env_values: &BridgeEnvValues,
existing_record: &InstallRecord,
) -> Result<()> {
ensure_managed_directories(paths)?;
let release_root = PathBuf::from(&metadata.release_root);
ensure_release_binary_link(&release_root)?;
write_managed_env(&paths.env_file, env_values)?;
write_user_service(&paths.unit_file)?;
point_current_release(&paths.current_link, &release_root)?;
daemon_reload()?;
ensure_service_started()?;
let next_record = build_rollback_record(existing_record, metadata);
write_install_record(&paths.install_record_file, &next_record)?;
Ok(())
}
fn install_release(
paths: &ManagedPaths,
version: &str,
cargo_binary: &str,
registry: &str,
) -> Result<PathBuf> {
let release_root = paths.release_root_for_version(version);
fs::create_dir_all(&release_root)
.with_context(|| format!("创建 release 目录失败: {}", release_root.display()))?;
let output = Command::new(cargo_binary)
.arg("install")
.arg("--locked")
.arg("--force")
.arg("--registry")
.arg(registry)
.arg("--root")
.arg(&release_root)
.arg("--version")
.arg(version)
.arg("--bin")
.arg(BINARY_NAME)
.arg(CRATE_NAME)
.output()
.with_context(|| format!("执行 cargo install 失败: {cargo_binary}"))?;
if !output.status.success() {
bail!(
"cargo install 失败(version={version}, registry={registry}): stdout={}; stderr={}",
String::from_utf8_lossy(&output.stdout).trim(),
String::from_utf8_lossy(&output.stderr).trim(),
);
}
ensure_release_binary_link(&release_root)?;
Ok(release_root)
}
fn managed_paths() -> Result<ManagedPaths> {
let home_dir = env::var_os("HOME")
.map(PathBuf::from)
.context("未找到 HOME 环境变量")?;
Ok(ManagedPaths::new(home_dir))
}
fn resolve_current_release_root() -> Result<PathBuf> {
let current_exe = env::current_exe().context("读取当前可执行文件路径失败")?;
let canonical = current_exe
.canonicalize()
.with_context(|| format!("解析当前可执行文件路径失败: {}", current_exe.display()))?;
let parent = canonical.parent().context("当前可执行文件路径缺少父目录")?;
if parent.file_name().and_then(|value| value.to_str()) == Some("bin") {
return parent
.parent()
.map(Path::to_path_buf)
.context("无法解析 release 根目录");
}
Ok(parent.to_path_buf())
}
fn current_release_metadata(release_root: &Path) -> Result<ReleaseMetadata> {
let executable_path =
release_binary_path(release_root).context("release 缺少 bridge 可执行文件")?;
Ok(ReleaseMetadata {
artifact_id: release_root
.file_name()
.and_then(|value| value.to_str())
.unwrap_or(CRATE_NAME)
.to_string(),
version: BRIDGE_VERSION.to_string(),
build_hash: BRIDGE_BUILD_HASH.to_string(),
sha256: sha256_file(&executable_path)?,
protocol_version: BRIDGE_PROTOCOL_VERSION,
release_root: release_root.to_string_lossy().to_string(),
executable_path: executable_path.to_string_lossy().to_string(),
})
}
fn load_release_metadata(release_root: &Path) -> Result<ReleaseMetadata> {
let current_exe = env::current_exe()
.context("读取当前可执行文件路径失败")?
.canonicalize()
.context("解析当前可执行文件路径失败")?;
let release_binary =
release_binary_path(release_root).context("release 缺少 bridge 可执行文件")?;
let canonical_release_binary = release_binary
.canonicalize()
.with_context(|| format!("解析 release 可执行文件失败: {}", release_binary.display()))?;
if canonical_release_binary == current_exe {
return current_release_metadata(release_root);
}
let output = Command::new(&release_binary)
.arg("manage")
.arg("metadata")
.output()
.with_context(|| format!("执行 metadata 命令失败: {}", release_binary.display()))?;
if !output.status.success() {
bail!(
"读取 release metadata 失败: stdout={}; stderr={}",
String::from_utf8_lossy(&output.stdout).trim(),
String::from_utf8_lossy(&output.stderr).trim(),
);
}
let mut metadata: ReleaseMetadata =
serde_json::from_slice(&output.stdout).context("解析 release metadata 失败")?;
metadata.release_root = release_root.to_string_lossy().to_string();
metadata.artifact_id = release_root
.file_name()
.and_then(|value| value.to_str())
.unwrap_or(CRATE_NAME)
.to_string();
metadata.executable_path = release_binary.to_string_lossy().to_string();
Ok(metadata)
}
fn release_metadata_from_current_record(
record: &InstallRecord,
release_root: &Path,
) -> Option<ReleaseMetadata> {
metadata_from_record(
release_root,
record.current_artifact_id.clone(),
record.current_version.clone(),
record.current_build_hash.clone(),
record.current_sha256.clone(),
record.current_protocol_version,
)
}
fn release_metadata_from_previous_record(
record: &InstallRecord,
release_root: &Path,
) -> Option<ReleaseMetadata> {
metadata_from_record(
release_root,
record.previous_artifact_id.clone(),
record.previous_version.clone(),
record.previous_build_hash.clone(),
record.previous_sha256.clone(),
record.previous_protocol_version,
)
}
fn metadata_from_record(
release_root: &Path,
artifact_id: Option<String>,
version: Option<String>,
build_hash: Option<String>,
sha256: Option<String>,
protocol_version: Option<u32>,
) -> Option<ReleaseMetadata> {
let executable_path = release_binary_path(release_root)?;
Some(ReleaseMetadata {
artifact_id: artifact_id.unwrap_or_else(|| {
release_root
.file_name()
.and_then(|value| value.to_str())
.unwrap_or(CRATE_NAME)
.to_string()
}),
version: version?,
build_hash: build_hash?,
sha256: sha256?,
protocol_version: protocol_version?,
release_root: release_root.to_string_lossy().to_string(),
executable_path: executable_path.to_string_lossy().to_string(),
})
}
fn ensure_managed_directories(paths: &ManagedPaths) -> Result<()> {
for path in [
&paths.share_dir,
&paths.releases_dir,
&paths.config_dir,
&paths.systemd_user_dir,
&paths.state_dir,
] {
fs::create_dir_all(path)
.with_context(|| format!("创建受管目录失败: {}", path.display()))?;
}
Ok(())
}
fn release_binary_path(release_root: &Path) -> Option<PathBuf> {
let bin_path = release_root.join("bin").join(BINARY_NAME);
if bin_path.is_file() {
return Some(bin_path);
}
let root_path = release_root.join(BINARY_NAME);
if root_path.is_file() {
return Some(root_path);
}
None
}
fn ensure_release_binary_link(release_root: &Path) -> Result<()> {
let binary_path = release_root.join("bin").join(BINARY_NAME);
if !binary_path.is_file() {
let fallback_binary = release_root.join(BINARY_NAME);
if fallback_binary.is_file() {
return Ok(());
}
bail!("release 缺少 bridge 二进制: {}", release_root.display());
}
let link_path = release_root.join(BINARY_NAME);
if link_path.exists() || fs::symlink_metadata(&link_path).is_ok() {
let metadata = fs::symlink_metadata(&link_path)
.with_context(|| format!("读取 binary link 信息失败: {}", link_path.display()))?;
if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() {
bail!("binary link 路径被目录占用: {}", link_path.display());
}
fs::remove_file(&link_path)
.with_context(|| format!("移除旧 binary link 失败: {}", link_path.display()))?;
}
#[cfg(unix)]
{
symlink(Path::new("bin").join(BINARY_NAME), &link_path)
.with_context(|| format!("创建 binary link 失败: {}", link_path.display()))?;
}
#[cfg(not(unix))]
{
bail!("当前平台不支持创建 bridge 可执行文件软链接");
}
Ok(())
}
fn point_current_release(current_link: &Path, release_root: &Path) -> Result<()> {
if let Ok(metadata) = fs::symlink_metadata(current_link) {
if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() {
bail!("current 路径被非预期目录占用: {}", current_link.display());
}
fs::remove_file(current_link)
.with_context(|| format!("移除 current 链接失败: {}", current_link.display()))?;
}
#[cfg(unix)]
{
symlink(release_root, current_link)
.with_context(|| format!("创建 current 链接失败: {}", current_link.display()))?;
}
#[cfg(not(unix))]
{
bail!("当前平台不支持创建 current 软链接");
}
Ok(())
}
fn write_managed_env(path: &Path, values: &BridgeEnvValues) -> Result<()> {
let content = build_managed_env(values);
write_text_file(path, &content, 0o600)
}
fn write_user_service(path: &Path) -> Result<()> {
write_text_file(path, &build_user_service(), 0o644)
}
fn write_text_file(path: &Path, content: &str, mode: u32) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("创建文件父目录失败: {}", parent.display()))?;
}
fs::write(path, content).with_context(|| format!("写入文件失败: {}", path.display()))?;
#[cfg(unix)]
{
let permissions = fs::Permissions::from_mode(mode);
fs::set_permissions(path, permissions)
.with_context(|| format!("设置文件权限失败: {}", path.display()))?;
}
Ok(())
}
fn build_managed_env(values: &BridgeEnvValues) -> String {
let mut lines = vec![
format!(
"CODEX_MOBILE_TOKEN={}",
shell_quote_value(&values.bridge_token)
),
format!(
"CODEX_MOBILE_LISTEN_ADDR={}",
shell_quote_value(&values.listen_addr)
),
format!("CODEX_MOBILE_RUNTIME_LIMIT={}", values.runtime_limit),
format!(
"CODEX_MOBILE_DB_PATH={}",
shell_quote_value(&values.db_path.to_string_lossy())
),
format!("CODEX_BINARY={}", shell_quote_value(&values.codex_binary)),
format!("PATH={}", shell_quote_value(&values.launch_path)),
];
if let Some(codex_home) = values.codex_home.as_ref() {
lines.push(format!(
"CODEX_HOME={}",
shell_quote_value(&codex_home.to_string_lossy())
));
}
lines.join("\n") + "\n"
}
fn build_user_service() -> String {
[
"[Unit]",
"Description=Codex Mobile Bridge",
"After=network-online.target",
"Wants=network-online.target",
"StartLimitIntervalSec=0",
"",
"[Service]",
"Type=simple",
"EnvironmentFile=%h/.config/codex-mobile/bridge.env",
"ExecStart=%h/.local/share/codex-mobile/current/codex-mobile-bridge",
"WorkingDirectory=%h",
"Restart=always",
"RestartSec=3",
"KillMode=mixed",
"TimeoutStopSec=20",
"NoNewPrivileges=yes",
"",
"[Install]",
"WantedBy=default.target",
"",
]
.join("\n")
}
fn merge_env_values(
paths: &ManagedPaths,
existing_values: &HashMap<String, String>,
overrides: &EnvOverrides,
) -> Result<BridgeEnvValues> {
let bridge_token = overrides
.bridge_token
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| non_empty_map_value(existing_values, "CODEX_MOBILE_TOKEN"))
.context("缺少 bridge token,请先提供 --bridge-token 或已有 bridge.env")?;
let listen_addr = overrides
.listen_addr
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| non_empty_map_value(existing_values, "CODEX_MOBILE_LISTEN_ADDR"))
.unwrap_or_else(|| DEFAULT_LISTEN_ADDR.to_string());
let runtime_limit = overrides
.runtime_limit
.filter(|value| *value > 0)
.or_else(|| {
existing_values
.get("CODEX_MOBILE_RUNTIME_LIMIT")
.and_then(|value| value.trim().parse::<usize>().ok())
.filter(|value| *value > 0)
})
.unwrap_or(DEFAULT_RUNTIME_LIMIT);
let db_path = overrides
.db_path
.clone()
.or_else(|| non_empty_map_value(existing_values, "CODEX_MOBILE_DB_PATH").map(PathBuf::from))
.unwrap_or_else(|| paths.bridge_db_path.clone());
let codex_home = overrides
.codex_home
.clone()
.or_else(|| non_empty_map_value(existing_values, "CODEX_HOME").map(PathBuf::from));
let codex_binary = overrides
.codex_binary
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| non_empty_map_value(existing_values, "CODEX_BINARY"))
.or_else(|| {
env::var("CODEX_BINARY")
.ok()
.map(|value| value.trim().to_string())
})
.filter(|value| !value.trim().is_empty())
.context("缺少 CODEX_BINARY,请先传入 --codex-binary")?;
let launch_path = overrides
.launch_path
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| non_empty_map_value(existing_values, "PATH"))
.or_else(|| env::var("PATH").ok().map(|value| value.trim().to_string()))
.filter(|value| !value.trim().is_empty())
.context("缺少 PATH,请先传入 --launch-path")?;
Ok(BridgeEnvValues {
bridge_token,
listen_addr,
runtime_limit,
db_path,
codex_home,
codex_binary,
launch_path,
})
}
fn non_empty_map_value(values: &HashMap<String, String>, key: &str) -> Option<String> {
values
.get(key)
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn shell_quote_value(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
fn read_env_file(paths: &ManagedPaths) -> Result<HashMap<String, String>> {
if !paths.env_file.is_file() {
return Ok(HashMap::new());
}
let raw = fs::read_to_string(&paths.env_file)
.with_context(|| format!("读取 bridge.env 失败: {}", paths.env_file.display()))?;
Ok(parse_env_lines(&raw))
}
fn parse_env_lines(raw: &str) -> HashMap<String, String> {
raw.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#') && line.contains('='))
.filter_map(|line| {
let (key, value) = line.split_once('=')?;
Some((key.trim().to_string(), decode_env_value(value.trim())))
})
.collect()
}
fn decode_env_value(raw: &str) -> String {
if raw.starts_with('\'') && raw.ends_with('\'') && raw.len() >= 2 {
return raw[1..raw.len() - 1].replace("'\"'\"'", "'");
}
raw.to_string()
}
fn read_install_record(path_set: &ManagedPaths) -> Result<Option<InstallRecord>> {
if !path_set.install_record_file.is_file() {
return Ok(None);
}
let raw = fs::read_to_string(&path_set.install_record_file).with_context(|| {
format!(
"读取 install record 失败: {}",
path_set.install_record_file.display()
)
})?;
let parsed = serde_json::from_str(&raw).context("解析 install record 失败")?;
Ok(Some(parsed))
}
fn write_install_record(path: &Path, record: &InstallRecord) -> Result<()> {
let content = serde_json::to_string(record).context("序列化 install record 失败")?;
write_text_file(path, &content, 0o600)
}
fn daemon_reload() -> Result<()> {
run_shell("systemctl --user daemon-reload")
}
fn ensure_service_started() -> Result<()> {
run_shell(&format!(
"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"
))
}
fn run_shell(command: &str) -> Result<()> {
let output = Command::new("bash")
.arg("-lc")
.arg(format!(
"uid=\"$(id -u)\"; export XDG_RUNTIME_DIR=\"/run/user/$uid\"; export DBUS_SESSION_BUS_ADDRESS=\"unix:path=$XDG_RUNTIME_DIR/bus\"; {command}"
))
.output()
.context("执行 shell 命令失败")?;
if !output.status.success() {
bail!(
"shell 命令失败: stdout={}; stderr={}",
String::from_utf8_lossy(&output.stdout).trim(),
String::from_utf8_lossy(&output.stderr).trim(),
);
}
Ok(())
}
fn sha256_file(path: &Path) -> Result<String> {
let bytes = fs::read(path).with_context(|| format!("读取文件失败: {}", path.display()))?;
let mut hasher = Sha256::new();
hasher.update(&bytes);
Ok(hasher
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect())
}
fn build_activate_record(
existing_record: Option<&InstallRecord>,
metadata: &ReleaseMetadata,
operation: &str,
) -> InstallRecord {
let now = now_millis();
let promote_current = existing_record
.and_then(|record| record.current_artifact_id.as_ref())
.is_some_and(|artifact_id| artifact_id != &metadata.artifact_id);
let promoted_record = existing_record.filter(|_| promote_current);
InstallRecord {
install_state: "installed".to_string(),
current_artifact_id: Some(metadata.artifact_id.clone()),
current_version: Some(metadata.version.clone()),
current_build_hash: Some(metadata.build_hash.clone()),
current_sha256: Some(metadata.sha256.clone()),
current_protocol_version: Some(metadata.protocol_version),
current_release_path: Some(metadata.release_root.clone()),
previous_artifact_id: promoted_record
.and_then(|record| record.current_artifact_id.clone())
.or_else(|| existing_record.and_then(|record| record.previous_artifact_id.clone())),
previous_version: promoted_record
.and_then(|record| record.current_version.clone())
.or_else(|| existing_record.and_then(|record| record.previous_version.clone())),
previous_build_hash: promoted_record
.and_then(|record| record.current_build_hash.clone())
.or_else(|| existing_record.and_then(|record| record.previous_build_hash.clone())),
previous_sha256: promoted_record
.and_then(|record| record.current_sha256.clone())
.or_else(|| existing_record.and_then(|record| record.previous_sha256.clone())),
previous_protocol_version: promoted_record
.and_then(|record| record.current_protocol_version)
.or_else(|| existing_record.and_then(|record| record.previous_protocol_version)),
previous_release_path: promoted_record
.and_then(|record| record.current_release_path.clone())
.or_else(|| existing_record.and_then(|record| record.previous_release_path.clone())),
last_operation: Some(operation.to_string()),
last_operation_status: Some("success".to_string()),
last_operation_at_ms: now,
installed_at_ms: existing_record
.map(|record| record.installed_at_ms)
.filter(|value| *value > 0)
.unwrap_or(now),
updated_at_ms: now,
}
}
fn build_rollback_record(
existing_record: &InstallRecord,
metadata: &ReleaseMetadata,
) -> InstallRecord {
let now = now_millis();
InstallRecord {
install_state: "installed".to_string(),
current_artifact_id: Some(metadata.artifact_id.clone()),
current_version: Some(metadata.version.clone()),
current_build_hash: Some(metadata.build_hash.clone()),
current_sha256: Some(metadata.sha256.clone()),
current_protocol_version: Some(metadata.protocol_version),
current_release_path: Some(metadata.release_root.clone()),
previous_artifact_id: existing_record.current_artifact_id.clone(),
previous_version: existing_record.current_version.clone(),
previous_build_hash: existing_record.current_build_hash.clone(),
previous_sha256: existing_record.current_sha256.clone(),
previous_protocol_version: existing_record.current_protocol_version,
previous_release_path: existing_record.current_release_path.clone(),
last_operation: Some("rollback".to_string()),
last_operation_status: Some("success".to_string()),
last_operation_at_ms: now,
installed_at_ms: existing_record.installed_at_ms,
updated_at_ms: now,
}
}
fn now_millis() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decode_env_value_restores_single_quotes() {
let raw = "'a'\"'\"'b'";
assert_eq!(decode_env_value(raw), "a'b");
}
#[test]
fn merge_env_values_prefers_overrides_and_defaults() {
let home_dir = PathBuf::from("/home/demo");
let paths = ManagedPaths::new(home_dir);
let existing = HashMap::from([
(
"CODEX_MOBILE_TOKEN".to_string(),
"persisted-token".to_string(),
),
("CODEX_BINARY".to_string(), "/usr/bin/codex".to_string()),
("PATH".to_string(), "/usr/bin:/bin".to_string()),
]);
let overrides = EnvOverrides {
bridge_token: Some("override-token".to_string()),
runtime_limit: Some(9),
db_path: Some(PathBuf::from("/tmp/bridge.db")),
..EnvOverrides::default()
};
let merged = merge_env_values(&paths, &existing, &overrides).expect("merge failed");
assert_eq!(
merged,
BridgeEnvValues {
bridge_token: "override-token".to_string(),
listen_addr: DEFAULT_LISTEN_ADDR.to_string(),
runtime_limit: 9,
db_path: PathBuf::from("/tmp/bridge.db"),
codex_home: None,
codex_binary: "/usr/bin/codex".to_string(),
launch_path: "/usr/bin:/bin".to_string(),
}
);
}
#[test]
fn build_activate_record_promotes_previous_release() {
let existing = InstallRecord {
install_state: "installed".to_string(),
current_artifact_id: Some("codex-mobile-bridge-0.1.0".to_string()),
current_version: Some("0.1.0".to_string()),
current_build_hash: Some("build-1".to_string()),
current_sha256: Some("sha-1".to_string()),
current_protocol_version: Some(1),
current_release_path: Some("/releases/0.1.0".to_string()),
installed_at_ms: 10,
..InstallRecord::default()
};
let metadata = ReleaseMetadata {
artifact_id: "codex-mobile-bridge-0.2.0".to_string(),
version: "0.2.0".to_string(),
build_hash: "build-2".to_string(),
sha256: "sha-2".to_string(),
protocol_version: 2,
release_root: "/releases/0.2.0".to_string(),
executable_path: "/releases/0.2.0/bin/codex-mobile-bridge".to_string(),
};
let next = build_activate_record(Some(&existing), &metadata, "update");
assert_eq!(next.current_version.as_deref(), Some("0.2.0"));
assert_eq!(next.previous_version.as_deref(), Some("0.1.0"));
assert_eq!(
next.previous_release_path.as_deref(),
Some("/releases/0.1.0")
);
assert_eq!(next.installed_at_ms, 10);
assert_eq!(next.last_operation.as_deref(), Some("update"));
}
#[test]
fn build_rollback_record_swaps_current_and_previous() {
let existing = InstallRecord {
install_state: "installed".to_string(),
current_artifact_id: Some("codex-mobile-bridge-0.2.0".to_string()),
current_version: Some("0.2.0".to_string()),
current_build_hash: Some("build-2".to_string()),
current_sha256: Some("sha-2".to_string()),
current_protocol_version: Some(2),
current_release_path: Some("/releases/0.2.0".to_string()),
previous_artifact_id: Some("codex-mobile-bridge-0.1.0".to_string()),
previous_version: Some("0.1.0".to_string()),
previous_build_hash: Some("build-1".to_string()),
previous_sha256: Some("sha-1".to_string()),
previous_protocol_version: Some(1),
previous_release_path: Some("/releases/0.1.0".to_string()),
installed_at_ms: 10,
..InstallRecord::default()
};
let metadata = ReleaseMetadata {
artifact_id: "codex-mobile-bridge-0.1.0".to_string(),
version: "0.1.0".to_string(),
build_hash: "build-1".to_string(),
sha256: "sha-1".to_string(),
protocol_version: 1,
release_root: "/releases/0.1.0".to_string(),
executable_path: "/releases/0.1.0/bin/codex-mobile-bridge".to_string(),
};
let next = build_rollback_record(&existing, &metadata);
assert_eq!(next.current_version.as_deref(), Some("0.1.0"));
assert_eq!(next.previous_version.as_deref(), Some("0.2.0"));
assert_eq!(next.last_operation.as_deref(), Some("rollback"));
}
}