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());
}
}