Files
EasyCLIProxyAPI/src-tauri/src/core_runtime.rs
T

2816 lines
95 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(());
const BUNDLED_CORE_HANDLED_VERSION_FILE: &str = "cpa-gui-bundled-core-handled.txt";
pub(crate) fn lock_core_operation(
process_state: &CoreProcessState,
) -> Result<std::sync::MutexGuard<'static, ()>, String> {
let guard = CORE_OPERATION_LOCK
.try_lock()
.map_err(|_| "The kernel is performing another operation. Try again later".to_string())?;
process_state.ensure_active()?;
Ok(guard)
}
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!("Failed to create CPA kernel startup thread: {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!("Failed to start CPA kernel: {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}; failed to clean up unmanaged kernel process: {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 kernel startup thread exited".to_string())?;
result
.recv()
.map_err(|_| "CPA kernel startup thread did not return a startup result".to_string())?
.map_err(|error| format!("Failed to start CPA kernel: {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!("Kernel background task failed: {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(|| "This distribution has no bundled kernel matching the system architecture".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!("Offline kernel installation background task failed: {error}"))?
}
pub(crate) fn core_needs_bundled_install(install_dir: &Path, bundled_version: &str) -> bool {
let Ok(bundled_version) =
semver::Version::parse(bundled_version.trim().trim_start_matches('v'))
else {
return false;
};
if find_core_binary(install_dir).is_none() {
return true;
}
if bundled_core_version_handled(install_dir, &bundled_version) {
return false;
}
read_core_metadata(install_dir)
.and_then(|metadata| {
semver::Version::parse(metadata.version.trim().trim_start_matches('v')).ok()
})
.is_none_or(|installed| bundled_version > installed)
}
fn bundled_core_version_handled(install_dir: &Path, bundled_version: &semver::Version) -> bool {
fs::read_to_string(install_dir.join(BUNDLED_CORE_HANDLED_VERSION_FILE))
.ok()
.and_then(|version| semver::Version::parse(version.trim().trim_start_matches('v')).ok())
.is_some_and(|version| &version == bundled_version)
}
pub(crate) fn mark_bundled_core_version_handled(
install_dir: &Path,
bundled_version: &str,
) -> Result<(), String> {
write_bytes_atomically(
&install_dir.join(BUNDLED_CORE_HANDLED_VERSION_FILE),
bundled_version.as_bytes(),
)
.map_err(|error| format!("Failed to record processed bundled kernel version: {error}"))
}
pub(crate) fn remember_bundled_core_when_up_to_date(
install_dir: &Path,
bundled_version: &str,
) -> Result<(), String> {
let Ok(bundled) = semver::Version::parse(bundled_version.trim().trim_start_matches('v')) else {
return Ok(());
};
if bundled_core_version_handled(install_dir, &bundled) {
return Ok(());
}
let installed = read_core_metadata(install_dir).and_then(|metadata| {
semver::Version::parse(metadata.version.trim().trim_start_matches('v')).ok()
});
if find_core_binary(install_dir).is_some()
&& installed.is_some_and(|version| version >= bundled)
{
mark_bundled_core_version_handled(install_dir, bundled_version)?;
}
Ok(())
}
pub(crate) fn auto_install_bundled_core_if_needed(app: &tauri::AppHandle) -> Result<bool, String> {
let install_dir = core_install_dir()?;
let (info, archive_path) = match bundled_core_archive()? {
Some(bundled) => bundled,
None if find_core_binary(&install_dir).is_some() => return Ok(false),
None => return Err("CPA kernel was not detected, and this distribution has no matching offline kernel".to_string()),
};
if !core_needs_bundled_install(&install_dir, &info.version) {
remember_bundled_core_when_up_to_date(&install_dir, &info.version)?;
return Ok(false);
}
let window = app
.get_webview_window("main")
.map(|webview| webview.as_ref().window())
.ok_or_else(|| "Cannot access the main window to automatically install the offline kernel".to_string())?;
let state = app.state::<CoreDownloadState>();
state.start(CancellationToken::new(), Some(info.version.clone()))?;
let result = install_core_with_runtime_restore(
app,
app.state::<CoreProcessState>().inner(),
app.state::<GuiConfigState>().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?;
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!("Kernel installation background task failed: {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()?;
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!("Kernel installed, but failed to automatically resume operation: {restart_error}"))
}
(Err(install_error), Ok(())) => Err(install_error),
(Err(install_error), Err(restart_error)) => Err(format!(
"{install_error}; failed to automatically restore the previous kernel running state: {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> {
network_proxy::refresh(gui_config_state)?;
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> {
network_proxy::refresh(gui_config_state)?;
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, "Check version", 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 kernel is running. Stop it before installing or updating".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!("Invalid asset filename: {}", 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,
"Extracting",
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!("Unsupported archive type: {other}")),
}
ensure_not_cancelled(&token, Some(&archive_path))?;
let binary_path = find_core_binary(&staging_dir)
.ok_or_else(|| "CPA kernel binary was not found after extraction".to_string())?;
let binary_relative_path = binary_path
.strip_prefix(&staging_dir)
.map_err(|err| format!("Failed to calculate relative path of kernel binary: {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 kernel is running. Stop it before using the bundled kernel".to_string());
}
let archive_size = fs::metadata(archive_path)
.map_err(|error| format!("Failed to read bundled kernel archive: {error}"))?
.len();
state.progress(window, "Verify bundled kernel", 0, Some(archive_size), false);
validate_bundled_core_checksum(archive_path)?;
reset_dir(&staging_dir)?;
state.progress(
window,
"Extract bundled kernel",
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!("Unsupported bundled archive type: {other}")),
}
let binary_path = find_core_binary(&staging_dir)
.ok_or_else(|| "Bundled archive does not contain a CPA kernel binary".to_string())?;
let binary_relative_path = binary_path
.strip_prefix(&staging_dir)
.map_err(|error| format!("Failed to calculate bundled kernel binary path: {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)?;
mark_bundled_core_version_handled(&install_dir, &info.version)?;
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!("All kernel version check sources failed: {}", 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 release source request failed: {atom_error}; release page request failed: {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!("Failed to query latest GitCode kernel release: {error}"))?
.error_for_status()
.map_err(|error| format!("Failed to read latest GitCode kernel release: {error}"))?
.json::<GitcodeRelease>()
.await
.map_err(|error| format!("Failed to parse latest GitCode kernel release: {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 page request failed: {err}"))?;
let status = response.status();
let final_url = response.url().clone();
if !status.is_success() {
let body = response
.text()
.await
.map_err(|err| format!("Failed to read GitHub release page: {err}"))?;
return Err(format_github_error(status.as_u16(), &body));
}
let tag = release_tag_from_url(&final_url)
.ok_or_else(|| "GitHub release page did not return a version tag".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 request failed: {err}"))?;
let status = response.status();
let body = response
.text()
.await
.map_err(|err| format!("Failed to read 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 did not return a version tag".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!("Failed to parse version {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 returned an error ({status}): {}", message.trim());
}
}
let body = body.trim();
if body.is_empty() {
format!("GitHub returned an error ({status})")
} else {
format!("GitHub returned an error ({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("Download canceled".to_string()),
}
}
pub(crate) fn apply_configured_proxy(
builder: reqwest::ClientBuilder,
proxy_url: &str,
) -> Result<reqwest::ClientBuilder, String> {
let proxy_url = proxy_url.trim();
let builder = builder.no_proxy();
if proxy_url.is_empty() {
return Ok(builder);
}
let proxy = reqwest::Proxy::all(proxy_url)
.map_err(|_| "Invalid proxy URL".to_string())?
.no_proxy(reqwest::NoProxy::from_string("localhost,127.0.0.1,::1"));
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,
"Failed to create HTTP client",
)
}
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!("No release asset matches the current platform: {expected_name}"))?;
if matches.next().is_some() {
return Err(format!("Multiple release assets match: {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
)
}
#[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!("Download failed; switching to {}", 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("Kernel release has no available download URL".to_string());
}
Err(format!("All kernel download sources failed: {}", 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, "Preparing download", 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!("Failed to download kernel archive: {err}"))?,
_ = token.cancelled() => return Err("Download canceled".to_string()),
}
.error_for_status()
.map_err(|err| format!("Download URL returned an error status: {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!("Failed to create kernel archive: {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("Download canceled".to_string()),
} {
ensure_not_cancelled(token, Some(archive_path))?;
let chunk = chunk.map_err(|err| format!("Failed to read download data: {err}"))?;
file.write_all(&chunk)
.map_err(|err| format!("Failed to save download data: {err}"))?;
hasher.update(&chunk);
downloaded += chunk.len() as u64;
if progress.ready(Instant::now(), total == Some(downloaded)) {
state.progress(window, "Downloading", downloaded, total, true);
}
}
state.progress(window, "Downloading", downloaded, total, true);
file.flush()
.map_err(|err| format!("Failed to flush kernel archive: {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("Download canceled".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!("Unsupported operating system: {other}")),
};
let asset_arch = match arch {
"x86_64" => "amd64",
"aarch64" => "aarch64",
other => return Err(format!("Unsupported CPU architecture: {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,
};
// A tracked process remains running even if a single management-port probe
// times out. The port probe is still required before discovering an
// untracked process, but using it to override a known live PID makes the UI
// oscillate between running and stopped while continuing to show that PID.
let running = process_id.is_some();
let ready = running && management_port_open.unwrap_or(true);
let current_version = read_core_metadata(&install_dir).map(|metadata| metadata.version);
let message = if starting {
"CPA kernel is starting".to_string()
} else if !installed {
"CPA kernel is not installed. Install the latest version first".to_string()
} else if running {
"CPA kernel is running".to_string()
} else {
"CPA kernel is installed but not running".to_string()
};
Ok(CoreStatus {
installed,
running,
ready,
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;
};
for attempt in 0..3 {
if TcpStream::connect_timeout(&address, Duration::from_millis(150)).is_ok() {
return true;
}
if attempt < 2 {
thread::sleep(Duration::from_millis(50));
}
}
false
}
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("Application is exiting; kernel startup canceled"),
Self::Exited(status) => write!(formatter, "CPA kernel exited immediately after startup: {status}"),
Self::Spawn(error) => formatter.write_str(error),
Self::StatusCheck(error) => write!(formatter, "Failed to check CPA kernel startup status: {error}"),
Self::TimedOut(port) => {
write!(formatter, "CPA kernel startup timed out: management port {port} was not listening within 10 seconds")
}
}
}
}
pub(crate) fn core_start_log_path(install_dir: &Path, auth_dir: &str) -> Result<PathBuf, String> {
Ok(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)?;
}
let mut header_file = File::options()
.append(true)
.create(true)
.open(log_path)?;
writeln!(header_file, "\n===== CPA kernel startup {} =====", 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}; failed to write startup log {}: {log_error}",
path_to_string(log_path)
),
None => format!("{error}; startup log: {}", 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);
for variable in [
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
] {
command.env_remove(variable);
}
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 mut resolved_config = gui_config.clone();
resolved_config.proxy_url = network_proxy::resolve(gui_config);
let gui_config = &resolved_config;
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!("Failed to create credentials directory {}: {error}", path_to_string(&auth_dir)))?;
}
let binary_path = find_core_binary(&install_dir)
.ok_or_else(|| "CPA kernel is not installed. Install the latest version first".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 kernel is already running".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!(
"Port {} is in use by another program. Change the port and try again",
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!("Failed to automatically repair CPA kernel files: {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("The system terminated the CPA kernel again. Reinstall the kernel and try again"),
));
}
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();
for variable in [
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
] {
if proxy_url.is_empty() {
command.env_remove(variable);
} else {
command.env(variable, proxy_url);
}
}
command
.env("NO_PROXY", "localhost,127.0.0.1,::1")
.env("no_proxy", "localhost,127.0.0.1,::1");
}
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 kernel is not currently running".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!("Failed to read current executable path: {err}"))?;
exe_path
.parent()
.map(|path| path.to_path_buf())
.ok_or_else(|| format!("Current executable path has no parent directory: {}", 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(|| "Unable to determine macOS user directory".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::new();
if let Some(resources_dir) = macos_app_resources_dir(executable_dir) {
locations.push((
resources_dir.join(CORE_VERSION_FILE),
resources_dir.join("cpa-core"),
));
}
locations.push((base_dir.join(CORE_VERSION_FILE), base_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!("Failed to read bundled kernel information: {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!("Failed to read bundled kernel directory: {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!(
"Multiple bundled kernels match the current platform. Specify a release version in {}",
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!("Failed to read bundled kernel information: {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!("Failed to read bundled kernel file: {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!("Failed to preserve bundled kernel file {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(|| "Invalid bundled kernel archive filename".to_string())?;
fs::copy(archive_path, target_dir.join(archive_name))
.map_err(|error| format!("Failed to preserve selected bundled kernel archive: {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!("Failed to preserve bundled kernel checksum file: {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!(
"Failed to read legacy kernel configuration; update canceled to prevent configuration loss {}: {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!(
"New kernel is missing a configuration template; update canceled: {}",
path_to_string(&template_path)
));
}
let template = fs::read_to_string(&template_path).map_err(|error| {
format!(
"Failed to read new kernel configuration template {}: {error}",
path_to_string(&template_path)
)
})?;
let old_document = serde_norway::from_str::<serde_norway::Value>(&old_config)
.map_err(|error| format!("Failed to parse existing kernel configuration; update canceled to prevent configuration loss: {error}"))?;
if !old_document.is_mapping() {
return Err("Existing kernel configuration root must be a YAML mapping; update canceled".to_string());
}
let template_document = serde_norway::from_str::<serde_norway::Value>(&template)
.map_err(|error| format!("Failed to parse new kernel configuration template: {error}"))?;
let migrated = if core_config_uses_v8(&template_document) {
old_config
} else {
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!(
"Failed to write migrated new kernel configuration {}: {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("Bundled kernel archive has no parent directory".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(|| "Invalid bundled kernel archive filename".to_string())?;
let checksums = fs::read_to_string(&checksums_path)
.map_err(|error| format!("Failed to read bundled kernel checksum file: {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 Ok(());
};
let actual = sha256_file(archive_path)?;
if actual != expected {
return Err("Bundled kernel archive SHA-256 verification failed".to_string());
}
Ok(())
}
pub(crate) fn sha256_file(path: &Path) -> Result<String, String> {
let mut file = File::open(path).map_err(|error| format!("Failed to open checksum file: {error}"))?;
let mut hasher = Sha256::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = file
.read(&mut buffer)
.map_err(|error| format!("Failed to read checksum file: {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!("Failed to generate kernel metadata: {err}"))?;
fs::write(metadata_path, content).map_err(|err| format!("Failed to write kernel metadata: {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!(
"Download size verification failed: got {downloaded} bytes, expected {expected_total} bytes"
));
}
}
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("Downloaded file SHA-256 verification failed".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!("Failed to clean up temporary directory {}: {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!("Failed to stop CPA kernel on exit: {error}");
}
}
if let Err(error) = gui_config_state.set_run_on_startup(was_running) {
eprintln!("Failed to save kernel state before exit: {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!(
"Failed to create CPA kernel process job: {}",
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!("Failed to configure CPA kernel process job: {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!("Failed to manage CPA kernel child process: {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!("Failed to check CPA kernel process status: {error}"))?
.is_some()
{
return Ok(());
}
#[cfg(windows)]
{
child
.kill()
.map_err(|err| format!("Failed to stop CPA kernel process: {err}"))?;
child
.wait()
.map_err(|err| format!("Failed while waiting for CPA kernel process to exit: {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!("Failed to check CPA kernel process status: {err}")),
}
}
child
.kill()
.map_err(|err| format!("Failed to force-stop CPA kernel process: {err}"))?;
child
.wait()
.map_err(|err| format!("Failed while waiting for CPA kernel process to exit: {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},
};
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!("Failed to open CPA kernel process: 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!("Failed to stop CPA kernel process: {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 kernel process did not exit: PID {process_id}")),
_ => Err(format!(
"Failed while waiting for CPA kernel process to exit: 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!("Failed to send process signal: {err}"))?;
if status.success() {
Ok(())
} else {
Err(format!("Failed to send process signal: 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!("Failed to clean up directory {}: {err}", path_to_string(path)))?;
}
fs::create_dir_all(path).map_err(|err| format!("Failed to create directory {}: {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!(
"Kernel staging directory does not exist: {}",
path_to_string(staging_dir)
));
}
if !install_dir.exists() {
return fs::rename(staging_dir, install_dir)
.map_err(|err| format!("Failed to install new kernel directory: {err}"));
}
if !install_dir.is_dir() {
return Err(format!(
"Kernel installation path is not a directory: {}",
path_to_string(install_dir)
));
}
overlay_directory(staging_dir, install_dir)?;
fs::remove_dir_all(staging_dir).map_err(|err| format!("Failed to clean up kernel staging directory: {err}"))
}
fn overlay_directory(source_dir: &Path, target_dir: &Path) -> Result<(), String> {
for entry in fs::read_dir(source_dir)
.map_err(|err| format!("Failed to read kernel staging directory {}: {err}", path_to_string(source_dir)))?
{
let entry = entry.map_err(|err| format!("Failed to read kernel staging entry: {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!("Failed to read kernel staging entry type: {err}"))?;
if file_type.is_dir() {
if target_path.exists() && !target_path.is_dir() {
return Err(format!(
"Cannot overwrite a same-named file with a kernel directory: {}",
path_to_string(&target_path)
));
}
fs::create_dir_all(&target_path).map_err(|err| {
format!(
"Failed to create kernel installation subdirectory {}: {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!(
"Cannot overwrite a same-named directory with a kernel file: {}",
path_to_string(&target_path)
));
}
copy_core_file_replace(&source_path, &target_path)?;
} else {
return Err(format!(
"Kernel staging directory contains an unsupported entry: {}",
path_to_string(&source_path)
));
}
}
Ok(())
}
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!(
"Failed to overwrite kernel file {}: cannot determine parent directory",
path_to_string(target_path)
)
})?;
fs::create_dir_all(parent)
.map_err(|error| format!("Failed to create kernel file directory {}: {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!(
"Failed to overwrite kernel file {}: {error}",
path_to_string(target_path)
));
}
Ok(())
}
#[cfg(any(target_os = "macos", test))]
pub(crate) fn rematerialize_core_binary(binary_path: &Path) -> Result<(), String> {
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!("Failed to open tar.gz: {err}"))?;
let decoder = GzDecoder::new(archive_file);
let mut archive = Archive::new(decoder);
let entries = archive
.entries()
.map_err(|err| format!("Failed to read tar.gz entry: {err}"))?;
for entry in entries {
let mut entry = entry.map_err(|err| format!("Failed to read tar.gz entry: {err}"))?;
let entry_path = entry
.path()
.map_err(|err| format!("Failed to read tar.gz entry path: {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!("Failed to create directory: {err}"))?;
} else if entry_type.is_file() {
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent).map_err(|err| format!("Failed to create directory: {err}"))?;
}
entry
.unpack(&out_path)
.map_err(|err| format!("Failed to extract tar.gz file: {err}"))?;
} else {
return Err(format!(
"tar.gz contains an unsupported entry type: {}",
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!("Failed to open zip: {err}"))?;
let mut archive =
ZipArchive::new(archive_file).map_err(|err| format!("Failed to read zip: {err}"))?;
for index in 0..archive.len() {
let mut file = archive
.by_index(index)
.map_err(|err| format!("Failed to read zip entry: {err}"))?;
let enclosed_name = file
.enclosed_name()
.ok_or_else(|| format!("Unsafe zip entry path: {}", file.name()))?;
let out_path = checked_archive_path(install_dir, &enclosed_name)?;
if is_zip_symlink(&file) {
return Err(format!("zip contains an unsupported symbolic link entry: {}", file.name()));
}
if file.is_dir() {
fs::create_dir_all(&out_path).map_err(|err| format!("Failed to create directory: {err}"))?;
continue;
}
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent).map_err(|err| format!("Failed to create directory: {err}"))?;
}
let mut out_file = File::create(&out_path).map_err(|err| format!("Failed to create file: {err}"))?;
io::copy(&mut file, &mut out_file).map_err(|err| format!("Failed to write file: {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!("Failed to set file permissions: {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!(
"Unsafe archive entry path: {}",
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 = "linux",
target_os = "macos",
target_os = "windows"
))
}
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(|| "Main window does not exist".to_string())?;
#[cfg(target_os = "macos")]
set_macos_dock_visible(app_handle, !start_hidden);
#[cfg(target_os = "linux")]
let start_hidden = if start_hidden && !linux_tray_available(app_handle) {
eprintln!(
"System tray is unavailable; showing the main window instead of starting hidden"
);
false
} else {
start_hidden
};
if start_hidden {
return window
.hide()
.map_err(|error| format!("Failed to hide main window during silent startup: {error}"));
}
window
.show()
.map_err(|error| format!("Failed to show main window: {error}"))?;
if window.is_minimized().unwrap_or(false) {
window
.unminimize()
.map_err(|error| format!("Failed to restore main window: {error}"))?;
}
window
.set_focus()
.map_err(|error| format!("Failed to focus main window: {error}"))
}
pub(crate) fn path_to_string(path: &Path) -> String {
path.to_string_lossy().into_owned()
}