codex-mobile-bridge 0.3.3

Remote bridge and service manager for codex-mobile.
Documentation
use std::ffi::OsStr;
use std::path::{Path, PathBuf};

#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;

use anyhow::{Context, Result, bail};
use clap::Parser;

#[derive(Debug, Clone, Parser)]
#[command(author, version, about = "Codex Mobile App Server bridge")]
pub struct Config {
    #[arg(long, env = "CODEX_MOBILE_LISTEN_ADDR", default_value = "0.0.0.0:8787")]
    pub listen_addr: String,

    #[arg(long, env = "CODEX_MOBILE_TOKEN")]
    pub token: String,

    #[arg(long, env = "CODEX_MOBILE_RUNTIME_LIMIT", default_value_t = 4)]
    pub runtime_limit: usize,

    #[arg(
        long,
        env = "CODEX_MOBILE_DB_PATH",
        default_value = "~/.local/state/codex-mobile/bridge.db"
    )]
    pub db_path: PathBuf,

    #[arg(long, env = "CODEX_HOME")]
    pub codex_home: Option<PathBuf>,

    #[arg(long, env = "CODEX_BINARY", default_value = "codex")]
    pub codex_binary: String,

    #[arg(long = "directory-bookmark")]
    pub directory_bookmarks: Vec<PathBuf>,
}

impl Config {
    pub fn validated(mut self) -> Result<Self> {
        if self.token.trim().is_empty() {
            bail!("bridge token 不能为空");
        }

        self.db_path = expand_path(&self.db_path)?;
        self.codex_home = self
            .codex_home
            .as_ref()
            .map(|path| expand_path(path))
            .transpose()?;
        self.codex_binary = resolve_codex_binary(
            &self.codex_binary,
            std::env::var_os("PATH").as_deref(),
            std::env::var_os("HOME").as_deref().map(Path::new),
        )?;

        let normalized_roots = self
            .directory_bookmarks
            .iter()
            .map(|path| expand_path(path))
            .collect::<Result<Vec<_>>>()?;
        self.directory_bookmarks = normalized_roots;

        Ok(self)
    }
}

pub fn expand_path(path: &Path) -> Result<PathBuf> {
    let raw = path.to_string_lossy();
    if raw == "~" {
        return home_dir();
    }

    if let Some(stripped) = raw.strip_prefix("~/") {
        return Ok(home_dir()?.join(stripped));
    }

    if path.is_absolute() {
        return Ok(path.to_path_buf());
    }

    let cwd = std::env::current_dir().context("读取当前工作目录失败")?;
    Ok(cwd.join(path))
}

fn home_dir() -> Result<PathBuf> {
    std::env::var_os("HOME")
        .map(PathBuf::from)
        .context("未找到 HOME 环境变量")
}

pub fn resolve_codex_binary(
    raw: &str,
    path_env: Option<&OsStr>,
    home_env: Option<&Path>,
) -> Result<String> {
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        bail!("CODEX_BINARY 不能为空");
    }

    if trimmed.contains('/') || trimmed.starts_with('~') {
        return Ok(expand_path(Path::new(trimmed))?
            .to_string_lossy()
            .to_string());
    }

    if let Some(resolved) = find_in_path(trimmed, path_env) {
        return Ok(resolved.to_string_lossy().to_string());
    }

    if let Some(resolved) = find_in_home_bins(trimmed, home_env) {
        return Ok(resolved.to_string_lossy().to_string());
    }

    Ok(trimmed.to_string())
}

fn find_in_path(binary: &str, path_env: Option<&OsStr>) -> Option<PathBuf> {
    let path_env = path_env?;
    std::env::split_paths(path_env)
        .map(|dir| dir.join(binary))
        .find(|candidate| is_executable(candidate))
}

fn find_in_home_bins(binary: &str, home_env: Option<&Path>) -> Option<PathBuf> {
    let home = home_env?;
    [
        home.join(".npm-global/bin").join(binary),
        home.join(".local/bin").join(binary),
        home.join("bin").join(binary),
        home.join(".cargo/bin").join(binary),
    ]
    .into_iter()
    .find(|candidate| is_executable(candidate))
}

fn is_executable(path: &Path) -> bool {
    let Ok(metadata) = std::fs::metadata(path) else {
        return false;
    };
    if !metadata.is_file() {
        return false;
    }

    #[cfg(unix)]
    {
        metadata.permissions().mode() & 0o111 != 0
    }

    #[cfg(not(unix))]
    {
        true
    }
}

#[cfg(test)]
mod tests {
    use std::env;
    use std::ffi::OsString;
    use std::fs;
    #[cfg(unix)]
    use std::os::unix::fs::PermissionsExt;

    use super::resolve_codex_binary;

    #[test]
    fn resolve_codex_binary_uses_path_entry_when_available() {
        let base_dir =
            env::temp_dir().join(format!("codex-mobile-config-test-{}", std::process::id()));
        fs::create_dir_all(&base_dir).expect("创建测试目录失败");
        let binary_path = base_dir.join("codex");
        fs::write(&binary_path, "#!/bin/sh\n").expect("写入可执行文件失败");
        #[cfg(unix)]
        {
            fs::set_permissions(&binary_path, fs::Permissions::from_mode(0o755))
                .expect("设置权限失败");
        }

        let path_env = OsString::from(&base_dir);
        let resolved = resolve_codex_binary(
            "codex",
            Some(path_env.as_os_str()),
            Some(base_dir.as_path()),
        )
        .expect("解析 codex 失败");

        assert_eq!(resolved, binary_path.to_string_lossy());
    }

    #[test]
    fn resolve_codex_binary_falls_back_to_common_home_bin() {
        let home_dir =
            env::temp_dir().join(format!("codex-mobile-home-test-{}", std::process::id()));
        let npm_dir = home_dir.join(".npm-global/bin");
        fs::create_dir_all(&npm_dir).expect("创建 npm bin 目录失败");
        let binary_path = npm_dir.join("codex");
        fs::write(&binary_path, "#!/bin/sh\n").expect("写入可执行文件失败");
        #[cfg(unix)]
        {
            fs::set_permissions(&binary_path, fs::Permissions::from_mode(0o755))
                .expect("设置权限失败");
        }

        let path_env = OsString::from("/usr/bin");
        let resolved = resolve_codex_binary("codex", Some(path_env.as_os_str()), Some(&home_dir))
            .expect("应回退到 HOME bin");

        assert_eq!(resolved, binary_path.to_string_lossy());
    }
}