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