Files
EasyCLIProxyAPI/src-tauri/src/core_runtime.rs
T
lzt404 9042421292 fix(config): prevent API provider loss during alias updates and migration
- Validate provider integrity before saving alias changes
- Reject rendered YAML that differs from the expected configuration
- Abort migration when existing config cannot be read or parsed
- Remove lossy config recovery fallback
- Add regression tests for provider loss, locked files, malformed config, and repeated updates
2026-09-11 16:25:14 +08:00

2708 lines
91 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
pub(crate) static CORE_OPERATION_LOCK: Mutex<()> = Mutex::new(());
pub(crate) fn lock_core_operation(
process_state: &CoreProcessState,
) -> Result<std::sync::MutexGuard<'static, ()>, String> {
let guard = CORE_OPERATION_LOCK
.try_lock()
.map_err(|_| "内核正在执行其他操作,请稍后重试".to_string())?;
process_state.ensure_active()?;
Ok(guard)
}
// Own the guard from spawn, including while the management port is starting.
pub(crate) struct CoreChild {
child: Child,
#[cfg(windows)]
job: isize,
}
impl std::ops::Deref for CoreChild {
type Target = Child;
fn deref(&self) -> &Child {
&self.child
}
}
impl std::ops::DerefMut for CoreChild {
fn deref_mut(&mut self) -> &mut Child {
&mut self.child
}
}
impl Drop for CoreChild {
fn drop(&mut self) {
#[cfg(windows)]
{
close_windows_handle(self.job);
let _ = self.child.wait();
}
#[cfg(not(windows))]
if !matches!(self.child.try_wait(), Ok(Some(_))) {
let _ = terminate_child(&mut self.child);
}
}
}
#[cfg(any(target_os = "linux", test))]
struct CoreSpawnRequest {
command: Command,
reply: std::sync::mpsc::SyncSender<io::Result<Child>>,
}
#[cfg(any(target_os = "linux", test))]
static CORE_PROCESS_SPAWNER: LazyLock<Result<std::sync::mpsc::Sender<CoreSpawnRequest>, String>> =
LazyLock::new(|| {
let (sender, receiver) = std::sync::mpsc::channel::<CoreSpawnRequest>();
thread::Builder::new()
.name("cpa-core-spawner".to_string())
.spawn(move || {
for mut request in receiver {
configure_child_lifetime(&mut request.command);
let result = request.command.spawn();
if let Err(std::sync::mpsc::SendError(Ok(mut child))) =
request.reply.send(result)
{
let _ = child.kill();
let _ = child.wait();
}
}
})
.map_err(|error| format!("创建 CPA 内核启动线程失败: {error}"))?;
Ok(sender)
});
pub(crate) fn spawn_core_child(command: Command) -> Result<CoreChild, String> {
#[cfg(target_os = "linux")]
let child = spawn_core_child_on_lifetime_thread(command)?;
#[cfg(not(target_os = "linux"))]
let child = {
let mut command = command;
command
.spawn()
.map_err(|error| format!("启动 CPA 内核失败: {error}"))?
};
#[cfg(windows)]
let job = match attach_child_to_windows_job(&child) {
Ok(job) => job,
Err(error) => {
let mut child = child;
return match terminate_child(&mut child) {
Ok(()) => Err(error),
Err(cleanup_error) => Err(format!(
"{error};清理未托管的内核进程也失败: {cleanup_error}"
)),
};
}
};
Ok(CoreChild {
child,
#[cfg(windows)]
job,
})
}
#[cfg(any(target_os = "linux", test))]
pub(crate) fn spawn_core_child_on_lifetime_thread(command: Command) -> Result<Child, String> {
let sender = CORE_PROCESS_SPAWNER.as_ref().map_err(Clone::clone)?;
let (reply, result) = std::sync::mpsc::sync_channel(1);
sender
.send(CoreSpawnRequest { command, reply })
.map_err(|_| "CPA 内核启动线程已退出".to_string())?;
result
.recv()
.map_err(|_| "CPA 内核启动线程未返回启动结果".to_string())?
.map_err(|error| format!("启动 CPA 内核失败: {error}"))
}
async fn run_core_command(
app: tauri::AppHandle,
operation: fn(&CoreProcessState, &GuiConfigState) -> Result<CoreStatus, String>,
) -> Result<CoreStatus, String> {
tauri::async_runtime::spawn_blocking(move || {
let _guard = lock_core_operation(app.state::<CoreProcessState>().inner())?;
let status = operation(
app.state::<CoreProcessState>().inner(),
app.state::<GuiConfigState>().inner(),
)?;
emit_core_status(&app, &status);
Ok(status)
})
.await
.map_err(|error| format!("内核后台任务失败: {error}"))?
}
#[tauri::command]
pub(crate) async fn check_latest_core(
app: tauri::AppHandle,
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<CoreLatest, String> {
let _detection_guard = VERSION_SOURCE_DETECTION_LOCK.lock().await;
let platform = current_core_platform()?;
let config = gui_config_state.snapshot()?;
let proxy_url = config.proxy_url.clone();
let client = http_client(&proxy_url, &config.custom_download_mirrors)?;
let requested_source = config.selected_download_candidate();
let (release, resolved_source) = fetch_release(
&client,
None,
requested_source.clone(),
&config.custom_download_mirrors,
)
.await?;
persist_automatic_download_source_switch(
&app,
gui_config_state.inner(),
&requested_source,
&resolved_source,
)?;
let asset = select_release_asset(&release, &platform)?;
Ok(CoreLatest {
version: normalize_version(&release.tag_name),
asset_name: asset.name.clone(),
})
}
#[tauri::command]
pub(crate) fn detect_bundled_core() -> Result<Option<BundledCoreInfo>, String> {
bundled_core_archive().map(|value| value.map(|(info, _)| info))
}
#[tauri::command]
pub(crate) async fn install_bundled_core(
app: tauri::AppHandle,
window: tauri::Window,
) -> Result<CoreInstallResult, String> {
tauri::async_runtime::spawn_blocking(move || {
let _guard = lock_core_operation(app.state::<CoreProcessState>().inner())?;
let state = app.state::<CoreDownloadState>();
let process_state = app.state::<CoreProcessState>();
let gui_config_state = app.state::<GuiConfigState>();
let (info, archive_path) = bundled_core_archive()?
.ok_or_else(|| "当前发行包没有匹配此系统架构的内置内核".to_string())?;
let token = CancellationToken::new();
state.start(token, Some(info.version.clone()))?;
let result = install_core_with_runtime_restore(
&app,
process_state.inner(),
gui_config_state.inner(),
|| install_bundled_core_inner(&window, state.inner(), &info, &archive_path),
);
if result.is_err() {
let _ = cleanup_core_work_dirs();
}
state.finish(&window, result.clone());
result
})
.await
.map_err(|error| format!("离线内核安装后台任务失败: {error}"))?
}
pub(crate) fn core_needs_bundled_bootstrap(install_dir: &Path) -> bool {
find_core_binary(install_dir).is_none()
}
pub(crate) fn auto_install_bundled_core_if_missing(app: &tauri::AppHandle) -> Result<bool, String> {
let install_dir = core_install_dir()?;
if !core_needs_bundled_bootstrap(&install_dir) {
return Ok(false);
}
let (info, archive_path) = bundled_core_archive()?
.ok_or_else(|| "未检测到 CPA 内核,且当前发行包没有匹配的离线内核".to_string())?;
let window = app
.get_webview_window("main")
.map(|webview| webview.as_ref().window())
.ok_or_else(|| "无法获取主窗口,不能自动安装离线内核".to_string())?;
let state = app.state::<CoreDownloadState>();
state.start(CancellationToken::new(), Some(info.version.clone()))?;
let result = install_bundled_core_inner(&window, state.inner(), &info, &archive_path);
if result.is_err() {
let _ = cleanup_core_work_dirs();
}
state.finish(&window, result.clone());
result?;
Ok(true)
}
#[tauri::command]
pub(crate) fn cancel_core_install(state: tauri::State<'_, CoreDownloadState>) {
state.cancel();
}
#[tauri::command]
pub(crate) fn get_core_install_task(state: tauri::State<'_, CoreDownloadState>) -> CoreInstallTask {
state.snapshot()
}
#[tauri::command]
pub(crate) async fn install_core_version(
app: tauri::AppHandle,
window: tauri::Window,
version: Option<String>,
) -> Result<CoreInstallResult, String> {
tauri::async_runtime::spawn_blocking(move || {
let _guard = lock_core_operation(app.state::<CoreProcessState>().inner())?;
let state = app.state::<CoreDownloadState>();
let process_state = app.state::<CoreProcessState>();
let gui_config_state = app.state::<GuiConfigState>();
let config = gui_config_state.snapshot()?;
let proxy_url = config.proxy_url.clone();
let token = CancellationToken::new();
state.start(token.clone(), version.clone())?;
let (was_running, install_result) =
match pause_core_for_install(&app, process_state.inner(), &config) {
Ok(was_running) => (
was_running,
tauri::async_runtime::block_on(install_core_version_inner(
&app,
&window,
state.inner(),
gui_config_state.inner(),
token,
version,
&proxy_url,
config.selected_download_candidate(),
config.custom_download_mirrors.clone(),
)),
),
Err(error) => (false, Err(error)),
};
let result = restore_core_after_install(
&app,
process_state.inner(),
&config,
was_running,
install_result,
);
if result.is_err() {
let _ = cleanup_core_work_dirs();
}
state.finish(&window, result.clone());
result
})
.await
.map_err(|error| format!("内核安装后台任务失败: {error}"))?
}
fn install_core_with_runtime_restore<F>(
app: &tauri::AppHandle,
process_state: &CoreProcessState,
gui_config_state: &GuiConfigState,
install: F,
) -> Result<CoreInstallResult, String>
where
F: FnOnce() -> Result<CoreInstallResult, String>,
{
let config = gui_config_state.snapshot()?;
let was_running = pause_core_for_install(app, process_state, &config)?;
let result = install();
restore_core_after_install(app, process_state, &config, was_running, result)
}
fn pause_core_for_install(
app: &tauri::AppHandle,
process_state: &CoreProcessState,
config: &GuiConfigFile,
) -> Result<bool, String> {
let was_running = pause_core_process_for_install(process_state)?;
if was_running {
emit_current_core_status(app, process_state, config.port);
}
Ok(was_running)
}
pub(crate) fn pause_core_process_for_install(
process_state: &CoreProcessState,
) -> Result<bool, String> {
process_state.ensure_active()?;
// A process can hold files even when its HTTP port is not responding.
let was_running = current_core_status(Some(process_state), None)?.running;
if was_running {
stop_core_process_inner(process_state)?;
}
Ok(was_running)
}
fn restore_core_after_install<T>(
app: &tauri::AppHandle,
process_state: &CoreProcessState,
config: &GuiConfigFile,
was_running: bool,
install_result: Result<T, String>,
) -> Result<T, String> {
let restart_result = if was_running && !process_state.is_shutting_down() {
start_core_process_inner(process_state, config)
} else {
Ok(())
};
emit_current_core_status(app, process_state, config.port);
combine_install_and_restart_results(install_result, restart_result)
}
pub(crate) fn combine_install_and_restart_results<T>(
install_result: Result<T, String>,
restart_result: Result<(), String>,
) -> Result<T, String> {
match (install_result, restart_result) {
(Ok(result), Ok(())) => Ok(result),
(Ok(_), Err(restart_error)) => {
Err(format!("内核已安装,但自动恢复运行失败: {restart_error}"))
}
(Err(install_error), Ok(())) => Err(install_error),
(Err(install_error), Err(restart_error)) => Err(format!(
"{install_error};自动恢复原内核运行状态也失败: {restart_error}"
)),
}
}
fn emit_current_core_status(app: &tauri::AppHandle, process_state: &CoreProcessState, port: u16) {
if let Ok(status) = current_core_status(Some(process_state), Some(port)) {
emit_core_status(app, &status);
}
}
#[tauri::command]
pub(crate) async fn start_core_process(app: tauri::AppHandle) -> Result<CoreStatus, String> {
run_core_command(app, start_core_process_with_state).await
}
pub(crate) fn start_core_process_with_state(
process_state: &CoreProcessState,
gui_config_state: &GuiConfigState,
) -> Result<CoreStatus, String> {
let config = gui_config_state.snapshot()?;
start_core_process_inner(process_state, &config)?;
if let Err(error) = gui_config_state.set_run_on_startup(true) {
let _ = stop_core_process_inner(process_state);
return Err(error);
}
current_core_status(Some(process_state), Some(config.port))
}
#[tauri::command]
pub(crate) async fn stop_core_process(app: tauri::AppHandle) -> Result<CoreStatus, String> {
run_core_command(app, stop_core_process_with_state).await
}
pub(crate) fn stop_core_process_with_state(
process_state: &CoreProcessState,
gui_config_state: &GuiConfigState,
) -> Result<CoreStatus, String> {
stop_core_process_inner(process_state)?;
let config = gui_config_state.set_run_on_startup(false)?;
current_core_status(Some(process_state), Some(config.port))
}
#[tauri::command]
pub(crate) async fn restart_core_process(app: tauri::AppHandle) -> Result<CoreStatus, String> {
run_core_command(app, restart_core_process_with_state).await
}
pub(crate) fn restart_core_process_with_state(
process_state: &CoreProcessState,
gui_config_state: &GuiConfigState,
) -> Result<CoreStatus, String> {
let config = gui_config_state.snapshot()?;
if current_core_status(Some(process_state), None)?.running {
stop_core_process_inner(process_state)?;
}
start_core_process_inner(process_state, &config)?;
if let Err(error) = gui_config_state.set_run_on_startup(true) {
let _ = stop_core_process_inner(process_state);
return Err(error);
}
current_core_status(Some(process_state), Some(config.port))
}
pub(crate) async fn install_core_version_inner(
app: &tauri::AppHandle,
window: &tauri::Window,
state: &CoreDownloadState,
gui_config_state: &GuiConfigState,
token: CancellationToken,
version: Option<String>,
proxy_url: &str,
download_source: VersionDownloadCandidate,
custom_mirrors: Vec<String>,
) -> Result<CoreInstallResult, String> {
let platform = current_core_platform()?;
let client = http_client(proxy_url, &custom_mirrors)?;
state.progress(window, "检查版本", 0, None, true);
let requested_source = download_source.clone();
let (release, resolved_source) = fetch_release_cancelable(
&client,
version.as_deref(),
&token,
download_source,
&custom_mirrors,
)
.await?;
let asset = select_release_asset(&release, &platform)?;
let download_candidates = core_download_candidates(
&release.tag_name,
asset,
resolved_source,
configured_gitcode_core_repository(),
&custom_mirrors,
);
let install_dir = core_install_dir()?;
let base_dir = core_base_dir()?;
let staging_dir = base_dir.join("cpa-core.staging");
let download_dir = base_dir.join("cpa-core.download");
if current_core_status(None, None)?.running {
return Err("CPA 内核正在运行,请先停止后再安装或更新".to_string());
}
reset_dir(&staging_dir)?;
reset_dir(&download_dir)?;
let archive_file_name = Path::new(&asset.name)
.file_name()
.and_then(|file_name| file_name.to_str())
.ok_or_else(|| format!("非法 asset 文件名: {}", asset.name))?;
let archive_path = download_dir.join(archive_file_name);
let (downloaded, successful_source) = download_asset(
&client,
asset,
&download_candidates,
&archive_path,
window,
state,
&token,
)
.await?;
validate_downloaded_asset(asset, &downloaded)?;
persist_automatic_download_source_switch(
app,
gui_config_state,
&requested_source,
&successful_source,
)?;
ensure_not_cancelled(&token, Some(&archive_path))?;
state.progress(
window,
"解压中",
downloaded.size,
Some(downloaded.size),
false,
);
match platform.archive_kind.as_str() {
"tar.gz" => extract_tar_gz(&archive_path, &staging_dir)?,
"zip" => extract_zip(&archive_path, &staging_dir)?,
other => return Err(format!("不支持的压缩包类型: {other}")),
}
ensure_not_cancelled(&token, Some(&archive_path))?;
let binary_path = find_core_binary(&staging_dir)
.ok_or_else(|| "解压后未找到 CPA 内核二进制文件".to_string())?;
let binary_relative_path = binary_path
.strip_prefix(&staging_dir)
.map_err(|err| format!("计算内核二进制相对路径失败: {err}"))?
.to_path_buf();
migrate_core_config_for_update(&install_dir, &staging_dir)?;
preserve_bundled_core_assets(&install_dir, &staging_dir)?;
write_core_metadata(
&staging_dir,
&CoreMetadata {
version: normalize_version(&release.tag_name),
asset_name: asset.name.clone(),
installed_at_unix: unix_now(),
},
)?;
overlay_install_dir(&install_dir, &staging_dir)?;
let _ = fs::remove_dir_all(&download_dir);
Ok(CoreInstallResult {
version: normalize_version(&release.tag_name),
asset_name: asset.name.clone(),
install_dir: path_to_string(&install_dir),
binary_path: Some(path_to_string(&install_dir.join(binary_relative_path))),
})
}
pub(crate) fn install_bundled_core_inner(
window: &tauri::Window,
state: &CoreDownloadState,
info: &BundledCoreInfo,
archive_path: &Path,
) -> Result<CoreInstallResult, String> {
let platform = current_core_platform()?;
let install_dir = core_install_dir()?;
let base_dir = core_base_dir()?;
let staging_dir = base_dir.join("cpa-core.staging");
if current_core_status(None, None)?.running {
return Err("CPA 内核正在运行,请先停止后再使用内置内核".to_string());
}
let archive_size = fs::metadata(archive_path)
.map_err(|error| format!("读取内置内核压缩包失败: {error}"))?
.len();
state.progress(window, "校验内置内核", 0, Some(archive_size), false);
validate_bundled_core_checksum(archive_path)?;
reset_dir(&staging_dir)?;
state.progress(
window,
"解压内置内核",
archive_size,
Some(archive_size),
false,
);
match platform.archive_kind.as_str() {
"tar.gz" => extract_tar_gz(archive_path, &staging_dir)?,
"zip" => extract_zip(archive_path, &staging_dir)?,
other => return Err(format!("不支持的内置压缩包类型: {other}")),
}
let binary_path = find_core_binary(&staging_dir)
.ok_or_else(|| "内置压缩包中没有 CPA 内核二进制文件".to_string())?;
let binary_relative_path = binary_path
.strip_prefix(&staging_dir)
.map_err(|error| format!("计算内置内核二进制路径失败: {error}"))?
.to_path_buf();
migrate_core_config_for_update(&install_dir, &staging_dir)?;
preserve_bundled_core_assets(&install_dir, &staging_dir)?;
preserve_selected_bundled_core_asset(archive_path, &staging_dir)?;
write_core_metadata(
&staging_dir,
&CoreMetadata {
version: info.version.clone(),
asset_name: info.asset_name.clone(),
installed_at_unix: unix_now(),
},
)?;
overlay_install_dir(&install_dir, &staging_dir)?;
Ok(CoreInstallResult {
version: info.version.clone(),
asset_name: info.asset_name.clone(),
install_dir: path_to_string(&install_dir),
binary_path: Some(path_to_string(&install_dir.join(binary_relative_path))),
})
}
pub(crate) async fn fetch_release(
client: &reqwest::Client,
version: Option<&str>,
source: VersionDownloadCandidate,
custom_mirrors: &[String],
) -> Result<(GithubRelease, VersionDownloadCandidate), String> {
if let Some(version) = version {
return Ok((
release_from_tag_for_repositories(
version,
configured_gitcode_core_repository(),
&source,
),
source,
));
}
let gitcode_repository = configured_gitcode_core_repository();
let mut failures = Vec::new();
for candidate in
version_download_source_candidates(source, gitcode_repository.is_some(), custom_mirrors)
{
let result = match candidate.source {
VersionDownloadSource::Gitcode => {
fetch_release_from_gitcode(
client,
gitcode_repository.expect("GitCode candidate requires a configured repository"),
)
.await
}
VersionDownloadSource::Github
| VersionDownloadSource::GhProxy
| VersionDownloadSource::GhFast
| VersionDownloadSource::Custom => fetch_release_from_github(client, &candidate).await,
};
match result {
Ok(release) => return Ok((release, candidate)),
Err(error) => failures.push(format!("{}: {error}", candidate.display_name())),
}
}
Err(format!("所有内核版本检测源均失败: {}", failures.join(";")))
}
pub(crate) async fn fetch_release_from_github(
client: &reqwest::Client,
source: &VersionDownloadCandidate,
) -> Result<GithubRelease, String> {
let atom_result = fetch_release_from_atom(client, source).await;
match atom_result {
Ok(release) => Ok(release),
Err(atom_error) => fetch_release_from_page(client, source)
.await
.map_err(|page_error| {
format!("GitHub 发布源请求失败: {atom_error};release 页面请求失败: {page_error}")
}),
}
}
pub(crate) async fn fetch_release_from_gitcode(
client: &reqwest::Client,
repository: &str,
) -> Result<GithubRelease, String> {
let release_url = format!("https://api.gitcode.com/api/v5/repos/{repository}/releases/latest");
let release = client
.get(release_url)
.header(reqwest::header::ACCEPT, "application/json")
.header(reqwest::header::USER_AGENT, USER_AGENT)
.send()
.await
.map_err(|error| format!("查询 GitCode 最新内核发行版失败: {error}"))?
.error_for_status()
.map_err(|error| format!("读取 GitCode 最新内核发行版失败: {error}"))?
.json::<GitcodeRelease>()
.await
.map_err(|error| format!("解析 GitCode 最新内核发行版失败: {error}"))?;
validate_release_tag(&release.tag_name)?;
Ok(release_from_gitcode_tag(&release.tag_name, repository))
}
pub(crate) async fn fetch_release_from_page(
client: &reqwest::Client,
source: &VersionDownloadCandidate,
) -> Result<GithubRelease, String> {
let release_page_url = version_source_url(source, RELEASE_PAGE_URL);
let response = client
.get(release_page_url)
.header(reqwest::header::ACCEPT, "text/html,application/xhtml+xml")
.header(reqwest::header::USER_AGENT, USER_AGENT)
.send()
.await
.map_err(|err| format!("GitHub release 页面请求失败: {err}"))?;
let status = response.status();
let final_url = response.url().clone();
if !status.is_success() {
let body = response
.text()
.await
.map_err(|err| format!("读取 GitHub release 页面失败: {err}"))?;
return Err(format_github_error(status.as_u16(), &body));
}
let tag = release_tag_from_url(&final_url)
.ok_or_else(|| "GitHub release 页面没有返回版本标签".to_string())?;
Ok(release_from_tag_for_repositories(
&tag,
configured_gitcode_core_repository(),
source,
))
}
pub(crate) async fn fetch_release_from_atom(
client: &reqwest::Client,
source: &VersionDownloadCandidate,
) -> Result<GithubRelease, String> {
let release_atom_url = version_source_url(source, RELEASE_ATOM_URL);
let response = client
.get(release_atom_url)
.header(
reqwest::header::ACCEPT,
"application/atom+xml,application/xml,text/xml",
)
.header(reqwest::header::USER_AGENT, USER_AGENT)
.send()
.await
.map_err(|err| format!("GitHub Atom feed 请求失败: {err}"))?;
let status = response.status();
let body = response
.text()
.await
.map_err(|err| format!("读取 GitHub Atom feed 失败: {err}"))?;
if !status.is_success() {
return Err(format_github_error(status.as_u16(), &body));
}
let tag = release_tag_from_atom(&body)
.ok_or_else(|| "GitHub Atom feed 没有返回版本标签".to_string())?;
Ok(release_from_tag_for_repositories(
&tag,
configured_gitcode_core_repository(),
source,
))
}
pub(crate) fn release_tag_from_atom(xml: &str) -> Option<String> {
let entry = xml.split_once("<entry>")?.1;
if let Some(tag_path) = entry.split_once("/releases/tag/").map(|(_, value)| value) {
let tag = tag_path
.split(['\"', '<', '?', '#'])
.next()
.unwrap_or_default()
.trim_matches('/');
if !tag.is_empty() {
return Some(normalize_version(tag));
}
}
let title = entry
.split_once("<title>")?
.1
.split_once("</title>")?
.0
.trim();
(!title.is_empty()).then(|| normalize_version(title))
}
#[cfg(test)]
pub(crate) fn release_from_tag(tag: &str) -> GithubRelease {
release_from_tag_for_repositories(
tag,
configured_gitcode_core_repository(),
&VersionDownloadCandidate::builtin(VersionDownloadSource::Github),
)
}
pub(crate) fn release_from_gitcode_tag(tag: &str, repository: &str) -> GithubRelease {
release_from_tag_for_repositories(
tag,
Some(repository),
&VersionDownloadCandidate::builtin(VersionDownloadSource::Gitcode),
)
}
pub(crate) fn release_from_tag_for_repositories(
tag: &str,
gitcode_repository: Option<&str>,
source: &VersionDownloadCandidate,
) -> GithubRelease {
let tag = normalize_version(tag);
let version = tag.trim_start_matches('v');
let assets = [
("linux", "amd64", "tar.gz"),
("linux", "aarch64", "tar.gz"),
("darwin", "amd64", "tar.gz"),
("darwin", "aarch64", "tar.gz"),
("windows", "amd64", "zip"),
("windows", "aarch64", "zip"),
]
.into_iter()
.map(|(os, arch, extension)| {
let name = format!("CLIProxyAPI_{version}_{os}_{arch}.{extension}");
let github_url = format!("{RELEASE_DOWNLOAD_PREFIX}{tag}/{name}");
let gitcode_url = gitcode_repository
.map(|repository| gitcode_release_attachment_url(repository, &tag, &name));
let (browser_download_url, fallback_download_urls) = match source.source {
VersionDownloadSource::Gitcode => match gitcode_url {
Some(gitcode_url) => (gitcode_url, vec![github_url]),
None => (github_url, Vec::new()),
},
VersionDownloadSource::GhProxy | VersionDownloadSource::GhFast => {
let mirror_url = version_source_url(source, &github_url);
let mut fallbacks = vec![github_url];
fallbacks.extend(gitcode_url);
(mirror_url, fallbacks)
}
VersionDownloadSource::Github => (github_url, gitcode_url.into_iter().collect()),
VersionDownloadSource::Custom => {
let mirror_url = version_source_url(source, &github_url);
let mut fallbacks = vec![github_url];
fallbacks.extend(gitcode_url);
(mirror_url, fallbacks)
}
};
GithubAsset {
browser_download_url,
fallback_download_urls,
name,
size: None,
digest: None,
}
})
.collect();
GithubRelease {
tag_name: tag,
assets,
}
}
pub(crate) fn core_download_candidates(
tag: &str,
asset: &GithubAsset,
preferred: VersionDownloadCandidate,
gitcode_repository: Option<&str>,
custom_mirrors: &[String],
) -> Vec<(VersionDownloadCandidate, String)> {
let tag = normalize_version(tag);
let generated_github_url = format!("{RELEASE_DOWNLOAD_PREFIX}{tag}/{}", asset.name);
let provided_urls = std::iter::once(&asset.browser_download_url)
.chain(asset.fallback_download_urls.iter())
.collect::<Vec<_>>();
let github_url = provided_urls
.iter()
.find(|url| core_download_source_name(url) == "GitHub")
.map(|url| (*url).clone())
.unwrap_or(generated_github_url);
let gitcode_url = provided_urls
.iter()
.find(|url| core_download_source_name(url) == "GitCode")
.map(|url| (*url).clone())
.or_else(|| {
gitcode_repository
.map(|repository| gitcode_release_attachment_url(repository, &tag, &asset.name))
});
version_download_source_candidates(preferred, gitcode_url.is_some(), custom_mirrors)
.into_iter()
.filter_map(|candidate| {
let url = match candidate.source {
VersionDownloadSource::Github => github_url.clone(),
VersionDownloadSource::Gitcode => gitcode_url.clone()?,
VersionDownloadSource::GhProxy
| VersionDownloadSource::GhFast
| VersionDownloadSource::Custom => version_source_url(&candidate, &github_url),
};
Some((candidate, url))
})
.collect()
}
pub(crate) fn release_tag_from_url(url: &reqwest::Url) -> Option<String> {
let mut segments = url.path_segments()?;
let tag = segments.next_back()?.trim();
if tag.is_empty() || tag == "latest" {
None
} else {
Some(tag.to_string())
}
}
pub(crate) fn is_app_update_available(current: &str, latest: &str) -> Result<bool, String> {
let parse = |value: &str| {
semver::Version::parse(value.trim().trim_start_matches('v'))
.map_err(|error| format!("无法解析版本号 {value}: {error}"))
};
Ok(parse(latest)? > parse(current)?)
}
#[cfg(test)]
pub(crate) fn parse_release_assets(html: &str) -> Vec<GithubAsset> {
let mut assets = Vec::new();
let mut cursor = 0;
while let Some(relative_start) = html[cursor..].find("releases/download/") {
let download_start = cursor + relative_start;
let Some(href_start) = html[..download_start].rfind("href=\"") else {
cursor = download_start + "releases/download/".len();
continue;
};
let href_start = href_start + "href=\"".len();
let Some(relative_end) = html[download_start..].find('"') else {
break;
};
let href_end = download_start + relative_end;
let href = &html[href_start..href_end];
let Some(name) = href.rsplit('/').next().filter(|name| !name.is_empty()) else {
cursor = href_end + 1;
continue;
};
let item_end = html[href_end..]
.find("</li>")
.map(|offset| href_end + offset)
.unwrap_or(html.len());
let item = &html[href_start..item_end];
let digest = item.find("sha256:").and_then(|offset| {
let value = &item[offset + "sha256:".len()..];
let hash: String = value
.chars()
.take_while(|character| character.is_ascii_hexdigit())
.collect();
(hash.len() == 64).then(|| format!("sha256:{hash}"))
});
let browser_download_url = if href.starts_with("http://") || href.starts_with("https://") {
href.to_string()
} else {
format!("https://github.com{href}")
};
if !assets.iter().any(|asset: &GithubAsset| asset.name == name) {
assets.push(GithubAsset {
name: name.to_string(),
browser_download_url,
fallback_download_urls: Vec::new(),
size: None,
digest,
});
}
cursor = href_end + 1;
}
assets
}
pub(crate) fn format_github_error(status: u16, body: &str) -> String {
if let Ok(value) = serde_json::from_str::<serde_json::Value>(body) {
if let Some(message) = value.get("message").and_then(|item| item.as_str()) {
return format!("GitHub 返回错误 ({status}): {}", message.trim());
}
}
let body = body.trim();
if body.is_empty() {
format!("GitHub 返回错误 ({status})")
} else {
format!("GitHub 返回错误 ({status}): {}", truncate_for_error(body))
}
}
pub(crate) async fn fetch_release_cancelable(
client: &reqwest::Client,
version: Option<&str>,
token: &CancellationToken,
source: VersionDownloadCandidate,
custom_mirrors: &[String],
) -> Result<(GithubRelease, VersionDownloadCandidate), String> {
tokio::select! {
result = fetch_release(client, version, source, custom_mirrors) => result,
_ = token.cancelled() => Err("已取消下载".to_string()),
}
}
pub(crate) fn apply_configured_proxy(
builder: reqwest::ClientBuilder,
proxy_url: &str,
) -> Result<reqwest::ClientBuilder, String> {
let proxy_url = proxy_url.trim();
if proxy_url.is_empty() {
return Ok(builder);
}
let proxy =
reqwest::Proxy::all(proxy_url).map_err(|error| format!("代理 URL 无效: {error}"))?;
Ok(builder.proxy(proxy))
}
pub(crate) fn build_http_client_with_proxy(
builder: reqwest::ClientBuilder,
proxy_url: &str,
error_prefix: &str,
) -> Result<reqwest::Client, String> {
apply_configured_proxy(builder, proxy_url)
.map_err(|error| format!("{error_prefix}: {error}"))?
.build()
.map_err(|error| format!("{error_prefix}: {error}"))
}
pub(crate) fn http_client(
proxy_url: &str,
custom_mirrors: &[String],
) -> Result<reqwest::Client, String> {
build_http_client_with_proxy(
reqwest::Client::builder()
.redirect(release_https_redirect_policy_with_mirrors(custom_mirrors))
.connect_timeout(Duration::from_secs(15))
.read_timeout(Duration::from_secs(30))
.timeout(Duration::from_secs(600)),
proxy_url,
"创建 HTTP 客户端失败",
)
}
pub(crate) fn select_release_asset<'a>(
release: &'a GithubRelease,
platform: &CorePlatform,
) -> Result<&'a GithubAsset, String> {
let expected_name = core_release_asset_name(&release.tag_name, platform);
let mut matches = release
.assets
.iter()
.filter(|asset| asset.name == expected_name && !asset.name.contains("_no-plugin"));
let asset = matches
.next()
.ok_or_else(|| format!("未找到匹配当前平台的 release asset: {expected_name}"))?;
if matches.next().is_some() {
return Err(format!("找到多个匹配的 release asset: {expected_name}"));
}
Ok(asset)
}
pub(crate) fn core_release_asset_name(version: &str, platform: &CorePlatform) -> String {
let version = normalize_version(version);
let version = version.trim_start_matches('v');
format!(
"CLIProxyAPI_{}_{}_{}.{}",
version, platform.asset_os, platform.asset_arch, platform.archive_kind
)
}
// Download progress and cancellation require the complete transfer context here.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn download_asset(
client: &reqwest::Client,
asset: &GithubAsset,
download_candidates: &[(VersionDownloadCandidate, String)],
archive_path: &Path,
window: &tauri::Window,
state: &CoreDownloadState,
token: &CancellationToken,
) -> Result<(DownloadedArchive, VersionDownloadCandidate), String> {
let mut failures = Vec::new();
for (index, (candidate, url)) in download_candidates.iter().enumerate() {
if index > 0 {
state.progress(
window,
&format!("下载失败,正在切换到 {}", candidate.display_name()),
0,
asset.size,
true,
);
}
let result = download_asset_inner(
client,
url,
archive_path,
asset.size,
asset.digest.as_deref(),
window,
state,
token,
)
.await;
match result {
Ok(downloaded) => return Ok((downloaded, candidate.clone())),
Err(error) if token.is_cancelled() => {
let _ = fs::remove_file(archive_path);
return Err(error);
}
Err(error) => {
let _ = fs::remove_file(archive_path);
failures.push(error);
}
}
}
if failures.is_empty() {
let _ = fs::remove_file(archive_path);
return Err("内核发行版没有可用的下载地址".to_string());
}
Err(format!("所有内核下载源均失败: {}", failures.join(";")))
}
pub(crate) fn core_download_source_name(url: &str) -> String {
let host = reqwest::Url::parse(url)
.ok()
.and_then(|url| url.host_str().map(str::to_string));
match host.as_deref() {
Some("gh-proxy.com") => "gh-proxy.com".to_string(),
Some("ghfast.top") => "ghfast.top".to_string(),
Some(host) if host == "api.gitcode.com" || host.ends_with(".gitcode.com") => {
"GitCode".to_string()
}
Some(
"github.com" | "objects.githubusercontent.com" | "release-assets.githubusercontent.com",
) => "GitHub".to_string(),
Some(host) => host.to_string(),
None => "GitHub".to_string(),
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn download_asset_inner(
client: &reqwest::Client,
url: &str,
archive_path: &Path,
expected_total: Option<u64>,
expected_digest: Option<&str>,
window: &tauri::Window,
state: &CoreDownloadState,
token: &CancellationToken,
) -> Result<DownloadedArchive, String> {
state.progress(window, "准备下载", 0, expected_total, true);
ensure_not_cancelled(token, Some(archive_path))?;
let request = client
.get(url)
.header(reqwest::header::USER_AGENT, USER_AGENT)
.send();
let response = tokio::select! {
response = request => response.map_err(|err| format!("下载内核压缩包失败: {err}"))?,
_ = token.cancelled() => return Err("已取消下载".to_string()),
}
.error_for_status()
.map_err(|err| format!("下载地址返回错误状态: {err}"))?;
let total = expected_total.or_else(|| response.content_length());
let mut stream = response.bytes_stream();
let mut file =
File::create(archive_path).map_err(|err| format!("创建内核压缩包失败: {err}"))?;
let mut downloaded = 0_u64;
let mut hasher = Sha256::new();
let mut progress = crate::progress::ProgressThrottle::default();
while let Some(chunk) = tokio::select! {
chunk = stream.next() => chunk,
_ = token.cancelled() => return Err("已取消下载".to_string()),
} {
ensure_not_cancelled(token, Some(archive_path))?;
let chunk = chunk.map_err(|err| format!("读取下载数据失败: {err}"))?;
file.write_all(&chunk)
.map_err(|err| format!("保存下载数据失败: {err}"))?;
hasher.update(&chunk);
downloaded += chunk.len() as u64;
if progress.ready(Instant::now(), total == Some(downloaded)) {
state.progress(window, "下载中", downloaded, total, true);
}
}
state.progress(window, "下载中", downloaded, total, true);
file.flush()
.map_err(|err| format!("刷新内核压缩包失败: {err}"))?;
ensure_not_cancelled(token, Some(archive_path))?;
let sha256 = format!("{:x}", hasher.finalize());
validate_download_metadata(downloaded, expected_total, &sha256, expected_digest)?;
Ok(DownloadedArchive {
size: downloaded,
sha256,
})
}
pub(crate) fn ensure_not_cancelled(
token: &CancellationToken,
archive_path: Option<&Path>,
) -> Result<(), String> {
if token.is_cancelled() {
if let Some(archive_path) = archive_path {
let _ = fs::remove_file(archive_path);
}
return Err("已取消下载".to_string());
}
Ok(())
}
pub(crate) fn current_core_platform() -> Result<CorePlatform, String> {
let os = env::consts::OS;
let arch = env::consts::ARCH;
let (asset_os, archive_kind) = match os {
"linux" => ("linux", "tar.gz"),
"macos" => ("darwin", "tar.gz"),
"windows" => ("windows", "zip"),
other => return Err(format!("不支持的操作系统: {other}")),
};
let asset_arch = match arch {
"x86_64" => "amd64",
"aarch64" => "aarch64",
other => return Err(format!("不支持的 CPU 架构: {other}")),
};
Ok(CorePlatform {
os: os.to_string(),
arch: arch.to_string(),
asset_os: asset_os.to_string(),
asset_arch: asset_arch.to_string(),
archive_kind: archive_kind.to_string(),
})
}
pub(crate) fn current_core_status(
process_state: Option<&CoreProcessState>,
management_port: Option<u16>,
) -> Result<CoreStatus, String> {
let install_dir = core_install_dir()?;
let binary_path = find_core_binary(&install_dir);
let installed = binary_path.is_some();
let starting = process_state.is_some_and(CoreProcessState::is_starting);
let managed_pid = process_state.and_then(|state| state.managed_pid());
let management_port_open = management_port.map(is_management_port_open);
let process_id = match managed_pid {
Some(process_id) => Some(process_id),
None if management_port_open.unwrap_or(true) => binary_path
.as_ref()
.and_then(|path| find_core_process_ids(path).first().copied()),
None => None,
};
let running = process_id.is_some() && management_port_open.unwrap_or(true);
let current_version = read_core_metadata(&install_dir).map(|metadata| metadata.version);
let message = if starting {
"CPA 内核正在启动".to_string()
} else if !installed {
"未安装 CPA 内核,请先安装最新版".to_string()
} else if running {
"CPA 内核正在运行".to_string()
} else {
"CPA 内核已安装,当前未运行".to_string()
};
Ok(CoreStatus {
installed,
running,
starting,
managed: managed_pid.is_some(),
process_id,
current_version,
install_dir: path_to_string(&install_dir),
binary_path: binary_path.map(|path| path_to_string(&path)),
message,
})
}
pub(crate) fn is_management_port_open(port: u16) -> bool {
let listen_host = read_installed_core_config_settings()
.map(|settings| settings.host)
.unwrap_or_else(|_| "127.0.0.1".to_string());
let Ok(address) = core_management_address(&listen_host, port) else {
return false;
};
TcpStream::connect_timeout(&address, Duration::from_millis(150)).is_ok()
}
fn core_management_address(listen_host: &str, port: u16) -> Result<SocketAddr, String> {
let host = core_connect_host(listen_host);
let ip = host
.parse::<IpAddr>()
.map_err(|_| format!("Invalid core listen IP: {listen_host}"))?;
Ok(SocketAddr::new(ip, port))
}
pub(crate) enum CoreStartupFailure {
ShuttingDown,
Exited(std::process::ExitStatus),
Spawn(String),
StatusCheck(io::Error),
TimedOut(u16),
}
impl CoreStartupFailure {
fn child_has_exited(&self) -> bool {
matches!(self, Self::Exited(_))
}
#[cfg(target_os = "macos")]
fn was_killed_by_sigkill(&self) -> bool {
use std::os::unix::process::ExitStatusExt;
matches!(
self,
Self::Exited(status)
if status.code().is_none() && status.signal() == Some(libc::SIGKILL)
)
}
}
impl std::fmt::Display for CoreStartupFailure {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ShuttingDown => formatter.write_str("应用正在退出,已取消内核启动"),
Self::Exited(status) => write!(formatter, "CPA 内核启动后立即退出: {status}"),
Self::Spawn(error) => formatter.write_str(error),
Self::StatusCheck(error) => write!(formatter, "检查 CPA 内核启动状态失败: {error}"),
Self::TimedOut(port) => {
write!(formatter, "CPA 内核启动超时:10 秒内未监听管理端口 {port}")
}
}
}
}
pub(crate) fn core_start_log_path(install_dir: &Path, auth_dir: &str) -> PathBuf {
core_logs_dir_path(auth_dir, install_dir).join("core-start-output.log")
}
pub(crate) fn core_start_stdio(log_path: &Path) -> io::Result<(Stdio, Stdio)> {
if let Some(parent) = log_path.parent() {
fs::create_dir_all(parent)?;
}
// Keep only the current process run so console output cannot grow without
// bound across restarts. Both child handles use append mode to avoid their
// independent file cursors overwriting each other's output.
let mut header_file = File::options()
.write(true)
.create(true)
.truncate(true)
.open(log_path)?;
writeln!(header_file, "===== CPA 内核启动 {} =====", unix_now())?;
drop(header_file);
let stdout_file = File::options().append(true).open(log_path)?;
let stderr_file = File::options().append(true).create(true).open(log_path)?;
Ok((Stdio::from(stdout_file), Stdio::from(stderr_file)))
}
fn core_start_error_with_log(error: &str, log_path: &Path, log_error: Option<&str>) -> String {
match log_error {
Some(log_error) => format!(
"{error};无法写入启动日志 {}: {log_error}",
path_to_string(log_path)
),
None => format!("{error};启动日志: {}", path_to_string(log_path)),
}
}
struct CoreStartAttemptFailure {
failure: CoreStartupFailure,
log_error: Option<String>,
}
impl CoreStartAttemptFailure {
#[cfg(target_os = "macos")]
fn was_killed_by_sigkill(&self) -> bool {
self.failure.was_killed_by_sigkill()
}
fn message(&self, log_path: &Path) -> String {
self.message_with_detail(log_path, None)
}
fn message_with_detail(&self, log_path: &Path, detail: Option<&str>) -> String {
let error = match detail {
Some(detail) => format!("{};{detail}", self.failure),
None => self.failure.to_string(),
};
core_start_error_with_log(&error, log_path, self.log_error.as_deref())
}
}
fn start_core_process_once(
process_state: &CoreProcessState,
binary_path: &Path,
config_path: &str,
install_dir: &Path,
log_path: &Path,
management_address: SocketAddr,
) -> Result<CoreChild, CoreStartAttemptFailure> {
let mut command = Command::new(binary_path);
command
.args(["-config", config_path])
.current_dir(install_dir)
.stdin(Stdio::null());
let log_error = match core_start_stdio(log_path) {
Ok((stdout, stderr)) => {
command.stdout(stdout).stderr(stderr);
None
}
Err(error) => {
command.stdout(Stdio::null()).stderr(Stdio::null());
Some(error.to_string())
}
};
configure_background_command(&mut command);
let mut child = match spawn_core_child(command) {
Ok(child) => child,
Err(error) => {
return Err(CoreStartAttemptFailure {
failure: CoreStartupFailure::Spawn(error),
log_error,
});
}
};
match wait_for_core_management_port(&mut child, management_address, process_state) {
Ok(()) => Ok(child),
Err(failure) => {
if !failure.child_has_exited() {
let _ = terminate_child(&mut child);
}
Err(CoreStartAttemptFailure { failure, log_error })
}
}
}
pub(crate) fn start_core_process_inner(
process_state: &CoreProcessState,
gui_config: &GuiConfigFile,
) -> Result<(), String> {
process_state.ensure_active()?;
let install_dir = core_install_dir()?;
if !gui_config.auth_dir.trim().is_empty() {
let auth_dir = auth_dir_path_for_core(&gui_config.auth_dir, &install_dir);
fs::create_dir_all(&auth_dir)
.map_err(|error| format!("创建凭证目录失败 {}: {error}", path_to_string(&auth_dir)))?;
}
let binary_path = find_core_binary(&install_dir)
.ok_or_else(|| "未安装 CPA 内核,请先安装最新版".to_string())?;
let existing_process_ids = find_core_process_ids(&binary_path);
if process_state.managed_pid().is_some() || !existing_process_ids.is_empty() {
if !existing_process_ids.is_empty() {
process_state.adopt_process_ids(&binary_path, existing_process_ids)?;
}
return Err("CPA 内核已经在运行".to_string());
}
let management_address = core_management_address(&gui_config.host, gui_config.port)?;
if TcpStream::connect_timeout(&management_address, Duration::from_millis(250)).is_ok() {
return Err(format!(
"端口 {} 已被其他程序占用,请更换端口后重试",
gui_config.port
));
}
let config_path = merge_core_config_for_start(&install_dir, gui_config)?;
let config_path = path_to_string(&config_path);
let log_path = core_start_log_path(&install_dir, &gui_config.auth_dir);
let start_once = || {
start_core_process_once(
process_state,
&binary_path,
&config_path,
&install_dir,
&log_path,
management_address,
)
};
#[cfg(target_os = "macos")]
let child = match start_once() {
Ok(child) => child,
Err(failure) if failure.was_killed_by_sigkill() => {
if let Err(heal_error) = rematerialize_core_binary(&binary_path) {
return Err(failure.message_with_detail(
&log_path,
Some(&format!("自动修复 CPA 内核文件失败: {heal_error}")),
));
}
match start_once() {
Ok(child) => child,
Err(failure) if failure.was_killed_by_sigkill() => {
return Err(failure.message_with_detail(
&log_path,
Some("系统再次终止 CPA 内核,请重新安装内核后重试"),
));
}
Err(failure) => return Err(failure.message(&log_path)),
}
}
Err(failure) => return Err(failure.message(&log_path)),
};
#[cfg(not(target_os = "macos"))]
let child = start_once().map_err(|failure| failure.message(&log_path))?;
process_state.store_child(child)?;
Ok(())
}
pub(crate) fn wait_for_core_management_port(
child: &mut Child,
address: SocketAddr,
process_state: &CoreProcessState,
) -> Result<(), CoreStartupFailure> {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
if process_state.is_shutting_down() {
return Err(CoreStartupFailure::ShuttingDown);
}
if let Some(status) = child.try_wait().map_err(CoreStartupFailure::StatusCheck)? {
return Err(CoreStartupFailure::Exited(status));
}
if TcpStream::connect_timeout(&address, Duration::from_millis(200)).is_ok() {
return Ok(());
}
if Instant::now() >= deadline {
return Err(CoreStartupFailure::TimedOut(address.port()));
}
thread::sleep(Duration::from_millis(100));
}
}
#[cfg(any(target_os = "linux", test))]
fn configure_child_lifetime(command: &mut Command) {
#[cfg(target_os = "linux")]
{
use std::os::unix::process::CommandExt;
let parent_process_id = unsafe { libc::getpid() };
unsafe {
command.pre_exec(move || {
if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) == -1 {
return Err(io::Error::last_os_error());
}
if libc::getppid() != parent_process_id {
return Err(io::Error::other(
"EasyCLIProxyAPI exited before the core process started",
));
}
Ok(())
});
}
}
#[cfg(not(target_os = "linux"))]
let _ = command;
}
pub(crate) fn configure_background_command(command: &mut Command) {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
command.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
let _ = command;
}
pub(crate) fn configure_networked_command(command: &mut Command, proxy_url: &str) {
let proxy_url = proxy_url.trim();
if proxy_url.is_empty() {
return;
}
for variable in [
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
] {
command.env(variable, proxy_url);
}
}
pub(crate) fn stop_core_process_inner(process_state: &CoreProcessState) -> Result<(), String> {
let mut stopped_any = false;
let mut errors = Vec::new();
if let Some(mut child) = process_state.take_child() {
stopped_any = true;
if let Err(error) = terminate_child(&mut child) {
errors.push(error);
}
}
let mut process_ids = process_state
.take_adopted_processes()
.into_iter()
.filter(|process| {
process_executable_path(process.process_id)
.as_deref()
.is_some_and(|path| executable_paths_match(&process.binary_path, path))
})
.map(|process| process.process_id)
.collect::<Vec<_>>();
if let Ok(install_dir) = core_install_dir() {
if let Some(binary_path) = find_core_binary(&install_dir) {
process_ids.extend(find_core_process_ids(&binary_path));
}
}
process_ids.sort_unstable();
process_ids.dedup();
for process_id in process_ids {
if !is_process_alive(process_id) {
continue;
}
stopped_any = true;
if let Err(error) = terminate_process_id(process_id) {
errors.push(error);
}
}
if !errors.is_empty() {
Err(errors.join(";"))
} else if stopped_any {
Ok(())
} else {
Err("CPA 内核当前未运行".to_string())
}
}
pub(crate) fn core_install_dir() -> Result<PathBuf, String> {
Ok(core_base_dir()?.join("cpa-core"))
}
pub(crate) fn executable_dir() -> Result<PathBuf, String> {
let exe_path = env::current_exe().map_err(|err| format!("读取当前程序路径失败: {err}"))?;
exe_path
.parent()
.map(|path| path.to_path_buf())
.ok_or_else(|| format!("当前程序路径没有父目录: {}", path_to_string(&exe_path)))
}
pub(crate) fn macos_app_resources_dir(executable_dir: &Path) -> Option<PathBuf> {
if executable_dir.file_name().and_then(|name| name.to_str()) != Some("MacOS") {
return None;
}
let contents_dir = executable_dir.parent()?;
if contents_dir.file_name().and_then(|name| name.to_str()) != Some("Contents") {
return None;
}
let app_dir = contents_dir.parent()?;
if app_dir.extension().and_then(|extension| extension.to_str()) != Some("app") {
return None;
}
Some(contents_dir.join("Resources"))
}
pub(crate) fn core_base_dir() -> Result<PathBuf, String> {
let executable_dir = executable_dir()?;
#[cfg(target_os = "macos")]
if macos_app_resources_dir(&executable_dir).is_some() {
let home_dir = env::var_os("HOME")
.map(PathBuf::from)
.ok_or_else(|| "无法确定 macOS 用户目录".to_string())?;
return Ok(home_dir
.join("Library")
.join("Application Support")
.join("com.cpa.gui"));
}
Ok(executable_dir)
}
pub(crate) fn bundled_core_locations(
base_dir: &Path,
executable_dir: &Path,
) -> Vec<(PathBuf, PathBuf)> {
let mut locations = vec![(base_dir.join(CORE_VERSION_FILE), base_dir.join("cpa-core"))];
if let Some(resources_dir) = macos_app_resources_dir(executable_dir) {
locations.push((
resources_dir.join(CORE_VERSION_FILE),
resources_dir.join("cpa-core"),
));
}
if let Some(project_root) = source_project_root(executable_dir) {
if project_root != base_dir {
locations.push((
project_root.join(CORE_VERSION_FILE),
project_root.join("cpa-core"),
));
}
}
locations
}
pub(crate) fn bundled_core_archive() -> Result<Option<(BundledCoreInfo, PathBuf)>, String> {
let platform = current_core_platform()?;
let base_dir = core_base_dir()?;
let executable_dir = executable_dir()?;
let locations = bundled_core_locations(&base_dir, &executable_dir);
let configured_version = locations.iter().find_map(|(version_path, _)| {
fs::read_to_string(version_path)
.ok()
.map(|value| normalize_version(value.trim()))
.filter(|value| value != "v")
});
if let Some(version) = configured_version {
let asset_name = core_release_asset_name(&version, &platform);
for (_, archive_dir) in &locations {
let archive_path = archive_dir.join(&asset_name);
if !archive_path.is_file() {
continue;
}
let size_bytes = fs::metadata(&archive_path)
.map_err(|error| format!("读取内置内核信息失败: {error}"))?
.len();
return Ok(Some((
BundledCoreInfo {
version,
asset_name,
size_bytes,
},
archive_path,
)));
}
return Ok(None);
}
let suffix = format!(
"_{}_{}.{}",
platform.asset_os, platform.asset_arch, platform.archive_kind
);
let mut matches = Vec::new();
for (_, archive_dir) in &locations {
if !archive_dir.is_dir() {
continue;
}
for entry in fs::read_dir(archive_dir)
.map_err(|error| format!("读取内置内核目录失败: {error}"))?
.filter_map(Result::ok)
{
let name = entry.file_name().to_string_lossy().into_owned();
if !entry.path().is_file()
|| !name.starts_with("CLIProxyAPI_")
|| !name.ends_with(&suffix)
|| name.contains("_no-plugin")
{
continue;
}
let Some(version) = name
.strip_prefix("CLIProxyAPI_")
.and_then(|value| value.strip_suffix(&suffix))
else {
continue;
};
let version = normalize_version(version);
if matches.iter().any(|(existing, _, _)| existing == &version) {
continue;
}
matches.push((version, name, entry.path()));
}
}
matches.sort_by(|left, right| left.0.cmp(&right.0));
if matches.len() > 1 {
return Err(format!(
"发现多个匹配当前平台的内置内核,请在 {} 中指定发行版本",
CORE_VERSION_FILE
));
}
let Some((version, asset_name, archive_path)) = matches.pop() else {
return Ok(None);
};
let size_bytes = fs::metadata(&archive_path)
.map_err(|error| format!("读取内置内核信息失败: {error}"))?
.len();
Ok(Some((
BundledCoreInfo {
version,
asset_name,
size_bytes,
},
archive_path,
)))
}
pub(crate) fn source_project_root(start: &Path) -> Option<PathBuf> {
start.ancestors().find_map(|directory| {
(directory.join("package.json").is_file() && directory.join("src-tauri").is_dir())
.then(|| directory.to_path_buf())
})
}
pub(crate) fn preserve_bundled_core_assets(
source_dir: &Path,
target_dir: &Path,
) -> Result<(), String> {
if !source_dir.is_dir() {
return Ok(());
}
for entry in fs::read_dir(source_dir)
.map_err(|error| format!("读取内置内核文件失败: {error}"))?
.filter_map(Result::ok)
{
let path = entry.path();
if !path.is_file() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
let is_archive = name.starts_with("CLIProxyAPI_")
&& (name.ends_with(".tar.gz") || name.ends_with(".zip"))
&& !name.contains("_no-plugin");
if !is_archive && name != CORE_CHECKSUMS_FILE {
continue;
}
fs::copy(&path, target_dir.join(&name))
.map_err(|error| format!("保留内置内核文件 {name} 失败: {error}"))?;
}
Ok(())
}
pub(crate) fn preserve_selected_bundled_core_asset(
archive_path: &Path,
target_dir: &Path,
) -> Result<(), String> {
let archive_name = archive_path
.file_name()
.ok_or_else(|| "内置内核压缩包文件名无效".to_string())?;
fs::copy(archive_path, target_dir.join(archive_name))
.map_err(|error| format!("保留所选内置内核压缩包失败: {error}"))?;
if let Some(source_dir) = archive_path.parent() {
let checksums = source_dir.join(CORE_CHECKSUMS_FILE);
if checksums.is_file() {
fs::copy(&checksums, target_dir.join(CORE_CHECKSUMS_FILE))
.map_err(|error| format!("保留内置内核校验文件失败: {error}"))?;
}
}
Ok(())
}
pub(crate) fn migrate_core_config_for_update(
source_dir: &Path,
target_dir: &Path,
) -> Result<(), String> {
if !source_dir.is_dir() {
return Ok(());
}
let old_config_path = source_dir.join(CORE_CONFIG_FILE);
let old_config = match fs::read_to_string(&old_config_path) {
Ok(content) => content,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(error) => {
return Err(format!(
"读取旧版内核配置失败,为避免配置丢失已取消更新 {}: {error}",
path_to_string(&old_config_path)
));
}
};
let template_path = target_dir.join(CORE_EXAMPLE_CONFIG_FILE);
if !template_path.is_file() {
return Err(format!(
"新版内核缺少配置模板,已取消更新: {}",
path_to_string(&template_path)
));
}
let template = fs::read_to_string(&template_path).map_err(|error| {
format!(
"读取新版内核配置模板失败 {}: {error}",
path_to_string(&template_path)
)
})?;
let migrated = merge_core_config_fields(&template, Some(&old_config))?;
let config_path = target_dir.join(CORE_CONFIG_FILE);
fs::write(&config_path, migrated).map_err(|error| {
format!(
"写入迁移后的新版内核配置失败 {}: {error}",
path_to_string(&config_path)
)
})?;
Ok(())
}
pub(crate) fn validate_bundled_core_checksum(archive_path: &Path) -> Result<(), String> {
let Some(directory) = archive_path.parent() else {
return Err("内置内核压缩包没有父目录".to_string());
};
let checksums_path = directory.join(CORE_CHECKSUMS_FILE);
if !checksums_path.is_file() {
return Ok(());
}
let archive_name = archive_path
.file_name()
.and_then(|value| value.to_str())
.ok_or_else(|| "内置内核压缩包文件名无效".to_string())?;
let checksums = fs::read_to_string(&checksums_path)
.map_err(|error| format!("读取内置内核校验文件失败: {error}"))?;
let expected = checksums.lines().find_map(|line| {
let mut fields = line.split_whitespace();
let digest = fields.next()?;
let name = fields.next()?.trim_start_matches('*');
(name == archive_name && digest.len() == 64).then(|| digest.to_ascii_lowercase())
});
let Some(expected) = expected else {
return Err(format!("校验文件中没有 {archive_name} 的 SHA-256"));
};
let actual = sha256_file(archive_path)?;
if actual != expected {
return Err("内置内核压缩包 SHA-256 校验失败".to_string());
}
Ok(())
}
pub(crate) fn sha256_file(path: &Path) -> Result<String, String> {
let mut file = File::open(path).map_err(|error| format!("打开校验文件失败: {error}"))?;
let mut hasher = Sha256::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = file
.read(&mut buffer)
.map_err(|error| format!("读取校验文件失败: {error}"))?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(format!("{:x}", hasher.finalize()))
}
pub(crate) fn read_core_metadata(install_dir: &Path) -> Option<CoreMetadata> {
let metadata_path = install_dir.join(CORE_METADATA_FILE);
let content = fs::read_to_string(metadata_path).ok()?;
serde_json::from_str(&content).ok()
}
pub(crate) fn write_core_metadata(
install_dir: &Path,
metadata: &CoreMetadata,
) -> Result<(), String> {
let metadata_path = install_dir.join(CORE_METADATA_FILE);
let content = serde_json::to_string_pretty(metadata)
.map_err(|err| format!("生成内核元数据失败: {err}"))?;
fs::write(metadata_path, content).map_err(|err| format!("写入内核元数据失败: {err}"))
}
pub(crate) fn validate_downloaded_asset(
asset: &GithubAsset,
downloaded: &DownloadedArchive,
) -> Result<(), String> {
validate_download_metadata(
downloaded.size,
asset.size,
&downloaded.sha256,
asset.digest.as_deref(),
)
}
pub(crate) fn validate_download_metadata(
downloaded: u64,
expected_total: Option<u64>,
sha256: &str,
expected_digest: Option<&str>,
) -> Result<(), String> {
if let Some(expected_total) = expected_total {
if downloaded != expected_total {
return Err(format!(
"下载大小校验失败: 实际 {downloaded} 字节,期望 {expected_total} 字节"
));
}
}
if let Some(expected_digest) = expected_digest {
let expected = expected_digest
.strip_prefix("sha256:")
.unwrap_or(expected_digest)
.to_ascii_lowercase();
if !expected.is_empty() && sha256 != expected {
return Err("下载文件 SHA-256 校验失败".to_string());
}
}
Ok(())
}
pub(crate) fn cleanup_core_work_dirs() -> Result<(), String> {
let base_dir = core_base_dir()?;
let mut last_error = None;
for name in ["cpa-core.staging", "cpa-core.download"] {
let path = base_dir.join(name);
if path.exists() {
if let Err(err) = fs::remove_dir_all(&path) {
last_error = Some(format!("清理临时目录失败 {}: {err}", path_to_string(&path)));
}
}
}
if let Some(error) = last_error {
Err(error)
} else {
Ok(())
}
}
pub(crate) fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0)
}
pub(crate) fn normalize_version(version: &str) -> String {
let version = version.trim();
if version.starts_with('v') {
version.to_string()
} else {
format!("v{version}")
}
}
pub(crate) fn is_core_running(binary_path: &Path) -> bool {
!find_core_process_ids(binary_path).is_empty()
}
pub(crate) fn find_core_process_ids(binary_path: &Path) -> Vec<u32> {
find_candidate_core_process_ids()
.into_iter()
.filter(|process_id| {
process_executable_path(*process_id)
.as_deref()
.is_some_and(|path| executable_paths_match(binary_path, path))
})
.collect()
}
pub(crate) fn executable_paths_match(expected: &Path, actual: &Path) -> bool {
let expected = fs::canonicalize(expected).unwrap_or_else(|_| expected.to_path_buf());
let actual = fs::canonicalize(actual).unwrap_or_else(|_| actual.to_path_buf());
#[cfg(windows)]
{
path_to_string(&expected).eq_ignore_ascii_case(&path_to_string(&actual))
}
#[cfg(not(windows))]
{
expected == actual
}
}
#[cfg(all(not(target_os = "linux"), target_os = "windows"))]
pub(crate) fn find_candidate_core_process_ids() -> Vec<u32> {
use std::{ffi::OsString, mem, os::windows::ffi::OsStringExt};
use windows_sys::Win32::{
Foundation::{CloseHandle, INVALID_HANDLE_VALUE},
System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
TH32CS_SNAPPROCESS,
},
};
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
if snapshot == INVALID_HANDLE_VALUE {
return Vec::new();
}
let mut entry = unsafe { mem::zeroed::<PROCESSENTRY32W>() };
entry.dwSize = mem::size_of::<PROCESSENTRY32W>() as u32;
let mut process_ids = Vec::new();
if unsafe { Process32FirstW(snapshot, &mut entry) } != 0 {
loop {
let name_length = entry
.szExeFile
.iter()
.position(|character| *character == 0)
.unwrap_or(entry.szExeFile.len());
let image_name = OsString::from_wide(&entry.szExeFile[..name_length]);
if entry.th32ProcessID != 0
&& image_name
.to_string_lossy()
.eq_ignore_ascii_case(core_binary_name())
{
process_ids.push(entry.th32ProcessID);
}
if unsafe { Process32NextW(snapshot, &mut entry) } == 0 {
break;
}
}
}
unsafe { CloseHandle(snapshot) };
process_ids
}
#[cfg(all(not(target_os = "linux"), not(target_os = "windows")))]
pub(crate) fn find_candidate_core_process_ids() -> Vec<u32> {
Command::new("pgrep")
.args(["-x", core_binary_name()])
.output()
.ok()
.map(|output| {
String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| line.trim().parse::<u32>().ok())
.collect()
})
.unwrap_or_default()
}
#[cfg(target_os = "linux")]
pub(crate) fn find_candidate_core_process_ids() -> Vec<u32> {
Command::new("pgrep")
.args(["-x", core_binary_name()])
.output()
.ok()
.map(|output| {
String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| line.trim().parse::<u32>().ok())
.collect()
})
.unwrap_or_default()
}
#[cfg(target_os = "linux")]
pub(crate) fn process_executable_path(process_id: u32) -> Option<PathBuf> {
fs::read_link(format!("/proc/{process_id}/exe")).ok()
}
#[cfg(target_os = "macos")]
pub(crate) fn process_executable_path(process_id: u32) -> Option<PathBuf> {
use std::{ffi::OsString, os::unix::ffi::OsStringExt};
extern "C" {
fn proc_pidpath(pid: i32, buffer: *mut std::ffi::c_void, buffer_size: u32) -> i32;
}
let mut buffer = vec![0_u8; 4096];
let length = unsafe {
proc_pidpath(
process_id.try_into().ok()?,
buffer.as_mut_ptr().cast(),
buffer.len() as u32,
)
};
if length <= 0 {
return None;
}
buffer.truncate(length as usize);
Some(PathBuf::from(OsString::from_vec(buffer)))
}
#[cfg(windows)]
pub(crate) fn process_executable_path(process_id: u32) -> Option<PathBuf> {
use std::{ffi::OsString, os::windows::ffi::OsStringExt};
use windows_sys::Win32::{
Foundation::{CloseHandle, HANDLE},
System::Threading::{
OpenProcess, QueryFullProcessImageNameW, PROCESS_QUERY_LIMITED_INFORMATION,
},
};
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, process_id) };
if handle.is_null() {
return None;
}
let mut buffer = vec![0_u16; 32_768];
let mut length = buffer.len() as u32;
let success =
unsafe { QueryFullProcessImageNameW(handle, 0, buffer.as_mut_ptr(), &mut length) };
unsafe { CloseHandle(handle as HANDLE) };
if success == 0 {
return None;
}
buffer.truncate(length as usize);
Some(PathBuf::from(OsString::from_wide(&buffer)))
}
pub(crate) fn adopt_existing_core_processes(
process_state: &CoreProcessState,
) -> Result<Vec<u32>, String> {
let install_dir = core_install_dir()?;
let Some(binary_path) = find_core_binary(&install_dir) else {
process_state.clear_adopted_processes()?;
return Ok(Vec::new());
};
let process_ids = find_core_process_ids(&binary_path);
process_state.adopt_process_ids(&binary_path, process_ids.clone())?;
Ok(process_ids)
}
pub(crate) fn shutdown_managed_core(
process_state: &CoreProcessState,
gui_config_state: &GuiConfigState,
) {
let was_running = process_state.managed_pid().is_some()
|| core_install_dir()
.ok()
.and_then(|install_dir| find_core_binary(&install_dir))
.is_some_and(|binary_path| is_core_running(&binary_path));
if was_running {
if let Err(error) = stop_core_process_inner(process_state) {
eprintln!("退出时关闭 CPA 内核失败: {error}");
}
}
if let Err(error) = gui_config_state.set_run_on_startup(was_running) {
eprintln!("保存退出前的内核状态失败: {error}");
}
}
#[cfg(windows)]
pub(crate) fn attach_child_to_windows_job(child: &Child) -> Result<isize, String> {
use std::{mem, os::windows::io::AsRawHandle, ptr};
use windows_sys::Win32::{
Foundation::{CloseHandle, HANDLE},
System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
},
};
unsafe {
let job = CreateJobObjectW(ptr::null(), ptr::null());
if job.is_null() {
return Err(format!(
"创建 CPA 内核进程作业失败: {}",
io::Error::last_os_error()
));
}
let mut information: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = mem::zeroed();
information.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
let configured = SetInformationJobObject(
job,
JobObjectExtendedLimitInformation,
&information as *const _ as *const _,
mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
);
if configured == 0 {
let error = io::Error::last_os_error();
CloseHandle(job);
return Err(format!("配置 CPA 内核进程作业失败: {error}"));
}
let process_handle = child.as_raw_handle() as HANDLE;
if AssignProcessToJobObject(job, process_handle) == 0 {
let error = io::Error::last_os_error();
CloseHandle(job);
return Err(format!("托管 CPA 内核子进程失败: {error}"));
}
Ok(job as isize)
}
}
#[cfg(windows)]
pub(crate) fn close_windows_handle(handle: isize) {
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
unsafe {
CloseHandle(handle as HANDLE);
}
}
pub(crate) fn terminate_child(child: &mut Child) -> Result<(), String> {
if child
.try_wait()
.map_err(|error| format!("检查 CPA 内核进程状态失败: {error}"))?
.is_some()
{
return Ok(());
}
#[cfg(windows)]
{
child
.kill()
.map_err(|err| format!("关闭 CPA 内核进程失败: {err}"))?;
child
.wait()
.map_err(|err| format!("等待 CPA 内核进程退出失败: {err}"))?;
Ok(())
}
#[cfg(not(windows))]
{
let process_id = child.id();
send_process_signal(process_id, "TERM")?;
for _ in 0..20 {
match child.try_wait() {
Ok(Some(_)) => return Ok(()),
Ok(None) => thread::sleep(Duration::from_millis(100)),
Err(err) => return Err(format!("检查 CPA 内核进程状态失败: {err}")),
}
}
child
.kill()
.map_err(|err| format!("强制关闭 CPA 内核进程失败: {err}"))?;
child
.wait()
.map_err(|err| format!("等待 CPA 内核进程退出失败: {err}"))?;
Ok(())
}
}
pub(crate) fn terminate_process_id(process_id: u32) -> Result<(), String> {
#[cfg(windows)]
{
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use windows_sys::Win32::{
Foundation::{ERROR_INVALID_PARAMETER, WAIT_OBJECT_0, WAIT_TIMEOUT},
System::Threading::{OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE},
};
// Keep a handle to this process until it is signalled. taskkill exiting
// successfully only means termination was requested, not that file
// handles have been released.
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, process_id) };
if handle.is_null() {
let error = io::Error::last_os_error();
if error.raw_os_error() == Some(ERROR_INVALID_PARAMETER as i32) {
return Ok(());
}
return Err(format!("打开 CPA 内核进程失败: PID {process_id}: {error}"));
}
let handle = unsafe { OwnedHandle::from_raw_handle(handle) };
let raw_handle = handle.as_raw_handle();
if unsafe { WaitForSingleObject(raw_handle, 0) } == WAIT_OBJECT_0 {
return Ok(());
}
let mut command = Command::new("taskkill");
command
.args(["/PID", &process_id.to_string(), "/T", "/F"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
configure_background_command(&mut command);
let status = command
.status()
.map_err(|err| format!("关闭 CPA 内核进程失败: {err}"))?;
let wait =
unsafe { WaitForSingleObject(raw_handle, if status.success() { 10_000 } else { 0 }) };
match wait {
WAIT_OBJECT_0 => Ok(()),
WAIT_TIMEOUT => Err(format!("CPA 内核进程未退出: PID {process_id}")),
_ => Err(format!(
"等待 CPA 内核进程退出失败: PID {process_id}: {}",
io::Error::last_os_error()
)),
}
}
#[cfg(not(windows))]
{
send_process_signal(process_id, "TERM")?;
for _ in 0..20 {
if !is_process_alive(process_id) {
return Ok(());
}
thread::sleep(Duration::from_millis(100));
}
send_process_signal(process_id, "KILL")
}
}
#[cfg(not(windows))]
pub(crate) fn send_process_signal(process_id: u32, signal: &str) -> Result<(), String> {
let status = Command::new("kill")
.args([format!("-{signal}"), process_id.to_string()])
.status()
.map_err(|err| format!("发送进程信号失败: {err}"))?;
if status.success() {
Ok(())
} else {
Err(format!("发送进程信号失败: PID {process_id}"))
}
}
#[cfg(windows)]
pub(crate) fn is_process_alive(process_id: u32) -> bool {
use windows_sys::Win32::{
Foundation::{CloseHandle, WAIT_TIMEOUT},
System::Threading::{OpenProcess, WaitForSingleObject},
};
const SYNCHRONIZE_ACCESS: u32 = 0x0010_0000;
let handle = unsafe { OpenProcess(SYNCHRONIZE_ACCESS, 0, process_id) };
if handle.is_null() {
return false;
}
let result = unsafe { WaitForSingleObject(handle, 0) };
unsafe { CloseHandle(handle) };
result == WAIT_TIMEOUT
}
#[cfg(not(windows))]
pub(crate) fn is_process_alive(process_id: u32) -> bool {
let process_id = process_id.to_string();
Command::new("kill")
.args(["-0", &process_id])
.status()
.map(|status| status.success())
.unwrap_or(false)
}
pub(crate) fn reset_dir(path: &Path) -> Result<(), String> {
if path.exists() {
fs::remove_dir_all(path)
.map_err(|err| format!("清理目录失败 {}: {err}", path_to_string(path)))?;
}
fs::create_dir_all(path).map_err(|err| format!("创建目录失败 {}: {err}", path_to_string(path)))
}
pub(crate) fn overlay_install_dir(install_dir: &Path, staging_dir: &Path) -> Result<(), String> {
if !staging_dir.is_dir() {
return Err(format!(
"内核暂存目录不存在: {}",
path_to_string(staging_dir)
));
}
if !install_dir.exists() {
return fs::rename(staging_dir, install_dir)
.map_err(|err| format!("安装新内核目录失败: {err}"));
}
if !install_dir.is_dir() {
return Err(format!(
"内核安装路径不是目录: {}",
path_to_string(install_dir)
));
}
overlay_directory(staging_dir, install_dir)?;
fs::remove_dir_all(staging_dir).map_err(|err| format!("清理内核暂存目录失败: {err}"))
}
fn overlay_directory(source_dir: &Path, target_dir: &Path) -> Result<(), String> {
for entry in fs::read_dir(source_dir)
.map_err(|err| format!("读取内核暂存目录失败 {}: {err}", path_to_string(source_dir)))?
{
let entry = entry.map_err(|err| format!("读取内核暂存条目失败: {err}"))?;
let source_path = entry.path();
let target_path = target_dir.join(entry.file_name());
let file_type = entry
.file_type()
.map_err(|err| format!("读取内核暂存条目类型失败: {err}"))?;
if file_type.is_dir() {
if target_path.exists() && !target_path.is_dir() {
return Err(format!(
"无法用内核目录覆盖同名文件: {}",
path_to_string(&target_path)
));
}
fs::create_dir_all(&target_path).map_err(|err| {
format!(
"创建内核安装子目录失败 {}: {err}",
path_to_string(&target_path)
)
})?;
overlay_directory(&source_path, &target_path)?;
} else if file_type.is_file() {
if target_path.exists() && !target_path.is_file() {
return Err(format!(
"无法用内核文件覆盖同名目录: {}",
path_to_string(&target_path)
));
}
copy_core_file_replace(&source_path, &target_path)?;
} else {
return Err(format!(
"内核暂存目录包含不支持的条目: {}",
path_to_string(&source_path)
));
}
}
Ok(())
}
/// Copies through a sibling temporary file and atomically replaces the target.
///
/// In particular, do not change this back to copying over an existing file:
/// macOS caches code-signature validation by vnode, so in-place updates can
/// leave an otherwise valid executable permanently rejected with SIGKILL.
pub(crate) fn copy_core_file_replace(source_path: &Path, target_path: &Path) -> Result<(), String> {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
let parent = target_path.parent().ok_or_else(|| {
format!(
"覆盖内核文件失败 {}: 无法确定父目录",
path_to_string(target_path)
)
})?;
fs::create_dir_all(parent)
.map_err(|error| format!("创建内核文件目录失败 {}: {error}", path_to_string(parent)))?;
let file_name = target_path
.file_name()
.map(|name| name.to_string_lossy())
.unwrap_or_else(|| "cpa-core".into());
let temporary_path = parent.join(format!(
".{file_name}.replace.{}.{}",
std::process::id(),
SEQUENCE.fetch_add(1, Ordering::Relaxed)
));
let replace_result = (|| -> io::Result<()> {
fs::copy(source_path, &temporary_path)?;
let temporary_file = File::options().write(true).open(&temporary_path)?;
temporary_file.sync_all()?;
drop(temporary_file);
replace_file_atomically(&temporary_path, target_path)
})();
if let Err(error) = replace_result {
let _ = fs::remove_file(&temporary_path);
return Err(format!(
"覆盖内核文件失败 {}: {error}",
path_to_string(target_path)
));
}
Ok(())
}
#[cfg(any(target_os = "macos", test))]
pub(crate) fn rematerialize_core_binary(binary_path: &Path) -> Result<(), String> {
// Replacing the path with an identical copy gives it a fresh vnode and
// clears the macOS signature-cache state left by older in-place updates.
copy_core_file_replace(binary_path, binary_path)
}
pub(crate) fn extract_tar_gz(archive_path: &Path, install_dir: &Path) -> Result<(), String> {
let archive_file =
File::open(archive_path).map_err(|err| format!("打开 tar.gz 失败: {err}"))?;
let decoder = GzDecoder::new(archive_file);
let mut archive = Archive::new(decoder);
let entries = archive
.entries()
.map_err(|err| format!("读取 tar.gz 条目失败: {err}"))?;
for entry in entries {
let mut entry = entry.map_err(|err| format!("读取 tar.gz 条目失败: {err}"))?;
let entry_path = entry
.path()
.map_err(|err| format!("读取 tar.gz 条目路径失败: {err}"))?;
let out_path = checked_archive_path(install_dir, entry_path.as_ref())?;
let entry_type = entry.header().entry_type();
if entry_type.is_dir() {
fs::create_dir_all(&out_path).map_err(|err| format!("创建目录失败: {err}"))?;
} else if entry_type.is_file() {
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent).map_err(|err| format!("创建目录失败: {err}"))?;
}
entry
.unpack(&out_path)
.map_err(|err| format!("解压 tar.gz 文件失败: {err}"))?;
} else {
return Err(format!(
"tar.gz 包含不支持的条目类型: {}",
path_to_string(&out_path)
));
}
}
Ok(())
}
pub(crate) fn extract_zip(archive_path: &Path, install_dir: &Path) -> Result<(), String> {
let archive_file = File::open(archive_path).map_err(|err| format!("打开 zip 失败: {err}"))?;
let mut archive =
ZipArchive::new(archive_file).map_err(|err| format!("读取 zip 失败: {err}"))?;
for index in 0..archive.len() {
let mut file = archive
.by_index(index)
.map_err(|err| format!("读取 zip 条目失败: {err}"))?;
let enclosed_name = file
.enclosed_name()
.ok_or_else(|| format!("zip 条目路径不安全: {}", file.name()))?;
let out_path = checked_archive_path(install_dir, &enclosed_name)?;
if is_zip_symlink(&file) {
return Err(format!("zip 包含不支持的符号链接条目: {}", file.name()));
}
if file.is_dir() {
fs::create_dir_all(&out_path).map_err(|err| format!("创建目录失败: {err}"))?;
continue;
}
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent).map_err(|err| format!("创建目录失败: {err}"))?;
}
let mut out_file = File::create(&out_path).map_err(|err| format!("创建文件失败: {err}"))?;
io::copy(&mut file, &mut out_file).map_err(|err| format!("写入文件失败: {err}"))?;
#[cfg(unix)]
if let Some(mode) = file.unix_mode() {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&out_path, fs::Permissions::from_mode(mode))
.map_err(|err| format!("设置文件权限失败: {err}"))?;
}
}
Ok(())
}
pub(crate) fn checked_archive_path(base_dir: &Path, entry_path: &Path) -> Result<PathBuf, String> {
if entry_path.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
}) {
return Err(format!(
"压缩包条目路径不安全: {}",
path_to_string(entry_path)
));
}
Ok(base_dir.join(entry_path))
}
pub(crate) fn is_zip_symlink(file: &zip::read::ZipFile<'_>) -> bool {
file.unix_mode()
.map(|mode| mode & 0o170000 == 0o120000)
.unwrap_or(false)
}
pub(crate) fn find_core_binary(install_dir: &Path) -> Option<PathBuf> {
let binary_path = install_dir.join(core_binary_name());
if binary_path.is_file() {
return Some(binary_path);
}
let mut dirs = vec![install_dir.to_path_buf()];
while let Some(dir) = dirs.pop() {
let entries = fs::read_dir(dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
dirs.push(path);
} else if path
.file_name()
.and_then(|file_name| file_name.to_str())
.map(|file_name| file_name == core_binary_name())
.unwrap_or(false)
{
return Some(path);
}
}
}
None
}
pub(crate) fn core_binary_name() -> &'static str {
if env::consts::OS == "windows" {
"cli-proxy-api.exe"
} else {
"cli-proxy-api"
}
}
pub(crate) fn should_start_hidden(config: &GuiConfigFile) -> bool {
config.silent_start && cfg!(any(target_os = "windows", target_os = "macos"))
}
pub(crate) fn should_start_core_on_launch(config: &GuiConfigFile) -> bool {
config.start_core_on_launch
}
pub(crate) fn configure_initial_main_window(
app_handle: &tauri::AppHandle,
start_hidden: bool,
) -> Result<(), String> {
let window = app_handle
.get_webview_window("main")
.ok_or_else(|| "主窗口不存在".to_string())?;
#[cfg(target_os = "macos")]
set_macos_dock_visible(app_handle, !start_hidden);
if start_hidden {
return window
.hide()
.map_err(|error| format!("静默启动时隐藏主窗口失败: {error}"));
}
window
.show()
.map_err(|error| format!("显示主窗口失败: {error}"))?;
if window.is_minimized().unwrap_or(false) {
window
.unminimize()
.map_err(|error| format!("恢复主窗口失败: {error}"))?;
}
window
.set_focus()
.map_err(|error| format!("聚焦主窗口失败: {error}"))
}
pub(crate) fn path_to_string(path: &Path) -> String {
path.to_string_lossy().into_owned()
}