diff --git a/README.md b/README.md index 60a31ac..709fdc4 100644 --- a/README.md +++ b/README.md @@ -97,8 +97,9 @@ Android 版目前走的是 DNS 代理接管方案: 桌面端默认提供: - `开始加速 / 停止加速` +- `恢复 hosts / 彻底恢复原始状态` - 一键最小化 -- 错误详情展示 +- 状态与最近操作日志展示 - 当前上游、DoH、证书和域名接管范围预览 - 配置和关于面板 @@ -134,6 +135,28 @@ cargo run --bin linuxdo-accelerator -- init-config sudo cargo run --bin linuxdo-accelerator -- setup ``` +仅准备 `hosts` 规则: + +```bash +sudo cargo run --bin linuxdo-accelerator -- apply-hosts +``` + +手动确保首次 `hosts` 基线备份存在: + +```bash +cargo run --bin linuxdo-accelerator -- backup-hosts +``` + +恢复到首次接管前的 `hosts` 完整备份: + +```bash +sudo cargo run --bin linuxdo-accelerator -- restore-hosts +``` + +> `backup-hosts` 默认只创建**首次基线备份**,不会覆盖已有备份。 +> `clean-hosts` / `restore-hosts` 仅适合在加速服务已停止时使用; +> 如果目标是“尽量完整回到初始状态”,优先使用 `cleanup`。 + 前台直接启动: ```bash @@ -176,6 +199,9 @@ linuxdo-accelerator --config /path/to/linuxdo-accelerator.toml ``` 程序会改用该配置文件;对应的 `runtime` 和 `certs` 目录也会优先跟着这个配置目录走。 +`runtime` 目录中还会保存 `hosts.backup` 与 `hosts.backup.json`,用于完整恢复首次接管前的 +`hosts` 内容与原始文件属性。 +此外还会写入 `operations.log`,记录启动、停止、恢复和清理等关键操作结果,便于排查问题。 ## Config Example @@ -207,7 +233,7 @@ server_common_name = "linux.do" - `linuxdo-accelerator` - 默认直接打开桌面 GUI - 传入命令参数后作为 CLI 使用 - - 负责 `setup / start / stop / status / gui` 等命令 + - 负责 `setup / apply-hosts / backup-hosts / restore-hosts / start / stop / status / gui` 等命令 - Windows 下打包为可双击启动的 `.exe` - Linux 下由 `.desktop` 启动 - macOS 下打包为 `.app / .dmg` @@ -279,6 +305,16 @@ macOS 不再走本地交叉编译脚本,而是通过 GitHub Actions 原生构 - macOS 本机编译与窗口最小化恢复链路 - 证书、`hosts` 和运行状态文件统一管理 +## Hosts Safety Notes + +- 首次写入系统 `hosts` 前,会在 `runtime` 目录自动创建完整备份 +- `clean-hosts` 只删除 `linuxdo-accelerator` 自己维护的 marker block +- `restore-hosts` 会用完整备份覆盖当前 `hosts`,适合停止使用后的显式恢复 +- `cleanup` 会优先尝试恢复首次接管前的完整备份;如果完整备份缺失、损坏、状态异常或恢复失败,会退化为仅清理 marker block;遇到损坏 / 异常备份时还会顺带清掉失效备份,避免后续继续卡在错误状态 +- Windows 下会在写入前自动处理 `hosts` 的只读属性,并在写入后恢复原始文件属性 +- Windows 下对 `hosts` 原子替换增加了共享冲突重试,降低杀软 / 系统进程短暂占用导致的恢复失败概率 +- 当前恢复范围以 `hosts` 内容与常见文件属性为主,不额外恢复自定义 ACL / owner + ## Inspirations - [docmirror/dev-sidecar](https://github.com/docmirror/dev-sidecar) diff --git a/src/cli.rs b/src/cli.rs index 956df65..d35ff62 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -3,9 +3,9 @@ use std::path::PathBuf; use anyhow::Result; use clap::{Parser, Subcommand}; +use crate::config::AppConfig; #[cfg(any(windows, target_os = "linux", target_os = "macos"))] use crate::gui; -use crate::config::AppConfig; use crate::service; #[derive(Debug, Parser)] @@ -30,6 +30,8 @@ pub enum Command { Status, CleanHosts, ApplyHosts, + BackupHosts, + RestoreHosts, UninstallCert, Cleanup, #[command(hide = true)] @@ -49,8 +51,8 @@ pub async fn run(cli: Cli) -> Result<()> { None | Some(Command::Gui) => { #[cfg(any(windows, target_os = "linux", target_os = "macos"))] { - let config_path = service::init_config(cli.config.clone())?; - gui::run(config_path)?; + let config_path = service::init_config(cli.config.clone())?; + gui::run(config_path)?; } #[cfg(target_os = "android")] { @@ -87,6 +89,14 @@ pub async fn run(cli: Cli) -> Result<()> { service::apply_hosts_only(cli.config)?; println!("hosts applied"); } + Some(Command::BackupHosts) => { + service::backup_hosts(cli.config)?; + println!("hosts backup ready"); + } + Some(Command::RestoreHosts) => { + service::restore_hosts(cli.config)?; + println!("hosts restored"); + } Some(Command::UninstallCert) => { service::uninstall_certificate(cli.config)?; println!("certificate removed"); diff --git a/src/gui.rs b/src/gui.rs index 073eaee..0a2dec1 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -20,6 +20,8 @@ use tray_icon::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; use crate::branding; use crate::config::AppConfig; +use crate::hosts::validate_hosts_backup_file; +use crate::hosts_store::{BackupState, backup_state}; #[cfg(target_os = "windows")] use crate::paths::AppPaths; use crate::platform::run_elevated; @@ -30,6 +32,7 @@ use crate::platform::{ apply_app_window_icon, close_app_window, hide_app_window, restore_app_window, update_windows_shortcuts_for_exe, }; +use crate::runtime_log::{append as append_runtime_log, read_recent_lines}; use crate::service; use crate::state::ServiceState; @@ -130,10 +133,13 @@ struct AcceleratorApp { config_path: PathBuf, config: AppConfig, status: ServiceState, + hosts_backup_state: BackupState, + recent_logs: Vec, feedback: String, busy: bool, action_rx: Option>>, pending_action: Option, + confirm_action: Option, optimistic_running: Option<(bool, Instant)>, last_refresh: Instant, show_about: bool, @@ -187,6 +193,8 @@ impl AcceleratorApp { let config = AppConfig::load_or_create(&config_path).unwrap_or_default(); let status = service::status(Some(config_path.clone())).unwrap_or_default(); + let hosts_backup_state = load_hosts_backup_state(&config_path); + let recent_logs = load_recent_runtime_logs(&config_path); #[cfg(target_os = "windows")] schedule_windows_shortcut_icon_refresh(&config_path); let logo = cc.egui_ctx.load_texture( @@ -210,10 +218,13 @@ impl AcceleratorApp { config_path, config, status, + hosts_backup_state, + recent_logs, feedback: String::new(), busy: false, action_rx: None, pending_action: None, + confirm_action: None, optimistic_running: None, last_refresh: Instant::now() - Duration::from_secs(2), show_about: false, @@ -244,6 +255,8 @@ impl AcceleratorApp { if let Ok(status) = service::status(Some(self.config_path.clone())) { self.status = self.apply_optimistic_state(status); } + self.hosts_backup_state = load_hosts_backup_state(&self.config_path); + self.recent_logs = load_recent_runtime_logs(&self.config_path); if let Ok(config) = AppConfig::load_or_create(&self.config_path) { self.config = config; } @@ -277,10 +290,7 @@ impl AcceleratorApp { } self.busy = true; - self.feedback = match action { - GuiAction::Start => "正在申请权限并启动加速...".to_string(), - GuiAction::Stop => "正在停止加速...".to_string(), - }; + self.feedback = action.pending_message().to_string(); let config_path = self.config_path.clone(); let (tx, rx) = mpsc::channel(); @@ -315,15 +325,27 @@ impl AcceleratorApp { self.status.last_error = None; self.optimistic_running = Some((false, deadline)); } + Some(GuiAction::RestoreHosts) | Some(GuiAction::Cleanup) => { + self.optimistic_running = None; + self.refresh_status(); + } None => {} } } Err(message) => { self.optimistic_running = None; - self.status.running = false; - self.status.pid = None; - self.status.status_text = "启动失败".to_string(); - self.status.last_error = Some(message.clone()); + match self.pending_action { + Some(GuiAction::Start) => { + self.status.running = false; + self.status.pid = None; + self.status.status_text = "启动失败".to_string(); + self.status.last_error = Some(message.clone()); + } + _ => { + self.refresh_status(); + self.status.last_error = Some(message.clone()); + } + } self.feedback = format!("操作失败: {message}"); } } @@ -363,6 +385,207 @@ impl AcceleratorApp { self.status.status_text.clone() } + fn recent_logs_or_placeholder(&self) -> Vec { + if self.recent_logs.is_empty() { + vec!["暂无运行日志。执行开始、停止、恢复等操作后会在这里显示。".to_string()] + } else { + self.recent_logs.clone() + } + } + + fn hosts_backup_badge(&self) -> (&'static str, egui::Color32) { + match self.hosts_backup_state { + BackupState::Ready => ("已检测到备份", egui::Color32::from_rgb(106, 220, 155)), + BackupState::Missing => ("未检测到备份", egui::Color32::from_rgb(250, 196, 92)), + BackupState::Inconsistent => ("备份状态异常", egui::Color32::from_rgb(255, 120, 100)), + } + } + + fn maintenance_hint(&self) -> &'static str { + if self.status.running { + return "恢复 hosts 需要先停止加速;彻底恢复会自动停止并清理。"; + } + + match self.hosts_backup_state { + BackupState::Ready => "恢复 hosts 只恢复 hosts;彻底恢复还会卸载证书并清理状态。", + BackupState::Missing => { + "未发现 hosts 备份;仍可用“彻底恢复原始状态”清理程序写入的规则。" + } + BackupState::Inconsistent => { + "hosts 备份状态异常;建议直接用“彻底恢复原始状态”尽量回到初始状态。" + } + } + } + + fn can_restore_hosts(&self) -> bool { + !self.busy && !self.status.running && self.hosts_backup_state == BackupState::Ready + } + + fn confirm_summary(&self, action: GuiAction) -> String { + match action { + GuiAction::RestoreHosts => "会用首次接管前的备份覆盖当前 hosts 文件。".to_string(), + GuiAction::Cleanup => { + "会停止加速,并尽量把本程序对系统做的改动恢复到初始状态。".to_string() + } + GuiAction::Start | GuiAction::Stop => "确认执行该操作。".to_string(), + } + } + + fn confirm_details(&self, action: GuiAction) -> Vec { + match action { + GuiAction::RestoreHosts => vec![ + "仅恢复 hosts 文件,不会卸载根证书。".to_string(), + "恢复前需要确保加速已经停止。".to_string(), + "恢复完成后,如需再次加速,可重新点击“开始加速”。".to_string(), + ], + GuiAction::Cleanup => { + let mut lines = vec![ + "会停止当前加速服务。".to_string(), + "会移除回环别名并清理运行状态。".to_string(), + "会卸载根证书;后续如需继续使用,需要重新安装。".to_string(), + ]; + match self.hosts_backup_state { + BackupState::Ready => lines.insert( + 1, + "当前已检测到完整 hosts 备份,会优先恢复首次接管前的原始内容。" + .to_string(), + ), + BackupState::Missing => lines.insert( + 1, + "当前未检测到完整 hosts 备份,无法保证完整恢复原始 hosts;会退化为仅清理本程序写入的规则。" + .to_string(), + ), + BackupState::Inconsistent => lines.insert( + 1, + "当前 hosts 备份状态异常,无法直接做完整恢复;会退化为仅清理本程序写入的规则。" + .to_string(), + ), + } + lines + } + GuiAction::Start | GuiAction::Stop => Vec::new(), + } + } + + fn confirm_status_note(&self, action: GuiAction) -> Option<(String, egui::Color32)> { + match action { + GuiAction::Cleanup => { + let (label, color) = self.hosts_backup_badge(); + Some((format!("当前检测结果:{label}"), color)) + } + GuiAction::RestoreHosts => Some(( + if self.status.running { + "当前状态:加速中,需先停止后再恢复 hosts。".to_string() + } else { + "当前状态:已停止,可直接恢复 hosts。".to_string() + }, + if self.status.running { + egui::Color32::from_rgb(250, 196, 92) + } else { + egui::Color32::from_rgb(106, 220, 155) + }, + )), + GuiAction::Start | GuiAction::Stop => None, + } + } + + fn show_confirm_action_dialog(&mut self, ctx: &egui::Context) { + let Some(action) = self.confirm_action else { + return; + }; + + let mut open = true; + let mut confirmed = false; + let mut cancelled = false; + egui::Window::new(action.confirm_title()) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) + .default_width(500.0) + .open(&mut open) + .show(ctx, |ui| { + ui.set_width(500.0); + ui.spacing_mut().item_spacing = egui::vec2(8.0, 8.0); + + egui::ScrollArea::vertical() + .max_height(260.0) + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.label( + RichText::new(self.confirm_summary(action)) + .font(FontId::proportional(13.0)) + .strong(), + ); + + if let Some((note, color)) = self.confirm_status_note(action) { + ui.add_space(2.0); + egui::Frame::new() + .fill(color.linear_multiply(0.12)) + .stroke(egui::Stroke::new(1.0, color.linear_multiply(0.72))) + .inner_margin(egui::Margin::symmetric(10, 8)) + .corner_radius(egui::CornerRadius::same(10)) + .show(ui, |ui| { + ui.label( + RichText::new(note) + .font(FontId::proportional(11.2)) + .strong() + .color(color), + ); + }); + } + + ui.add_space(2.0); + for line in self.confirm_details(action) { + ui.label( + RichText::new(format!("• {line}")) + .font(FontId::proportional(11.5)) + .color(egui::Color32::from_rgb(213, 218, 222)), + ); + } + + ui.add_space(2.0); + ui.label( + RichText::new("这些操作会再次申请管理员权限。") + .font(FontId::proportional(10.8)) + .color(egui::Color32::from_rgb(165, 174, 182)), + ); + }); + + ui.add_space(10.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("取消").clicked() { + cancelled = true; + } + if ui + .add_enabled( + !self.busy, + egui::Button::new( + RichText::new(action.confirm_button()) + .strong() + .color(egui::Color32::from_rgb(24, 24, 22)), + ) + .fill(egui::Color32::from_rgb(243, 180, 66)) + .stroke(egui::Stroke::new( + 1.0, + egui::Color32::from_rgb(216, 158, 58), + )) + .min_size(egui::vec2(168.0, 30.0)), + ) + .clicked() + { + confirmed = true; + } + }); + }); + + if confirmed { + self.confirm_action = None; + self.trigger_action(action); + } else if cancelled || !open { + self.confirm_action = None; + } + } + #[cfg(target_os = "windows")] fn minimize_to_tray(&mut self, ctx: &egui::Context) { if let Some(tray) = &self.tray { @@ -508,12 +731,15 @@ impl eframe::App for AcceleratorApp { egui::CentralPanel::default().show(ctx, |ui| { ui.spacing_mut().item_spacing = egui::vec2(10.0, 10.0); - let bg = egui::Color32::from_rgb(15, 18, 22); - egui::Frame::new() - .fill(bg) - .inner_margin(egui::Margin::same(12)) - .corner_radius(egui::CornerRadius::same(18)) + egui::ScrollArea::vertical() + .auto_shrink([false, false]) .show(ui, |ui| { + let bg = egui::Color32::from_rgb(15, 18, 22); + egui::Frame::new() + .fill(bg) + .inner_margin(egui::Margin::same(12)) + .corner_radius(egui::CornerRadius::same(18)) + .show(ui, |ui| { let (headline, accent) = self.headline_status(); ui.horizontal(|ui| { ui.add(egui::Image::new((self.logo.id(), egui::vec2(38.0, 38.0)))); @@ -585,14 +811,28 @@ impl eframe::App for AcceleratorApp { } else { "开始加速" }; + let (primary_fill, primary_text) = if self.status.running { + ( + egui::Color32::from_rgb(176, 73, 62), + egui::Color32::from_rgb(250, 246, 244), + ) + } else { + ( + egui::Color32::from_rgb(243, 180, 66), + egui::Color32::from_rgb(24, 24, 22), + ) + }; if ui .add_enabled( !self.busy, egui::Button::new( RichText::new(primary_label) .font(FontId::proportional(13.0)) - .strong(), + .strong() + .color(primary_text), ) + .fill(primary_fill) + .stroke(egui::Stroke::new(1.0, primary_fill.linear_multiply(0.85))) .min_size(egui::vec2(ui.available_width(), 40.0)), ) .clicked() @@ -609,7 +849,10 @@ impl eframe::App for AcceleratorApp { if ui .add_enabled( !self.busy, - egui::Button::new("最小化") + egui::Button::new( + RichText::new("最小化") + .color(egui::Color32::from_rgb(236, 239, 241)), + ) .min_size(egui::vec2(78.0, 28.0)), ) .clicked() @@ -618,7 +861,10 @@ impl eframe::App for AcceleratorApp { } if ui .add( - egui::Button::new("设置") + egui::Button::new( + RichText::new("设置") + .color(egui::Color32::from_rgb(236, 239, 241)), + ) .min_size(egui::vec2(64.0, 28.0)), ) .clicked() @@ -627,7 +873,10 @@ impl eframe::App for AcceleratorApp { } if ui .add( - egui::Button::new("关于") + egui::Button::new( + RichText::new("关于") + .color(egui::Color32::from_rgb(236, 239, 241)), + ) .min_size(egui::vec2(56.0, 28.0)), ) .clicked() @@ -637,6 +886,91 @@ impl eframe::App for AcceleratorApp { }); }); + egui::Frame::new() + .fill(egui::Color32::from_rgb(34, 27, 21)) + .stroke(egui::Stroke::new( + 1.0, + egui::Color32::from_rgb(81, 58, 45), + )) + .inner_margin(egui::Margin::same(12)) + .corner_radius(egui::CornerRadius::same(14)) + .show(&mut columns[0], |ui| { + let (backup_label, backup_color) = self.hosts_backup_badge(); + ui.horizontal(|ui| { + ui.label( + RichText::new("恢复与清理") + .font(FontId::proportional(11.5)) + .strong() + .color(egui::Color32::from_rgb(255, 193, 122)), + ); + egui::Frame::new() + .fill(backup_color.linear_multiply(0.14)) + .stroke(egui::Stroke::new( + 1.0, + backup_color.linear_multiply(0.7), + )) + .inner_margin(egui::Margin::symmetric(8, 4)) + .corner_radius(egui::CornerRadius::same(255)) + .show(ui, |ui| { + ui.label( + RichText::new(backup_label) + .font(FontId::proportional(10.5)) + .strong() + .color(backup_color), + ); + }); + }); + ui.add_space(4.0); + ui.label( + RichText::new(self.maintenance_hint()) + .font(FontId::proportional(11.2)) + .color(egui::Color32::from_rgb(225, 227, 230)), + ); + ui.add_space(4.0); + ui.label( + RichText::new("这些操作会再次弹出管理员确认。") + .font(FontId::proportional(10.6)) + .color(egui::Color32::from_rgb(176, 184, 191)), + ); + ui.add_space(8.0); + if ui + .add_enabled( + self.can_restore_hosts(), + egui::Button::new( + RichText::new("恢复 hosts") + .color(egui::Color32::from_rgb(245, 249, 247)), + ) + .fill(egui::Color32::from_rgb(45, 99, 84)) + .stroke(egui::Stroke::new( + 1.0, + egui::Color32::from_rgb(66, 132, 114), + )) + .min_size(egui::vec2(ui.available_width(), 34.0)), + ) + .clicked() + { + self.confirm_action = Some(GuiAction::RestoreHosts); + } + if ui + .add_enabled( + !self.busy, + egui::Button::new( + RichText::new("彻底恢复原始状态") + .color(egui::Color32::from_rgb(252, 246, 245)), + ) + .fill(egui::Color32::from_rgb(132, 62, 56)) + .stroke(egui::Stroke::new( + 1.0, + egui::Color32::from_rgb(171, 84, 76), + )) + .min_size(egui::vec2(ui.available_width(), 34.0)), + ) + .clicked() + { + self.confirm_action = Some(GuiAction::Cleanup); + } + }); + egui::Frame::new() .fill(egui::Color32::from_rgb(20, 24, 30)) .stroke(egui::Stroke::new( @@ -647,28 +981,56 @@ impl eframe::App for AcceleratorApp { .corner_radius(egui::CornerRadius::same(12)) .show(&mut columns[0], |ui| { ui.label( - RichText::new("错误详情") + RichText::new("状态与日志") .font(FontId::proportional(11.0)) .strong() .color(egui::Color32::from_rgb(154, 167, 177)), ); ui.add_space(4.0); - let details = self.status.last_error.as_deref().unwrap_or( - "当前没有错误。启动失败时会直接显示真实原因。", + ui.label( + RichText::new(format!("当前状态:{}", self.status.status_text)) + .font(FontId::proportional(11.5)) + .color(egui::Color32::from_rgb(225, 230, 234)), + ); + ui.add_space(4.0); + ui.label( + RichText::new("最近错误") + .font(FontId::proportional(10.5)) + .strong() + .color(egui::Color32::from_rgb(165, 174, 182)), + ); + let details = self + .status + .last_error + .as_deref() + .unwrap_or("当前没有错误。运行异常时会直接显示真实原因。"); + ui.label( + RichText::new(details) + .font(FontId::proportional(11.2)) + .color(if self.status.last_error.is_some() { + egui::Color32::from_rgb(255, 124, 102) + } else { + egui::Color32::from_rgb(198, 205, 211) + }), + ); + ui.add_space(8.0); + ui.label( + RichText::new("最近操作日志") + .font(FontId::proportional(10.5)) + .strong() + .color(egui::Color32::from_rgb(165, 174, 182)), ); egui::ScrollArea::vertical() - .max_height(128.0) + .max_height(132.0) .auto_shrink([false, false]) .show(ui, |ui| { - ui.label( - RichText::new(details) - .font(FontId::proportional(11.5)) - .color(if self.status.last_error.is_some() { - egui::Color32::from_rgb(255, 124, 102) - } else { - egui::Color32::from_rgb(198, 205, 211) - }), - ); + for line in self.recent_logs_or_placeholder() { + ui.label( + RichText::new(line) + .font(FontId::monospace(10.2)) + .color(egui::Color32::from_rgb(198, 205, 211)), + ); + } }); }); @@ -758,8 +1120,11 @@ impl eframe::App for AcceleratorApp { }); }); }); + }); }); + self.show_confirm_action_dialog(ctx); + if self.show_config { egui::Window::new("设置") .collapsible(false) @@ -814,6 +1179,62 @@ impl eframe::App for AcceleratorApp { enum GuiAction { Start, Stop, + RestoreHosts, + Cleanup, +} + +impl GuiAction { + fn pending_message(self) -> &'static str { + match self { + Self::Start => "正在申请权限并启动加速...", + Self::Stop => "正在停止加速...", + Self::RestoreHosts => "正在恢复 hosts 备份...", + Self::Cleanup => "正在恢复原始状态...", + } + } + + fn subcommand(self) -> &'static str { + match self { + Self::Start => "helper-start", + Self::Stop => "helper-stop", + Self::RestoreHosts => "restore-hosts", + Self::Cleanup => "cleanup", + } + } + + fn confirm_title(self) -> &'static str { + match self { + Self::RestoreHosts => "确认恢复 hosts", + Self::Cleanup => "确认彻底恢复原始状态", + Self::Start | Self::Stop => "确认操作", + } + } + + fn confirm_button(self) -> &'static str { + match self { + Self::RestoreHosts => "确认恢复 hosts", + Self::Cleanup => "确认恢复原始状态", + Self::Start | Self::Stop => "确认", + } + } + + fn fallback_success_message(self) -> &'static str { + match self { + Self::Start => "加速已启动,可以直接最小化窗口", + Self::Stop => "加速已停止", + Self::RestoreHosts => "hosts 已恢复为备份", + Self::Cleanup => "已恢复原始状态", + } + } + + fn error_context(self) -> &'static str { + match self { + Self::Start => "elevation or command execution failed", + Self::Stop => "failed to stop acceleration from GUI", + Self::RestoreHosts => "failed to restore hosts from GUI", + Self::Cleanup => "failed to cleanup accelerator state from GUI", + } + } } fn execute_action(config_path: &Path, action: GuiAction) -> Result { @@ -823,24 +1244,56 @@ fn execute_action(config_path: &Path, action: GuiAction) -> Result { .with_context(|| "macOS certificate preparation failed")?; } + let before_status = service::status(Some(config_path.to_path_buf())).unwrap_or_default(); + if let Ok(paths) = service::resolve_paths(Some(config_path.to_path_buf())) { + let _ = append_runtime_log( + &paths, + "INFO", + action.subcommand(), + "GUI 已发起管理员操作请求", + ); + } let cli_binary = locate_action_binary()?; - let subcommand = match action { - GuiAction::Start => "helper-start", - GuiAction::Stop => "helper-stop", - }; let args = vec![ "--config".to_string(), config_path.to_string_lossy().into_owned(), - subcommand.to_string(), + action.subcommand().to_string(), ]; if let Err(error) = run_elevated(&cli_binary, &args) { - if let Ok(status) = service::status(Some(config_path.to_path_buf())) - && let Some(last_error) = status.last_error - { - return Err(Error::msg(last_error)) - .with_context(|| "elevation or command execution failed"); + if let Ok(status) = service::status(Some(config_path.to_path_buf())) { + if let Some(last_error) = status.last_error.clone() { + if let Ok(paths) = service::resolve_paths(Some(config_path.to_path_buf())) { + let _ = append_runtime_log( + &paths, + "ERROR", + action.subcommand(), + &format!("GUI 操作失败:{last_error}"), + ); + } + return Err(Error::msg(last_error)).with_context(|| action.error_context()); + } + if !matches!(action, GuiAction::Start) && service_state_changed(&before_status, &status) + { + if let Ok(paths) = service::resolve_paths(Some(config_path.to_path_buf())) { + let _ = append_runtime_log( + &paths, + "WARN", + action.subcommand(), + &format!("GUI 检测到状态已变化:{}", status.status_text), + ); + } + return Err(Error::msg(status.status_text)).with_context(|| action.error_context()); + } } - return Err(error).with_context(|| "elevation or command execution failed"); + if let Ok(paths) = service::resolve_paths(Some(config_path.to_path_buf())) { + let _ = append_runtime_log( + &paths, + "ERROR", + action.subcommand(), + &format!("GUI 提权执行失败:{error}"), + ); + } + return Err(error).with_context(|| action.error_context()); } let deadline = Instant::now() + Duration::from_secs(12); @@ -853,8 +1306,17 @@ fn execute_action(config_path: &Path, action: GuiAction) -> Result { GuiAction::Stop if !status.running => { return Ok("加速已停止".to_string()); } + GuiAction::RestoreHosts | GuiAction::Cleanup => { + if let Some(error) = status.last_error.clone() { + bail!(error); + } + if service_state_changed(&before_status, &status) { + return Ok(status.status_text); + } + return Ok(action.fallback_success_message().to_string()); + } _ => { - if let Some(error) = status.last_error { + if let Some(error) = status.last_error.clone() { bail!(error); } if Instant::now() >= deadline { @@ -869,6 +1331,36 @@ fn execute_action(config_path: &Path, action: GuiAction) -> Result { } } +fn service_state_changed(before: &ServiceState, after: &ServiceState) -> bool { + before.running != after.running + || before.pid != after.pid + || before.status_text != after.status_text + || before.last_error != after.last_error + || before.updated_at != after.updated_at +} + +fn load_hosts_backup_state(config_path: &Path) -> BackupState { + service::resolve_paths(Some(config_path.to_path_buf())) + .map(|paths| match backup_state(&paths) { + BackupState::Ready => { + if validate_hosts_backup_file(&paths).is_ok() { + BackupState::Ready + } else { + BackupState::Inconsistent + } + } + state => state, + }) + .unwrap_or(BackupState::Missing) +} + +fn load_recent_runtime_logs(config_path: &Path) -> Vec { + service::resolve_paths(Some(config_path.to_path_buf())) + .ok() + .and_then(|paths| read_recent_lines(&paths, 12).ok()) + .unwrap_or_default() +} + #[cfg(target_os = "linux")] fn spawn_linux_tray_shell(config_path: &Path) -> Result<()> { let gui_binary = locate_gui_binary()?; @@ -915,6 +1407,7 @@ fn locate_action_binary() -> Result { locate_current_or_sibling_binary(action_binary_name()) } +#[cfg(target_os = "linux")] fn locate_gui_binary() -> Result { locate_current_or_sibling_binary(gui_binary_name()) } @@ -945,6 +1438,7 @@ fn action_binary_name() -> &'static str { } } +#[cfg(target_os = "linux")] fn gui_binary_name() -> &'static str { action_binary_name() } @@ -984,11 +1478,13 @@ fn install_theme(ctx: &egui::Context) { style.visuals.widgets.noninteractive.fg_stroke.color = egui::Color32::from_rgb(236, 239, 241); style.visuals.widgets.inactive.bg_fill = egui::Color32::from_rgb(39, 45, 53); style.visuals.widgets.inactive.weak_bg_fill = egui::Color32::from_rgb(39, 45, 53); + style.visuals.widgets.inactive.fg_stroke.color = egui::Color32::from_rgb(236, 239, 241); style.visuals.widgets.hovered.bg_fill = egui::Color32::from_rgb(58, 69, 79); style.visuals.widgets.hovered.fg_stroke.color = egui::Color32::WHITE; style.visuals.widgets.active.bg_fill = egui::Color32::from_rgb(243, 180, 66); style.visuals.widgets.active.fg_stroke.color = egui::Color32::from_rgb(24, 24, 22); style.visuals.widgets.open.bg_fill = egui::Color32::from_rgb(44, 50, 58); + style.visuals.widgets.open.fg_stroke.color = egui::Color32::from_rgb(236, 239, 241); style.visuals.selection.bg_fill = egui::Color32::from_rgb(243, 180, 66); style.visuals.selection.stroke.color = egui::Color32::from_rgb(24, 24, 22); style.visuals.window_fill = egui::Color32::from_rgb(14, 17, 21); diff --git a/src/hosts.rs b/src/hosts.rs index 8f8f28f..b018d46 100644 --- a/src/hosts.rs +++ b/src/hosts.rs @@ -4,15 +4,18 @@ use std::path::PathBuf; use anyhow::{Context, Result}; use crate::config::AppConfig; +use crate::hosts_store::{ + ensure_hosts_backup, restore_hosts_from_backup, validate_hosts_backup, write_hosts_content, +}; use crate::paths::AppPaths; const START_MARKER: &str = "# >>> linuxdo-accelerator >>>"; const END_MARKER: &str = "# <<< linuxdo-accelerator <<<"; -pub fn apply_hosts(config: &AppConfig, _paths: &AppPaths) -> Result<()> { +pub fn apply_hosts(config: &AppConfig, paths: &AppPaths) -> Result<()> { #[cfg(target_os = "android")] { - return apply_android_hosts(config, _paths); + return apply_android_hosts(config, paths); } #[cfg(not(target_os = "android"))] @@ -20,9 +23,16 @@ pub fn apply_hosts(config: &AppConfig, _paths: &AppPaths) -> Result<()> { let path = hosts_path(); let original = fs::read_to_string(&path) .with_context(|| format!("failed to read hosts file {}", path.display()))?; + let backup_baseline = backup_baseline_content(&original); + ensure_hosts_backup(paths, &path, &backup_baseline)?; let content = render_managed_hosts(&original, config); - fs::write(&path, content) + if content == original { + return Ok(()); + } + + // 先生成备份,再用原子替换方式落盘,尽量避免 hosts 被写坏。 + write_hosts_content(&path, &original, &content) .with_context(|| format!("failed to update hosts file {}", path.display()))?; Ok(()) } @@ -40,12 +50,64 @@ pub fn remove_hosts(_paths: &AppPaths) -> Result<()> { let original = fs::read_to_string(&path) .with_context(|| format!("failed to read hosts file {}", path.display()))?; let stripped = strip_managed_block(&original); - fs::write(&path, stripped) + + if stripped == original { + return Ok(()); + } + + write_hosts_content(&path, &original, &stripped) .with_context(|| format!("failed to clean hosts file {}", path.display()))?; Ok(()) } } +pub fn backup_hosts_file(paths: &AppPaths) -> Result<()> { + #[cfg(target_os = "android")] + { + return backup_android_hosts(paths); + } + + #[cfg(not(target_os = "android"))] + { + let path = hosts_path(); + let original = fs::read_to_string(&path) + .with_context(|| format!("failed to read hosts file {}", path.display()))?; + let backup_baseline = backup_baseline_content(&original); + ensure_hosts_backup(paths, &path, &backup_baseline) + } +} + +pub fn restore_hosts_file(paths: &AppPaths) -> Result<()> { + #[cfg(target_os = "android")] + { + return restore_android_hosts(paths); + } + + #[cfg(not(target_os = "android"))] + { + let path = hosts_path(); + let original = fs::read_to_string(&path) + .with_context(|| format!("failed to read hosts file {}", path.display()))?; + + // 完整恢复会覆盖当前 hosts,因此使用首次备份作为明确回滚点。 + restore_hosts_from_backup(paths, &path, &original)?; + Ok(()) + } +} + +pub fn validate_hosts_backup_file(paths: &AppPaths) -> Result<()> { + #[cfg(target_os = "android")] + { + let _ = paths; + return Ok(()); + } + + #[cfg(not(target_os = "android"))] + { + validate_hosts_backup(paths, &hosts_path()) + } +} + #[cfg(not(target_os = "android"))] fn render_managed_hosts(original: &str, config: &AppConfig) -> String { let newline = detect_newline(original); @@ -69,6 +131,11 @@ fn render_managed_hosts(original: &str, config: &AppConfig) -> String { content } +#[cfg(not(target_os = "android"))] +fn backup_baseline_content(original: &str) -> String { + strip_managed_block(original) +} + #[cfg(target_os = "android")] fn apply_android_hosts(_config: &AppConfig, paths: &AppPaths) -> Result<()> { let marker_path = android_hosts_marker_path(paths); @@ -94,6 +161,26 @@ fn remove_android_hosts(paths: &AppPaths) -> Result<()> { Ok(()) } +#[cfg(target_os = "android")] +fn backup_android_hosts(paths: &AppPaths) -> Result<()> { + fs::write( + &paths.hosts_backup_path, + "android uses internal dns_hosts overrides; no system hosts backup is required\n", + ) + .with_context(|| format!("failed to write {}", paths.hosts_backup_path.display()))?; + fs::write( + &paths.hosts_backup_meta_path, + "{\"android\":true,\"mode\":\"dns_hosts_override\"}\n", + ) + .with_context(|| format!("failed to write {}", paths.hosts_backup_meta_path.display()))?; + Ok(()) +} + +#[cfg(target_os = "android")] +fn restore_android_hosts(paths: &AppPaths) -> Result<()> { + remove_android_hosts(paths) +} + #[cfg(target_os = "android")] fn android_hosts_marker_path(paths: &AppPaths) -> PathBuf { paths.runtime_dir.join("android-dns-hosts.txt") @@ -110,27 +197,32 @@ fn detect_newline(content: &str) -> &'static str { #[cfg(not(target_os = "android"))] fn strip_managed_block(content: &str) -> String { - let mut output = Vec::new(); - let mut skipping = false; - for line in content.lines() { - if line.trim() == START_MARKER { - skipping = true; - continue; - } - if line.trim() == END_MARKER { - skipping = false; - continue; - } - if !skipping { - output.push(line); - } - } - - output.join(if content.contains("\r\n") { + let newline = if content.contains("\r\n") { "\r\n" } else { "\n" - }) + }; + let lines: Vec<&str> = content.lines().collect(); + let mut output = Vec::with_capacity(lines.len()); + let mut index = 0; + + while index < lines.len() { + if lines[index].trim() == START_MARKER { + if let Some(end_index) = lines[index + 1..] + .iter() + .position(|line| line.trim() == END_MARKER) + .map(|offset| index + 1 + offset) + { + index = end_index + 1; + continue; + } + } + + output.push(lines[index]); + index += 1; + } + + output.join(newline) } #[cfg(not(target_os = "android"))] @@ -150,3 +242,80 @@ fn hosts_path() -> PathBuf { unreachable!() } } + +#[cfg(test)] +mod tests { + use super::{backup_baseline_content, render_managed_hosts, strip_managed_block}; + use crate::config::AppConfig; + + #[test] + fn strip_managed_block_only_removes_owned_lines() { + let content = [ + "127.0.0.1 localhost", + "# >>> linuxdo-accelerator >>>", + "127.211.73.84 linux.do", + "# <<< linuxdo-accelerator <<<", + "1.1.1.1 example.com", + ] + .join("\n"); + + let stripped = strip_managed_block(&content); + assert_eq!(stripped, "127.0.0.1 localhost\n1.1.1.1 example.com"); + } + + #[test] + fn render_managed_hosts_skips_wildcards_and_replaces_old_block() { + let mut config = AppConfig::default(); + config.hosts_ip = "127.211.73.84".to_string(); + config.hosts_domains = vec![ + "linux.do".to_string(), + "*.linux.do".to_string(), + "www.linux.do".to_string(), + ]; + + let original = [ + "127.0.0.1 localhost", + "# >>> linuxdo-accelerator >>>", + "127.0.0.1 stale.example", + "# <<< linuxdo-accelerator <<<", + ] + .join("\n"); + + let rendered = render_managed_hosts(&original, &config); + + assert!(rendered.contains("127.0.0.1 localhost")); + assert!(rendered.contains("127.211.73.84 linux.do")); + assert!(rendered.contains("127.211.73.84 www.linux.do")); + assert!(!rendered.contains("stale.example")); + assert!(!rendered.contains("*.linux.do")); + } + + #[test] + fn strip_managed_block_keeps_tail_when_end_marker_is_missing() { + let content = [ + "127.0.0.1 localhost", + "# >>> linuxdo-accelerator >>>", + "127.211.73.84 linux.do", + "1.1.1.1 example.com", + ] + .join("\n"); + + let stripped = strip_managed_block(&content); + assert_eq!(stripped, content); + } + + #[test] + fn backup_baseline_strips_existing_managed_block() { + let content = [ + "127.0.0.1 localhost", + "# >>> linuxdo-accelerator >>>", + "127.211.73.84 linux.do", + "# <<< linuxdo-accelerator <<<", + "1.1.1.1 example.com", + ] + .join("\n"); + + let backup = backup_baseline_content(&content); + assert_eq!(backup, "127.0.0.1 localhost\n1.1.1.1 example.com"); + } +} diff --git a/src/hosts_store.rs b/src/hosts_store.rs new file mode 100644 index 0000000..0079afc --- /dev/null +++ b/src/hosts_store.rs @@ -0,0 +1,709 @@ +use std::fs::{self, File}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; +#[cfg(windows)] +use std::{thread, time::Duration}; + +use anyhow::{Context, Result, anyhow, bail}; +use serde::{Deserialize, Serialize}; + +#[cfg(windows)] +use std::ffi::OsStr; +#[cfg(windows)] +use std::os::windows::ffi::OsStrExt; +#[cfg(windows)] +use std::os::windows::fs::MetadataExt; +#[cfg(windows)] +use windows_sys::Win32::Storage::FileSystem::{ + FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_READONLY, REPLACEFILE_WRITE_THROUGH, ReplaceFileW, + SetFileAttributesW, +}; + +use crate::paths::AppPaths; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct HostsBackupMeta { + source_path: String, + backup_created_at_unix_ms: u64, + original_attributes: HostsFileAttributes, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct HostsFileAttributes { + readonly: bool, + #[cfg(windows)] + raw_file_attributes: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BackupState { + Missing, + Ready, + Inconsistent, +} + +pub(crate) fn ensure_hosts_backup( + paths: &AppPaths, + source_path: &Path, + backup_content: &str, +) -> Result<()> { + match backup_state(paths) { + BackupState::Ready => { + validate_hosts_backup(paths, source_path)?; + return Ok(()); + } + BackupState::Inconsistent => { + bail!( + "hosts backup state is inconsistent; expected both {} and {}", + paths.hosts_backup_path.display(), + paths.hosts_backup_meta_path.display() + ); + } + BackupState::Missing => {} + } + + let attributes = snapshot_file_attributes(source_path)?; + let meta = HostsBackupMeta { + source_path: source_path.display().to_string(), + backup_created_at_unix_ms: unix_timestamp_ms()?, + original_attributes: attributes, + }; + let backup_temp_path = temp_backup_path(&paths.hosts_backup_path); + let meta_temp_path = temp_backup_path(&paths.hosts_backup_meta_path); + + let serialized = + serde_json::to_vec_pretty(&meta).context("failed to serialize hosts backup metadata")?; + + cleanup_temp_file(&backup_temp_path)?; + cleanup_temp_file(&meta_temp_path)?; + write_temp_file(&backup_temp_path, backup_content)?; + write_temp_file( + &meta_temp_path, + std::str::from_utf8(&serialized).context("failed to encode hosts backup metadata")?, + )?; + + if let Err(error) = fs::rename(&backup_temp_path, &paths.hosts_backup_path) + .with_context(|| format!("failed to move {}", backup_temp_path.display())) + { + let _ = cleanup_temp_file(&backup_temp_path); + let _ = cleanup_temp_file(&meta_temp_path); + return Err(error); + } + + if let Err(error) = fs::rename(&meta_temp_path, &paths.hosts_backup_meta_path) + .with_context(|| format!("failed to move {}", meta_temp_path.display())) + { + let _ = fs::remove_file(&paths.hosts_backup_path); + let _ = cleanup_temp_file(&backup_temp_path); + let _ = cleanup_temp_file(&meta_temp_path); + return Err(error); + } + + Ok(()) +} + +pub(crate) fn write_hosts_content( + path: &Path, + original_content: &str, + new_content: &str, +) -> Result<()> { + let current_attributes = snapshot_file_attributes(path)?; + write_hosts_content_inner( + path, + original_content, + new_content, + ¤t_attributes, + ¤t_attributes, + ) +} + +pub(crate) fn restore_hosts_from_backup( + paths: &AppPaths, + source_path: &Path, + current_content: &str, +) -> Result<()> { + validate_hosts_backup(paths, source_path)?; + + let backup = fs::read_to_string(&paths.hosts_backup_path) + .with_context(|| format!("failed to read {}", paths.hosts_backup_path.display()))?; + let meta = load_backup_meta(paths)?; + if !source_path_matches(source_path, &meta.source_path) { + bail!( + "hosts backup source path mismatch; expected {}, got {}", + source_path.display(), + meta.source_path + ); + } + + if backup == current_content { + restore_file_attributes(source_path, &meta.original_attributes).with_context(|| { + format!( + "failed to restore hosts attributes {}", + source_path.display() + ) + })?; + return Ok(()); + } + + let current_attributes = snapshot_file_attributes(source_path)?; + write_hosts_content_inner( + source_path, + current_content, + &backup, + ¤t_attributes, + &meta.original_attributes, + ) + .with_context(|| format!("failed to restore hosts file {}", source_path.display())) +} + +pub(crate) fn backup_state(paths: &AppPaths) -> BackupState { + match ( + paths.hosts_backup_path.exists(), + paths.hosts_backup_meta_path.exists(), + ) { + (false, false) => BackupState::Missing, + (true, true) => BackupState::Ready, + _ => BackupState::Inconsistent, + } +} + +pub(crate) fn validate_hosts_backup(paths: &AppPaths, source_path: &Path) -> Result<()> { + match backup_state(paths) { + BackupState::Ready => {} + BackupState::Missing => { + bail!( + "hosts backup is unavailable; expected both {} and {}", + paths.hosts_backup_path.display(), + paths.hosts_backup_meta_path.display() + ); + } + BackupState::Inconsistent => { + bail!( + "hosts backup state is inconsistent; expected both {} and {}", + paths.hosts_backup_path.display(), + paths.hosts_backup_meta_path.display() + ); + } + } + + let _ = fs::read_to_string(&paths.hosts_backup_path) + .with_context(|| format!("failed to read {}", paths.hosts_backup_path.display()))?; + let meta = load_backup_meta(paths)?; + if !source_path_matches(source_path, &meta.source_path) { + bail!( + "hosts backup source path mismatch; expected {}, got {}", + source_path.display(), + meta.source_path + ); + } + Ok(()) +} + +pub(crate) fn clear_hosts_backup(paths: &AppPaths) -> Result<()> { + let backup_temp_path = temp_backup_path(&paths.hosts_backup_path); + let meta_temp_path = temp_backup_path(&paths.hosts_backup_meta_path); + let mut errors = Vec::new(); + + for path in [ + &paths.hosts_backup_path, + &paths.hosts_backup_meta_path, + &backup_temp_path, + &meta_temp_path, + ] { + if let Err(error) = cleanup_temp_file(path) { + errors.push(format!("failed to remove {}: {error:#}", path.display())); + } + } + + if errors.is_empty() { + Ok(()) + } else { + bail!(errors.join("; ")); + } +} + +fn load_backup_meta(paths: &AppPaths) -> Result { + let raw = fs::read(&paths.hosts_backup_meta_path) + .with_context(|| format!("failed to read {}", paths.hosts_backup_meta_path.display()))?; + serde_json::from_slice(&raw).context("failed to parse hosts backup metadata") +} + +fn source_path_matches(source_path: &Path, recorded_path: &str) -> bool { + #[cfg(windows)] + { + return source_path + .to_string_lossy() + .replace('/', "\\") + .eq_ignore_ascii_case(&recorded_path.replace('/', "\\")); + } + + #[cfg(not(windows))] + { + source_path.to_string_lossy() == recorded_path + } +} + +fn write_hosts_content_inner( + path: &Path, + original_content: &str, + new_content: &str, + current_attributes: &HostsFileAttributes, + desired_attributes: &HostsFileAttributes, +) -> Result<()> { + let temp_path = temp_hosts_path(path); + let mut replaced = false; + + if temp_path.exists() { + let _ = fs::remove_file(&temp_path); + } + + if current_attributes.readonly { + set_file_writable(path, current_attributes)?; + } + + let result = (|| -> Result<()> { + write_temp_file(&temp_path, new_content)?; + replace_file_atomically(&temp_path, path)?; + replaced = true; + restore_file_attributes(path, desired_attributes)?; + Ok(()) + })(); + + if let Err(error) = result { + let _ = cleanup_temp_file(&temp_path); + + if !replaced && path.exists() { + let _ = restore_file_attributes(path, current_attributes); + } else if replaced && path.exists() { + let _ = write_hosts_content_without_replace(path, original_content, current_attributes); + } + + return Err(error); + } + + Ok(()) +} + +fn write_hosts_content_without_replace( + path: &Path, + content: &str, + final_attributes: &HostsFileAttributes, +) -> Result<()> { + if final_attributes.readonly { + set_file_writable(path, final_attributes)?; + } + fs::write(path, content).with_context(|| format!("failed to rewrite {}", path.display()))?; + restore_file_attributes(path, final_attributes)?; + Ok(()) +} + +fn write_temp_file(path: &Path, content: &str) -> Result<()> { + let mut file = + File::create(path).with_context(|| format!("failed to create {}", path.display()))?; + file.write_all(content.as_bytes()) + .with_context(|| format!("failed to write {}", path.display()))?; + file.sync_all() + .with_context(|| format!("failed to sync {}", path.display()))?; + Ok(()) +} + +fn cleanup_temp_file(path: &Path) -> Result<()> { + if path.exists() { + fs::remove_file(path).with_context(|| format!("failed to remove {}", path.display()))?; + } + Ok(()) +} + +fn temp_hosts_path(path: &Path) -> PathBuf { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("hosts"); + path.with_file_name(format!("{file_name}.linuxdo-accelerator.tmp")) +} + +fn temp_backup_path(path: &Path) -> PathBuf { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("backup"); + path.with_file_name(format!("{file_name}.linuxdo-accelerator.tmp")) +} + +fn unix_timestamp_ms() -> Result { + Ok(SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| anyhow!("system time is before unix epoch: {error}"))? + .as_millis() as u64) +} + +fn snapshot_file_attributes(path: &Path) -> Result { + let metadata = + fs::metadata(path).with_context(|| format!("failed to stat {}", path.display()))?; + + #[cfg(windows)] + { + Ok(HostsFileAttributes { + readonly: metadata.permissions().readonly(), + raw_file_attributes: metadata.file_attributes(), + }) + } + + #[cfg(not(windows))] + { + Ok(HostsFileAttributes { + readonly: metadata.permissions().readonly(), + }) + } +} + +fn set_file_writable(path: &Path, attributes: &HostsFileAttributes) -> Result<()> { + #[cfg(windows)] + { + let writable_attributes = normalize_windows_file_attributes( + attributes.raw_file_attributes & !FILE_ATTRIBUTE_READONLY, + ); + set_windows_file_attributes(path, writable_attributes) + } + + #[cfg(not(windows))] + { + let _ = attributes; + let mut permissions = fs::metadata(path) + .with_context(|| format!("failed to stat {}", path.display()))? + .permissions(); + permissions.set_readonly(false); + fs::set_permissions(path, permissions) + .with_context(|| format!("failed to clear readonly on {}", path.display())) + } +} + +fn restore_file_attributes(path: &Path, attributes: &HostsFileAttributes) -> Result<()> { + #[cfg(windows)] + { + set_windows_file_attributes( + path, + normalize_windows_file_attributes(attributes.raw_file_attributes), + ) + } + + #[cfg(not(windows))] + { + let mut permissions = fs::metadata(path) + .with_context(|| format!("failed to stat {}", path.display()))? + .permissions(); + permissions.set_readonly(attributes.readonly); + fs::set_permissions(path, permissions) + .with_context(|| format!("failed to set permissions on {}", path.display())) + } +} + +#[cfg(windows)] +fn set_windows_file_attributes(path: &Path, attributes: u32) -> Result<()> { + let wide = wide_null(path.as_os_str()); + let result = unsafe { SetFileAttributesW(wide.as_ptr(), attributes) }; + if result == 0 { + return Err(std::io::Error::last_os_error()) + .with_context(|| format!("failed to set file attributes {}", path.display())); + } + Ok(()) +} + +#[cfg(windows)] +fn normalize_windows_file_attributes(attributes: u32) -> u32 { + if attributes == 0 { + FILE_ATTRIBUTE_NORMAL + } else { + attributes + } +} + +#[cfg(windows)] +fn replace_file_atomically(temp_path: &Path, target_path: &Path) -> Result<()> { + let temp_wide = wide_null(temp_path.as_os_str()); + let target_wide = wide_null(target_path.as_os_str()); + const MAX_RETRIES: u32 = 8; + + for attempt in 0..MAX_RETRIES { + let replaced = unsafe { + ReplaceFileW( + target_wide.as_ptr(), + temp_wide.as_ptr(), + std::ptr::null(), + REPLACEFILE_WRITE_THROUGH, + std::ptr::null(), + std::ptr::null(), + ) + }; + if replaced != 0 { + return Ok(()); + } + + let error = std::io::Error::last_os_error(); + let is_last_attempt = attempt + 1 >= MAX_RETRIES; + if is_last_attempt || !should_retry_windows_replace_error(&error) { + return Err(error).with_context(|| { + format!( + "failed to replace {} with {}", + target_path.display(), + temp_path.display() + ) + }); + } + + thread::sleep(replace_retry_delay(attempt)); + } + + unreachable!("windows replace retry loop should have returned before reaching the end") +} + +#[cfg(windows)] +fn should_retry_windows_replace_error(error: &std::io::Error) -> bool { + should_retry_windows_replace_error_code(error.raw_os_error()) +} + +#[cfg(windows)] +fn should_retry_windows_replace_error_code(code: Option) -> bool { + matches!(code, Some(5 | 32 | 33)) +} + +#[cfg(windows)] +fn replace_retry_delay(attempt: u32) -> Duration { + Duration::from_millis(80 * u64::from(attempt + 1)) +} + +#[cfg(not(windows))] +fn replace_file_atomically(temp_path: &Path, target_path: &Path) -> Result<()> { + fs::rename(temp_path, target_path).with_context(|| { + format!( + "failed to replace {} with {}", + target_path.display(), + temp_path.display() + ) + }) +} + +#[cfg(windows)] +fn wide_null(value: &OsStr) -> Vec { + value.encode_wide().chain(std::iter::once(0)).collect() +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::sync::atomic::{AtomicU64, Ordering}; + + use super::{ + BackupState, backup_state, clear_hosts_backup, ensure_hosts_backup, load_backup_meta, + restore_hosts_from_backup, validate_hosts_backup, write_hosts_content, + }; + use crate::paths::AppPaths; + + static NEXT_ID: AtomicU64 = AtomicU64::new(1); + + #[test] + fn write_hosts_content_restores_readonly_attribute() { + let test_dir = create_test_dir("write_hosts_content_restores_readonly_attribute"); + let file_path = test_dir.join("hosts"); + std::fs::write(&file_path, "before").unwrap(); + + let mut permissions = std::fs::metadata(&file_path).unwrap().permissions(); + permissions.set_readonly(true); + std::fs::set_permissions(&file_path, permissions).unwrap(); + + write_hosts_content(&file_path, "before", "after").unwrap(); + + assert_eq!(std::fs::read_to_string(&file_path).unwrap(), "after"); + assert!( + std::fs::metadata(&file_path) + .unwrap() + .permissions() + .readonly() + ); + + cleanup_test_dir(&test_dir); + } + + #[test] + fn ensure_hosts_backup_only_keeps_first_snapshot() { + let test_dir = create_test_dir("ensure_hosts_backup_only_keeps_first_snapshot"); + let paths = test_paths(&test_dir); + std::fs::create_dir_all(&paths.runtime_dir).unwrap(); + let source_path = test_dir.join("hosts"); + + std::fs::write(&source_path, "first").unwrap(); + ensure_hosts_backup(&paths, &source_path, "first").unwrap(); + + std::fs::write(&source_path, "second").unwrap(); + ensure_hosts_backup(&paths, &source_path, "second").unwrap(); + + assert_eq!( + std::fs::read_to_string(&paths.hosts_backup_path).unwrap(), + "first" + ); + + cleanup_test_dir(&test_dir); + } + + #[test] + fn restore_hosts_from_backup_rejects_inconsistent_backup_state() { + let test_dir = + create_test_dir("restore_hosts_from_backup_rejects_inconsistent_backup_state"); + let paths = test_paths(&test_dir); + std::fs::create_dir_all(&paths.runtime_dir).unwrap(); + let source_path = test_dir.join("hosts"); + std::fs::write(&source_path, "current").unwrap(); + std::fs::write(&paths.hosts_backup_path, "backup").unwrap(); + + let error = restore_hosts_from_backup(&paths, &source_path, "current").unwrap_err(); + assert!( + error + .to_string() + .contains("hosts backup state is inconsistent") + ); + + cleanup_test_dir(&test_dir); + } + + #[test] + fn restore_hosts_from_backup_rejects_source_path_mismatch() { + let test_dir = create_test_dir("restore_hosts_from_backup_rejects_source_path_mismatch"); + let paths = test_paths(&test_dir); + std::fs::create_dir_all(&paths.runtime_dir).unwrap(); + let source_path = test_dir.join("hosts"); + std::fs::write(&source_path, "original").unwrap(); + ensure_hosts_backup(&paths, &source_path, "original").unwrap(); + + let mut meta = load_backup_meta(&paths).unwrap(); + meta.source_path = r"C:\different\hosts".to_string(); + std::fs::write( + &paths.hosts_backup_meta_path, + serde_json::to_string_pretty(&meta).unwrap(), + ) + .unwrap(); + + let error = restore_hosts_from_backup(&paths, &source_path, "original").unwrap_err(); + assert!( + error + .to_string() + .contains("hosts backup source path mismatch") + ); + + cleanup_test_dir(&test_dir); + } + + #[test] + fn validate_hosts_backup_rejects_broken_metadata() { + let test_dir = create_test_dir("validate_hosts_backup_rejects_broken_metadata"); + let paths = test_paths(&test_dir); + std::fs::create_dir_all(&paths.runtime_dir).unwrap(); + let source_path = test_dir.join("hosts"); + + std::fs::write(&source_path, "original").unwrap(); + std::fs::write(&paths.hosts_backup_path, "backup").unwrap(); + std::fs::write(&paths.hosts_backup_meta_path, "{not-json").unwrap(); + + let error = validate_hosts_backup(&paths, &source_path).unwrap_err(); + assert!( + error + .to_string() + .contains("failed to parse hosts backup metadata") + ); + + cleanup_test_dir(&test_dir); + } + + #[test] + fn clear_hosts_backup_removes_partial_and_temp_files() { + let test_dir = create_test_dir("clear_hosts_backup_removes_partial_and_temp_files"); + let paths = test_paths(&test_dir); + std::fs::create_dir_all(&paths.runtime_dir).unwrap(); + + std::fs::write(&paths.hosts_backup_path, "backup").unwrap(); + let backup_temp_path = paths + .hosts_backup_path + .with_file_name("hosts.backup.linuxdo-accelerator.tmp"); + let meta_temp_path = paths + .hosts_backup_meta_path + .with_file_name("hosts.backup.json.linuxdo-accelerator.tmp"); + std::fs::write(&backup_temp_path, "temp").unwrap(); + std::fs::write(&meta_temp_path, "temp").unwrap(); + + clear_hosts_backup(&paths).unwrap(); + + assert_eq!(backup_state(&paths), BackupState::Missing); + assert!(!backup_temp_path.exists()); + assert!(!meta_temp_path.exists()); + + cleanup_test_dir(&test_dir); + } + + #[cfg(windows)] + #[test] + fn windows_retryable_replace_errors_cover_common_lock_cases() { + assert!(super::should_retry_windows_replace_error_code(Some(5))); + assert!(super::should_retry_windows_replace_error_code(Some(32))); + assert!(super::should_retry_windows_replace_error_code(Some(33))); + assert!(!super::should_retry_windows_replace_error_code(Some(2))); + assert!(!super::should_retry_windows_replace_error_code(None)); + } + + fn create_test_dir(name: &str) -> PathBuf { + let mut path = std::env::temp_dir(); + let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); + path.push(format!("linuxdo-accelerator-{name}-{id}")); + if path.exists() { + let _ = std::fs::remove_dir_all(&path); + } + std::fs::create_dir_all(&path).unwrap(); + path + } + + fn cleanup_test_dir(path: &PathBuf) { + if path.exists() { + clear_readonly_recursively(path); + } + let _ = std::fs::remove_dir_all(path); + } + + fn clear_readonly_recursively(path: &PathBuf) { + if path.is_dir() { + if let Ok(entries) = std::fs::read_dir(path) { + for entry in entries.flatten() { + let child = entry.path(); + let child_path = PathBuf::from(child); + clear_readonly_recursively(&child_path); + } + } + } + + if let Ok(metadata) = std::fs::metadata(path) { + let mut permissions = metadata.permissions(); + if permissions.readonly() { + permissions.set_readonly(false); + let _ = std::fs::set_permissions(path, permissions); + } + } + } + + fn test_paths(root: &PathBuf) -> AppPaths { + let config_dir = root.join("config"); + let data_dir = root.join("data"); + let runtime_dir = data_dir.join("runtime"); + let cert_dir = data_dir.join("certs"); + + AppPaths { + config_path: config_dir.join("linuxdo-accelerator.toml"), + config_dir, + data_dir, + runtime_dir: runtime_dir.clone(), + cert_dir, + state_path: runtime_dir.join("service-state.json"), + pid_path: runtime_dir.join("linuxdo-accelerator.pid"), + runtime_log_path: runtime_dir.join("operations.log"), + hosts_backup_path: runtime_dir.join("hosts.backup"), + hosts_backup_meta_path: runtime_dir.join("hosts.backup.json"), + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 91dc93c..745c673 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,8 +6,10 @@ pub mod config; #[cfg(any(windows, target_os = "linux", target_os = "macos"))] pub mod gui; pub mod hosts; +mod hosts_store; pub mod paths; pub mod platform; pub mod proxy; +pub mod runtime_log; pub mod service; pub mod state; diff --git a/src/paths.rs b/src/paths.rs index ca0e62b..ba955b5 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -15,6 +15,9 @@ pub struct AppPaths { pub cert_dir: PathBuf, pub state_path: PathBuf, pub pid_path: PathBuf, + pub runtime_log_path: PathBuf, + pub hosts_backup_path: PathBuf, + pub hosts_backup_meta_path: PathBuf, } impl AppPaths { @@ -49,6 +52,9 @@ impl AppPaths { let cert_dir = data_dir.join("certs"); let state_path = runtime_dir.join("service-state.json"); let pid_path = runtime_dir.join("linuxdo-accelerator.pid"); + let runtime_log_path = runtime_dir.join("operations.log"); + let hosts_backup_path = runtime_dir.join("hosts.backup"); + let hosts_backup_meta_path = runtime_dir.join("hosts.backup.json"); Ok(Self { config_path, @@ -58,6 +64,9 @@ impl AppPaths { cert_dir, state_path, pid_path, + runtime_log_path, + hosts_backup_path, + hosts_backup_meta_path, }) } diff --git a/src/platform.rs b/src/platform.rs index dcb00c0..d84ea6f 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -1,12 +1,15 @@ #[cfg(target_os = "windows")] use std::ffi::OsString; use std::fs; +#[cfg(target_os = "macos")] use std::net::Ipv4Addr; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use anyhow::{Context, Result, anyhow, bail}; +#[cfg(target_os = "android")] use md5::compute as md5_compute; +#[cfg(target_os = "android")] use x509_parser::prelude::{FromDer, X509Certificate}; use crate::config::AppConfig; @@ -47,6 +50,7 @@ use windows_sys::Win32::UI::WindowsAndMessaging::{ const LINUX_CA_FILE_NAME: &str = "linuxdo-accelerator-root-ca.crt"; +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] enum MacosCaState { Missing, Matching, @@ -235,19 +239,19 @@ pub fn is_process_running(pid: u32) -> bool { } } -pub fn ensure_loopback_alias(config: &AppConfig) -> Result<()> { +pub fn ensure_loopback_alias(_config: &AppConfig) -> Result<()> { #[cfg(target_os = "macos")] { - ensure_macos_loopback_aliases(config)?; + ensure_macos_loopback_aliases(_config)?; } Ok(()) } -pub fn remove_loopback_alias(config: &AppConfig) -> Result<()> { +pub fn remove_loopback_alias(_config: &AppConfig) -> Result<()> { #[cfg(target_os = "macos")] { - remove_macos_loopback_aliases(config)?; + remove_macos_loopback_aliases(_config)?; } Ok(()) @@ -382,7 +386,9 @@ fn remove_macos_loopback_aliases(config: &AppConfig) -> Result<()> { let output = Command::new("ifconfig") .args(["lo0", "-alias", &addr]) .output() - .with_context(|| format!("failed to execute ifconfig for loopback alias cleanup {addr}"))?; + .with_context(|| { + format!("failed to execute ifconfig for loopback alias cleanup {addr}") + })?; if output.status.success() { continue; } @@ -926,6 +932,7 @@ fn android_fixup_ca_permissions(path: &Path) -> Result<()> { Ok(()) } +#[cfg(target_os = "android")] fn android_cert_subject_hash_old(cert_pem: &[u8]) -> Result { let mut reader = std::io::BufReader::new(cert_pem); let mut certs = rustls_pemfile::certs(&mut reader); @@ -941,6 +948,7 @@ fn android_cert_subject_hash_old(cert_pem: &[u8]) -> Result { Ok(format!("{value:08x}")) } +#[cfg(target_os = "android")] fn android_cert_common_name(cert_pem: &[u8]) -> Result { let mut reader = std::io::BufReader::new(cert_pem); let mut certs = rustls_pemfile::certs(&mut reader); @@ -1343,6 +1351,7 @@ fn run_android_elevated(_executable: &Path, _args: &[String]) -> Result<()> { bail!("android elevation is unavailable on this platform") } +#[cfg(target_os = "android")] fn shell_quote_arg(value: &str) -> String { if value.is_empty() { return "''".to_string(); @@ -1350,16 +1359,19 @@ fn shell_quote_arg(value: &str) -> String { format!("'{}'", value.replace('\'', "'\"'\"'")) } +#[cfg(target_os = "macos")] fn shell_join(executable: &Path, args: &[String]) -> String { let mut parts = vec![shell_quote(&executable.to_string_lossy())]; parts.extend(args.iter().map(|arg| shell_quote(arg))); parts.join(" ") } +#[cfg(target_os = "macos")] fn shell_quote(value: &str) -> String { format!("'{}'", value.replace('\'', "'\"'\"'")) } +#[cfg(target_os = "macos")] fn applescript_escape(value: &str) -> String { value.replace('\\', "\\\\").replace('"', "\\\"") } @@ -1373,11 +1385,6 @@ fn should_fallback_to_macos_sudo(detail: &str) -> bool { || detail.contains("授权失败") } -#[cfg(not(target_os = "macos"))] -fn should_fallback_to_macos_sudo(_detail: &str) -> bool { - false -} - #[cfg(target_os = "macos")] fn run_macos_sudo_prompt(executable: &Path, args: &[String]) -> Result<()> { let password = prompt_macos_administrator_password()?; diff --git a/src/runtime_log.rs b/src/runtime_log.rs new file mode 100644 index 0000000..3c3d266 --- /dev/null +++ b/src/runtime_log.rs @@ -0,0 +1,137 @@ +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result, anyhow}; + +use crate::paths::AppPaths; +use crate::platform::sync_user_ownership; + +pub(crate) fn append(paths: &AppPaths, level: &str, action: &str, message: &str) -> Result<()> { + if let Some(parent) = paths.runtime_log_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&paths.runtime_log_path) + .with_context(|| format!("failed to open {}", paths.runtime_log_path.display()))?; + + writeln!( + file, + "[{}] {:<5} {:<14} {}", + unix_timestamp_secs()?, + level, + action, + sanitize_message(message) + ) + .with_context(|| format!("failed to write {}", paths.runtime_log_path.display()))?; + file.sync_all() + .with_context(|| format!("failed to sync {}", paths.runtime_log_path.display()))?; + sync_user_ownership(&paths.runtime_log_path)?; + Ok(()) +} + +pub(crate) fn read_recent_lines(paths: &AppPaths, max_lines: usize) -> Result> { + if max_lines == 0 || !paths.runtime_log_path.exists() { + return Ok(Vec::new()); + } + + let content = fs::read_to_string(&paths.runtime_log_path) + .with_context(|| format!("failed to read {}", paths.runtime_log_path.display()))?; + let mut lines = content + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(ToOwned::to_owned) + .collect::>(); + + if lines.len() > max_lines { + lines.drain(0..lines.len() - max_lines); + } + Ok(lines) +} + +fn sanitize_message(message: &str) -> String { + message + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>() + .join(" | ") +} + +fn unix_timestamp_secs() -> Result { + Ok(SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| anyhow!("system time is before unix epoch: {error}"))? + .as_secs()) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::sync::atomic::{AtomicU64, Ordering}; + + use super::{append, read_recent_lines}; + use crate::paths::AppPaths; + + static NEXT_ID: AtomicU64 = AtomicU64::new(1); + + #[test] + fn read_recent_lines_only_returns_tail() { + let root = create_test_dir("read_recent_lines_only_returns_tail"); + let paths = test_paths(&root); + std::fs::create_dir_all(&paths.runtime_dir).unwrap(); + + append(&paths, "INFO", "start", "one").unwrap(); + append(&paths, "INFO", "start", "two").unwrap(); + append(&paths, "INFO", "start", "three").unwrap(); + + let lines = read_recent_lines(&paths, 2).unwrap(); + assert_eq!(lines.len(), 2); + assert!(lines[0].contains("two")); + assert!(lines[1].contains("three")); + + cleanup_test_dir(&root); + } + + fn create_test_dir(name: &str) -> PathBuf { + let mut path = std::env::temp_dir(); + let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); + path.push(format!("linuxdo-accelerator-runtime-log-{name}-{id}")); + if path.exists() { + let _ = std::fs::remove_dir_all(&path); + } + std::fs::create_dir_all(&path).unwrap(); + path + } + + fn cleanup_test_dir(path: &PathBuf) { + if path.exists() { + let _ = std::fs::remove_dir_all(path); + } + } + + fn test_paths(root: &PathBuf) -> AppPaths { + let config_dir = root.join("config"); + let data_dir = root.join("data"); + let runtime_dir = data_dir.join("runtime"); + let cert_dir = data_dir.join("certs"); + + AppPaths { + config_path: config_dir.join("linuxdo-accelerator.toml"), + config_dir, + data_dir, + runtime_dir: runtime_dir.clone(), + cert_dir, + state_path: runtime_dir.join("service-state.json"), + pid_path: runtime_dir.join("linuxdo-accelerator.pid"), + runtime_log_path: runtime_dir.join("operations.log"), + hosts_backup_path: runtime_dir.join("hosts.backup"), + hosts_backup_meta_path: runtime_dir.join("hosts.backup.json"), + } + } +} diff --git a/src/service.rs b/src/service.rs index 3db0468..304de8a 100644 --- a/src/service.rs +++ b/src/service.rs @@ -6,13 +6,17 @@ use anyhow::{Result, bail}; use crate::certs::{ensure_bundle, load_or_create_bundle}; use crate::config::AppConfig; -use crate::hosts::{apply_hosts, remove_hosts}; +use crate::hosts::{ + apply_hosts, backup_hosts_file, remove_hosts, restore_hosts_file, validate_hosts_backup_file, +}; +use crate::hosts_store::{BackupState, backup_state, clear_hosts_backup}; use crate::paths::AppPaths; use crate::platform::{ ensure_elevated, ensure_loopback_alias, install_ca, is_process_running, remove_loopback_alias, spawn_detached, terminate_process, uninstall_ca, }; use crate::proxy::run_proxy; +use crate::runtime_log; use crate::state; pub fn resolve_paths(config_override: Option) -> Result { @@ -29,26 +33,51 @@ pub fn init_config(config_path: Option) -> Result { pub fn setup(config_path: Option) -> Result<()> { let paths = resolve_paths(config_path)?; - let config = AppConfig::load_or_create(&paths.config_path)?; - ensure_elevated(&config, true)?; - ensure_loopback_alias(&config)?; - let bundle = ensure_bundle(&config, &paths.cert_dir)?; - install_ca(&bundle.ca_cert_path, &config.ca_common_name)?; - apply_hosts(&config, &paths)?; - state::mark_stopped(&paths, "系统加速环境已准备")?; - Ok(()) + log_info(&paths, "setup", "开始准备系统加速环境"); + let result = (|| -> Result<()> { + let config = AppConfig::load_or_create(&paths.config_path)?; + ensure_elevated(&config, true)?; + ensure_loopback_alias(&config)?; + let bundle = ensure_bundle(&config, &paths.cert_dir)?; + install_ca(&bundle.ca_cert_path, &config.ca_common_name)?; + apply_hosts(&config, &paths)?; + state::mark_stopped(&paths, "系统加速环境已准备")?; + Ok(()) + })(); + match &result { + Ok(_) => log_info(&paths, "setup", "系统加速环境已准备"), + Err(error) => log_error(&paths, "setup", &format!("{error:#}")), + } + result } pub fn prepare_certificate(config_path: Option) -> Result<()> { let paths = resolve_paths(config_path)?; - let config = AppConfig::load_or_create(&paths.config_path)?; - let bundle = ensure_bundle(&config, &paths.cert_dir)?; - install_ca(&bundle.ca_cert_path, &config.ca_common_name)?; - Ok(()) + log_info(&paths, "prepare-cert", "开始准备根证书"); + let result = (|| -> Result<()> { + let config = AppConfig::load_or_create(&paths.config_path)?; + let bundle = ensure_bundle(&config, &paths.cert_dir)?; + install_ca(&bundle.ca_cert_path, &config.ca_common_name)?; + Ok(()) + })(); + match &result { + Ok(_) => log_info(&paths, "prepare-cert", "根证书已准备完成"), + Err(error) => log_error(&paths, "prepare-cert", &format!("{error:#}")), + } + result } pub async fn run_foreground(config_path: Option, with_setup: bool) -> Result<()> { let paths = resolve_paths(config_path)?; + log_info( + &paths, + "daemon", + if with_setup { + "守护进程启动:包含环境准备" + } else { + "守护进程启动:直接进入前台代理" + }, + ); let config = AppConfig::load_or_create(&paths.config_path)?; ensure_elevated(&config, with_setup)?; ensure_loopback_alias(&config)?; @@ -69,9 +98,11 @@ pub async fn run_foreground(config_path: Option, with_setup: bool) -> R let _ = state::clear_pid(&paths); match &result { Ok(_) => { + log_info(&paths, "daemon", "加速服务已正常退出"); let _ = state::mark_stopped(&paths, "加速服务已退出"); } Err(error) => { + log_error(&paths, "daemon", &format!("{error:#}")); let _ = state::mark_error(&paths, &error.to_string()); } } @@ -80,6 +111,7 @@ pub async fn run_foreground(config_path: Option, with_setup: bool) -> R pub fn helper_start(config_path: Option) -> Result<()> { let paths = resolve_paths(config_path)?; + log_info(&paths, "helper-start", "收到启动请求,开始准备加速环境"); let start_result = (|| -> Result<()> { let config = AppConfig::load_or_create(&paths.config_path)?; ensure_elevated(&config, true)?; @@ -87,6 +119,7 @@ pub fn helper_start(config_path: Option) -> Result<()> { let current = state::refresh(&paths)?; if current.running { + log_info(&paths, "helper-start", "检测到服务已在运行,跳过重复启动"); return Ok(()); } @@ -121,8 +154,14 @@ pub fn helper_start(config_path: Option) -> Result<()> { if let Err(error) = &start_result { let _ = remove_hosts(&paths); + let _ = remove_loopback_alias( + &AppConfig::load_or_create(&paths.config_path).unwrap_or_default(), + ); let _ = state::clear_pid(&paths); let _ = state::mark_error(&paths, &format!("{error:#}")); + log_error(&paths, "helper-start", &format!("{error:#}")); + } else { + log_info(&paths, "helper-start", "加速服务启动成功"); } start_result @@ -130,59 +169,174 @@ pub fn helper_start(config_path: Option) -> Result<()> { pub fn helper_stop(config_path: Option) -> Result<()> { let paths = resolve_paths(config_path)?; - let config = AppConfig::load_or_create(&paths.config_path)?; - ensure_elevated(&config, true)?; + log_info(&paths, "helper-stop", "收到停止请求,开始清理加速状态"); + let result = (|| -> Result<()> { + let config = AppConfig::load_or_create(&paths.config_path)?; + ensure_elevated(&config, true)?; + let mut issues = Vec::new(); + if let Some(issue) = terminate_running_service(&paths)? { + issues.push(issue); + } + if let Err(error) = remove_hosts(&paths) { + issues.push(format!("failed to clean managed hosts block: {error:#}")); + } + if let Err(error) = remove_loopback_alias(&config) { + issues.push(format!("failed to remove loopback alias: {error:#}")); + } + if let Err(error) = state::clear_pid(&paths) { + issues.push(format!("failed to clear pid file: {error:#}")); + } - if let Some(pid) = state::read_pid(&paths)? { - if is_process_running(pid) { - terminate_process(pid)?; - wait_until_stopped(pid, Duration::from_secs(5)); + let status_message = if issues.is_empty() { + "加速已停止".to_string() + } else { + "已执行停止请求,但存在残留清理问题".to_string() + }; + if let Err(error) = state::mark_stopped(&paths, &status_message) { + issues.push(format!("failed to update service state: {error:#}")); } - } - remove_hosts(&paths)?; - remove_loopback_alias(&config)?; - state::clear_pid(&paths)?; - state::mark_stopped(&paths, "加速已停止")?; - Ok(()) + if issues.is_empty() { + Ok(()) + } else { + bail!(issues.join("; ")); + } + })(); + match &result { + Ok(_) => log_info(&paths, "helper-stop", "加速服务已停止并完成清理"), + Err(error) => log_warn(&paths, "helper-stop", &format!("{error:#}")), + } + result } pub fn cleanup(config_path: Option) -> Result<()> { let paths = resolve_paths(config_path)?; - let config = AppConfig::load_or_create(&paths.config_path)?; - ensure_elevated(&config, true)?; - let _ = helper_stop(Some(paths.config_path.clone())); - uninstall_ca(&config.ca_common_name)?; - state::mark_stopped(&paths, "已清理证书和 hosts")?; - Ok(()) + log_info(&paths, "cleanup", "收到彻底恢复请求,开始恢复系统修改"); + let result = (|| -> Result<()> { + let config = AppConfig::load_or_create(&paths.config_path)?; + ensure_elevated(&config, true)?; + let mut issues = Vec::new(); + if let Some(issue) = terminate_running_service(&paths)? { + issues.push(issue); + } + let (hosts_message, hosts_warning) = cleanup_hosts_state(&paths); + if let Some(warning) = hosts_warning { + issues.push(warning); + } + + if let Err(error) = remove_loopback_alias(&config) { + issues.push(format!("failed to remove loopback alias: {error:#}")); + } + if let Err(error) = uninstall_ca(&config.ca_common_name) { + issues.push(format!("failed to uninstall certificate: {error:#}")); + } + if let Err(error) = state::clear_pid(&paths) { + issues.push(format!("failed to clear pid file: {error:#}")); + } + + let status_message = if issues.is_empty() { + format!("已卸载证书并{hosts_message}") + } else { + format!("已执行 cleanup,但存在残留问题:{hosts_message}") + }; + if let Err(error) = state::mark_stopped(&paths, &status_message) { + issues.push(format!("failed to update service state: {error:#}")); + } + + if issues.is_empty() { + Ok(()) + } else { + bail!(issues.join("; ")); + } + })(); + match &result { + Ok(_) => log_info(&paths, "cleanup", "已完成彻底恢复原始状态"), + Err(error) => log_warn(&paths, "cleanup", &format!("{error:#}")), + } + result } pub fn clean_hosts(config_path: Option) -> Result<()> { let paths = resolve_paths(config_path)?; - let config = AppConfig::load_or_create(&paths.config_path)?; - ensure_elevated(&config, true)?; - remove_hosts(&paths)?; - remove_loopback_alias(&config)?; - state::mark_stopped(&paths, "hosts 已清理")?; - Ok(()) + log_info(&paths, "clean-hosts", "开始清理托管 hosts 规则"); + let result = (|| -> Result<()> { + let config = AppConfig::load_or_create(&paths.config_path)?; + ensure_elevated(&config, true)?; + ensure_service_stopped_for_hosts_change(&paths, "clean-hosts")?; + remove_hosts(&paths)?; + remove_loopback_alias(&config)?; + state::mark_stopped(&paths, "hosts 已清理")?; + Ok(()) + })(); + match &result { + Ok(_) => log_info(&paths, "clean-hosts", "托管 hosts 规则已清理"), + Err(error) => log_error(&paths, "clean-hosts", &format!("{error:#}")), + } + result } pub fn apply_hosts_only(config_path: Option) -> Result<()> { let paths = resolve_paths(config_path)?; - let config = AppConfig::load_or_create(&paths.config_path)?; - ensure_elevated(&config, true)?; - ensure_loopback_alias(&config)?; - apply_hosts(&config, &paths)?; - Ok(()) + log_info(&paths, "apply-hosts", "开始应用 hosts 接管规则"); + let result = (|| -> Result<()> { + let config = AppConfig::load_or_create(&paths.config_path)?; + ensure_elevated(&config, true)?; + ensure_loopback_alias(&config)?; + apply_hosts(&config, &paths)?; + Ok(()) + })(); + match &result { + Ok(_) => log_info(&paths, "apply-hosts", "hosts 接管规则已应用"), + Err(error) => log_error(&paths, "apply-hosts", &format!("{error:#}")), + } + result +} + +pub fn backup_hosts(config_path: Option) -> Result<()> { + let paths = resolve_paths(config_path)?; + log_info(&paths, "backup-hosts", "开始确保 hosts 基线备份存在"); + let result = backup_hosts_file(&paths); + match &result { + Ok(_) => log_info(&paths, "backup-hosts", "hosts 基线备份已就绪"), + Err(error) => log_error(&paths, "backup-hosts", &format!("{error:#}")), + } + result +} + +pub fn restore_hosts(config_path: Option) -> Result<()> { + let paths = resolve_paths(config_path)?; + log_info(&paths, "restore-hosts", "开始恢复 hosts 备份"); + let result = (|| -> Result<()> { + let config = AppConfig::load_or_create(&paths.config_path)?; + ensure_elevated(&config, true)?; + ensure_service_stopped_for_hosts_change(&paths, "restore-hosts")?; + restore_hosts_file(&paths)?; + remove_loopback_alias(&config)?; + state::mark_stopped(&paths, "hosts 已恢复为备份")?; + Ok(()) + })(); + match &result { + Ok(_) => log_info(&paths, "restore-hosts", "hosts 已恢复为备份"), + Err(error) => log_error(&paths, "restore-hosts", &format!("{error:#}")), + } + result } pub fn uninstall_certificate(config_path: Option) -> Result<()> { let paths = resolve_paths(config_path)?; - let config = AppConfig::load_or_create(&paths.config_path)?; - ensure_elevated(&config, true)?; - uninstall_ca(&config.ca_common_name)?; - state::mark_stopped(&paths, "根证书已卸载")?; - Ok(()) + log_info(&paths, "uninstall-cert", "开始卸载根证书"); + let result = (|| -> Result<()> { + let config = AppConfig::load_or_create(&paths.config_path)?; + ensure_elevated(&config, true)?; + uninstall_ca(&config.ca_common_name)?; + state::mark_stopped(&paths, "根证书已卸载")?; + Ok(()) + })(); + match &result { + Ok(_) => log_info(&paths, "uninstall-cert", "根证书已卸载"), + Err(error) => log_error(&paths, "uninstall-cert", &format!("{error:#}")), + } + result } pub fn status(config_path: Option) -> Result { @@ -206,14 +360,119 @@ fn wait_until_running(paths: &AppPaths, timeout: Duration) -> Result<()> { bail!("daemon start timed out") } -fn wait_until_stopped(pid: u32, timeout: Duration) { +fn ensure_service_stopped_for_hosts_change(paths: &AppPaths, command_name: &str) -> Result<()> { + let current = state::refresh(paths)?; + if current.running { + bail!("service is still running; stop or cleanup before `{command_name}`"); + } + Ok(()) +} + +fn terminate_running_service(paths: &AppPaths) -> Result> { + if let Some(pid) = state::read_pid(paths)? { + if is_process_running(pid) { + if let Err(error) = terminate_process(pid) { + return Ok(Some(format!( + "failed to terminate running service pid {pid}: {error:#}" + ))); + } + if let Err(error) = wait_until_stopped(pid, Duration::from_secs(5)) { + return Ok(Some(format!( + "running service pid {pid} did not stop cleanly: {error:#}" + ))); + } + } + } + Ok(None) +} + +fn cleanup_hosts_state(paths: &AppPaths) -> (String, Option) { + match backup_state(paths) { + BackupState::Ready => match validate_hosts_backup_file(paths) { + Ok(()) => match restore_hosts_file(paths) { + Ok(()) => ("恢复原始 hosts".to_string(), None), + Err(error) => match remove_hosts(paths) { + Ok(()) => ( + "清理托管 hosts 规则(完整恢复失败)".to_string(), + Some(format!( + "failed to fully restore original hosts backup: {error:#}; cleanup fell back to removing the managed hosts block" + )), + ), + Err(remove_error) => ( + "hosts 未清理".to_string(), + Some(format!( + "failed to fully restore original hosts backup: {error:#}; fallback removal also failed: {remove_error:#}" + )), + ), + }, + }, + Err(validation_error) => match remove_hosts(paths) { + Ok(()) => match clear_hosts_backup(paths) { + Ok(()) => ("清理托管 hosts 规则并重置损坏的备份状态".to_string(), None), + Err(clear_error) => ( + "清理托管 hosts 规则(备份损坏)".to_string(), + Some(format!( + "hosts backup is invalid: {validation_error:#}; managed hosts block was removed but clearing stale backup artifacts failed: {clear_error:#}" + )), + ), + }, + Err(remove_error) => ( + "hosts 未清理".to_string(), + Some(format!( + "hosts backup is invalid: {validation_error:#}; fallback removal failed: {remove_error:#}" + )), + ), + }, + }, + BackupState::Missing => match remove_hosts(paths) { + Ok(()) => ("清理托管 hosts 规则".to_string(), None), + Err(error) => ( + "hosts 未清理".to_string(), + Some(format!("failed to clean managed hosts block: {error:#}")), + ), + }, + BackupState::Inconsistent => match remove_hosts(paths) { + Ok(()) => match clear_hosts_backup(paths) { + Ok(()) => ("清理托管 hosts 规则并重置异常备份状态".to_string(), None), + Err(clear_error) => ( + "清理托管 hosts 规则(备份状态异常)".to_string(), + Some(format!( + "hosts backup state is inconsistent; managed hosts block was removed but clearing stale backup artifacts failed: {clear_error:#}" + )), + ), + }, + Err(error) => ( + "hosts 未清理".to_string(), + Some(format!( + "hosts backup state is inconsistent; fallback removal failed: {error:#}" + )), + ), + }, + } +} + +fn wait_until_stopped(pid: u32, timeout: Duration) -> Result<()> { let deadline = Instant::now() + timeout; while Instant::now() < deadline { if !is_process_running(pid) { - break; + return Ok(()); } thread::sleep(Duration::from_millis(200)); } + + bail!("process {pid} did not stop within {:?}", timeout) +} + +fn log_info(paths: &AppPaths, action: &str, message: &str) { + let _ = runtime_log::append(paths, "INFO", action, message); +} + +fn log_warn(paths: &AppPaths, action: &str, message: &str) { + let _ = runtime_log::append(paths, "WARN", action, message); +} + +fn log_error(paths: &AppPaths, action: &str, message: &str) { + let _ = runtime_log::append(paths, "ERROR", action, message); } fn current_cli_binary() -> Result {