codex-mobile-bridge 0.3.3

Remote bridge and service manager for codex-mobile.
Documentation
use super::*;

#[derive(Debug, Clone, Default)]
pub(super) struct ShellCapture {
    pub stdout: String,
    pub stderr: String,
}

pub(super) 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(())
}

pub(super) 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
}

pub(super) 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(())
}

pub(super) 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(())
}

pub(super) fn write_managed_env(path: &Path, values: &BridgeEnvValues) -> Result<()> {
    let content = build_managed_env(values);
    write_text_file(path, &content, 0o600)
}

pub(super) 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"
}

pub(super) 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=control-group",
        "SendSIGKILL=yes",
        "FinalKillSignal=SIGKILL",
        "TimeoutStopSec=20",
        "TimeoutStopFailureMode=kill",
        "MemoryAccounting=yes",
        "OOMPolicy=kill",
        "NoNewPrivileges=yes",
        "",
        "[Install]",
        "WantedBy=default.target",
        "",
    ]
    .join("\n")
}

pub(super) 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)
}

pub(super) fn shell_quote_value(value: &str) -> String {
    format!("'{}'", value.replace('\'', "'\"'\"'"))
}

pub(super) 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()
}

pub(super) 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()
}

pub(super) 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))
}

pub(super) 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)
}

pub(super) fn daemon_reload() -> Result<()> {
    run_shell("systemctl --user daemon-reload")
}

pub(super) 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"
    ))
}

pub(super) fn start_service() -> Result<()> {
    run_shell(&format!(
        "systemctl --user enable {SERVICE_NAME} >/dev/null && systemctl --user start {SERVICE_NAME}"
    ))
}

pub(super) fn stop_service() -> Result<()> {
    run_shell(&format!(
        "if systemctl --user list-unit-files {SERVICE_NAME} >/dev/null 2>&1; then systemctl --user stop {SERVICE_NAME}; fi"
    ))
}

pub(super) fn restart_service() -> Result<()> {
    run_shell(&format!(
        "systemctl --user enable {SERVICE_NAME} >/dev/null && systemctl --user restart {SERVICE_NAME}"
    ))
}

pub(super) fn disable_service() -> Result<()> {
    run_shell(&format!(
        "if systemctl --user list-unit-files {SERVICE_NAME} >/dev/null 2>&1; then systemctl --user disable {SERVICE_NAME} >/dev/null || true; fi"
    ))
}

pub(super) fn remove_path_if_exists(path: &Path) -> Result<()> {
    let metadata = match fs::symlink_metadata(path) {
        Ok(value) => value,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
        Err(error) => {
            return Err(error).with_context(|| format!("读取路径信息失败: {}", path.display()));
        }
    };

    if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() {
        bail!("目标路径是目录,拒绝删除: {}", path.display());
    }
    fs::remove_file(path).with_context(|| format!("删除路径失败: {}", path.display()))
}

fn run_shell(command: &str) -> Result<()> {
    run_shell_capture(command).map(|_| ())
}

pub(super) fn run_shell_capture(command: &str) -> Result<ShellCapture> {
    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 命令失败")?;
    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
    if !output.status.success() {
        bail!(
            "shell 命令失败(exit={}): stdout={}; stderr={}",
            output.status.code().unwrap_or(-1),
            stdout.trim(),
            stderr.trim(),
        );
    }
    Ok(ShellCapture { stdout, stderr })
}