diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cde0ec5..5465cbd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,12 +20,23 @@ env: jobs: lint-test: name: Lint & Unit Tests - runs-on: - group: hf-mount-ci-pub + # GitHub-hosted so the job also runs on forks without access to the + # huggingface runner groups; the heavy integration jobs below stay on + # internal runners and skip on forks. + runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # This job runs on fork PRs; don't leave the workflow token in the + # local git config where later build/test steps could read it. + persist-credentials: false - name: Configure internal registries + # Head-repo check: on fork PRs github.repository is the *base* repo, + # which must not grant fork code the internal registry setup. + if: >- + (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'huggingface/hf-mount') || + (github.event_name != 'pull_request' && github.repository == 'huggingface/hf-mount') run: curl -sSL https://registries.huggingface.tech/setup.sh | bash # Pin nightly so rustfmt rules don't drift between CI and contributor machines. @@ -61,11 +72,24 @@ jobs: - name: Clippy (all) run: cargo clippy --features fuse,nfs -- -D warnings + # The GUI only ships for Windows/macOS, but compile-checking it here + # catches breakage on every PR without waiting for the platform builders. + - name: Clippy (NFS + GUI) + run: cargo clippy --no-default-features --features nfs,gui --bins --tests -- -D warnings + - name: Unit tests run: cargo test --lib --features fuse,nfs + - name: GUI unit tests + run: cargo test --no-default-features --features nfs,gui --bin hf-mount-gui + smoke-test: name: Smoke Tests (FUSE + NFS) + # Internal runner group + HF_TOKEN: only for code from the upstream repo + # itself. The head-repo check keeps fork-PR code off internal runners. + if: >- + (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'huggingface/hf-mount') || + (github.event_name != 'pull_request' && github.repository == 'huggingface/hf-mount') runs-on: group: hf-mount-ci-pub needs: lint-test @@ -99,6 +123,9 @@ jobs: fsx: name: fsx (data integrity) + if: >- + (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'huggingface/hf-mount') || + (github.event_name != 'pull_request' && github.repository == 'huggingface/hf-mount') runs-on: group: hf-mount-ci-pub needs: lint-test @@ -123,6 +150,9 @@ jobs: xfstests: name: xfstests (filesystem exerciser) + if: >- + (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'huggingface/hf-mount') || + (github.event_name != 'pull_request' && github.repository == 'huggingface/hf-mount') runs-on: group: hf-mount-ci-pub-m5dn-24xlarge needs: lint-test @@ -147,6 +177,9 @@ jobs: pjdfstest: name: POSIX Compliance (pjdfstest) + if: >- + (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'huggingface/hf-mount') || + (github.event_name != 'pull_request' && github.repository == 'huggingface/hf-mount') runs-on: group: hf-mount-ci-pub needs: lint-test @@ -216,6 +249,9 @@ jobs: bench: name: Benchmarks + if: >- + (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'huggingface/hf-mount') || + (github.event_name != 'pull_request' && github.repository == 'huggingface/hf-mount') runs-on: group: hf-mount-ci-pub needs: lint-test diff --git a/Cargo.toml b/Cargo.toml index d7ccd04..f2d48a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,7 +63,7 @@ required-features = ["nfs"] [[bin]] name = "hf-mount-gui" -path = "src/bin/hf-mount-gui.rs" +path = "src/bin/hf-mount-gui/main.rs" required-features = ["nfs", "gui"] [[bin]] diff --git a/README.md b/README.md index dfc942c..7e706cf 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,9 @@ Download the GUI binary from [GitHub Releases](https://github.com/huggingface/hf - macOS Apple Silicon app bundle: `hf-mount-gui-arm64-apple-darwin.app.zip` - macOS Apple Silicon raw binary: `hf-mount-gui-arm64-apple-darwin` -Windows users must enable Client for NFS and run the GUI from an Administrator session. The GUI includes a **Check setup** action that validates elevation, the Windows NFS client tools, port `111`, and the mount target before starting. If it was launched without elevation, use **Restart as admin** in the GUI and approve the Windows UAC prompt. Fill in the repo or bucket ID, mount point, optional token, then press **Start mount**. Press **Stop mount** to unmount. +The window has three tabs: **Mount** (the source/mount form and Start/Stop actions), **Activity** (the session log, with copy-to-clipboard), and **Setup** (environment checks with fix actions). A status bar at the bottom always shows the current mount state and, while mounted, the elapsed time. + +Windows users must enable Client for NFS and run the GUI from an Administrator session. The **Setup** tab validates elevation, the Windows NFS client tools, port `111`, and the mount target; each failing check comes with a fix action (**Enable NFS** launches the feature install, **Restart as admin** relaunches with a UAC prompt) or a copyable command. On Windows the mount-point field has a **Free** drive-letter picker that lists currently unassigned letters. Fill in the repo or bucket ID, mount point, optional token, then press **Start mount**. Press **Stop** to unmount — this also works while the mount is still starting. Recently mounted sources are offered in a **Recent** dropdown for one-click refill. Enable **Background** before starting if the mount should keep running after the GUI window is closed. Enable **Start at login** to register the saved GUI mount profile for autostart. On Windows this creates a user logon Scheduled Task with highest privileges; on macOS it writes a user LaunchAgent; on Linux desktops it writes an XDG autostart entry. The GUI saves the mount profile under the user config directory and writes background status/log files there as well. Inline HF tokens are not saved in the profile; background and autostart mounts use `HF_TOKEN` from the worker environment or a saved token-file path. diff --git a/src/bin/hf-mount-fuse-sidecar.rs b/src/bin/hf-mount-fuse-sidecar.rs index a96cef7..a889b1e 100644 --- a/src/bin/hf-mount-fuse-sidecar.rs +++ b/src/bin/hf-mount-fuse-sidecar.rs @@ -126,7 +126,13 @@ fn main() { // One tokio runtime shared across all volumes. Each `build_with_runtime` // call below borrows its handle, avoiding N full multi-threaded runtimes // (~4 worker threads each) for N volumes. See #96. - let runtime = build_runtime(); + let runtime = match build_runtime() { + Ok(runtime) => runtime, + Err(e) => { + error!("Failed to create tokio runtime: {e}"); + std::process::exit(1); + } + }; let pending = wait_for_configs(&args.tmp_dir, args.poll_secs, args.timeout_secs, args.expected_mounts); if pending.is_empty() { @@ -163,8 +169,20 @@ fn main() { error_paths.push(error_path.clone()); let vfs_registry = Arc::clone(&vfs_registry); let rt_handle = runtime.handle().clone(); + let panic_error_path = error_path.clone(); + let panic_label = label.clone(); handles.push(std::thread::spawn(move || { - run_mount(fuse_fds, mount.mount_args, error_path, vfs_registry, rt_handle); + // The readiness wait loop below has no timeout, so a panic that + // unwound this thread before `run_mount` wrote its ready or error + // marker would hang pod readiness forever. Convert a last-resort + // panic (e.g. an `expect` deep in client construction) into an error + // marker so the wait loop sees the failure and reports it. + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + run_mount(fuse_fds, mount.mount_args, error_path, vfs_registry, rt_handle); + })); + if outcome.is_err() { + write_error(&panic_error_path, &format!("Mount thread panicked for {}", panic_label)); + } })); } @@ -388,22 +406,12 @@ fn run_mount( ) { let label = mount_args.source.label(); - // build_with_runtime() panics on auth/config errors (e.g. invalid token, - // CAS 401). Catch the panic so we can write the error to the error file - // for the CSI driver to report as FailedMount. - let setup = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - build_with_runtime(mount_args.source, mount_args.options, false, runtime) - })) { + // Auth/config errors (e.g. invalid token, CAS 401) are written to the + // error file for the CSI driver to report as FailedMount. + let setup = match build_with_runtime(mount_args.source, mount_args.options, false, runtime) { Ok(s) => s, - Err(panic) => { - let msg = match panic.downcast_ref::() { - Some(s) => s.clone(), - None => match panic.downcast_ref::<&str>() { - Some(s) => s.to_string(), - None => "unknown panic".to_string(), - }, - }; - write_error(&error_path, &format!("Setup failed for {}: {}", label, msg)); + Err(e) => { + write_error(&error_path, &format!("Setup failed for {}: {}", label, e)); return; } }; diff --git a/src/bin/hf-mount-gui.rs b/src/bin/hf-mount-gui.rs deleted file mode 100644 index 30cbfb2..0000000 --- a/src/bin/hf-mount-gui.rs +++ /dev/null @@ -1,2637 +0,0 @@ -#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")] - -use std::collections::VecDeque; -use std::fs::OpenOptions; -use std::io::Write; -use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; -use std::sync::{Arc, Mutex, Once}; -use std::thread::{self, JoinHandle}; -use std::time::{SystemTime, UNIX_EPOCH}; - -#[cfg(windows)] -use std::net::{TcpListener, UdpSocket}; -#[cfg(unix)] -use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; -#[cfg(unix)] -use std::os::unix::process::CommandExt; -#[cfg(windows)] -use std::os::windows::process::CommandExt; - -use eframe::egui::{self, RichText, TextEdit}; -use hf_mount::nfs::NfsMountEvent; -use hf_mount::setup::{default_cache_dir, CacheMode, MountOptions, Source}; -use serde::{Deserialize, Serialize}; - -#[cfg(windows)] -const CREATE_NO_WINDOW: u32 = 0x08000000; -#[cfg(windows)] -const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; -#[cfg(windows)] -const DETACHED_PROCESS: u32 = 0x00000008; - -const BACKGROUND_WORKER_ARG: &str = "--background-worker"; -const AUTOSTART_NAME: &str = "hf-mount autostart"; -#[cfg(target_os = "macos")] -const AUTOSTART_LABEL: &str = "co.huggingface.hf-mount-gui-autostart"; -const MAX_LOG_LINES: usize = 80; -const WORKER_STATUS_STALE_AFTER_SECS: u64 = 120; - -static BACKEND_INIT: Once = Once::new(); - -fn load_icon() -> egui::IconData { - let icon_bytes = include_bytes!("../../assets/icon.rgba"); - egui::IconData { - rgba: icon_bytes.to_vec(), - width: 64, - height: 64, - } -} - -fn main() { - if handle_cli_command() { - return; - } - - BACKEND_INIT.call_once(|| { - hf_mount::setup::raise_fd_limit(); - hf_mount::setup::init_tracing(false); - }); - - let native_options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_inner_size([1120.0, 720.0]) - .with_min_inner_size([820.0, 560.0]) - .with_icon(load_icon()), - ..Default::default() - }; - if let Err(e) = eframe::run_native( - "hf-mount", - native_options, - Box::new(|cc| { - apply_theme(&cc.egui_ctx); - Ok(Box::new(MountGuiApp::load())) - }), - ) { - eprintln!("failed to start hf-mount GUI: {e}"); - std::process::exit(1); - } -} - -fn handle_cli_command() -> bool { - let mut args = std::env::args().skip(1); - let Some(arg) = args.next() else { - return false; - }; - - match arg.as_str() { - "-h" | "--help" => { - print_help(); - true - } - "-V" | "--version" => { - println!("hf-mount-gui {}", env!("CARGO_PKG_VERSION")); - true - } - "--check-setup" => { - let mount_point = args.next().unwrap_or_else(default_mount_point); - let checks = run_preflight_checks(&mount_point); - for check in &checks { - println!("[{}] {}: {}", check_level_label(check.level), check.label, check.detail); - } - if checks.iter().any(|check| check.level == CheckLevel::Fail) { - std::process::exit(1); - } - true - } - BACKGROUND_WORKER_ARG => { - if let Err(e) = run_background_worker() { - eprintln!("background worker failed: {e}"); - std::process::exit(1); - } - true - } - other => { - eprintln!("unknown argument: {other}"); - print_help(); - std::process::exit(2); - } - } -} - -fn print_help() { - println!( - "hf-mount-gui {version}\n\ - Native GUI for mounting Hugging Face repos and buckets through the NFS backend.\n\n\ - USAGE:\n\ - hf-mount-gui\n\ - hf-mount-gui --help\n\ - hf-mount-gui --version\n\ - hf-mount-gui --check-setup [MOUNT_POINT]\n\ - hf-mount-gui --background-worker\n\n\ - Windows requires Client for NFS and an Administrator session.", - version = env!("CARGO_PKG_VERSION") - ); -} - -fn apply_theme(ctx: &egui::Context) { - let mut style = (*ctx.style()).clone(); - let mut visuals = egui::Visuals::dark(); - let rounding = egui::Rounding::same(8.0); - - visuals.panel_fill = app_bg(); - visuals.window_fill = panel_bg(); - visuals.extreme_bg_color = input_bg(); - visuals.faint_bg_color = elevated_bg(); - visuals.code_bg_color = input_bg(); - visuals.selection.bg_fill = egui::Color32::from_rgb(74, 74, 70); - visuals.selection.stroke = egui::Stroke::new(1.0, text_primary()); - visuals.hyperlink_color = action_orange(); - visuals.warn_fg_color = warning_fg(); - visuals.error_fg_color = error_fg(); - visuals.window_rounding = rounding; - visuals.menu_rounding = rounding; - - for widgets in [ - &mut visuals.widgets.noninteractive, - &mut visuals.widgets.inactive, - &mut visuals.widgets.hovered, - &mut visuals.widgets.active, - &mut visuals.widgets.open, - ] { - widgets.rounding = rounding; - } - - visuals.widgets.noninteractive.bg_fill = panel_bg(); - visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, border()); - visuals.widgets.noninteractive.fg_stroke = egui::Stroke::new(1.0, text_primary()); - visuals.widgets.inactive.bg_fill = input_bg(); - visuals.widgets.inactive.weak_bg_fill = elevated_bg(); - visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, border()); - visuals.widgets.inactive.fg_stroke = egui::Stroke::new(1.0, text_primary()); - visuals.widgets.hovered.bg_fill = egui::Color32::from_rgb(70, 70, 66); - visuals.widgets.hovered.bg_stroke = egui::Stroke::new(1.0, egui::Color32::from_rgb(86, 86, 82)); - visuals.widgets.active.bg_fill = egui::Color32::from_rgb(76, 76, 72); - visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, text_secondary()); - - style.visuals = visuals; - style.spacing.item_spacing = egui::vec2(10.0, 8.0); - style.spacing.button_padding = egui::vec2(13.0, 7.0); - style.spacing.window_margin = egui::Margin::same(0.0); - ctx.set_style(style); -} - -fn app_bg() -> egui::Color32 { - egui::Color32::from_rgb(40, 40, 38) -} - -fn sidebar_bg() -> egui::Color32 { - egui::Color32::from_rgb(27, 27, 27) -} - -fn panel_bg() -> egui::Color32 { - egui::Color32::from_rgb(59, 59, 57) -} - -fn elevated_bg() -> egui::Color32 { - egui::Color32::from_rgb(48, 48, 47) -} - -fn input_bg() -> egui::Color32 { - egui::Color32::from_rgb(48, 48, 47) -} - -fn border() -> egui::Color32 { - egui::Color32::from_rgb(74, 74, 71) -} - -fn primary_button_bg() -> egui::Color32 { - egui::Color32::from_rgb(242, 242, 242) -} - -fn primary_button_text() -> egui::Color32 { - egui::Color32::from_rgb(36, 36, 34) -} - -fn action_orange() -> egui::Color32 { - egui::Color32::from_rgb(240, 122, 50) -} - -fn success_fg() -> egui::Color32 { - egui::Color32::from_rgb(102, 192, 133) -} - -fn text_primary() -> egui::Color32 { - egui::Color32::from_rgb(242, 242, 242) -} - -fn text_secondary() -> egui::Color32 { - egui::Color32::from_rgb(167, 167, 162) -} - -fn muted_text() -> egui::Color32 { - egui::Color32::from_rgb(126, 126, 120) -} - -fn warning_fg() -> egui::Color32 { - egui::Color32::from_rgb(240, 122, 50) -} - -fn error_fg() -> egui::Color32 { - egui::Color32::from_rgb(238, 107, 107) -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -enum GuiSource { - Repo, - Bucket, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -enum MountState { - Ready, - Mounting, - Mounted, - Stopping, - Stopped, - Failed, -} - -#[derive(Clone, Debug)] -struct SharedStatus { - state: MountState, - headline: String, - detail: String, - log: VecDeque, -} - -impl Default for SharedStatus { - fn default() -> Self { - let mut log = VecDeque::new(); - log.push_back("Ready".to_string()); - Self { - state: MountState::Ready, - headline: "Ready".to_string(), - detail: "Configure a source and start the mount.".to_string(), - log, - } - } -} - -type SharedMountStatus = Arc>; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum CheckLevel { - Pass, - Warn, - Fail, -} - -#[derive(Clone, Debug)] -struct CheckItem { - level: CheckLevel, - label: String, - detail: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -struct MountProfile { - source: GuiSource, - source_id: String, - revision: String, - mount_point: String, - #[serde(default)] - token_file: String, - hub_endpoint: String, - cache_dir: String, - read_only: bool, - run_in_background: bool, - #[serde(default)] - nfs_allow_unsafe_loopback: bool, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -enum WorkerState { - Mounting, - Mounted, - Stopping, - Stopped, - Failed, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -struct WorkerStatus { - state: WorkerState, - headline: String, - detail: String, - #[serde(default)] - mount_point: Option, - #[serde(default)] - pid: Option, - #[serde(default)] - updated_at_secs: u64, -} - -impl WorkerState { - fn mount_state(&self) -> MountState { - match self { - WorkerState::Mounting => MountState::Mounting, - WorkerState::Mounted => MountState::Mounted, - WorkerState::Stopping => MountState::Stopping, - WorkerState::Stopped => MountState::Stopped, - WorkerState::Failed => MountState::Failed, - } - } -} - -struct MountGuiApp { - source: GuiSource, - source_id: String, - revision: String, - mount_point: String, - hf_token: String, - token_file: String, - hub_endpoint: String, - cache_dir: String, - read_only: bool, - run_in_background: bool, - nfs_allow_unsafe_loopback: bool, - autostart_enabled: bool, - show_advanced: bool, - checks: Vec, - status: SharedMountStatus, - mount_thread: Option>, - background_child: Option, - active_background: bool, - active_mount_point: Option, -} - -impl Default for MountGuiApp { - fn default() -> Self { - let mount_point = default_mount_point(); - let checks = run_preflight_checks(&mount_point); - Self { - source: GuiSource::Repo, - source_id: "openai-community/gpt2".to_string(), - revision: "main".to_string(), - mount_point, - hf_token: std::env::var("HF_TOKEN").unwrap_or_default(), - token_file: String::new(), - hub_endpoint: "https://huggingface.co".to_string(), - cache_dir: default_cache_dir().to_string_lossy().into_owned(), - read_only: true, - run_in_background: false, - nfs_allow_unsafe_loopback: false, - autostart_enabled: autostart_is_enabled(), - show_advanced: false, - checks, - status: Arc::new(Mutex::new(SharedStatus::default())), - mount_thread: None, - background_child: None, - active_background: false, - active_mount_point: None, - } - } -} - -impl eframe::App for MountGuiApp { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - self.collect_finished_mount(); - ctx.request_repaint_after(std::time::Duration::from_millis(500)); - - egui::SidePanel::left("left-rail") - .resizable(false) - .exact_width(268.0) - .frame( - egui::Frame::none() - .fill(sidebar_bg()) - .inner_margin(egui::Margin::symmetric(18.0, 18.0)), - ) - .show(ctx, |ui| { - self.draw_sidebar(ui); - }); - - egui::CentralPanel::default() - .frame( - egui::Frame::none() - .fill(app_bg()) - .inner_margin(egui::Margin::symmetric(30.0, 24.0)), - ) - .show(ctx, |ui| { - self.draw_main_workspace(ui); - }); - } - - fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { - if !self.active_background - && let Some(mount_point) = &self.active_mount_point - { - let _ = unmount_path(mount_point); - } - } -} - -impl MountGuiApp { - fn load() -> Self { - let mut app = Self::default(); - match load_mount_profile() { - Ok(Some(profile)) => { - app.apply_profile(profile); - app.checks = run_preflight_checks(&app.mount_point); - } - Ok(None) => {} - Err(e) => push_log(&app.status, format!("Could not load saved settings: {e}")), - } - app.autostart_enabled = autostart_is_enabled(); - app.reconcile_background_worker(); - app - } - - fn apply_profile(&mut self, profile: MountProfile) { - self.source = profile.source; - self.source_id = profile.source_id; - self.revision = profile.revision; - self.mount_point = profile.mount_point; - self.token_file = profile.token_file; - self.hub_endpoint = profile.hub_endpoint; - self.cache_dir = profile.cache_dir; - self.read_only = profile.read_only || self.source == GuiSource::Repo; - self.run_in_background = profile.run_in_background; - self.nfs_allow_unsafe_loopback = profile.nfs_allow_unsafe_loopback; - } - - fn profile(&self) -> MountProfile { - MountProfile { - source: self.source, - source_id: self.source_id.clone(), - revision: self.revision.clone(), - mount_point: self.mount_point.clone(), - token_file: self.token_file.clone(), - hub_endpoint: self.hub_endpoint.clone(), - cache_dir: self.cache_dir.clone(), - read_only: self.source == GuiSource::Repo || self.read_only, - run_in_background: self.run_in_background, - nfs_allow_unsafe_loopback: self.nfs_allow_unsafe_loopback, - } - } - - fn save_profile(&self) -> Result<(), String> { - save_mount_profile(&self.profile()) - } - - fn apply_autostart_setting(&mut self) { - let requested = self.autostart_enabled; - if requested && let Err(e) = self.save_profile() { - self.autostart_enabled = false; - set_status(&self.status, MountState::Failed, "Could not save settings", e); - return; - } - - match set_autostart_enabled(requested) { - Ok(()) => { - let (headline, detail) = if requested { - ("Autostart enabled", "The saved mount will start at login.") - } else { - ("Autostart disabled", "The login startup entry was removed.") - }; - set_status(&self.status, MountState::Stopped, headline, detail); - } - Err(e) => { - self.autostart_enabled = !requested; - set_status(&self.status, MountState::Failed, "Could not update autostart", e); - } - } - } - - fn reconcile_background_worker(&mut self) { - match read_worker_status() { - Ok(Some(status)) => self.sync_worker_status(status, true), - Ok(None) => {} - Err(e) => push_log(&self.status, format!("Could not read background status: {e}")), - } - } - - fn apply_worker_status(&mut self) { - match read_worker_status() { - Ok(Some(status)) => self.sync_worker_status(status, false), - Ok(None) => { - if self.active_background { - self.active_background = false; - self.active_mount_point = None; - set_status( - &self.status, - MountState::Failed, - "Background worker unavailable", - "No background status file was found.", - ); - } - } - Err(e) => push_log(&self.status, format!("Could not read background status: {e}")), - } - } - - fn sync_worker_status(&mut self, status: WorkerStatus, recovered: bool) { - let mount_point = self.status_mount_point(&status); - let active_state = worker_state_is_active(&status.state); - - if active_state && worker_status_is_live(&status, mount_point.as_deref()) { - self.active_background = true; - if let Some(mount_point) = mount_point { - self.active_mount_point = Some(mount_point); - } - if recovered { - push_log(&self.status, "Reconnected to background worker"); - } - } else { - if active_state && (self.active_background || recovered) { - set_status( - &self.status, - MountState::Failed, - "Background worker unavailable", - "Saved background state is stale; start the mount again.", - ); - } - self.active_background = false; - self.background_child = None; - if self.mount_thread.is_none() { - self.active_mount_point = None; - } - } - - if !active_state || self.active_background { - set_status_if_changed(&self.status, status.state.mount_state(), status.headline, status.detail); - } - } - - fn status_mount_point(&self, status: &WorkerStatus) -> Option { - status - .mount_point - .as_deref() - .map(str::trim) - .filter(|mount_point| !mount_point.is_empty()) - .map(PathBuf::from) - .or_else(|| { - let mount_point = self.mount_point.trim(); - (!mount_point.is_empty()).then(|| PathBuf::from(mount_point)) - }) - } - - fn draw_sidebar(&mut self, ui: &mut egui::Ui) { - let status = self.status.lock().expect("status mutex poisoned").clone(); - ui.set_height(ui.available_height()); - - ui.label(RichText::new("hf-mount").size(21.0).strong().color(text_primary())); - ui.label(RichText::new("Native NFS mounter").size(13.0).color(text_secondary())); - ui.add_space(22.0); - - nav_row(ui, "Mount", true); - nav_row(ui, "Setup", false); - nav_row(ui, "Activity", false); - - ui.add_space(20.0); - ui.separator(); - ui.add_space(14.0); - - section_title(ui, "Session"); - ui.add_space(8.0); - status_chip(ui, &status.state); - ui.add_space(8.0); - ui.label(RichText::new(status.headline).strong().color(text_primary())); - ui.label(RichText::new(status.detail).size(12.0).color(text_secondary())); - - ui.add_space(18.0); - section_title(ui, "Readiness"); - ui.add_space(8.0); - checks_summary(ui, &self.checks); - ui.add_space(10.0); - for check in &self.checks { - compact_check_row(ui, check); - ui.add_space(5.0); - } - - ui.add_space(18.0); - section_title(ui, "Activity"); - ui.add_space(8.0); - egui::ScrollArea::vertical() - .id_source("activity-log") - .stick_to_bottom(true) - .auto_shrink([false, false]) - .show(ui, |ui| { - for line in status.log { - ui.label(RichText::new(line).monospace().size(11.0).color(text_secondary())); - } - }); - } - - fn draw_main_workspace(&mut self, ui: &mut egui::Ui) { - self.draw_header(ui); - ui.add_space(44.0); - - let composer_width = ui.available_width().min(860.0); - let leading_space = ((ui.available_width() - composer_width) / 2.0).max(0.0); - ui.horizontal_top(|ui| { - ui.add_space(leading_space); - ui.vertical(|ui| { - ui.set_width(composer_width); - self.draw_composer_panel(ui); - }); - }); - } - - fn draw_header(&mut self, ui: &mut egui::Ui) { - ui.horizontal(|ui| { - ui.vertical(|ui| { - ui.label( - RichText::new("Mount Hugging Face storage") - .size(25.0) - .strong() - .color(text_primary()), - ); - ui.label( - RichText::new("Choose a repo or bucket, then start the local NFS mount.").color(text_secondary()), - ); - }); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let status = self.status.lock().expect("status mutex poisoned").clone(); - meta_label(ui, platform_label()); - meta_label(ui, "NFS backend"); - status_chip(ui, &status.state); - }); - }); - } - - fn draw_composer_panel(&mut self, ui: &mut egui::Ui) { - composer(ui, |ui| { - section_title(ui, "Mount"); - ui.add_space(14.0); - self.draw_config_fields(ui); - ui.add_space(14.0); - if !self.is_mount_thread_running() - && let Some(blocker) = self.first_blocking_check().cloned() - { - self.draw_blocker_row(ui, &blocker); - ui.add_space(12.0); - } - self.draw_action_buttons(ui); - }); - } - - fn draw_action_buttons(&mut self, ui: &mut egui::Ui) { - let running = self.is_mount_thread_running(); - let mounted = { - let status = self.status.lock().expect("status mutex poisoned"); - status.state == MountState::Mounted - }; - let blocked = !running && self.first_blocking_check().is_some(); - let full_width = ui.available_width(); - let small_gap = ui.spacing().item_spacing.x; - let half_width = ((full_width - small_gap) / 2.0).max(96.0); - let button_height = 38.0; - - let start_label = if running { - "Mount running" - } else if self.run_in_background { - "Start in background" - } else { - "Start mount" - }; - let start = egui::Button::new(RichText::new(start_label).strong().color(egui::Color32::WHITE)) - .fill(if running || blocked { - input_bg() - } else { - primary_button_bg() - }) - .min_size(egui::vec2(full_width, button_height)); - let start = if running || blocked { - start - } else { - egui::Button::new(RichText::new(start_label).strong().color(primary_button_text())) - .fill(primary_button_bg()) - .min_size(egui::vec2(full_width, button_height)) - }; - if ui.add_enabled(!running && !blocked, start).clicked() { - self.start_mount(); - } - - ui.add_space(8.0); - ui.horizontal(|ui| { - if ui - .add_sized( - [half_width, button_height], - egui::Button::new(RichText::new("Check setup").color(text_primary())), - ) - .clicked() - { - self.checks = run_preflight_checks(&self.mount_point); - push_log(&self.status, summarize_checks(&self.checks)); - } - - let stop = egui::Button::new(RichText::new("Stop").strong().color(text_primary())) - .fill(egui::Color32::from_rgb(77, 42, 42)); - ui.add_enabled_ui(running, |ui| { - if ui.add_sized([half_width, button_height], stop).clicked() { - self.stop_mount(); - } - }); - }); - - ui.add_space(8.0); - ui.horizontal(|ui| { - let open = egui::Button::new(RichText::new("Open mount").color(text_primary())); - ui.add_enabled_ui(mounted && self.active_mount_point.is_some(), |ui| { - if ui.add_sized([ui.available_width(), button_height], open).clicked() { - match open_mount_point(self.active_mount_point.as_deref()) { - Ok(()) => push_log(&self.status, "Opened mount point"), - Err(e) => set_status(&self.status, MountState::Failed, "Could not open mount point", e), - } - } - }); - }); - } - - fn draw_config_fields(&mut self, ui: &mut egui::Ui) { - field_row(ui, "Type", |ui| { - let before = self.source; - source_selector(ui, &mut self.source); - if before != self.source && self.source == GuiSource::Repo { - self.read_only = true; - } - }); - field_row( - ui, - match self.source { - GuiSource::Repo => "Repo ID", - GuiSource::Bucket => "Bucket ID", - }, - |ui| { - let hint = match self.source { - GuiSource::Repo => "openai-community/gpt2", - GuiSource::Bucket => "namespace/bucket", - }; - text_field(ui, &mut self.source_id, hint, false); - }, - ); - if self.source == GuiSource::Repo { - field_row(ui, "Revision", |ui| text_field(ui, &mut self.revision, "main", false)); - } - field_row(ui, "Mount point", |ui| { - text_field(ui, &mut self.mount_point, default_mount_hint(), false); - ui.add_space(3.0); - ui.horizontal(|ui| { - ui.label(RichText::new(mount_point_hint()).small().color(muted_text())); - #[cfg(windows)] - if ui.small_button("Use Z:").clicked() { - self.mount_point = "Z:".to_string(); - self.checks = run_preflight_checks(&self.mount_point); - } - }); - }); - field_row(ui, "Access", |ui| { - if self.source == GuiSource::Repo { - self.read_only = true; - let mut locked = true; - ui.add_enabled(false, egui::Checkbox::new(&mut locked, "Read-only")); - ui.label(RichText::new("Repos are always read-only").small().color(muted_text())); - } else { - ui.checkbox(&mut self.read_only, "Read-only"); - } - }); - field_row(ui, "Run", |ui| { - ui.horizontal_wrapped(|ui| { - ui.checkbox(&mut self.run_in_background, "Background"); - let changed = ui.checkbox(&mut self.autostart_enabled, "Start at login").changed(); - if changed { - self.apply_autostart_setting(); - } - }); - }); - field_row(ui, "HF token", |ui| { - text_field(ui, &mut self.hf_token, "Optional access token", true); - ui.add_space(3.0); - ui.label( - RichText::new("Uses HF_TOKEN automatically when set. Inline tokens are not saved.") - .small() - .color(muted_text()), - ); - }); - ui.add_space(2.0); - if ui - .button(if self.show_advanced { - "Hide advanced" - } else { - "Show advanced" - }) - .clicked() - { - self.show_advanced = !self.show_advanced; - } - if self.show_advanced { - ui.add_space(8.0); - field_row(ui, "Hub endpoint", |ui| { - text_field(ui, &mut self.hub_endpoint, "https://huggingface.co", false); - }); - field_row(ui, "Cache dir", |ui| { - text_field(ui, &mut self.cache_dir, "Cache directory", false); - }); - field_row(ui, "Token file", |ui| { - text_field(ui, &mut self.token_file, "Path to token file", false); - }); - field_row(ui, "NFS access", |ui| { - ui.checkbox(&mut self.nfs_allow_unsafe_loopback, "Allow unsafe loopback fallback"); - }); - } - } - - fn draw_blocker_row(&mut self, ui: &mut egui::Ui, blocker: &CheckItem) { - let detail = format!("{}: {}", blocker.label, blocker.detail); - egui::Frame::none() - .fill(egui::Color32::from_rgb(58, 46, 39)) - .stroke(egui::Stroke::new(1.0, action_orange())) - .rounding(8.0) - .inner_margin(egui::Margin::symmetric(12.0, 10.0)) - .show(ui, |ui| { - ui.horizontal_top(|ui| { - ui.label(RichText::new("Blocked").strong().color(action_orange())); - ui.vertical(|ui| { - ui.label(RichText::new(&detail).color(text_primary())); - if let Some(command) = blocker_command(blocker) { - ui.add_space(4.0); - ui.label(RichText::new(command).monospace().size(11.0).color(text_secondary())); - } - }); - }); - ui.add_space(10.0); - ui.horizontal_wrapped(|ui| { - self.draw_primary_blocker_action(ui, blocker); - - if let Some(command) = blocker_command(blocker) { - if ui.button("Copy command").clicked() { - ui.output_mut(|output| output.copied_text = command.to_string()); - push_log(&self.status, "Copied setup command"); - } - } - - if blocker.label == "Mount point" && ui.button("Use Z:").clicked() { - self.mount_point = "Z:".to_string(); - self.checks = run_preflight_checks(&self.mount_point); - push_log(&self.status, summarize_checks(&self.checks)); - } - - if ui.button("Recheck").clicked() { - self.checks = run_preflight_checks(&self.mount_point); - push_log(&self.status, summarize_checks(&self.checks)); - } - }); - }); - } - - fn draw_primary_blocker_action(&mut self, ui: &mut egui::Ui, blocker: &CheckItem) { - #[cfg(windows)] - { - if blocker.label == "Client for NFS" { - let enable = egui::Button::new(RichText::new("Enable NFS").strong().color(egui::Color32::WHITE)) - .fill(action_orange()); - if ui.add(enable).clicked() { - match enable_windows_nfs_client() { - Ok(()) => set_status( - &self.status, - MountState::Stopped, - "NFS enable requested", - "Approve the UAC prompt. Reboot if Windows asks, then press Recheck.", - ), - Err(e) => set_status(&self.status, MountState::Failed, "Could not enable NFS", e), - } - } - return; - } - - if blocker.label == "Administrator" { - let restart = egui::Button::new(RichText::new("Restart as admin").strong().color(text_primary())); - if ui.add(restart).clicked() { - match restart_as_administrator() { - Ok(()) => set_status( - &self.status, - MountState::Stopped, - "Elevation requested", - "Approve the Windows UAC prompt, then use the elevated window.", - ), - Err(e) => set_status(&self.status, MountState::Failed, "Could not relaunch as admin", e), - } - } - return; - } - } - - if blocker.label == "Mount point" { - return; - } - - ui.label( - RichText::new("Fix this blocker, then recheck.") - .small() - .color(text_secondary()), - ); - } - - fn start_mount(&mut self) { - let checks = run_preflight_checks(&self.mount_point); - let failure = checks.iter().find(|check| check.level == CheckLevel::Fail).cloned(); - self.checks = checks; - if let Some(failure) = failure { - set_status( - &self.status, - MountState::Failed, - format!("Setup check failed: {}", failure.label), - failure.detail, - ); - return; - } - - push_log(&self.status, summarize_checks(&self.checks)); - let source = match self.mount_source() { - Ok(source) => source, - Err(e) => { - set_status(&self.status, MountState::Failed, "Invalid source", e); - return; - } - }; - let options = match self.mount_options() { - Ok(options) => options, - Err(e) => { - set_status(&self.status, MountState::Failed, "Invalid mount options", e); - return; - } - }; - let mount_point = source.mount_point().to_path_buf(); - let mount_label = mount_point.display().to_string(); - let shared_status = self.status.clone(); - - let inline_token = optional_text(&self.hf_token); - if self.run_in_background - && optional_text(&self.token_file).is_none() - && inline_token.is_some() - && inline_token != current_env_hf_token() - { - set_status( - &self.status, - MountState::Failed, - "Token not available to background worker", - "Set HF_TOKEN before launching the GUI or provide a token file.", - ); - return; - } - - if let Err(e) = self.save_profile() { - set_status(&self.status, MountState::Failed, "Could not save settings", e); - return; - } - - set_status( - &shared_status, - MountState::Mounting, - "Preparing mount", - format!("Target: {mount_label}"), - ); - self.active_mount_point = Some(mount_point); - if self.run_in_background { - match spawn_background_worker(&mount_point) { - Ok(child) => { - self.background_child = Some(child); - self.active_background = true; - set_status( - &self.status, - MountState::Mounting, - "Background mount starting", - format!("Target: {mount_label}"), - ); - } - Err(e) => { - self.active_mount_point = None; - set_status(&self.status, MountState::Failed, "Could not start background mount", e); - } - } - return; - } - self.active_background = false; - self.mount_thread = Some(thread::spawn(move || run_mount(source, options, shared_status))); - } - - fn stop_mount(&mut self) { - let Some(mount_point) = self.active_mount_point.clone() else { - set_status( - &self.status, - MountState::Failed, - "No active mount", - "There is no recorded mount point to unmount.", - ); - return; - }; - - set_status( - &self.status, - MountState::Stopping, - "Unmount requested", - format!("Target: {}", mount_point.display()), - ); - - if let Err(e) = unmount_path(&mount_point) { - set_status(&self.status, MountState::Failed, "Unmount failed", e); - } - } - - fn collect_finished_mount(&mut self) { - if self.active_background { - self.apply_worker_status(); - } - - let background_result = self.background_child.as_mut().map(Child::try_wait).transpose(); - match background_result { - Ok(Some(Some(status))) => { - self.background_child = None; - self.active_background = false; - self.active_mount_point = None; - if status.success() { - set_status( - &self.status, - MountState::Stopped, - "Background mount stopped", - "The background worker exited cleanly.", - ); - } else { - set_status( - &self.status, - MountState::Failed, - "Background mount exited", - format!("Worker exited with {status}."), - ); - } - } - Ok(Some(None)) | Ok(None) => {} - Err(e) => { - self.background_child = None; - self.active_background = false; - set_status( - &self.status, - MountState::Failed, - "Could not inspect background mount", - e.to_string(), - ); - } - } - - let finished = self.mount_thread.as_ref().is_some_and(JoinHandle::is_finished); - if !finished { - return; - } - - if let Some(handle) = self.mount_thread.take() - && handle.join().is_err() - { - set_status( - &self.status, - MountState::Failed, - "Mount thread panicked", - "The backend thread exited unexpectedly.", - ); - } - self.active_mount_point = None; - - let current = self.status.lock().expect("status mutex poisoned").state.clone(); - if !matches!(current, MountState::Failed | MountState::Stopped) { - set_status( - &self.status, - MountState::Stopped, - "Unmounted", - "The mount process has stopped.", - ); - } - } - - fn is_mount_thread_running(&self) -> bool { - self.active_background - || self.background_child.is_some() - || self.mount_thread.as_ref().is_some_and(|handle| !handle.is_finished()) - } - - fn first_blocking_check(&self) -> Option<&CheckItem> { - self.checks - .iter() - .find(|check| check.label == "Client for NFS" && check.level == CheckLevel::Fail) - .or_else(|| self.checks.iter().find(|check| check.level == CheckLevel::Fail)) - } - - fn mount_source(&self) -> Result { - profile_mount_source(&self.profile()) - } - - fn mount_options(&self) -> Result { - let mut options = profile_mount_options(&self.profile())?; - if let Some(token) = optional_text(&self.hf_token) { - options.hf_token = Some(token); - } - Ok(options) - } -} - -fn composer(ui: &mut egui::Ui, add_contents: impl FnOnce(&mut egui::Ui)) { - egui::Frame::none() - .fill(panel_bg()) - .stroke(egui::Stroke::new(1.0, border())) - .rounding(10.0) - .inner_margin(egui::Margin::symmetric(18.0, 16.0)) - .show(ui, add_contents); -} - -fn section_title(ui: &mut egui::Ui, title: &str) { - ui.label(RichText::new(title).size(14.0).strong().color(text_primary())); -} - -fn nav_row(ui: &mut egui::Ui, label: &str, selected: bool) { - let fill = if selected { - elevated_bg() - } else { - egui::Color32::TRANSPARENT - }; - let text = if selected { text_primary() } else { text_secondary() }; - egui::Frame::none() - .fill(fill) - .rounding(8.0) - .inner_margin(egui::Margin::symmetric(10.0, 8.0)) - .show(ui, |ui| { - ui.label(RichText::new(label).strong().color(text)); - }); -} - -fn field_row(ui: &mut egui::Ui, label: &str, add_field: impl FnOnce(&mut egui::Ui)) { - if ui.available_width() < 430.0 { - ui.vertical(|ui| { - ui.label(RichText::new(label).color(text_secondary())); - add_field(ui); - }); - } else { - ui.horizontal(|ui| { - ui.set_min_height(42.0); - ui.add_sized( - [120.0, 28.0], - egui::Label::new(RichText::new(label).color(text_secondary())), - ); - ui.vertical(|ui| { - ui.set_width(ui.available_width()); - add_field(ui); - }); - }); - } -} - -fn source_selector(ui: &mut egui::Ui, source: &mut GuiSource) { - egui::Frame::none() - .fill(input_bg()) - .stroke(egui::Stroke::new(1.0, border())) - .rounding(8.0) - .inner_margin(egui::Margin::same(4.0)) - .show(ui, |ui| { - ui.horizontal(|ui| { - let spacing = ui.spacing().item_spacing.x; - let width = ((ui.available_width() - spacing) / 2.0).max(100.0); - if source_button(ui, "Repo", *source == GuiSource::Repo, width).clicked() { - *source = GuiSource::Repo; - } - if source_button(ui, "Bucket", *source == GuiSource::Bucket, width).clicked() { - *source = GuiSource::Bucket; - } - }); - }); -} - -fn source_button(ui: &mut egui::Ui, label: &str, selected: bool, width: f32) -> egui::Response { - let text_color = if selected { text_primary() } else { text_secondary() }; - let fill = if selected { - egui::Color32::from_rgb(70, 70, 66) - } else { - egui::Color32::TRANSPARENT - }; - ui.add_sized( - [width, 32.0], - egui::Button::new(RichText::new(label).strong().color(text_color)) - .fill(fill) - .stroke(egui::Stroke::NONE), - ) -} - -fn text_field(ui: &mut egui::Ui, value: &mut String, hint: &str, password: bool) { - ui.add_sized( - [ui.available_width(), 36.0], - TextEdit::singleline(value) - .desired_width(f32::INFINITY) - .hint_text(hint) - .password(password), - ); -} - -fn meta_label(ui: &mut egui::Ui, text: &str) { - ui.label(RichText::new(text).size(12.0).color(text_secondary())); -} - -fn status_chip(ui: &mut egui::Ui, state: &MountState) { - let (label, fg, bg) = match state { - MountState::Ready => ("Ready", text_secondary(), elevated_bg()), - MountState::Mounting => ("Mounting", action_orange(), egui::Color32::from_rgb(58, 46, 39)), - MountState::Mounted => ("Mounted", success_fg(), egui::Color32::from_rgb(34, 55, 42)), - MountState::Stopping => ("Stopping", action_orange(), egui::Color32::from_rgb(58, 46, 39)), - MountState::Stopped => ("Stopped", text_secondary(), elevated_bg()), - MountState::Failed => ("Error", error_fg(), egui::Color32::from_rgb(63, 39, 39)), - }; - chip(ui, label, fg, bg); -} - -fn checks_summary(ui: &mut egui::Ui, checks: &[CheckItem]) { - let failures = checks.iter().filter(|check| check.level == CheckLevel::Fail).count(); - let warnings = checks.iter().filter(|check| check.level == CheckLevel::Warn).count(); - - let (label, color) = if checks.is_empty() { - ("Not checked", text_secondary()) - } else if failures > 0 { - ("Action needed", error_fg()) - } else if warnings > 0 { - ("Usable with warnings", warning_fg()) - } else { - ("Ready to mount", success_fg()) - }; - ui.horizontal(|ui| { - chip(ui, label, color, elevated_bg()); - if !checks.is_empty() { - ui.label( - RichText::new(format!("{failures} blocking / {warnings} warning")) - .small() - .color(muted_text()), - ); - } - }); -} - -fn chip(ui: &mut egui::Ui, text: &str, fg: egui::Color32, bg: egui::Color32) { - egui::Frame::none() - .fill(bg) - .rounding(egui::Rounding::same(7.0)) - .inner_margin(egui::Margin::symmetric(8.0, 4.0)) - .show(ui, |ui| { - ui.label(RichText::new(text).small().strong().color(fg)); - }); -} - -fn compact_check_row(ui: &mut egui::Ui, check: &CheckItem) { - let (mark, color) = match check.level { - CheckLevel::Pass => ("OK", success_fg()), - CheckLevel::Warn => ("--", warning_fg()), - CheckLevel::Fail => ("FIX", error_fg()), - }; - ui.horizontal_top(|ui| { - ui.add_sized( - [34.0, 18.0], - egui::Label::new(RichText::new(mark).size(11.0).strong().color(color)), - ); - ui.vertical(|ui| { - ui.label(RichText::new(&check.label).size(13.0).strong().color(text_primary())); - ui.label(RichText::new(&check.detail).size(11.0).color(text_secondary())); - }); - }); -} - -fn check_level_label(level: CheckLevel) -> &'static str { - match level { - CheckLevel::Pass => "OK", - CheckLevel::Warn => "WARN", - CheckLevel::Fail => "FAIL", - } -} - -fn run_mount(source: Source, options: MountOptions, shared_status: SharedMountStatus) { - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let setup = hf_mount::setup::build(source, options, true); - let virtual_fs = setup.virtual_fs.clone(); - let mount_point = setup.mount_point.clone(); - let metadata_ttl_ms = setup.metadata_ttl_ms; - let read_only = setup.read_only; - let nfs_security = setup.nfs_security.clone(); - let status_for_events = shared_status.clone(); - setup.runtime.block_on(hf_mount::nfs::mount_nfs_with_callback( - virtual_fs, - &mount_point, - metadata_ttl_ms, - read_only, - nfs_security, - None, - move |event| handle_mount_event(&status_for_events, event), - )) - })); - - match result { - Ok(Ok(())) => set_status( - &shared_status, - MountState::Stopped, - "Unmounted", - "The mount stopped cleanly.", - ), - Ok(Err(e)) => set_status(&shared_status, MountState::Failed, "NFS mount failed", e.to_string()), - Err(payload) => set_status( - &shared_status, - MountState::Failed, - "Mount setup failed", - panic_message(payload), - ), - } -} - -fn handle_mount_event(status: &SharedMountStatus, event: NfsMountEvent) { - match event { - NfsMountEvent::ServerListening { port } => set_status( - status, - MountState::Mounting, - "Local NFS server is listening", - format!("127.0.0.1:{port}"), - ), - NfsMountEvent::MountCommand { command } => push_log(status, format!("Running {command}")), - NfsMountEvent::Mounted { mount_point } => set_status( - status, - MountState::Mounted, - "Mounted", - format!("Mounted at {mount_point}"), - ), - NfsMountEvent::ShuttingDown { reason } => set_status(status, MountState::Stopping, "Shutting down", reason), - } -} - -fn set_status(status: &SharedMountStatus, state: MountState, headline: impl Into, detail: impl Into) { - let headline = headline.into(); - let detail = detail.into(); - let mut status = status.lock().expect("status mutex poisoned"); - status.state = state; - status.headline = headline.clone(); - status.detail = detail.clone(); - push_log_locked(&mut status, format!("{headline}: {detail}")); -} - -fn set_status_if_changed( - status: &SharedMountStatus, - state: MountState, - headline: impl Into, - detail: impl Into, -) { - let headline = headline.into(); - let detail = detail.into(); - let mut status = status.lock().expect("status mutex poisoned"); - if status.state == state && status.headline == headline && status.detail == detail { - return; - } - status.state = state; - status.headline = headline.clone(); - status.detail = detail.clone(); - push_log_locked(&mut status, format!("{headline}: {detail}")); -} - -fn push_log(status: &SharedMountStatus, message: impl Into) { - let mut status = status.lock().expect("status mutex poisoned"); - push_log_locked(&mut status, message.into()); -} - -fn push_log_locked(status: &mut SharedStatus, message: String) { - status.log.push_back(message); - while status.log.len() > MAX_LOG_LINES { - status.log.pop_front(); - } -} - -fn run_preflight_checks(mount_point: &str) -> Vec { - #[cfg(windows)] - { - return windows_preflight_checks(mount_point); - } - #[cfg(target_os = "macos")] - { - return macos_preflight_checks(mount_point); - } - #[cfg(all(not(windows), not(target_os = "macos")))] - { - vec![CheckItem { - level: if mount_point.trim().is_empty() { - CheckLevel::Fail - } else { - CheckLevel::Pass - }, - label: "Mount point".to_string(), - detail: "Path is set.".to_string(), - }] - } -} - -#[cfg(windows)] -fn windows_preflight_checks(mount_point: &str) -> Vec { - let mut checks = Vec::new(); - let elevated = windows_is_elevated(); - checks.push(if elevated { - CheckItem { - level: CheckLevel::Pass, - label: "Administrator".to_string(), - detail: "The GUI is elevated.".to_string(), - } - } else { - CheckItem { - level: CheckLevel::Fail, - label: "Administrator".to_string(), - detail: "Restart as Administrator so hf-mount can bind the local NFS portmapper.".to_string(), - } - }); - - let mount_exe = windows_system32_exe("mount.exe"); - let umount_exe = windows_system32_exe("umount.exe"); - checks.push(if mount_exe.exists() && umount_exe.exists() { - CheckItem { - level: CheckLevel::Pass, - label: "Client for NFS".to_string(), - detail: "mount.exe and umount.exe are available.".to_string(), - } - } else { - CheckItem { - level: CheckLevel::Fail, - label: "Client for NFS".to_string(), - detail: "Enable Microsoft's Client for NFS optional feature and reboot if Windows asks.".to_string(), - } - }); - - checks.push(if elevated { - windows_portmapper_check() - } else { - CheckItem { - level: CheckLevel::Warn, - label: "Portmapper".to_string(), - detail: "Port 111 is checked after elevation.".to_string(), - } - }); - - checks.push(validate_windows_mount_point(mount_point)); - checks -} - -#[cfg(windows)] -fn windows_is_elevated() -> bool { - let mut command = Command::new(windows_system32_exe("fltmc.exe")); - command.creation_flags(CREATE_NO_WINDOW); - command.stdout(Stdio::null()).stderr(Stdio::null()); - command.status().map(|status| status.success()).unwrap_or(false) -} - -#[cfg(windows)] -fn validate_windows_mount_point(mount_point: &str) -> CheckItem { - let trimmed = mount_point.trim(); - if trimmed.is_empty() { - return CheckItem { - level: CheckLevel::Fail, - label: "Mount point".to_string(), - detail: "Choose a drive letter like Z: or an empty NTFS directory.".to_string(), - }; - } - - if let Some(drive) = windows_drive_letter(trimmed) { - let probe = format!("{drive}:\\"); - return if Path::new(&probe).exists() { - CheckItem { - level: CheckLevel::Fail, - label: "Mount point".to_string(), - detail: format!("{drive}: already exists. Pick an unused drive letter such as Y: or X:."), - } - } else { - CheckItem { - level: CheckLevel::Pass, - label: "Mount point".to_string(), - detail: format!("{drive}: is a drive-letter target."), - } - }; - } - - let path = Path::new(trimmed); - if path.is_absolute() { - if path.exists() && !path.is_dir() { - return CheckItem { - level: CheckLevel::Fail, - label: "Mount point".to_string(), - detail: "The target exists but is not a directory.".to_string(), - }; - } - let detail = if path.exists() { - "Directory target is absolute. A drive letter such as Z: is still the most reliable Windows target." - } else { - "Directory target is absolute and will be created if the Windows NFS client accepts it. A drive letter is more reliable." - }; - CheckItem { - level: CheckLevel::Warn, - label: "Mount point".to_string(), - detail: detail.to_string(), - } - } else { - CheckItem { - level: CheckLevel::Fail, - label: "Mount point".to_string(), - detail: "Use a drive letter or an absolute directory path.".to_string(), - } - } -} - -#[cfg(windows)] -fn windows_portmapper_check() -> CheckItem { - match ( - UdpSocket::bind(("127.0.0.1", 111)), - TcpListener::bind(("127.0.0.1", 111)), - ) { - (Ok(udp), Ok(tcp)) => { - drop(udp); - drop(tcp); - CheckItem { - level: CheckLevel::Pass, - label: "Portmapper".to_string(), - detail: "TCP and UDP port 111 are available for the local NFS portmapper.".to_string(), - } - } - (udp_result, tcp_result) => CheckItem { - level: CheckLevel::Fail, - label: "Portmapper".to_string(), - detail: format!( - "Port 111 is not available: UDP={} TCP={}. Close other NFS/portmap services or another hf-mount instance.", - bind_result_label(&udp_result), - bind_result_label(&tcp_result), - ), - }, - } -} - -#[cfg(windows)] -fn bind_result_label(result: &std::io::Result) -> String { - match result { - Ok(_) => "ok".to_string(), - Err(e) => e.to_string(), - } -} - -#[cfg(target_os = "macos")] -fn macos_preflight_checks(mount_point: &str) -> Vec { - let mount_cmd = Path::new("/sbin/mount_nfs"); - let mount_cmd_exists = mount_cmd.exists(); - let mount_path_absolute = Path::new(mount_point.trim()).is_absolute(); - vec![ - CheckItem { - level: if mount_cmd_exists { - CheckLevel::Pass - } else { - CheckLevel::Fail - }, - label: "mount_nfs".to_string(), - detail: if mount_cmd_exists { - "/sbin/mount_nfs is available.".to_string() - } else { - "/sbin/mount_nfs was not found.".to_string() - }, - }, - CheckItem { - level: if mount_path_absolute { - CheckLevel::Pass - } else { - CheckLevel::Fail - }, - label: "Mount point".to_string(), - detail: if mount_path_absolute { - "Mount point is an absolute path.".to_string() - } else { - "Use an absolute local directory path.".to_string() - }, - }, - ] -} - -fn summarize_checks(checks: &[CheckItem]) -> String { - if checks.iter().any(|check| check.level == CheckLevel::Fail) { - "Setup checks found a blocking issue".to_string() - } else if checks.iter().any(|check| check.level == CheckLevel::Warn) { - "Setup checks passed with warnings".to_string() - } else { - "Setup checks passed".to_string() - } -} - -fn blocker_command(check: &CheckItem) -> Option<&'static str> { - #[cfg(windows)] - { - match check.label.as_str() { - "Client for NFS" => Some(windows_enable_nfs_command()), - "Administrator" => Some("Start-Process hf-mount-gui.exe -Verb RunAs"), - "Portmapper" => Some("Close other NFS/portmap services or another hf-mount instance, then recheck."), - "Mount point" => Some("Use an unused drive letter such as Z:, Y:, or X:."), - _ => None, - } - } - #[cfg(not(windows))] - { - let _ = check; - None - } -} - -#[cfg(windows)] -fn windows_enable_nfs_command() -> &'static str { - "Enable-WindowsOptionalFeature -Online -FeatureName ServicesForNFS-ClientOnly,ClientForNFS-Infrastructure -All" -} - -#[cfg(windows)] -fn enable_windows_nfs_client() -> Result<(), String> { - let powershell = std::env::var_os("SystemRoot") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(r"C:\Windows")) - .join("System32") - .join("WindowsPowerShell") - .join("v1.0") - .join("powershell.exe"); - let elevated_args = format!( - "-NoProfile -ExecutionPolicy Bypass -Command \"{}\"", - windows_enable_nfs_command() - ); - - let status = Command::new(&powershell) - .creation_flags(CREATE_NO_WINDOW) - .args([ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-Command", - "Start-Process -FilePath $args[0] -Verb RunAs -ArgumentList $args[1]", - ]) - .arg(&powershell) - .arg(elevated_args) - .status() - .map_err(|e| format!("Failed to launch the UAC prompt: {e}"))?; - - if status.success() { - Ok(()) - } else { - Err(format!("PowerShell exited with {status}")) - } -} - -fn unmount_path(mount_point: &Path) -> Result<(), String> { - let mount_point = mount_point - .to_str() - .ok_or_else(|| "Mount point is not valid UTF-8".to_string())?; - let target = unmount_target(mount_point); - - let output = unmount_command(&target) - .output() - .map_err(|e| format!("Failed to run unmount command: {e}"))?; - - if output.status.success() { - return Ok(()); - } - - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - Err(format!( - "Unmount failed with {}: stdout={} stderr={}", - output.status, - stdout.trim(), - stderr.trim() - )) -} - -#[cfg(windows)] -fn unmount_command(mount_point: &str) -> Command { - let mut command = Command::new(windows_system32_exe("umount.exe")); - command.creation_flags(CREATE_NO_WINDOW); - command.args(["-f", mount_point]); - command -} - -#[cfg(target_os = "macos")] -fn unmount_command(mount_point: &str) -> Command { - let mut command = Command::new("/sbin/umount"); - command.arg(mount_point); - command -} - -#[cfg(all(not(windows), not(target_os = "macos")))] -fn unmount_command(mount_point: &str) -> Command { - let mut command = Command::new("umount"); - command.arg(mount_point); - command -} - -fn unmount_target(mount_point: &str) -> String { - #[cfg(windows)] - { - if let Some(drive) = windows_drive_letter(mount_point) { - return format!("{drive}:"); - } - } - mount_point.to_string() -} - -fn run_background_worker() -> Result<(), String> { - BACKEND_INIT.call_once(|| { - hf_mount::setup::raise_fd_limit(); - hf_mount::setup::init_tracing(false); - }); - - append_worker_log("Background worker starting"); - let profile = load_mount_profile()?.ok_or_else(|| "No saved mount settings were found.".to_string())?; - let source = profile_mount_source(&profile)?; - let options = profile_mount_options(&profile)?; - let worker_mount_point = source.mount_point().to_path_buf(); - let mount_label = worker_mount_point.display().to_string(); - write_worker_status( - WorkerState::Mounting, - "Background worker starting", - format!("Target: {mount_label}"), - Some(&worker_mount_point), - Some(std::process::id()), - )?; - - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let setup = hf_mount::setup::build(source, options, true); - let virtual_fs = setup.virtual_fs.clone(); - let mount_point = setup.mount_point.clone(); - let metadata_ttl_ms = setup.metadata_ttl_ms; - let read_only = setup.read_only; - let nfs_security = setup.nfs_security.clone(); - let event_mount_point = mount_point.clone(); - setup.runtime.block_on(hf_mount::nfs::mount_nfs_with_callback( - virtual_fs, - &mount_point, - metadata_ttl_ms, - read_only, - nfs_security, - None, - move |event| handle_background_mount_event(&event_mount_point, event), - )) - })); - - match result { - Ok(Ok(())) => { - append_worker_log("Background worker stopped cleanly"); - write_worker_status( - WorkerState::Stopped, - "Background mount stopped", - "The background worker exited cleanly.", - Some(&worker_mount_point), - Some(std::process::id()), - )?; - Ok(()) - } - Ok(Err(e)) => { - let message = e.to_string(); - append_worker_log(format!("NFS mount failed: {message}")); - let _ = write_worker_status( - WorkerState::Failed, - "NFS mount failed", - &message, - Some(&worker_mount_point), - Some(std::process::id()), - ); - Err(message) - } - Err(payload) => { - let message = panic_message(payload); - append_worker_log(format!("Mount setup failed: {message}")); - let _ = write_worker_status( - WorkerState::Failed, - "Mount setup failed", - &message, - Some(&worker_mount_point), - Some(std::process::id()), - ); - Err(message) - } - } -} - -fn spawn_background_worker(mount_point: &Path) -> Result { - let exe = std::env::current_exe().map_err(|e| format!("Could not locate current executable: {e}"))?; - let _ = std::fs::remove_file(worker_status_path()?); - write_worker_status( - WorkerState::Mounting, - "Background worker launching", - "Starting detached process.", - Some(mount_point), - None, - )?; - let log = worker_log_file()?; - let log_for_stderr = log - .try_clone() - .map_err(|e| format!("Failed to duplicate background log handle: {e}"))?; - let mut command = Command::new(exe); - command - .arg(BACKGROUND_WORKER_ARG) - .stdin(Stdio::null()) - .stdout(Stdio::from(log)) - .stderr(Stdio::from(log_for_stderr)); - detach_command(&mut command); - command - .spawn() - .map_err(|e| format!("Failed to launch background worker: {e}")) -} - -fn handle_background_mount_event(default_mount_point: &Path, event: NfsMountEvent) { - match event { - NfsMountEvent::ServerListening { port } => { - append_worker_log(format!("Local NFS server is listening on 127.0.0.1:{port}")); - let _ = write_worker_status( - WorkerState::Mounting, - "Local NFS server is listening", - format!("127.0.0.1:{port}"), - Some(default_mount_point), - Some(std::process::id()), - ); - } - NfsMountEvent::MountCommand { command } => { - append_worker_log(format!("Running {command}")); - } - NfsMountEvent::Mounted { mount_point } => { - append_worker_log(format!("Mounted at {mount_point}")); - let event_mount_point = Path::new(&mount_point); - let _ = write_worker_status( - WorkerState::Mounted, - "Mounted", - format!("Mounted at {mount_point}"), - Some(event_mount_point), - Some(std::process::id()), - ); - } - NfsMountEvent::ShuttingDown { reason } => { - append_worker_log(format!("Shutting down: {reason}")); - let _ = write_worker_status( - WorkerState::Stopping, - "Shutting down", - reason, - Some(default_mount_point), - Some(std::process::id()), - ); - } - } -} - -#[cfg(windows)] -fn detach_command(command: &mut Command) { - command.creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS); -} - -#[cfg(unix)] -fn detach_command(command: &mut Command) { - command.process_group(0); -} - -#[cfg(not(any(windows, unix)))] -fn detach_command(_command: &mut Command) {} - -fn read_worker_status() -> Result, String> { - let path = worker_status_path()?; - for attempt in 0..3 { - if !path.exists() { - if attempt < 2 { - std::thread::sleep(std::time::Duration::from_millis(10)); - continue; - } - return Ok(None); - } - - let bytes = std::fs::read(&path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?; - match serde_json::from_slice(&bytes) { - Ok(status) => return Ok(Some(status)), - Err(e) if attempt < 2 => { - let _ = e; - std::thread::sleep(std::time::Duration::from_millis(10)); - } - Err(e) => return Err(format!("Failed to parse {}: {e}", path.display())), - } - } - - Ok(None) -} - -fn write_worker_status( - state: WorkerState, - headline: impl Into, - detail: impl Into, - mount_point: Option<&Path>, - pid: Option, -) -> Result<(), String> { - let path = worker_status_path()?; - let status = WorkerStatus { - state, - headline: headline.into(), - detail: detail.into(), - mount_point: mount_point.map(|mount_point| mount_point.to_string_lossy().into_owned()), - pid, - updated_at_secs: current_unix_secs(), - }; - write_worker_status_record(&status, &path) -} - -fn write_worker_status_record(status: &WorkerStatus, path: &Path) -> Result<(), String> { - let json = serde_json::to_vec_pretty(&status).map_err(|e| format!("Failed to serialize worker status: {e}"))?; - write_file_replace(path, &json) -} - -fn append_worker_log(message: impl AsRef) { - let Ok(mut file) = worker_log_file() else { - return; - }; - let _ = writeln!(file, "{}", message.as_ref()); -} - -fn worker_log_file() -> Result { - let path = worker_log_path()?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create {}: {e}", parent.display()))?; - } - - let mut options = OpenOptions::new(); - options.create(true).append(true); - #[cfg(unix)] - { - options.mode(0o600).custom_flags(libc::O_NOFOLLOW); - } - let file = options - .open(&path) - .map_err(|e| format!("Failed to open {}: {e}", path.display()))?; - #[cfg(unix)] - { - let mode = file - .metadata() - .map_err(|e| format!("Failed to inspect {}: {e}", path.display()))? - .permissions() - .mode() - & 0o777; - if mode & 0o077 != 0 { - let mut perms = file - .metadata() - .map_err(|e| format!("Failed to inspect {}: {e}", path.display()))? - .permissions(); - perms.set_mode(0o600); - file.set_permissions(perms) - .map_err(|e| format!("Failed to chmod {}: {e}", path.display()))?; - } - } - Ok(file) -} - -fn profile_mount_source(profile: &MountProfile) -> Result { - let source_id = profile.source_id.trim(); - if source_id.is_empty() { - return Err("Source ID is required.".to_string()); - } - let mount_point = parse_path(&profile.mount_point, "Mount point")?; - Ok(match profile.source { - GuiSource::Repo => Source::Repo { - repo_id: source_id.to_string(), - mount_point, - revision: non_empty_or_default(&profile.revision, "main"), - }, - GuiSource::Bucket => Source::Bucket { - bucket_id: source_id.to_string(), - mount_point, - }, - }) -} - -fn profile_mount_options(profile: &MountProfile) -> Result { - Ok(MountOptions { - hf_token: current_env_hf_token(), - token_file: optional_text(&profile.token_file).map(PathBuf::from), - hub_endpoint: non_empty_or_default(&profile.hub_endpoint, "https://huggingface.co"), - cache_dir: parse_path(&profile.cache_dir, "Cache directory")?, - uid: None, - gid: None, - read_only: profile.source == GuiSource::Repo || profile.read_only, - advanced_writes: false, - poll_interval_secs: 30, - poll_listing_concurrency: 4, - cache_size: 10_000_000_000, - max_staging_size: 0, - no_disk_cache: false, - cache_mode: CacheMode::Chunk, - direct_io: false, - metadata_ttl_ms: 10_000, - metadata_ttl_minimal: false, - max_threads: 16, - flush_debounce_ms: 2_000, - flush_max_batch_window_ms: 30_000, - no_filter_os_files: false, - fuse_owner_only: false, - fuse_allow_other: false, - nfs_allow_unsafe_loopback: profile.nfs_allow_unsafe_loopback, - inode_soft_limit: 0, - lru_sweep_interval_ms: 5_000, - overlay: false, - }) -} - -fn load_mount_profile() -> Result, String> { - let path = profile_path()?; - if !path.exists() { - return Ok(None); - } - let bytes = std::fs::read(&path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?; - serde_json::from_slice(&bytes) - .map(Some) - .map_err(|e| format!("Failed to parse {}: {e}", path.display())) -} - -fn save_mount_profile(profile: &MountProfile) -> Result<(), String> { - let path = profile_path()?; - let json = serde_json::to_vec_pretty(profile).map_err(|e| format!("Failed to serialize settings: {e}"))?; - write_file_replace(&path, &json) -} - -fn write_file_replace(path: &Path, bytes: &[u8]) -> Result<(), String> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create {}: {e}", parent.display()))?; - } - - let temp_path = temp_sibling_path(path); - write_private_file(&temp_path, bytes).map_err(|e| format!("Failed to write {}: {e}", temp_path.display()))?; - - #[cfg(windows)] - if path.exists() { - std::fs::remove_file(path).map_err(|e| format!("Failed to replace {}: {e}", path.display()))?; - } - - std::fs::rename(&temp_path, path).map_err(|e| { - let _ = std::fs::remove_file(&temp_path); - format!("Failed to replace {} with {}: {e}", path.display(), temp_path.display()) - }) -} - -fn write_private_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> { - #[cfg(unix)] - { - let mut file = OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(path)?; - file.write_all(bytes) - } - #[cfg(not(unix))] - { - std::fs::write(path, bytes) - } -} - -fn temp_sibling_path(path: &Path) -> PathBuf { - let file_name = path - .file_name() - .map(|name| name.to_string_lossy()) - .unwrap_or_else(|| "hf-mount".into()); - let unique = format!(".{file_name}.{}.{}.tmp", std::process::id(), current_unix_nanos()); - path.with_file_name(unique) -} - -fn profile_path() -> Result { - Ok(app_config_dir()?.join("mount-profile.json")) -} - -fn worker_status_path() -> Result { - Ok(app_config_dir()?.join("background-status.json")) -} - -fn worker_log_path() -> Result { - Ok(app_config_dir()?.join("background.log")) -} - -fn app_config_dir() -> Result { - #[cfg(windows)] - { - let base = std::env::var_os("APPDATA") - .map(PathBuf::from) - .ok_or_else(|| "APPDATA is not set.".to_string())?; - return Ok(base.join("hf-mount")); - } - #[cfg(target_os = "macos")] - { - let home = std::env::var_os("HOME") - .map(PathBuf::from) - .ok_or_else(|| "HOME is not set.".to_string())?; - return Ok(home.join("Library").join("Application Support").join("hf-mount")); - } - #[cfg(all(not(windows), not(target_os = "macos")))] - { - if let Some(base) = std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from) { - return Ok(base.join("hf-mount")); - } - let home = std::env::var_os("HOME") - .map(PathBuf::from) - .ok_or_else(|| "HOME is not set.".to_string())?; - Ok(home.join(".config").join("hf-mount")) - } -} - -#[cfg(windows)] -fn autostart_is_enabled() -> bool { - let mut command = Command::new(windows_system32_exe("schtasks.exe")); - command.creation_flags(CREATE_NO_WINDOW); - command - .args(["/Query", "/TN", AUTOSTART_NAME]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .is_ok_and(|status| status.success()) -} - -#[cfg(not(windows))] -fn autostart_is_enabled() -> bool { - autostart_path().is_ok_and(|path| path.exists()) -} - -fn set_autostart_enabled(enabled: bool) -> Result<(), String> { - if enabled { - install_autostart() - } else { - remove_autostart() - } -} - -#[cfg(windows)] -fn install_autostart() -> Result<(), String> { - let exe = std::env::current_exe() - .map_err(|e| format!("Could not locate current executable: {e}"))? - .to_string_lossy() - .replace('"', "\\\""); - let task_run = format!("\"{exe}\" {BACKGROUND_WORKER_ARG}"); - let output = windows_schtasks() - .args([ - "/Create", - "/TN", - AUTOSTART_NAME, - "/TR", - &task_run, - "/SC", - "ONLOGON", - "/RL", - "HIGHEST", - "/F", - ]) - .output() - .map_err(|e| format!("Failed to create scheduled task: {e}"))?; - if output.status.success() { - Ok(()) - } else { - Err(command_output_error("schtasks /Create", output)) - } -} - -#[cfg(windows)] -fn remove_autostart() -> Result<(), String> { - let output = windows_schtasks() - .args(["/Delete", "/TN", AUTOSTART_NAME, "/F"]) - .output() - .map_err(|e| format!("Failed to remove scheduled task: {e}"))?; - if output.status.success() || !autostart_is_enabled() { - Ok(()) - } else { - Err(command_output_error("schtasks /Delete", output)) - } -} - -#[cfg(windows)] -fn windows_schtasks() -> Command { - let mut command = Command::new(windows_system32_exe("schtasks.exe")); - command.creation_flags(CREATE_NO_WINDOW); - command -} - -#[cfg(windows)] -fn command_output_error(label: &str, output: std::process::Output) -> String { - format!( - "{label} failed with {}: stdout={} stderr={}", - output.status, - String::from_utf8_lossy(&output.stdout).trim(), - String::from_utf8_lossy(&output.stderr).trim() - ) -} - -#[cfg(not(windows))] -fn install_autostart() -> Result<(), String> { - let path = autostart_path()?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create {}: {e}", parent.display()))?; - } - let content = autostart_file_contents()?; - std::fs::write(&path, content).map_err(|e| format!("Failed to write {}: {e}", path.display())) -} - -#[cfg(not(windows))] -fn remove_autostart() -> Result<(), String> { - let path = autostart_path()?; - if path.exists() { - std::fs::remove_file(&path).map_err(|e| format!("Failed to remove {}: {e}", path.display()))?; - } - Ok(()) -} - -#[cfg(target_os = "macos")] -fn autostart_path() -> Result { - let home = std::env::var_os("HOME") - .map(PathBuf::from) - .ok_or_else(|| "HOME is not set.".to_string())?; - Ok(home - .join("Library") - .join("LaunchAgents") - .join(format!("{AUTOSTART_LABEL}.plist"))) -} - -#[cfg(all(not(windows), not(target_os = "macos")))] -fn autostart_path() -> Result { - let base = if let Some(config_home) = std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from) { - config_home - } else { - let home = std::env::var_os("HOME") - .map(PathBuf::from) - .ok_or_else(|| "HOME is not set.".to_string())?; - home.join(".config") - }; - Ok(base.join("autostart").join("hf-mount.desktop")) -} - -#[cfg(target_os = "macos")] -fn autostart_file_contents() -> Result { - let exe = xml_escape( - &std::env::current_exe() - .map_err(|e| format!("Could not locate current executable: {e}"))? - .to_string_lossy(), - ); - let name = xml_escape(AUTOSTART_NAME); - let log_path = xml_escape(&worker_log_path()?.to_string_lossy()); - Ok(format!( - "\n\ - \n\ - \n\ - \n\ - Label\n\ - {AUTOSTART_LABEL}\n\ - ServiceDescription\n\ - {name}\n\ - ProgramArguments\n\ - \n\ - {exe}\n\ - {BACKGROUND_WORKER_ARG}\n\ - \n\ - RunAtLoad\n\ - \n\ - StandardOutPath\n\ - {log_path}\n\ - StandardErrorPath\n\ - {log_path}\n\ - \n\ - \n" - )) -} - -#[cfg(all(not(windows), not(target_os = "macos")))] -fn autostart_file_contents() -> Result { - let exe = desktop_exec_quote( - &std::env::current_exe() - .map_err(|e| format!("Could not locate current executable: {e}"))? - .to_string_lossy(), - ); - Ok(format!( - "[Desktop Entry]\n\ - Type=Application\n\ - Name={AUTOSTART_NAME}\n\ - Exec={exe} {BACKGROUND_WORKER_ARG}\n\ - Terminal=false\n\ - X-GNOME-Autostart-enabled=true\n" - )) -} - -#[cfg(target_os = "macos")] -fn xml_escape(text: &str) -> String { - text.replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") -} - -#[cfg(all(not(windows), not(target_os = "macos")))] -fn desktop_exec_quote(text: &str) -> String { - format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\"")) -} - -fn open_mount_point(mount_point: Option<&Path>) -> Result<(), String> { - let mount_point = mount_point.ok_or_else(|| "No active mount point is recorded.".to_string())?; - let target = open_target(mount_point)?; - - let mut command = open_command(&target); - command - .spawn() - .map_err(|e| format!("Failed to open mount point: {e}"))?; - Ok(()) -} - -fn open_target(mount_point: &Path) -> Result { - let text = mount_point - .to_str() - .ok_or_else(|| "Mount point is not valid UTF-8".to_string())?; - #[cfg(windows)] - { - if let Some(drive) = windows_drive_letter(text) { - return Ok(format!("{drive}:\\")); - } - } - Ok(text.to_string()) -} - -#[cfg(windows)] -fn open_command(target: &str) -> Command { - let mut command = Command::new("explorer.exe"); - command.creation_flags(CREATE_NO_WINDOW); - command.arg(target); - command -} - -#[cfg(target_os = "macos")] -fn open_command(target: &str) -> Command { - let mut command = Command::new("/usr/bin/open"); - command.arg(target); - command -} - -#[cfg(all(not(windows), not(target_os = "macos")))] -fn open_command(target: &str) -> Command { - let mut command = Command::new("xdg-open"); - command.arg(target); - command -} - -fn parse_path(text: &str, label: &str) -> Result { - let trimmed = text.trim(); - if trimmed.is_empty() { - return Err(format!("{label} is required.")); - } - Ok(PathBuf::from(trimmed)) -} - -fn optional_text(text: &str) -> Option { - let trimmed = text.trim(); - (!trimmed.is_empty()).then(|| trimmed.to_string()) -} - -fn current_env_hf_token() -> Option { - std::env::var("HF_TOKEN").ok().and_then(|token| optional_text(&token)) -} - -fn non_empty_or_default(text: &str, default: &str) -> String { - let trimmed = text.trim(); - if trimmed.is_empty() { - default.to_string() - } else { - trimmed.to_string() - } -} - -fn worker_state_is_active(state: &WorkerState) -> bool { - matches!( - state, - WorkerState::Mounting | WorkerState::Mounted | WorkerState::Stopping - ) -} - -fn worker_status_is_live(status: &WorkerStatus, mount_point: Option<&Path>) -> bool { - if status.pid.is_some_and(process_is_running) { - return true; - } - - if status.state == WorkerState::Mounted && mount_point.is_some_and(mount_point_appears_active) { - return true; - } - - status.updated_at_secs != 0 - && current_unix_secs().saturating_sub(status.updated_at_secs) <= WORKER_STATUS_STALE_AFTER_SECS -} - -fn mount_point_appears_active(mount_point: &Path) -> bool { - #[cfg(windows)] - if let Some(text) = mount_point.to_str() - && let Some(drive) = windows_drive_letter(text) - { - return Path::new(&format!("{drive}:\\")).exists(); - } - - mount_point.exists() -} - -#[cfg(windows)] -fn process_is_running(pid: u32) -> bool { - if pid == 0 { - return false; - } - - let filter = format!("PID eq {pid}"); - let mut command = Command::new(windows_system32_exe("tasklist.exe")); - command.creation_flags(CREATE_NO_WINDOW); - let output = command.args(["/FI", &filter, "/FO", "CSV", "/NH"]).output(); - let Ok(output) = output else { - return false; - }; - if !output.status.success() { - return false; - } - - let stdout = String::from_utf8_lossy(&output.stdout); - stdout.contains(&format!(",\"{pid}\",")) -} - -#[cfg(unix)] -fn process_is_running(pid: u32) -> bool { - if pid == 0 { - return false; - } - - let result = unsafe { libc::kill(pid as i32, 0) }; - result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) -} - -#[cfg(not(any(windows, unix)))] -fn process_is_running(_pid: u32) -> bool { - false -} - -fn current_unix_secs() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or_default() -} - -fn current_unix_nanos() -> u128 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_nanos()) - .unwrap_or_default() -} - -fn panic_message(payload: Box) -> String { - if let Some(message) = payload.downcast_ref::<&str>() { - (*message).to_string() - } else if let Some(message) = payload.downcast_ref::() { - message.clone() - } else { - "unknown panic".to_string() - } -} - -fn platform_label() -> &'static str { - #[cfg(windows)] - { - "Windows" - } - #[cfg(target_os = "macos")] - { - "macOS" - } - #[cfg(all(not(windows), not(target_os = "macos")))] - { - "Unix" - } -} - -fn default_mount_point() -> String { - #[cfg(windows)] - { - "Z:".to_string() - } - #[cfg(not(windows))] - { - std::env::temp_dir().join("hf-mount").to_string_lossy().into_owned() - } -} - -fn default_mount_hint() -> &'static str { - #[cfg(windows)] - { - "Z:" - } - #[cfg(not(windows))] - { - "/tmp/hf-mount" - } -} - -fn mount_point_hint() -> &'static str { - #[cfg(windows)] - { - "Use an unused drive letter. Directory targets are less reliable." - } - #[cfg(target_os = "macos")] - { - "Use an absolute folder path." - } - #[cfg(all(not(windows), not(target_os = "macos")))] - { - "Use an absolute folder path." - } -} - -#[cfg(windows)] -fn restart_as_administrator() -> Result<(), String> { - let exe = std::env::current_exe().map_err(|e| format!("Could not locate current executable: {e}"))?; - let powershell = std::env::var_os("SystemRoot") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(r"C:\Windows")) - .join("System32") - .join("WindowsPowerShell") - .join("v1.0") - .join("powershell.exe"); - - let status = Command::new(powershell) - .creation_flags(CREATE_NO_WINDOW) - .args([ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-Command", - "Start-Process -FilePath $args[0] -Verb RunAs", - ]) - .arg(exe) - .status() - .map_err(|e| format!("Failed to launch the UAC prompt: {e}"))?; - - if status.success() { - Ok(()) - } else { - Err(format!("PowerShell exited with {status}")) - } -} - -#[cfg(windows)] -fn windows_system32_exe(name: &str) -> PathBuf { - std::env::var_os("SystemRoot") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(r"C:\Windows")) - .join("System32") - .join(name) -} - -#[cfg(windows)] -fn windows_drive_letter(path: &str) -> Option { - let mut chars = path.chars(); - let drive = chars.next()?; - if !drive.is_ascii_alphabetic() || chars.next() != Some(':') { - return None; - } - match (chars.next(), chars.next()) { - (None, None) => Some(drive), - (Some('\\' | '/'), None) => Some(drive), - _ => None, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn sample_profile() -> MountProfile { - MountProfile { - source: GuiSource::Repo, - source_id: "openai-community/gpt2".to_string(), - revision: "main".to_string(), - mount_point: "/tmp/hf-mount".to_string(), - token_file: "/tmp/hf-token".to_string(), - hub_endpoint: "https://huggingface.co".to_string(), - cache_dir: "/tmp/hf-cache".to_string(), - read_only: true, - run_in_background: true, - nfs_allow_unsafe_loopback: false, - } - } - - #[test] - fn mount_profile_serialization_excludes_inline_token() { - let json = serde_json::to_string(&sample_profile()).unwrap(); - assert!(!json.contains("hf_token")); - assert!(json.contains("token_file")); - } - - #[test] - fn old_profile_token_field_is_ignored_on_load() { - let json = r#"{ - "source":"Repo", - "source_id":"openai-community/gpt2", - "revision":"main", - "mount_point":"/tmp/hf-mount", - "hf_token":"hf_secret", - "hub_endpoint":"https://huggingface.co", - "cache_dir":"/tmp/hf-cache", - "read_only":true, - "run_in_background":false - }"#; - let profile: MountProfile = serde_json::from_str(json).unwrap(); - let rewritten = serde_json::to_string(&profile).unwrap(); - assert!(!rewritten.contains("hf_secret")); - assert!(!rewritten.contains("hf_token")); - } -} diff --git a/src/bin/hf-mount-gui/activity_tab.rs b/src/bin/hf-mount-gui/activity_tab.rs new file mode 100644 index 0000000..43d2138 --- /dev/null +++ b/src/bin/hf-mount-gui/activity_tab.rs @@ -0,0 +1,64 @@ +//! Activity tab: the session log, plus background-worker log location. + +use eframe::egui::{self, RichText}; + +use crate::app::{MountGuiApp, push_log}; +use crate::theme::*; +use crate::widgets::secondary_button; +use crate::worker::worker_log_path; + +impl MountGuiApp { + pub fn draw_activity_tab(&mut self, ui: &mut egui::Ui) { + let status = self.current_status(); + + ui.horizontal(|ui| { + ui.label(RichText::new("Session log").size(14.0).strong().color(text_primary())); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if secondary_button(ui, "Copy log", !status.log.is_empty(), 90.0).clicked() { + let text = status.log.iter().cloned().collect::>().join("\n"); + ui.ctx().copy_text(text); + push_log(&self.status, "Copied session log"); + } + }); + }); + ui.add_space(6.0); + + let footer_height = 30.0; + let log_height = (ui.available_height() - footer_height).max(80.0); + egui::Frame::none() + .fill(panel_bg()) + .stroke(egui::Stroke::new(1.0, border())) + .rounding(8.0) + .inner_margin(egui::Margin::symmetric(10.0, 8.0)) + .show(ui, |ui| { + ui.set_height(log_height); + egui::ScrollArea::vertical() + .id_salt("activity-log") + .stick_to_bottom(true) + .auto_shrink([false, false]) + .show(ui, |ui| { + for line in &status.log { + ui.label(RichText::new(line).monospace().size(11.5).color(text_secondary())); + } + }); + }); + + ui.add_space(6.0); + if let Ok(path) = worker_log_path() { + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("Background worker log: {}", path.display())) + .size(11.0) + .color(muted_text()), + ); + if ui + .add(egui::Button::new(RichText::new("Copy path").size(11.0).color(text_secondary())).frame(false)) + .clicked() + { + ui.ctx().copy_text(path.display().to_string()); + push_log(&self.status, "Copied worker log path"); + } + }); + } + } +} diff --git a/src/bin/hf-mount-gui/app.rs b/src/bin/hf-mount-gui/app.rs new file mode 100644 index 0000000..068f6a7 --- /dev/null +++ b/src/bin/hf-mount-gui/app.rs @@ -0,0 +1,1037 @@ +//! Application state and frame layout: header with tabs, central tab body, +//! bottom status bar. Mount control (start/stop) and background-worker +//! synchronization live here; the tab bodies are in `*_tab.rs`. + +use std::collections::VecDeque; +use std::path::PathBuf; +use std::process::Child; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use eframe::egui::{self, RichText}; +use hf_mount::nfs::{MountShutdown, NfsMountEvent}; +use hf_mount::setup::{MountOptions, Source, default_cache_dir}; + +use crate::platform; +use crate::preflight::{CheckItem, CheckLevel, run_preflight_checks, summarize_checks}; +use crate::profile::{ + GuiSource, MountProfile, RecentSource, load_mount_profile, profile_mount_options, profile_mount_source, + save_mount_profile, source_id_problem, +}; +use crate::theme::*; +use crate::util::{current_env_hf_token, format_elapsed, optional_text, panic_message}; +use crate::widgets::{status_chip, tab_button}; +use crate::worker::{WorkerPoller, WorkerSnapshot, WorkerStatus, spawn_background_worker}; + +const MAX_LOG_LINES: usize = 200; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Tab { + Mount, + Activity, + Setup, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum MountState { + Ready, + Mounting, + Mounted, + Stopping, + Stopped, + Failed, +} + +#[derive(Clone, Debug)] +pub struct SharedStatus { + pub state: MountState, + pub headline: String, + pub detail: String, + pub log: VecDeque, +} + +impl Default for SharedStatus { + fn default() -> Self { + let mut log = VecDeque::new(); + log.push_back("Ready".to_string()); + Self { + state: MountState::Ready, + headline: "Ready".to_string(), + detail: "Configure a source and start the mount.".to_string(), + log, + } + } +} + +pub type SharedMountStatus = Arc>; + +pub fn set_status( + status: &SharedMountStatus, + state: MountState, + headline: impl Into, + detail: impl Into, +) { + let headline = headline.into(); + let detail = detail.into(); + let mut status = status.lock().expect("status mutex poisoned"); + status.state = state; + status.headline = headline.clone(); + status.detail = detail.clone(); + push_log_locked(&mut status, format!("{headline}: {detail}")); +} + +pub fn set_status_if_changed( + status: &SharedMountStatus, + state: MountState, + headline: impl Into, + detail: impl Into, +) { + let headline = headline.into(); + let detail = detail.into(); + let mut status = status.lock().expect("status mutex poisoned"); + if status.state == state && status.headline == headline && status.detail == detail { + return; + } + status.state = state; + status.headline = headline.clone(); + status.detail = detail.clone(); + push_log_locked(&mut status, format!("{headline}: {detail}")); +} + +pub fn push_log(status: &SharedMountStatus, message: impl Into) { + let mut status = status.lock().expect("status mutex poisoned"); + push_log_locked(&mut status, message.into()); +} + +fn push_log_locked(status: &mut SharedStatus, message: String) { + status.log.push_back(message); + while status.log.len() > MAX_LOG_LINES { + status.log.pop_front(); + } +} + +pub struct MountGuiApp { + // Form state. + pub source: GuiSource, + pub source_id: String, + pub revision: String, + pub mount_point: String, + pub hf_token: String, + pub show_token: bool, + pub token_file: String, + pub hub_endpoint: String, + pub cache_dir: String, + pub read_only: bool, + pub run_in_background: bool, + pub nfs_allow_unsafe_loopback: bool, + pub autostart_enabled: bool, + pub show_advanced: bool, + pub recent_sources: Vec, + + // UI state. + pub tab: Tab, + pub checks: Vec, + + // Mount state. + pub status: SharedMountStatus, + mount_thread: Option>, + mount_shutdown: Option, + stop_thread: Option>, + background_child: Option, + pub active_background: bool, + pub active_mount_point: Option, + mounted_since: Option, + worker_poller: Option, + last_worker_generation: u64, + /// Last worker status applied from the poller, used to tell genuinely new + /// worker reports from a stale file being re-read every poll. + last_worker_status: Option, + /// Set when the user intentionally terminated the background worker, so + /// the reaper doesn't report the kill as a failure. + background_stop_requested: bool, +} + +impl MountGuiApp { + pub fn new(cc: &eframe::CreationContext<'_>) -> Self { + let mount_point = platform::default_mount_point(); + let checks = run_preflight_checks(&mount_point); + let mut app = Self { + source: GuiSource::Repo, + source_id: "openai-community/gpt2".to_string(), + revision: "main".to_string(), + mount_point, + hf_token: std::env::var("HF_TOKEN").unwrap_or_default(), + show_token: false, + token_file: String::new(), + hub_endpoint: "https://huggingface.co".to_string(), + cache_dir: default_cache_dir().to_string_lossy().into_owned(), + read_only: true, + run_in_background: false, + nfs_allow_unsafe_loopback: false, + autostart_enabled: crate::autostart::autostart_is_enabled(), + show_advanced: false, + recent_sources: Vec::new(), + tab: Tab::Mount, + checks, + status: Arc::new(Mutex::new(SharedStatus::default())), + mount_thread: None, + mount_shutdown: None, + stop_thread: None, + background_child: None, + active_background: false, + active_mount_point: None, + mounted_since: None, + worker_poller: None, + last_worker_generation: 0, + last_worker_status: None, + background_stop_requested: false, + }; + + match load_mount_profile() { + Ok(Some(profile)) => { + app.apply_profile(profile); + app.checks = run_preflight_checks(&app.mount_point); + } + Ok(None) => {} + Err(e) => push_log(&app.status, format!("Could not load saved settings: {e}")), + } + + // Reconcile any existing background worker synchronously before the + // first frame, so opening a second GUI while a mount is already running + // gates Start immediately instead of racing the async poller and + // spawning a duplicate worker on the same status file. + app.reconcile_existing_worker(); + + // One always-on poller watches the background worker status file from + // its own thread; the UI thread only ever reads its snapshot. + let repaint_ctx = cc.egui_ctx.clone(); + app.worker_poller = Some(WorkerPoller::start(move || repaint_ctx.request_repaint())); + app + } + + fn apply_profile(&mut self, profile: MountProfile) { + self.source = profile.source; + self.source_id = profile.source_id; + self.revision = profile.revision; + self.mount_point = profile.mount_point; + self.token_file = profile.token_file; + self.hub_endpoint = profile.hub_endpoint; + self.cache_dir = profile.cache_dir; + self.read_only = profile.read_only || self.source == GuiSource::Repo; + self.run_in_background = profile.run_in_background; + self.nfs_allow_unsafe_loopback = profile.nfs_allow_unsafe_loopback; + self.recent_sources = profile.recent_sources; + } + + fn profile(&self) -> MountProfile { + MountProfile { + source: self.source, + source_id: self.source_id.clone(), + revision: self.revision.clone(), + mount_point: self.mount_point.clone(), + token_file: self.token_file.clone(), + hub_endpoint: self.hub_endpoint.clone(), + cache_dir: self.cache_dir.clone(), + read_only: self.source == GuiSource::Repo || self.read_only, + run_in_background: self.run_in_background, + nfs_allow_unsafe_loopback: self.nfs_allow_unsafe_loopback, + recent_sources: self.recent_sources.clone(), + } + } + + fn save_profile(&self) -> Result<(), String> { + save_mount_profile(&self.profile()) + } + + pub fn apply_recent_source(&mut self, recent: &RecentSource) { + self.source = recent.source; + self.source_id = recent.source_id.clone(); + if recent.source == GuiSource::Repo { + self.revision = if recent.revision.is_empty() { + "main".to_string() + } else { + recent.revision.clone() + }; + self.read_only = true; + } + } + + pub fn apply_autostart_setting(&mut self) { + let requested = self.autostart_enabled; + if requested && let Err(e) = self.save_profile() { + self.autostart_enabled = false; + set_status(&self.status, MountState::Failed, "Could not save settings", e); + return; + } + + match crate::autostart::set_autostart_enabled(requested) { + Ok(()) => { + push_log( + &self.status, + if requested { + "Autostart enabled: the saved mount starts at login" + } else { + "Autostart disabled: the login startup entry was removed" + }, + ); + } + Err(e) => { + self.autostart_enabled = !requested; + set_status(&self.status, MountState::Failed, "Could not update autostart", e); + } + } + } + + pub fn refresh_checks(&mut self) { + self.checks = run_preflight_checks(&self.mount_point); + push_log(&self.status, summarize_checks(&self.checks)); + } + + pub fn current_status(&self) -> SharedStatus { + self.status.lock().expect("status mutex poisoned").clone() + } + + pub fn is_mount_running(&self) -> bool { + self.active_background + || self.background_child.is_some() + || self.mount_thread.as_ref().is_some_and(|handle| !handle.is_finished()) + } + + pub fn is_stopping(&self) -> bool { + self.stop_thread.is_some() || self.mount_shutdown.as_ref().is_some_and(MountShutdown::is_requested) + } + + pub fn first_blocking_check(&self) -> Option<&CheckItem> { + self.checks + .iter() + .find(|check| check.label == "Client for NFS" && check.level == CheckLevel::Fail) + .or_else(|| self.checks.iter().find(|check| check.level == CheckLevel::Fail)) + } + + pub fn source_problem(&self) -> Option<&'static str> { + source_id_problem(self.source, &self.source_id) + } + + // ── Mount control ───────────────────────────────────────────────── + + pub fn start_mount(&mut self) { + let checks = run_preflight_checks(&self.mount_point); + let failure = checks.iter().find(|check| check.level == CheckLevel::Fail).cloned(); + self.checks = checks; + if let Some(failure) = failure { + set_status( + &self.status, + MountState::Failed, + format!("Setup check failed: {}", failure.label), + failure.detail, + ); + return; + } + + push_log(&self.status, summarize_checks(&self.checks)); + let profile = self.profile(); + let source = match profile_mount_source(&profile) { + Ok(source) => source, + Err(e) => { + set_status(&self.status, MountState::Failed, "Invalid source", e); + return; + } + }; + let options = match self.mount_options(&profile) { + Ok(options) => options, + Err(e) => { + set_status(&self.status, MountState::Failed, "Invalid mount options", e); + return; + } + }; + let mount_point = source.mount_point().to_path_buf(); + let mount_label = mount_point.display().to_string(); + + let inline_token = optional_text(&self.hf_token); + if self.run_in_background + && optional_text(&self.token_file).is_none() + && inline_token.is_some() + && inline_token != current_env_hf_token() + { + set_status( + &self.status, + MountState::Failed, + "Token not available to background worker", + "Set HF_TOKEN before launching the GUI or provide a token file.", + ); + return; + } + + self.remember_recent_source(); + if let Err(e) = self.save_profile() { + set_status(&self.status, MountState::Failed, "Could not save settings", e); + return; + } + + set_status( + &self.status, + MountState::Mounting, + "Preparing mount", + format!("Target: {mount_label}"), + ); + + if self.run_in_background { + self.background_stop_requested = false; + match spawn_background_worker(&mount_point) { + Ok(child) => { + self.background_child = Some(child); + self.active_background = true; + self.active_mount_point = Some(mount_point); + set_status( + &self.status, + MountState::Mounting, + "Background mount starting", + format!("Target: {mount_label}"), + ); + } + Err(e) => { + set_status(&self.status, MountState::Failed, "Could not start background mount", e); + } + } + return; + } + + self.active_background = false; + self.active_mount_point = Some(mount_point); + let shutdown = MountShutdown::new(); + self.mount_shutdown = Some(shutdown.clone()); + let shared_status = self.status.clone(); + self.mount_thread = Some(thread::spawn(move || { + run_mount(source, options, shared_status, shutdown) + })); + } + + fn remember_recent_source(&mut self) { + let entry = RecentSource { + source: self.source, + source_id: self.source_id.trim().to_string(), + revision: self.revision.trim().to_string(), + }; + let mut profile = self.profile(); + profile.remember_recent(entry); + self.recent_sources = profile.recent_sources; + } + + fn mount_options(&self, profile: &MountProfile) -> Result { + let mut options = profile_mount_options(profile)?; + if let Some(token) = optional_text(&self.hf_token) { + options.hf_token = Some(token); + } + Ok(options) + } + + pub fn stop_mount(&mut self) { + // Foreground mounts stop through the cooperative shutdown handle: the + // backend unmounts itself and the thread winds down. Works during + // `Mounting` too, unlike an external unmount command. + if self.mount_thread.as_ref().is_some_and(|handle| !handle.is_finished()) + && let Some(shutdown) = &self.mount_shutdown + { + shutdown.request(); + set_status( + &self.status, + MountState::Stopping, + "Stop requested", + "Waiting for the mount to shut down.", + ); + return; + } + + // A background worker that has not mounted yet cannot be stopped by + // unmounting — there is nothing mounted, and the worker would carry + // on and mount anyway. Terminate the worker process instead. + if self.active_background && !self.worker_reported_mounted() { + self.stop_unmounted_background_worker(); + return; + } + + // Mounted targets are stopped by unmounting; the worker notices the + // mount disappearing and exits. The unmount command can block on a + // wedged NFS mount, so it runs on its own thread. + let Some(mount_point) = self.active_mount_point.clone() else { + set_status( + &self.status, + MountState::Failed, + "No active mount", + "There is no recorded mount point to unmount.", + ); + return; + }; + + if self.stop_thread.is_some() { + return; // A stop is already in flight. + } + + set_status( + &self.status, + MountState::Stopping, + "Unmount requested", + format!("Target: {}", mount_point.display()), + ); + + let status = self.status.clone(); + self.stop_thread = Some(thread::spawn(move || { + if let Err(e) = platform::unmount_path(&mount_point) { + set_status(&status, MountState::Failed, "Unmount failed", e); + } + })); + } + + fn worker_reported_mounted(&self) -> bool { + self.last_worker_status + .as_ref() + .is_some_and(|status| status.state == crate::worker::WorkerState::Mounted) + } + + /// One-shot synchronous reconcile of an already-running background worker + /// at startup, before the async poller's first snapshot. Seeds the tracking + /// state so the first frame correctly reflects (and gates Start on) a live + /// worker. Blocking — only called once during construction. + fn reconcile_existing_worker(&mut self) { + let Ok(Some(status)) = crate::worker::read_worker_status() else { + return; + }; + if !status.state.is_active() { + return; + } + // Non-blocking heuristic only. The full liveness check stats the mount + // (and probes the process table), either of which can wedge on a dead + // NFS mount — that must never run before the first frame or the window + // never opens. Optimistically adopt a fresh-heartbeat worker so Start is + // gated immediately; the poller re-confirms on its own thread within one + // interval and clears this if the worker is actually gone. + if crate::worker::worker_status_heartbeat_fresh(&status) { + self.active_background = true; + self.active_mount_point = worker_mount_point(&status); + set_status_if_changed( + &self.status, + worker_mount_state(&status), + status.headline.clone(), + status.detail.clone(), + ); + self.last_worker_status = Some(status); + push_log(&self.status, "Reconnected to existing background worker"); + } + } + + fn stop_unmounted_background_worker(&mut self) { + // A pid from our own spawned child is trustworthy. One read back from + // the status file may have been recycled by another process — verify + // it still identifies as our worker before signaling anything. + let child_pid = self.background_child.as_ref().map(Child::id).filter(|pid| *pid != 0); + let status_pid = self + .last_worker_status + .as_ref() + .and_then(|status| status.pid) + .filter(|pid| *pid != 0); + + let pid = match (child_pid, status_pid) { + (Some(pid), _) => pid, + (None, Some(pid)) if crate::worker::worker_process_matches(pid) => pid, + (None, Some(_)) => { + // The recorded pid no longer belongs to a worker: it is dead. + // Clear the stale record instead of killing a stranger. + crate::worker::mark_worker_stopped(self.active_mount_point.as_deref()); + self.active_background = false; + self.active_mount_point = None; + set_status( + &self.status, + MountState::Stopped, + "Background mount stopped", + "No live worker process was found; cleared the stale record.", + ); + return; + } + (None, None) => { + set_status( + &self.status, + MountState::Failed, + "Could not stop background worker", + "The worker process id is not known yet; try again in a moment.", + ); + return; + } + }; + + if let Err(e) = platform::terminate_process(pid) { + set_status(&self.status, MountState::Failed, "Could not stop background worker", e); + return; + } + + self.background_stop_requested = true; + crate::worker::mark_worker_stopped(self.active_mount_point.as_deref()); + + // The worker may have mounted in the window between the last status + // poll and the kill — clean up any mount it managed to create. Track + // the cleanup in `stop_thread` (not detached) so window close waits for + // it (bounded) rather than killing it mid-unmount and orphaning a mount. + if let Some(mount_point) = self.active_mount_point.clone() + && self.stop_thread.is_none() + { + let status = self.status.clone(); + self.stop_thread = Some(thread::spawn(move || { + // Only clean up a mount we actually own. The worker may have + // mounted in the race window before the kill, but the configured + // path could equally be an unrelated pre-existing mount that we + // must not detach — confirm it is our loopback NFS export first. + if platform::mount_point_is_ours(&mount_point) + && let Err(e) = platform::unmount_path(&mount_point) + { + push_log(&status, format!("Post-stop cleanup unmount failed: {e}")); + } + })); + } + + self.active_background = false; + self.active_mount_point = None; + set_status( + &self.status, + MountState::Stopped, + "Background mount stopped", + "The worker was stopped before the mount completed.", + ); + } + + pub fn open_active_mount(&mut self) { + match platform::open_mount_point(self.active_mount_point.as_deref()) { + Ok(()) => push_log(&self.status, "Opened mount point"), + Err(e) => set_status(&self.status, MountState::Failed, "Could not open mount point", e), + } + } + + // ── Per-frame housekeeping ──────────────────────────────────────── + + fn collect_finished(&mut self) { + self.consume_worker_snapshot(); + self.collect_background_child(); + self.collect_mount_thread(); + self.collect_stop_thread(); + self.track_mounted_since(); + } + + fn consume_worker_snapshot(&mut self) { + let Some(snapshot) = self.worker_poller.as_ref().map(WorkerPoller::snapshot) else { + return; + }; + if snapshot.generation == 0 || snapshot.generation == self.last_worker_generation { + return; + } + let first = self.last_worker_generation == 0; + self.last_worker_generation = snapshot.generation; + self.apply_worker_snapshot(snapshot, first); + } + + fn apply_worker_snapshot(&mut self, snapshot: WorkerSnapshot, first: bool) { + if let Some(error) = &snapshot.error { + push_log(&self.status, format!("Could not read background status: {error}")); + return; + } + + let Some(status) = snapshot.status else { + self.last_worker_status = None; + if self.active_background { + self.active_background = false; + self.active_mount_point = None; + set_status( + &self.status, + MountState::Failed, + "Background worker unavailable", + "No background status file was found.", + ); + } + return; + }; + + // The poller re-reads the file every interval; only treat the report + // as news when its content actually changed since the last apply. + let status_changed = self.last_worker_status.as_ref() != Some(&status); + self.last_worker_status = Some(status.clone()); + + let active_state = status.state.is_active(); + if active_state && snapshot.live { + let newly_connected = !self.active_background; + self.active_background = true; + if let Some(mount_point) = worker_mount_point(&status) { + self.active_mount_point = Some(mount_point); + } + if newly_connected && first { + push_log(&self.status, "Reconnected to background worker"); + } + } else { + if active_state && (self.active_background || first) { + set_status( + &self.status, + MountState::Failed, + "Background worker unavailable", + "Saved background state is stale; start the mount again.", + ); + } + if self.active_background { + self.active_background = false; + self.background_child = None; + if self.mount_thread.is_none() { + self.active_mount_point = None; + } + } + } + + // Mirror the worker's reported status. Never clobber a live + // foreground mount, and only mirror terminal states when the report + // is new (or at startup) — otherwise a stale Stopped/Failed file + // would keep overwriting newer local status every poll interval. + let foreground_active = self.mount_thread.as_ref().is_some_and(|handle| !handle.is_finished()); + let terminal_report = !active_state && (status_changed || first); + if !foreground_active && (self.active_background || terminal_report) { + set_status_if_changed( + &self.status, + worker_mount_state(&status), + status.headline, + status.detail, + ); + } + } + + fn collect_background_child(&mut self) { + let background_result = self.background_child.as_mut().map(Child::try_wait).transpose(); + match background_result { + Ok(Some(Some(exit_status))) => { + self.background_child = None; + self.active_background = false; + self.active_mount_point = None; + // The worker's own status file usually carries a more specific + // message; only fall back to the exit code when it didn't. + let current = self.current_status().state; + if exit_status.success() { + if !matches!(current, MountState::Stopped | MountState::Failed) { + set_status( + &self.status, + MountState::Stopped, + "Background mount stopped", + "The background worker exited cleanly.", + ); + } + } else if self.background_stop_requested { + // The user terminated the worker; the kill is not a failure. + } else if current != MountState::Failed { + set_status( + &self.status, + MountState::Failed, + "Background mount exited", + format!("Worker exited with {exit_status}."), + ); + } + self.background_stop_requested = false; + } + Ok(Some(None)) | Ok(None) => {} + Err(e) => { + self.background_child = None; + self.active_background = false; + set_status( + &self.status, + MountState::Failed, + "Could not inspect background mount", + e.to_string(), + ); + } + } + } + + fn collect_mount_thread(&mut self) { + let finished = self.mount_thread.as_ref().is_some_and(JoinHandle::is_finished); + if !finished { + return; + } + + if let Some(handle) = self.mount_thread.take() + && handle.join().is_err() + { + set_status( + &self.status, + MountState::Failed, + "Mount thread panicked", + "The backend thread exited unexpectedly.", + ); + } + self.mount_shutdown = None; + if !self.active_background { + self.active_mount_point = None; + } + + let current = self.current_status().state; + if !matches!(current, MountState::Failed | MountState::Stopped) { + set_status( + &self.status, + MountState::Stopped, + "Unmounted", + "The mount process has stopped.", + ); + } + } + + fn collect_stop_thread(&mut self) { + if self.stop_thread.as_ref().is_some_and(JoinHandle::is_finished) + && let Some(handle) = self.stop_thread.take() + { + let _ = handle.join(); + } + } + + fn track_mounted_since(&mut self) { + let mounted = self.current_status().state == MountState::Mounted; + match (mounted, self.mounted_since) { + (true, None) => self.mounted_since = Some(Instant::now()), + (false, Some(_)) => self.mounted_since = None, + _ => {} + } + } + + // ── Frame layout ────────────────────────────────────────────────── + + fn draw_header(&mut self, ui: &mut egui::Ui) { + ui.add_space(12.0); + ui.horizontal(|ui| { + ui.add_space(16.0); + ui.label(RichText::new("hf-mount").size(16.0).strong().color(text_primary())); + ui.label( + RichText::new(format!("v{}", env!("CARGO_PKG_VERSION"))) + .size(11.0) + .color(muted_text()), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(16.0); + let status = self.current_status(); + status_chip(ui, &status.state); + }); + }); + ui.add_space(10.0); + ui.horizontal(|ui| { + ui.add_space(16.0); + ui.spacing_mut().item_spacing.x = 20.0; + for (tab, label) in [ + (Tab::Mount, "Mount"), + (Tab::Activity, "Activity"), + (Tab::Setup, "Setup"), + ] { + if tab_button(ui, label, self.tab == tab) { + self.tab = tab; + } + } + }); + ui.add_space(8.0); + let rect = ui.max_rect(); + ui.painter() + .hline(rect.x_range(), rect.bottom(), egui::Stroke::new(1.0, border())); + } + + fn draw_status_bar(&mut self, ui: &mut egui::Ui) { + let status = self.current_status(); + let rect = ui.max_rect(); + ui.painter() + .hline(rect.x_range(), rect.top(), egui::Stroke::new(1.0, border())); + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.add_space(16.0); + status_chip(ui, &status.state); + ui.label( + RichText::new(&status.headline) + .size(12.0) + .strong() + .color(text_primary()), + ); + ui.add(egui::Label::new(RichText::new(&status.detail).size(12.0).color(text_secondary())).truncate()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(16.0); + ui.label( + RichText::new(format!("{} · NFS", platform::platform_label())) + .size(11.0) + .color(muted_text()), + ); + if let Some(since) = self.mounted_since { + ui.label( + RichText::new(format_elapsed(since.elapsed().as_secs())) + .size(11.0) + .color(text_secondary()), + ); + } + }); + }); + ui.add_space(8.0); + } +} + +impl eframe::App for MountGuiApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.collect_finished(); + // Steady repaint for elapsed time and thread collection; worker + // updates additionally wake the UI through the poller's callback. + ctx.request_repaint_after(Duration::from_millis(1000)); + + egui::TopBottomPanel::top("header") + .frame(egui::Frame::none().fill(header_bg())) + .show_separator_line(false) + .show(ctx, |ui| self.draw_header(ui)); + + egui::TopBottomPanel::bottom("status-bar") + .frame(egui::Frame::none().fill(header_bg())) + .show_separator_line(false) + .show(ctx, |ui| self.draw_status_bar(ui)); + + egui::CentralPanel::default() + .frame( + egui::Frame::none() + .fill(app_bg()) + .inner_margin(egui::Margin::symmetric(20.0, 16.0)), + ) + .show(ctx, |ui| match self.tab { + Tab::Mount => self.draw_mount_tab(ui), + Tab::Activity => self.draw_activity_tab(ui), + Tab::Setup => self.draw_setup_tab(ui), + }); + } + + fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { + let _ = self.save_profile(); + + // If the user just pressed Stop, `stop_mount` offloaded the unmount (or + // post-termination cleanup) to a worker thread; let it finish (bounded) + // so closing the window doesn't abort it and leave a mount behind. Only + // reap if it actually finished — a wedged unmount must not hang close. + if let Some(handle) = self.stop_thread.take() { + let deadline = Instant::now() + Duration::from_secs(8); + while !handle.is_finished() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(50)); + } + if handle.is_finished() { + let _ = handle.join(); + } + } + + // Background mounts survive the window by design. + if self.active_background { + return; + } + if let Some(shutdown) = &self.mount_shutdown { + shutdown.request(); + } + if let Some(handle) = &self.mount_thread { + let deadline = Instant::now() + Duration::from_secs(8); + while !handle.is_finished() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(50)); + } + if !handle.is_finished() + && let Some(mount_point) = self.active_mount_point.clone() + { + // The backend did not wind down in time — force the unmount so + // no dead mount point is left behind. Run it on a detached thread + // with a bounded wait: `unmount_path` can block on a wedged NFS + // mount, and window close must stay bounded. If it doesn't finish + // in time we drop the handle and let the exiting process reap it. + let unmount = thread::spawn(move || { + let _ = platform::unmount_path(&mount_point); + }); + let deadline = Instant::now() + Duration::from_secs(5); + while !unmount.is_finished() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(50)); + } + } + } + } +} + +fn worker_mount_point(status: &WorkerStatus) -> Option { + status + .mount_point + .as_deref() + .map(str::trim) + .filter(|mount_point| !mount_point.is_empty()) + .map(PathBuf::from) +} + +fn worker_mount_state(status: &WorkerStatus) -> MountState { + match status.state { + crate::worker::WorkerState::Mounting => MountState::Mounting, + crate::worker::WorkerState::Mounted => MountState::Mounted, + crate::worker::WorkerState::Stopping => MountState::Stopping, + crate::worker::WorkerState::Stopped => MountState::Stopped, + crate::worker::WorkerState::Failed => MountState::Failed, + } +} + +/// Foreground mount body: build the VFS, run the NFS backend, surface every +/// state change through `shared_status`. Runs on a dedicated thread. +fn run_mount(source: Source, options: MountOptions, shared_status: SharedMountStatus, shutdown: MountShutdown) { + let status_for_events = shared_status.clone(); + let shutdown_probe = shutdown.clone(); + // catch_unwind is a last resort for panics deep inside the backend; setup + // and mount errors arrive as plain Results. + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let setup = hf_mount::setup::build(source, options, true).map_err(|e| e.to_string())?; + let virtual_fs = setup.virtual_fs.clone(); + let mount_point = setup.mount_point.clone(); + let params = hf_mount::nfs::NfsMountParams { + metadata_ttl_ms: setup.metadata_ttl_ms, + read_only: setup.read_only, + security: setup.nfs_security.clone(), + shutdown: Some(shutdown), + }; + setup + .runtime + .block_on(hf_mount::nfs::mount_nfs_with_callback( + virtual_fs, + &mount_point, + params, + None, + move |event| handle_mount_event(&status_for_events, event), + )) + .map_err(|e| e.to_string()) + })); + + match result { + Ok(Ok(())) => set_status( + &shared_status, + MountState::Stopped, + "Unmounted", + "The mount stopped cleanly.", + ), + // An error after the user asked to stop is a consequence of the + // teardown, not a failure worth alarming about. + Ok(Err(message)) if shutdown_probe.is_requested() => set_status( + &shared_status, + MountState::Stopped, + "Stopped", + format!("Mount cancelled during startup ({message})."), + ), + Ok(Err(message)) => set_status(&shared_status, MountState::Failed, "Mount failed", message), + Err(payload) => set_status( + &shared_status, + MountState::Failed, + "Mount crashed", + panic_message(payload), + ), + } +} + +fn handle_mount_event(status: &SharedMountStatus, event: NfsMountEvent) { + match event { + NfsMountEvent::ServerListening { port } => set_status( + status, + MountState::Mounting, + "Local NFS server is listening", + format!("127.0.0.1:{port}"), + ), + NfsMountEvent::MountCommand { command } => push_log(status, format!("Running {command}")), + NfsMountEvent::Mounted { mount_point } => set_status( + status, + MountState::Mounted, + "Mounted", + format!("Mounted at {mount_point}"), + ), + NfsMountEvent::ShuttingDown { reason } => set_status(status, MountState::Stopping, "Shutting down", reason), + } +} diff --git a/src/bin/hf-mount-gui/autostart.rs b/src/bin/hf-mount-gui/autostart.rs new file mode 100644 index 0000000..05d9294 --- /dev/null +++ b/src/bin/hf-mount-gui/autostart.rs @@ -0,0 +1,203 @@ +//! Start-at-login registration: a Scheduled Task on Windows, a LaunchAgent on +//! macOS, an XDG autostart entry on Linux desktops. All variants launch the +//! GUI executable with `--background-worker` using the saved profile. + +use crate::worker::BACKGROUND_WORKER_ARG; + +pub const AUTOSTART_NAME: &str = "hf-mount autostart"; +#[cfg(target_os = "macos")] +const AUTOSTART_LABEL: &str = "co.huggingface.hf-mount-gui-autostart"; + +pub fn set_autostart_enabled(enabled: bool) -> Result<(), String> { + if enabled { + install_autostart() + } else { + remove_autostart() + } +} + +#[cfg(windows)] +pub fn autostart_is_enabled() -> bool { + use std::process::Stdio; + + windows_schtasks() + .args(["/Query", "/TN", AUTOSTART_NAME]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) +} + +#[cfg(not(windows))] +pub fn autostart_is_enabled() -> bool { + autostart_path().is_ok_and(|path| path.exists()) +} + +#[cfg(windows)] +fn install_autostart() -> Result<(), String> { + let exe = std::env::current_exe() + .map_err(|e| format!("Could not locate current executable: {e}"))? + .to_string_lossy() + .replace('"', "\\\""); + let task_run = format!("\"{exe}\" {BACKGROUND_WORKER_ARG}"); + let output = windows_schtasks() + .args([ + "/Create", + "/TN", + AUTOSTART_NAME, + "/TR", + &task_run, + "/SC", + "ONLOGON", + "/RL", + "HIGHEST", + "/F", + ]) + .output() + .map_err(|e| format!("Failed to create scheduled task: {e}"))?; + if output.status.success() { + Ok(()) + } else { + Err(command_output_error("schtasks /Create", output)) + } +} + +#[cfg(windows)] +fn remove_autostart() -> Result<(), String> { + let output = windows_schtasks() + .args(["/Delete", "/TN", AUTOSTART_NAME, "/F"]) + .output() + .map_err(|e| format!("Failed to remove scheduled task: {e}"))?; + if output.status.success() || !autostart_is_enabled() { + Ok(()) + } else { + Err(command_output_error("schtasks /Delete", output)) + } +} + +#[cfg(windows)] +fn windows_schtasks() -> std::process::Command { + use std::os::windows::process::CommandExt; + + let mut command = std::process::Command::new(hf_mount::windows::system32_exe("schtasks.exe")); + command.creation_flags(crate::platform::CREATE_NO_WINDOW); + command +} + +#[cfg(windows)] +fn command_output_error(label: &str, output: std::process::Output) -> String { + format!( + "{label} failed with {}: stdout={} stderr={}", + output.status, + String::from_utf8_lossy(&output.stdout).trim(), + String::from_utf8_lossy(&output.stderr).trim() + ) +} + +#[cfg(not(windows))] +fn install_autostart() -> Result<(), String> { + let path = autostart_path()?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create {}: {e}", parent.display()))?; + } + let content = autostart_file_contents()?; + std::fs::write(&path, content).map_err(|e| format!("Failed to write {}: {e}", path.display())) +} + +#[cfg(not(windows))] +fn remove_autostart() -> Result<(), String> { + let path = autostart_path()?; + if path.exists() { + std::fs::remove_file(&path).map_err(|e| format!("Failed to remove {}: {e}", path.display()))?; + } + Ok(()) +} + +#[cfg(target_os = "macos")] +fn autostart_path() -> Result { + let home = std::env::var_os("HOME") + .map(std::path::PathBuf::from) + .ok_or_else(|| "HOME is not set.".to_string())?; + Ok(home + .join("Library") + .join("LaunchAgents") + .join(format!("{AUTOSTART_LABEL}.plist"))) +} + +#[cfg(all(not(windows), not(target_os = "macos")))] +fn autostart_path() -> Result { + let base = if let Some(config_home) = std::env::var_os("XDG_CONFIG_HOME").map(std::path::PathBuf::from) { + config_home + } else { + let home = std::env::var_os("HOME") + .map(std::path::PathBuf::from) + .ok_or_else(|| "HOME is not set.".to_string())?; + home.join(".config") + }; + Ok(base.join("autostart").join("hf-mount.desktop")) +} + +#[cfg(target_os = "macos")] +fn autostart_file_contents() -> Result { + let exe = xml_escape( + &std::env::current_exe() + .map_err(|e| format!("Could not locate current executable: {e}"))? + .to_string_lossy(), + ); + let name = xml_escape(AUTOSTART_NAME); + let log_path = xml_escape(&crate::worker::worker_log_path()?.to_string_lossy()); + Ok(format!( + "\n\ + \n\ + \n\ + \n\ + Label\n\ + {AUTOSTART_LABEL}\n\ + ServiceDescription\n\ + {name}\n\ + ProgramArguments\n\ + \n\ + {exe}\n\ + {BACKGROUND_WORKER_ARG}\n\ + \n\ + RunAtLoad\n\ + \n\ + StandardOutPath\n\ + {log_path}\n\ + StandardErrorPath\n\ + {log_path}\n\ + \n\ + \n" + )) +} + +#[cfg(all(not(windows), not(target_os = "macos")))] +fn autostart_file_contents() -> Result { + let exe = desktop_exec_quote( + &std::env::current_exe() + .map_err(|e| format!("Could not locate current executable: {e}"))? + .to_string_lossy(), + ); + Ok(format!( + "[Desktop Entry]\n\ + Type=Application\n\ + Name={AUTOSTART_NAME}\n\ + Exec={exe} {BACKGROUND_WORKER_ARG}\n\ + Terminal=false\n\ + X-GNOME-Autostart-enabled=true\n" + )) +} + +#[cfg(target_os = "macos")] +fn xml_escape(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +#[cfg(all(not(windows), not(target_os = "macos")))] +fn desktop_exec_quote(text: &str) -> String { + format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\"")) +} diff --git a/src/bin/hf-mount-gui/main.rs b/src/bin/hf-mount-gui/main.rs new file mode 100644 index 0000000..8080ebe --- /dev/null +++ b/src/bin/hf-mount-gui/main.rs @@ -0,0 +1,117 @@ +//! Native GUI for mounting Hugging Face repos and buckets through the NFS +//! backend. The same executable doubles as the detached background worker +//! (`--background-worker`) and the headless setup checker (`--check-setup`). +#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")] + +mod activity_tab; +mod app; +mod autostart; +mod mount_tab; +mod platform; +mod preflight; +mod profile; +mod setup_tab; +mod theme; +mod util; +mod widgets; +mod worker; + +use eframe::egui; + +use crate::app::MountGuiApp; +use crate::preflight::{CheckLevel, check_level_label, run_preflight_checks}; +use crate::worker::BACKGROUND_WORKER_ARG; + +fn load_icon() -> egui::IconData { + let icon_bytes = include_bytes!("../../../assets/icon.rgba"); + egui::IconData { + rgba: icon_bytes.to_vec(), + width: 64, + height: 64, + } +} + +fn main() { + if handle_cli_command() { + return; + } + + util::init_backend_once(); + + let native_options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_inner_size([780.0, 640.0]) + .with_min_inner_size([620.0, 480.0]) + .with_icon(load_icon()), + ..Default::default() + }; + if let Err(e) = eframe::run_native( + "hf-mount", + native_options, + Box::new(|cc| { + theme::apply_theme(&cc.egui_ctx); + Ok(Box::new(MountGuiApp::new(cc))) + }), + ) { + eprintln!("failed to start hf-mount GUI: {e}"); + std::process::exit(1); + } +} + +/// Handle CLI-style invocations. Returns `true` when the invocation was a CLI +/// command and the GUI should not start. +fn handle_cli_command() -> bool { + let mut args = std::env::args().skip(1); + let Some(arg) = args.next() else { + return false; + }; + + match arg.as_str() { + "-h" | "--help" => { + print_help(); + true + } + "-V" | "--version" => { + println!("hf-mount-gui {}", env!("CARGO_PKG_VERSION")); + true + } + "--check-setup" => { + let mount_point = args.next().unwrap_or_else(platform::default_mount_point); + let checks = run_preflight_checks(&mount_point); + for check in &checks { + println!("[{}] {}: {}", check_level_label(check.level), check.label, check.detail); + } + if checks.iter().any(|check| check.level == CheckLevel::Fail) { + std::process::exit(1); + } + true + } + BACKGROUND_WORKER_ARG => { + if let Err(e) = worker::run_background_worker() { + eprintln!("background worker failed: {e}"); + std::process::exit(1); + } + true + } + other => { + eprintln!("unknown argument: {other}"); + print_help(); + std::process::exit(2); + } + } +} + +fn print_help() { + println!( + "hf-mount-gui {version}\n\ + Native GUI for mounting Hugging Face repos and buckets through the NFS backend.\n\n\ + USAGE:\n\ + hf-mount-gui\n\ + hf-mount-gui --help\n\ + hf-mount-gui --version\n\ + hf-mount-gui --check-setup [MOUNT_POINT]\n\ + hf-mount-gui --background-worker\n\n\ + Windows requires Client for NFS and an Administrator session.", + version = env!("CARGO_PKG_VERSION") + ); +} diff --git a/src/bin/hf-mount-gui/mount_tab.rs b/src/bin/hf-mount-gui/mount_tab.rs new file mode 100644 index 0000000..423d9ef --- /dev/null +++ b/src/bin/hf-mount-gui/mount_tab.rs @@ -0,0 +1,344 @@ +//! Mount tab: the source/mount form, blocker banner, and Start/Stop actions. + +use eframe::egui::{self, RichText}; + +use crate::app::{MountGuiApp, MountState, Tab, push_log}; +use crate::platform; +use crate::preflight::{CheckItem, blocker_command}; +use crate::profile::GuiSource; +use crate::theme::*; +use crate::widgets::{ + danger_button, field_error, field_hint, field_row, primary_button, secondary_button, segmented_pair, text_field, +}; + +impl MountGuiApp { + pub fn draw_mount_tab(&mut self, ui: &mut egui::Ui) { + egui::ScrollArea::vertical() + .id_salt("mount-form") + .auto_shrink([false, false]) + .show(ui, |ui| { + let form_width = ui.available_width().min(640.0); + ui.vertical(|ui| { + ui.set_width(form_width); + self.draw_form(ui); + ui.add_space(12.0); + if !self.is_mount_running() + && let Some(blocker) = self.first_blocking_check().cloned() + { + self.draw_blocker_banner(ui, &blocker); + ui.add_space(12.0); + } + self.draw_actions(ui); + }); + }); + } + + fn draw_form(&mut self, ui: &mut egui::Ui) { + field_row(ui, "Type", |ui| { + let before = self.source; + segmented_pair( + ui, + &mut self.source, + [(GuiSource::Repo, "Repo"), (GuiSource::Bucket, "Bucket")], + ); + if before != self.source && self.source == GuiSource::Repo { + self.read_only = true; + } + }); + + let id_label = match self.source { + GuiSource::Repo => "Repo ID", + GuiSource::Bucket => "Bucket ID", + }; + let mut recent_pick = None; + field_row(ui, id_label, |ui| { + let hint = match self.source { + GuiSource::Repo => "openai-community/gpt2", + GuiSource::Bucket => "namespace/bucket", + }; + text_field(ui, &mut self.source_id, hint, false); + if let Some(problem) = self.source_problem() { + ui.add_space(2.0); + field_error(ui, problem); + } + if !self.recent_sources.is_empty() { + ui.add_space(2.0); + egui::ComboBox::from_id_salt("recent-sources") + .selected_text(RichText::new("Recent").size(12.0).color(text_secondary())) + .width(220.0) + .show_ui(ui, |ui| { + for recent in &self.recent_sources { + if ui.selectable_label(false, recent.label()).clicked() { + recent_pick = Some(recent.clone()); + } + } + }); + } + }); + if let Some(recent) = recent_pick { + self.apply_recent_source(&recent); + } + + if self.source == GuiSource::Repo { + field_row(ui, "Revision", |ui| { + text_field(ui, &mut self.revision, "main", false); + }); + } + + field_row(ui, "Mount point", |ui| { + #[cfg(windows)] + { + let mut picked = None; + ui.horizontal(|ui| { + let picker_width = 64.0; + let field_width = (ui.available_width() - picker_width - ui.spacing().item_spacing.x).max(120.0); + ui.add_sized( + [field_width, 30.0], + egui::TextEdit::singleline(&mut self.mount_point) + .desired_width(f32::INFINITY) + .hint_text(platform::default_mount_hint()), + ); + egui::ComboBox::from_id_salt("drive-letter") + .selected_text(RichText::new("Free").size(12.0).color(text_secondary())) + .width(picker_width) + .show_ui(ui, |ui| { + let letters = platform::free_drive_letters(); + if letters.is_empty() { + ui.label(RichText::new("No free letters found").color(muted_text())); + } + for letter in letters { + if ui.selectable_label(false, format!("{letter}:")).clicked() { + picked = Some(format!("{letter}:")); + } + } + }); + }); + if let Some(target) = picked { + self.mount_point = target; + self.refresh_checks(); + } + } + #[cfg(not(windows))] + { + text_field(ui, &mut self.mount_point, platform::default_mount_hint(), false); + } + ui.add_space(2.0); + field_hint(ui, platform::mount_point_hint()); + }); + + field_row(ui, "Access", |ui| { + if self.source == GuiSource::Repo { + self.read_only = true; + let mut locked = true; + ui.horizontal(|ui| { + ui.add_enabled(false, egui::Checkbox::new(&mut locked, "Read-only")); + field_hint(ui, "Repos are always read-only."); + }); + } else { + ui.checkbox(&mut self.read_only, "Read-only"); + } + }); + + field_row(ui, "Run", |ui| { + ui.horizontal_wrapped(|ui| { + ui.checkbox(&mut self.run_in_background, "Background") + .on_hover_text("Keep the mount running after this window is closed."); + if ui + .checkbox(&mut self.autostart_enabled, "Start at login") + .on_hover_text("Register the saved mount to start when you log in.") + .changed() + { + self.apply_autostart_setting(); + } + }); + }); + + field_row(ui, "HF token", |ui| { + ui.horizontal(|ui| { + let toggle_width = 52.0; + let field_width = (ui.available_width() - toggle_width - ui.spacing().item_spacing.x).max(120.0); + ui.add_sized( + [field_width, 30.0], + egui::TextEdit::singleline(&mut self.hf_token) + .desired_width(f32::INFINITY) + .hint_text("Optional access token") + .password(!self.show_token), + ); + let label = if self.show_token { "Hide" } else { "Show" }; + if ui.add_sized([toggle_width, 30.0], egui::Button::new(label)).clicked() { + self.show_token = !self.show_token; + } + }); + ui.add_space(2.0); + field_hint(ui, "Uses HF_TOKEN automatically when set. Inline tokens are not saved."); + }); + + ui.add_space(4.0); + let advanced_label = if self.show_advanced { + "Hide advanced options" + } else { + "Show advanced options" + }; + if ui + .add(egui::Button::new(RichText::new(advanced_label).size(12.0).color(text_secondary())).frame(false)) + .clicked() + { + self.show_advanced = !self.show_advanced; + } + if self.show_advanced { + ui.add_space(6.0); + field_row(ui, "Hub endpoint", |ui| { + text_field(ui, &mut self.hub_endpoint, "https://huggingface.co", false); + }); + field_row(ui, "Cache dir", |ui| { + text_field(ui, &mut self.cache_dir, "Cache directory", false); + }); + field_row(ui, "Token file", |ui| { + text_field(ui, &mut self.token_file, "Path to token file", false); + ui.add_space(2.0); + field_hint(ui, "Re-read on each request; used by background and autostart mounts."); + }); + field_row(ui, "NFS access", |ui| { + ui.checkbox(&mut self.nfs_allow_unsafe_loopback, "Allow unsafe loopback fallback") + .on_hover_text( + "Permit NFS without enforceable local caller authorization. Required for \ + credential-backed mounts on Windows.", + ); + }); + } + } + + fn draw_blocker_banner(&mut self, ui: &mut egui::Ui, blocker: &CheckItem) { + egui::Frame::none() + .fill(warning_chip_bg()) + .stroke(egui::Stroke::new(1.0, accent())) + .rounding(8.0) + .inner_margin(egui::Margin::symmetric(12.0, 10.0)) + .show(ui, |ui| { + ui.label( + RichText::new(format!("{}: {}", blocker.label, blocker.detail)) + .size(13.0) + .color(text_primary()), + ); + if let Some(command) = blocker_command(blocker) { + ui.add_space(2.0); + ui.label(RichText::new(command).monospace().size(11.0).color(text_secondary())); + } + ui.add_space(8.0); + ui.horizontal_wrapped(|ui| { + self.draw_blocker_action(ui, blocker); + if let Some(command) = blocker_command(blocker) + && ui.button("Copy command").clicked() + { + ui.ctx().copy_text(command.to_string()); + push_log(&self.status, "Copied setup command"); + } + if ui.button("Recheck").clicked() { + self.refresh_checks(); + } + if ui + .add(egui::Button::new(RichText::new("Details in Setup").color(text_secondary())).frame(false)) + .clicked() + { + self.tab = Tab::Setup; + } + }); + }); + } + + pub(crate) fn draw_blocker_action(&mut self, ui: &mut egui::Ui, blocker: &CheckItem) { + #[cfg(windows)] + { + if blocker.label == "Client for NFS" { + let enable = + egui::Button::new(RichText::new("Enable NFS").strong().color(egui::Color32::WHITE)).fill(accent()); + if ui.add(enable).clicked() { + match platform::enable_windows_nfs_client() { + Ok(()) => crate::app::set_status( + &self.status, + MountState::Stopped, + "NFS enable requested", + "Approve the UAC prompt. Reboot if Windows asks, then press Recheck.", + ), + Err(e) => crate::app::set_status(&self.status, MountState::Failed, "Could not enable NFS", e), + } + } + return; + } + + if blocker.label == "Administrator" { + if ui + .add(egui::Button::new( + RichText::new("Restart as admin").strong().color(text_primary()), + )) + .clicked() + { + match platform::restart_as_administrator() { + Ok(()) => crate::app::set_status( + &self.status, + MountState::Stopped, + "Elevation requested", + "Approve the Windows UAC prompt, then use the elevated window.", + ), + Err(e) => { + crate::app::set_status(&self.status, MountState::Failed, "Could not relaunch as admin", e) + } + } + } + return; + } + + if blocker.label == "Mount point" && ui.button("Use a free letter").clicked() { + self.mount_point = platform::default_mount_point(); + self.refresh_checks(); + } + } + #[cfg(not(windows))] + { + let _ = (ui, blocker); + } + } + + fn draw_actions(&mut self, ui: &mut egui::Ui) { + let running = self.is_mount_running(); + let stopping = self.is_stopping(); + let mounted = self.current_status().state == MountState::Mounted; + let blocked = self.first_blocking_check().is_some() || self.source_problem().is_some(); + + let start_label = if running { + "Mount running" + } else if self.run_in_background { + "Start in background" + } else { + "Start mount" + }; + + ui.horizontal(|ui| { + if primary_button(ui, start_label, !running && !blocked, 170.0).clicked() { + self.start_mount(); + } + if danger_button(ui, "Stop", running && !stopping, 90.0).clicked() { + self.stop_mount(); + } + if secondary_button(ui, "Open folder", mounted && self.active_mount_point.is_some(), 110.0).clicked() { + self.open_active_mount(); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if secondary_button(ui, "Check setup", true, 110.0).clicked() { + self.refresh_checks(); + } + }); + }); + + if let Some(mount_point) = &self.active_mount_point + && running + { + ui.add_space(6.0); + ui.label( + RichText::new(format!("Active target: {}", mount_point.display())) + .size(11.0) + .color(muted_text()), + ); + } + } +} diff --git a/src/bin/hf-mount-gui/platform.rs b/src/bin/hf-mount-gui/platform.rs new file mode 100644 index 0000000..a6dd693 --- /dev/null +++ b/src/bin/hf-mount-gui/platform.rs @@ -0,0 +1,450 @@ +//! OS integration: unmounting, opening folders, process liveness, elevation, +//! drive-letter enumeration. Everything that shells out lives here so the UI +//! code stays platform-free. + +use std::path::Path; +#[cfg(windows)] +use std::path::PathBuf; +use std::process::Command; + +#[cfg(windows)] +use std::os::windows::process::CommandExt; + +#[cfg(windows)] +use hf_mount::windows::{drive_letter, system32_exe}; + +#[cfg(windows)] +pub const CREATE_NO_WINDOW: u32 = 0x0800_0000; +#[cfg(windows)] +const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; +#[cfg(windows)] +const DETACHED_PROCESS: u32 = 0x0000_0008; + +pub fn platform_label() -> &'static str { + #[cfg(windows)] + { + "Windows" + } + #[cfg(target_os = "macos")] + { + "macOS" + } + #[cfg(all(not(windows), not(target_os = "macos")))] + { + "Linux" + } +} + +pub fn default_mount_point() -> String { + #[cfg(windows)] + { + // Prefer a letter that is actually unassigned right now. + free_drive_letters() + .first() + .map(|letter| format!("{letter}:")) + .unwrap_or_else(|| "Z:".to_string()) + } + #[cfg(not(windows))] + { + std::env::temp_dir().join("hf-mount").to_string_lossy().into_owned() + } +} + +pub fn default_mount_hint() -> &'static str { + #[cfg(windows)] + { + "Z:" + } + #[cfg(not(windows))] + { + "/tmp/hf-mount" + } +} + +pub fn mount_point_hint() -> &'static str { + #[cfg(windows)] + { + "Use an unused drive letter. Directory targets are less reliable." + } + #[cfg(not(windows))] + { + "Use an absolute folder path." + } +} + +// ── Unmount ─────────────────────────────────────────────────────────── + +/// Run the platform unmount command for `mount_point`. Blocking — call from a +/// worker thread, never from the UI thread (a wedged NFS mount can stall the +/// command for tens of seconds). +pub fn unmount_path(mount_point: &Path) -> Result<(), String> { + let mount_point = mount_point + .to_str() + .ok_or_else(|| "Mount point is not valid UTF-8".to_string())?; + let target = unmount_target(mount_point); + + let output = unmount_command(&target) + .output() + .map_err(|e| format!("Failed to run unmount command: {e}"))?; + + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + Err(format!( + "Unmount failed with {}: stdout={} stderr={}", + output.status, + stdout.trim(), + stderr.trim() + )) +} + +#[cfg(windows)] +fn unmount_command(mount_point: &str) -> Command { + let mut command = Command::new(system32_exe("umount.exe")); + command.creation_flags(CREATE_NO_WINDOW); + command.args(["-f", mount_point]); + command +} + +#[cfg(target_os = "macos")] +fn unmount_command(mount_point: &str) -> Command { + let mut command = Command::new("/sbin/umount"); + command.arg(mount_point); + command +} + +#[cfg(all(not(windows), not(target_os = "macos")))] +fn unmount_command(mount_point: &str) -> Command { + let mut command = Command::new("umount"); + command.arg(mount_point); + command +} + +fn unmount_target(mount_point: &str) -> String { + #[cfg(windows)] + { + if let Some(drive) = drive_letter(mount_point) { + return format!("{drive}:"); + } + } + mount_point.to_string() +} + +// ── Open in file manager ────────────────────────────────────────────── + +pub fn open_mount_point(mount_point: Option<&Path>) -> Result<(), String> { + let mount_point = mount_point.ok_or_else(|| "No active mount point is recorded.".to_string())?; + let target = open_target(mount_point)?; + + open_command(&target) + .spawn() + .map_err(|e| format!("Failed to open mount point: {e}"))?; + Ok(()) +} + +fn open_target(mount_point: &Path) -> Result { + let text = mount_point + .to_str() + .ok_or_else(|| "Mount point is not valid UTF-8".to_string())?; + #[cfg(windows)] + { + if let Some(drive) = drive_letter(text) { + return Ok(format!("{drive}:\\")); + } + } + Ok(text.to_string()) +} + +#[cfg(windows)] +fn open_command(target: &str) -> Command { + let mut command = Command::new("explorer.exe"); + command.creation_flags(CREATE_NO_WINDOW); + command.arg(target); + command +} + +#[cfg(target_os = "macos")] +fn open_command(target: &str) -> Command { + let mut command = Command::new("/usr/bin/open"); + command.arg(target); + command +} + +#[cfg(all(not(windows), not(target_os = "macos")))] +fn open_command(target: &str) -> Command { + let mut command = Command::new("xdg-open"); + command.arg(target); + command +} + +// ── Process management ──────────────────────────────────────────────── + +/// Whether `pid` is alive *and* still looks like our detached background +/// worker — its command line carries `marker` (`--background-worker`), or on +/// Windows, where command lines aren't exposed, its image name matches this +/// executable. Guards against a recycled PID handing us an unrelated process +/// to track or terminate. Blocking — poller thread or explicit user action. +#[cfg(target_os = "linux")] +pub fn worker_process_alive(pid: u32, marker: &str) -> bool { + if pid == 0 { + return false; + } + match std::fs::read(format!("/proc/{pid}/cmdline")) { + Ok(bytes) => String::from_utf8_lossy(&bytes) + .split('\0') + .any(|argument| argument == marker), + Err(_) => false, + } +} + +#[cfg(target_os = "macos")] +pub fn worker_process_alive(pid: u32, marker: &str) -> bool { + if pid == 0 { + return false; + } + let output = Command::new("/bin/ps") + .args(["-p", &pid.to_string(), "-o", "command="]) + .output(); + let Ok(output) = output else { + return false; + }; + // Match the marker as a whole argument, not a substring, so an unrelated + // command like `--background-worker-helper` can't pass the identity check. + output.status.success() + && String::from_utf8_lossy(&output.stdout) + .split_whitespace() + .any(|argument| argument == marker) +} + +#[cfg(windows)] +pub fn worker_process_alive(pid: u32, marker: &str) -> bool { + if pid == 0 { + return false; + } + // tasklist exposes only the image name, not the command line, so it cannot + // tell a detached `--background-worker` process from a foreground GUI window + // that happens to have inherited a recycled PID. Query the actual command + // line and require the marker as a whole argument. Failing closed here is + // deliberate: a `Mounted` worker's liveness is confirmed via the mount table + // (see worker::worker_status_is_live), so this strict check only gates the + // brief Mounting/Stopping window and the explicit Stop/terminate path. + match windows_process_command_line(pid) { + Some(command_line) => command_line.split_whitespace().any(|argument| argument == marker), + None => false, + } +} + +/// Command line of `pid` via PowerShell CIM, or `None` if it can't be obtained +/// (process gone, query failed). Blocking — poller thread / explicit action. +#[cfg(windows)] +fn windows_process_command_line(pid: u32) -> Option { + let script = format!("(Get-CimInstance Win32_Process -Filter \"ProcessId={pid}\").CommandLine"); + let output = Command::new(powershell_exe()) + .creation_flags(CREATE_NO_WINDOW) + .args(["-NoProfile", "-NonInteractive", "-Command", &script]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let command_line = String::from_utf8_lossy(&output.stdout).trim().to_string(); + (!command_line.is_empty()).then_some(command_line) +} + +#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] +pub fn worker_process_alive(_pid: u32, _marker: &str) -> bool { + false +} + +/// Terminate a process by id. Used to stop a background worker that has not +/// mounted yet (unmounting cannot reach it). SIGTERM on Unix lets a mounted +/// worker unmount gracefully; on Windows `taskkill /T /F` also takes down any +/// helper children. +#[cfg(windows)] +pub fn terminate_process(pid: u32) -> Result<(), String> { + if pid == 0 { + return Err("invalid process id".to_string()); + } + let mut command = Command::new(system32_exe("taskkill.exe")); + command.creation_flags(CREATE_NO_WINDOW); + let output = command + .args(["/PID", &pid.to_string(), "/T", "/F"]) + .output() + .map_err(|e| format!("Failed to run taskkill: {e}"))?; + if output.status.success() { + Ok(()) + } else { + Err(format!( + "taskkill failed with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )) + } +} + +#[cfg(unix)] +pub fn terminate_process(pid: u32) -> Result<(), String> { + if pid == 0 { + return Err("invalid process id".to_string()); + } + // The worker is spawned as a process-group leader (`detach_command`), and + // during startup it may be blocked on a mount_nfs/mount.nfs child with no + // signal handler installed yet. Signal the whole group so the helper dies + // with the worker; fall back to the single pid when it isn't a leader + // (e.g. launched by an init system that grouped it differently). + // SAFETY: kill with SIGTERM has no preconditions beyond a valid pid value. + let group_result = unsafe { libc::kill(-(pid as i32), libc::SIGTERM) }; + if group_result == 0 { + return Ok(()); + } + if unsafe { libc::kill(pid as i32, libc::SIGTERM) } == 0 { + Ok(()) + } else { + Err(format!( + "Failed to signal process {pid}: {}", + std::io::Error::last_os_error() + )) + } +} + +#[cfg(not(any(windows, unix)))] +pub fn terminate_process(_pid: u32) -> Result<(), String> { + Err("process termination is not supported on this platform".to_string()) +} + +/// Whether the path currently has an active mount. Uses the platform mount +/// table / filesystem type via the backend's check — a leftover empty +/// directory from a crashed worker does not count. Blocking on a wedged NFS +/// mount — poller thread only. +pub fn mount_point_appears_active(mount_point: &Path) -> bool { + mount_point.to_str().is_some_and(hf_mount::nfs::is_mounted) +} + +/// Whether `mount_point` is currently mounted by *our* loopback NFS export, as +/// opposed to an unrelated filesystem the user may already have mounted there. +/// Confirms ownership before a speculative cleanup unmount so we never detach a +/// pre-existing mount. Blocking on a wedged mount — worker thread only. +pub fn mount_point_is_ours(mount_point: &Path) -> bool { + mount_point.to_str().is_some_and(hf_mount::nfs::is_loopback_nfs_mount) +} + +/// Detach a child process from the GUI so it survives window close. +pub fn detach_command(command: &mut Command) { + #[cfg(windows)] + command.creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + #[cfg(not(any(windows, unix)))] + let _ = command; +} + +// ── Windows elevation & setup actions ───────────────────────────────── + +#[cfg(windows)] +pub fn windows_is_elevated() -> bool { + let mut command = Command::new(system32_exe("fltmc.exe")); + command.creation_flags(CREATE_NO_WINDOW); + command + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + command.status().map(|status| status.success()).unwrap_or(false) +} + +#[cfg(windows)] +fn powershell_exe() -> PathBuf { + std::env::var_os("SystemRoot") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\Windows")) + .join("System32") + .join("WindowsPowerShell") + .join("v1.0") + .join("powershell.exe") +} + +#[cfg(windows)] +pub fn windows_enable_nfs_command() -> &'static str { + "Enable-WindowsOptionalFeature -Online -FeatureName ServicesForNFS-ClientOnly,ClientForNFS-Infrastructure -All" +} + +/// Launch an elevated PowerShell that enables the Client for NFS feature. +/// The user still has to approve the UAC prompt. +#[cfg(windows)] +pub fn enable_windows_nfs_client() -> Result<(), String> { + let powershell = powershell_exe(); + let elevated_args = format!( + "-NoProfile -ExecutionPolicy Bypass -Command \"{}\"", + windows_enable_nfs_command() + ); + + let status = Command::new(&powershell) + .creation_flags(CREATE_NO_WINDOW) + .args([ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + "Start-Process -FilePath $args[0] -Verb RunAs -ArgumentList $args[1]", + ]) + .arg(&powershell) + .arg(elevated_args) + .status() + .map_err(|e| format!("Failed to launch the UAC prompt: {e}"))?; + + if status.success() { + Ok(()) + } else { + Err(format!("PowerShell exited with {status}")) + } +} + +/// Relaunch the GUI elevated (UAC prompt). The current instance keeps running; +/// the user is expected to continue in the elevated window. +#[cfg(windows)] +pub fn restart_as_administrator() -> Result<(), String> { + let exe = std::env::current_exe().map_err(|e| format!("Could not locate current executable: {e}"))?; + + let status = Command::new(powershell_exe()) + .creation_flags(CREATE_NO_WINDOW) + .args([ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + "Start-Process -FilePath $args[0] -Verb RunAs", + ]) + .arg(exe) + .status() + .map_err(|e| format!("Failed to launch the UAC prompt: {e}"))?; + + if status.success() { + Ok(()) + } else { + Err(format!("PowerShell exited with {status}")) + } +} + +// ── Drive letters (Windows) ─────────────────────────────────────────── + +/// Currently unassigned drive letters, best first (`Z:` downwards). Uses a +/// single `GetLogicalDrives` syscall — no per-letter filesystem probing, so it +/// never blocks on wedged network drives. +#[cfg(windows)] +pub fn free_drive_letters() -> Vec { + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetLogicalDrives() -> u32; + } + // SAFETY: GetLogicalDrives takes no arguments and only returns a bitmask. + let mask = unsafe { GetLogicalDrives() }; + if mask == 0 { + // Failure: report nothing rather than guessing wrong. + return Vec::new(); + } + hf_mount::windows::free_drive_letters(mask) +} diff --git a/src/bin/hf-mount-gui/preflight.rs b/src/bin/hf-mount-gui/preflight.rs new file mode 100644 index 0000000..0024972 --- /dev/null +++ b/src/bin/hf-mount-gui/preflight.rs @@ -0,0 +1,232 @@ +//! Environment readiness checks: elevation, NFS client tools, portmapper +//! availability, mount-point validity. Shared by the Setup tab, the Mount +//! tab's blocker banner, and the `--check-setup` CLI command. + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CheckLevel { + Pass, + Warn, + Fail, +} + +#[derive(Clone, Debug)] +pub struct CheckItem { + pub level: CheckLevel, + pub label: String, + pub detail: String, +} + +impl CheckItem { + fn pass(label: &str, detail: impl Into) -> Self { + Self { + level: CheckLevel::Pass, + label: label.to_string(), + detail: detail.into(), + } + } + + // Only the Windows checks produce warnings today. + #[cfg(windows)] + fn warn(label: &str, detail: impl Into) -> Self { + Self { + level: CheckLevel::Warn, + label: label.to_string(), + detail: detail.into(), + } + } + + fn fail(label: &str, detail: impl Into) -> Self { + Self { + level: CheckLevel::Fail, + label: label.to_string(), + detail: detail.into(), + } + } +} + +pub fn check_level_label(level: CheckLevel) -> &'static str { + match level { + CheckLevel::Pass => "OK", + CheckLevel::Warn => "WARN", + CheckLevel::Fail => "FAIL", + } +} + +pub fn summarize_checks(checks: &[CheckItem]) -> String { + if checks.iter().any(|check| check.level == CheckLevel::Fail) { + "Setup checks found a blocking issue".to_string() + } else if checks.iter().any(|check| check.level == CheckLevel::Warn) { + "Setup checks passed with warnings".to_string() + } else { + "Setup checks passed".to_string() + } +} + +/// Shell command (or instruction) the user can run to fix a failing check. +pub fn blocker_command(check: &CheckItem) -> Option<&'static str> { + #[cfg(windows)] + { + match check.label.as_str() { + "Client for NFS" => Some(crate::platform::windows_enable_nfs_command()), + "Administrator" => Some("Start-Process hf-mount-gui.exe -Verb RunAs"), + "Portmapper" => Some("Close other NFS/portmap services or another hf-mount instance, then recheck."), + "Mount point" => Some("Use an unused drive letter such as Z:, Y:, or X:."), + _ => None, + } + } + #[cfg(not(windows))] + { + let _ = check; + None + } +} + +/// Run all platform checks. Spawns short-lived helper processes on Windows — +/// call from event handlers / startup, not on every frame. +pub fn run_preflight_checks(mount_point: &str) -> Vec { + #[cfg(windows)] + { + windows_preflight_checks(mount_point) + } + #[cfg(target_os = "macos")] + { + macos_preflight_checks(mount_point) + } + #[cfg(all(not(windows), not(target_os = "macos")))] + { + linux_preflight_checks(mount_point) + } +} + +#[cfg(windows)] +fn windows_preflight_checks(mount_point: &str) -> Vec { + use std::path::Path; + + let mut checks = Vec::new(); + let elevated = crate::platform::windows_is_elevated(); + checks.push(if elevated { + CheckItem::pass("Administrator", "The GUI is elevated.") + } else { + CheckItem::fail( + "Administrator", + "Restart as Administrator so hf-mount can bind the local NFS portmapper.", + ) + }); + + let mount_exe = hf_mount::windows::system32_exe("mount.exe"); + let umount_exe = hf_mount::windows::system32_exe("umount.exe"); + checks.push(if mount_exe.exists() && umount_exe.exists() { + CheckItem::pass("Client for NFS", "mount.exe and umount.exe are available.") + } else { + CheckItem::fail( + "Client for NFS", + "Enable Microsoft's Client for NFS optional feature and reboot if Windows asks.", + ) + }); + + checks.push(if elevated { + windows_portmapper_check() + } else { + CheckItem::warn("Portmapper", "Port 111 is checked after elevation.") + }); + + let trimmed = mount_point.trim(); + checks.push(if trimmed.is_empty() { + CheckItem::fail("Mount point", "Choose a drive letter like Z: or an empty NTFS directory.") + } else if let Some(drive) = hf_mount::windows::drive_letter(trimmed) { + let probe = format!("{drive}:\\"); + if Path::new(&probe).exists() { + CheckItem::fail( + "Mount point", + format!("{drive}: already exists. Pick an unused drive letter such as Y: or X:."), + ) + } else { + CheckItem::pass("Mount point", format!("{drive}: is a free drive-letter target.")) + } + } else { + let path = Path::new(trimmed); + if !path.is_absolute() { + CheckItem::fail("Mount point", "Use a drive letter or an absolute directory path.") + } else if path.exists() && !path.is_dir() { + CheckItem::fail("Mount point", "The target exists but is not a directory.") + } else if path.exists() { + CheckItem::warn( + "Mount point", + "Directory target is absolute. A drive letter such as Z: is still the most reliable Windows target.", + ) + } else { + CheckItem::warn( + "Mount point", + "Directory target is absolute and will be created if the Windows NFS client accepts it. A drive letter is more reliable.", + ) + } + }); + checks +} + +#[cfg(windows)] +fn windows_portmapper_check() -> CheckItem { + use std::net::{TcpListener, UdpSocket}; + + match ( + UdpSocket::bind(("127.0.0.1", 111)), + TcpListener::bind(("127.0.0.1", 111)), + ) { + (Ok(udp), Ok(tcp)) => { + drop(udp); + drop(tcp); + CheckItem::pass( + "Portmapper", + "TCP and UDP port 111 are available for the local NFS portmapper.", + ) + } + (udp_result, tcp_result) => CheckItem::fail( + "Portmapper", + format!( + "Port 111 is not available: UDP={} TCP={}. Close other NFS/portmap services or another hf-mount instance.", + bind_result_label(&udp_result), + bind_result_label(&tcp_result), + ), + ), + } +} + +#[cfg(windows)] +fn bind_result_label(result: &std::io::Result) -> String { + match result { + Ok(_) => "ok".to_string(), + Err(e) => e.to_string(), + } +} + +#[cfg(target_os = "macos")] +fn macos_preflight_checks(mount_point: &str) -> Vec { + use std::path::Path; + + let mount_cmd_exists = Path::new("/sbin/mount_nfs").exists(); + let mount_path_absolute = Path::new(mount_point.trim()).is_absolute(); + vec![ + if mount_cmd_exists { + CheckItem::pass("mount_nfs", "/sbin/mount_nfs is available.") + } else { + CheckItem::fail("mount_nfs", "/sbin/mount_nfs was not found.") + }, + if mount_path_absolute { + CheckItem::pass("Mount point", "Mount point is an absolute path.") + } else { + CheckItem::fail("Mount point", "Use an absolute local directory path.") + }, + ] +} + +#[cfg(all(not(windows), not(target_os = "macos")))] +fn linux_preflight_checks(mount_point: &str) -> Vec { + use std::path::Path; + + let mount_path_absolute = Path::new(mount_point.trim()).is_absolute(); + vec![if mount_path_absolute { + CheckItem::pass("Mount point", "Mount point is an absolute path.") + } else { + CheckItem::fail("Mount point", "Use an absolute local directory path.") + }] +} diff --git a/src/bin/hf-mount-gui/profile.rs b/src/bin/hf-mount-gui/profile.rs new file mode 100644 index 0000000..01faf96 --- /dev/null +++ b/src/bin/hf-mount-gui/profile.rs @@ -0,0 +1,267 @@ +//! Saved mount profile: what the user last configured, persisted as JSON in +//! the per-user config directory. Inline HF tokens are deliberately never +//! part of the profile — background and autostart mounts read `HF_TOKEN` +//! from the environment or a token file instead. + +use std::path::PathBuf; + +use hf_mount::setup::{CacheMode, MountOptions, Source}; +use serde::{Deserialize, Serialize}; + +use crate::util::{ + app_config_dir, current_env_hf_token, non_empty_or_default, optional_text, parse_path, write_file_replace, +}; + +pub const MAX_RECENT_SOURCES: usize = 5; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum GuiSource { + Repo, + Bucket, +} + +/// A previously mounted source, offered for one-click refill. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecentSource { + pub source: GuiSource, + pub source_id: String, + #[serde(default)] + pub revision: String, +} + +impl RecentSource { + pub fn label(&self) -> String { + match self.source { + GuiSource::Repo if !self.revision.is_empty() && self.revision != "main" => { + format!("repo {} @ {}", self.source_id, self.revision) + } + GuiSource::Repo => format!("repo {}", self.source_id), + GuiSource::Bucket => format!("bucket {}", self.source_id), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct MountProfile { + pub source: GuiSource, + pub source_id: String, + pub revision: String, + pub mount_point: String, + #[serde(default)] + pub token_file: String, + pub hub_endpoint: String, + pub cache_dir: String, + pub read_only: bool, + pub run_in_background: bool, + #[serde(default)] + pub nfs_allow_unsafe_loopback: bool, + #[serde(default)] + pub recent_sources: Vec, +} + +impl MountProfile { + /// Record `entry` as the most recent source, deduplicated and capped. + pub fn remember_recent(&mut self, entry: RecentSource) { + self.recent_sources.retain(|existing| *existing != entry); + self.recent_sources.insert(0, entry); + self.recent_sources.truncate(MAX_RECENT_SOURCES); + } +} + +pub fn profile_path() -> Result { + Ok(app_config_dir()?.join("mount-profile.json")) +} + +pub fn load_mount_profile() -> Result, String> { + let path = profile_path()?; + if !path.exists() { + return Ok(None); + } + let bytes = std::fs::read(&path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?; + serde_json::from_slice(&bytes) + .map(Some) + .map_err(|e| format!("Failed to parse {}: {e}", path.display())) +} + +pub fn save_mount_profile(profile: &MountProfile) -> Result<(), String> { + let path = profile_path()?; + let json = serde_json::to_vec_pretty(profile).map_err(|e| format!("Failed to serialize settings: {e}"))?; + write_file_replace(&path, &json) +} + +/// Validate the source id as typed in the form. Returns a human-readable +/// problem, or `None` when it looks plausible. +pub fn source_id_problem(source: GuiSource, source_id: &str) -> Option<&'static str> { + let trimmed = source_id.trim(); + if trimmed.is_empty() { + return Some(match source { + GuiSource::Repo => "Repo ID is required, e.g. openai-community/gpt2.", + GuiSource::Bucket => "Bucket ID is required, e.g. namespace/bucket.", + }); + } + if trimmed.chars().any(char::is_whitespace) { + return Some("IDs cannot contain spaces."); + } + if trimmed.starts_with('/') || trimmed.ends_with('/') { + return Some("Remove the leading/trailing slash."); + } + if source == GuiSource::Bucket { + // Buckets are namespace/bucket, optionally with a subfolder + // (namespace/bucket/path/to/dir) — require at least two non-empty + // segments and reject empty segments like `a//b`. + let mut segments = trimmed.split('/'); + let namespace = segments.next().filter(|seg| !seg.is_empty()); + let bucket = segments.next().filter(|seg| !seg.is_empty()); + if namespace.is_none() || bucket.is_none() || segments.any(str::is_empty) { + return Some("Buckets are namespace/bucket, e.g. myuser/my-bucket."); + } + } + None +} + +pub fn profile_mount_source(profile: &MountProfile) -> Result { + if let Some(problem) = source_id_problem(profile.source, &profile.source_id) { + return Err(problem.to_string()); + } + let source_id = profile.source_id.trim(); + let mount_point = parse_path(&profile.mount_point, "Mount point")?; + Ok(match profile.source { + GuiSource::Repo => Source::Repo { + repo_id: source_id.to_string(), + mount_point, + revision: non_empty_or_default(&profile.revision, "main"), + }, + GuiSource::Bucket => Source::Bucket { + bucket_id: source_id.to_string(), + mount_point, + }, + }) +} + +/// Mount options for the GUI's NFS backend. The token comes from `HF_TOKEN` +/// or the configured token file; an inline token (foreground mounts only) is +/// layered on by the caller. +pub fn profile_mount_options(profile: &MountProfile) -> Result { + Ok(MountOptions { + hf_token: current_env_hf_token(), + token_file: optional_text(&profile.token_file).map(PathBuf::from), + hub_endpoint: non_empty_or_default(&profile.hub_endpoint, "https://huggingface.co"), + cache_dir: parse_path(&profile.cache_dir, "Cache directory")?, + uid: None, + gid: None, + read_only: profile.source == GuiSource::Repo || profile.read_only, + advanced_writes: false, + poll_interval_secs: 30, + poll_listing_concurrency: 4, + cache_size: 10_000_000_000, + max_staging_size: 0, + no_disk_cache: false, + cache_mode: CacheMode::Chunk, + direct_io: false, + metadata_ttl_ms: 10_000, + metadata_ttl_minimal: false, + max_threads: 16, + flush_debounce_ms: 2_000, + flush_max_batch_window_ms: 30_000, + no_filter_os_files: false, + fuse_owner_only: false, + fuse_allow_other: false, + nfs_allow_unsafe_loopback: profile.nfs_allow_unsafe_loopback, + inode_soft_limit: 0, + lru_sweep_interval_ms: 5_000, + overlay: false, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_profile() -> MountProfile { + MountProfile { + source: GuiSource::Repo, + source_id: "openai-community/gpt2".to_string(), + revision: "main".to_string(), + mount_point: "/tmp/hf-mount".to_string(), + token_file: "/tmp/hf-token".to_string(), + hub_endpoint: "https://huggingface.co".to_string(), + cache_dir: "/tmp/hf-cache".to_string(), + read_only: true, + run_in_background: true, + nfs_allow_unsafe_loopback: false, + recent_sources: Vec::new(), + } + } + + #[test] + fn mount_profile_serialization_excludes_inline_token() { + let json = serde_json::to_string(&sample_profile()).unwrap(); + assert!(!json.contains("hf_token")); + assert!(json.contains("token_file")); + } + + #[test] + fn old_profile_token_field_is_ignored_on_load() { + let json = r#"{ + "source":"Repo", + "source_id":"openai-community/gpt2", + "revision":"main", + "mount_point":"/tmp/hf-mount", + "hf_token":"hf_secret", + "hub_endpoint":"https://huggingface.co", + "cache_dir":"/tmp/hf-cache", + "read_only":true, + "run_in_background":false + }"#; + let profile: MountProfile = serde_json::from_str(json).unwrap(); + let rewritten = serde_json::to_string(&profile).unwrap(); + assert!(!rewritten.contains("hf_secret")); + assert!(!rewritten.contains("hf_token")); + } + + #[test] + fn recent_sources_dedupe_and_cap() { + let mut profile = sample_profile(); + for i in 0..8 { + profile.remember_recent(RecentSource { + source: GuiSource::Repo, + source_id: format!("user/model-{i}"), + revision: "main".to_string(), + }); + } + assert_eq!(profile.recent_sources.len(), MAX_RECENT_SOURCES); + assert_eq!(profile.recent_sources[0].source_id, "user/model-7"); + + // Re-mounting an existing entry moves it to the front without duplicating. + profile.remember_recent(RecentSource { + source: GuiSource::Repo, + source_id: "user/model-5".to_string(), + revision: "main".to_string(), + }); + assert_eq!(profile.recent_sources.len(), MAX_RECENT_SOURCES); + assert_eq!(profile.recent_sources[0].source_id, "user/model-5"); + } + + #[test] + fn source_id_validation_catches_common_mistakes() { + assert!(source_id_problem(GuiSource::Repo, "").is_some()); + assert!(source_id_problem(GuiSource::Repo, "has space/model").is_some()); + assert!(source_id_problem(GuiSource::Repo, "/leading").is_some()); + assert!(source_id_problem(GuiSource::Bucket, "no-namespace").is_some()); + // Empty interior segments are rejected, but subfolder paths are valid: + // Source::Bucket supports namespace/bucket/path/to/dir. + assert!(source_id_problem(GuiSource::Bucket, "a//b").is_some()); + assert!(source_id_problem(GuiSource::Bucket, "namespace/bucket/checkpoints").is_none()); + assert!(source_id_problem(GuiSource::Repo, "gpt2").is_none()); + assert!(source_id_problem(GuiSource::Repo, "openai-community/gpt2").is_none()); + assert!(source_id_problem(GuiSource::Bucket, "myuser/my-bucket").is_none()); + } + + #[test] + fn repo_profiles_are_always_read_only() { + let mut profile = sample_profile(); + profile.read_only = false; + let options = profile_mount_options(&profile).unwrap(); + assert!(options.read_only); + } +} diff --git a/src/bin/hf-mount-gui/setup_tab.rs b/src/bin/hf-mount-gui/setup_tab.rs new file mode 100644 index 0000000..b40993b --- /dev/null +++ b/src/bin/hf-mount-gui/setup_tab.rs @@ -0,0 +1,129 @@ +//! Setup tab: full readiness check list with per-check fix actions. + +use eframe::egui::{self, RichText}; + +use crate::app::{MountGuiApp, push_log}; +use crate::platform; +use crate::preflight::{CheckLevel, blocker_command}; +use crate::theme::*; +use crate::widgets::{chip, secondary_button}; + +impl MountGuiApp { + pub fn draw_setup_tab(&mut self, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + ui.label( + RichText::new("Environment checks") + .size(14.0) + .strong() + .color(text_primary()), + ); + self.draw_checks_summary(ui); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if secondary_button(ui, "Run checks", true, 100.0).clicked() { + self.refresh_checks(); + } + }); + }); + ui.add_space(8.0); + + egui::ScrollArea::vertical() + .id_salt("setup-checks") + .auto_shrink([false, false]) + .show(ui, |ui| { + let checks = self.checks.clone(); + for check in &checks { + egui::Frame::none() + .fill(panel_bg()) + .stroke(egui::Stroke::new(1.0, border())) + .rounding(8.0) + .inner_margin(egui::Margin::symmetric(12.0, 10.0)) + .show(ui, |ui| { + ui.horizontal_top(|ui| { + let (mark, color) = match check.level { + CheckLevel::Pass => ("OK", success_fg()), + CheckLevel::Warn => ("WARN", warning_fg()), + CheckLevel::Fail => ("FIX", error_fg()), + }; + ui.allocate_ui_with_layout( + egui::vec2(44.0, 18.0), + egui::Layout::left_to_right(egui::Align::Min), + |ui| { + ui.label(RichText::new(mark).size(11.0).strong().color(color)); + }, + ); + ui.vertical(|ui| { + ui.label(RichText::new(&check.label).size(13.0).strong().color(text_primary())); + ui.label(RichText::new(&check.detail).size(12.0).color(text_secondary())); + if check.level != CheckLevel::Pass { + if let Some(command) = blocker_command(check) { + ui.add_space(4.0); + ui.label(RichText::new(command).monospace().size(11.0).color(muted_text())); + } + ui.add_space(6.0); + ui.horizontal_wrapped(|ui| { + if check.level == CheckLevel::Fail { + self.draw_blocker_action(ui, check); + } + if let Some(command) = blocker_command(check) + && ui.button("Copy command").clicked() + { + ui.ctx().copy_text(command.to_string()); + push_log(&self.status, "Copied setup command"); + } + }); + } + }); + }); + }); + ui.add_space(8.0); + } + + ui.add_space(8.0); + ui.label( + RichText::new(format!( + "hf-mount-gui v{} · {} · NFS backend", + env!("CARGO_PKG_VERSION"), + platform::platform_label() + )) + .size(11.0) + .color(muted_text()), + ); + ui.label( + RichText::new("CLI: hf-mount-gui --check-setup [MOUNT_POINT] runs these checks headlessly.") + .size(11.0) + .color(muted_text()), + ); + }); + } + + fn draw_checks_summary(&self, ui: &mut egui::Ui) { + let failures = self + .checks + .iter() + .filter(|check| check.level == CheckLevel::Fail) + .count(); + let warnings = self + .checks + .iter() + .filter(|check| check.level == CheckLevel::Warn) + .count(); + + let (label, color, bg) = if self.checks.is_empty() { + ("Not checked", text_secondary(), elevated_bg()) + } else if failures > 0 { + ("Action needed", error_fg(), error_chip_bg()) + } else if warnings > 0 { + ("Usable with warnings", warning_fg(), warning_chip_bg()) + } else { + ("Ready to mount", success_fg(), success_chip_bg()) + }; + chip(ui, label, color, bg); + if failures > 0 || warnings > 0 { + ui.label( + RichText::new(format!("{failures} blocking · {warnings} warning")) + .size(11.0) + .color(muted_text()), + ); + } + } +} diff --git a/src/bin/hf-mount-gui/theme.rs b/src/bin/hf-mount-gui/theme.rs new file mode 100644 index 0000000..f7ed924 --- /dev/null +++ b/src/bin/hf-mount-gui/theme.rs @@ -0,0 +1,128 @@ +//! Dark neutral theme: flat surfaces, 1px borders, 8px radius, one orange +//! accent. No gradients, no glows, no decorative chrome. + +use eframe::egui; + +pub fn app_bg() -> egui::Color32 { + egui::Color32::from_rgb(24, 24, 24) +} + +pub fn header_bg() -> egui::Color32 { + egui::Color32::from_rgb(28, 28, 28) +} + +pub fn panel_bg() -> egui::Color32 { + egui::Color32::from_rgb(33, 33, 33) +} + +pub fn elevated_bg() -> egui::Color32 { + egui::Color32::from_rgb(42, 42, 42) +} + +pub fn input_bg() -> egui::Color32 { + egui::Color32::from_rgb(42, 42, 42) +} + +pub fn border() -> egui::Color32 { + egui::Color32::from_rgb(58, 58, 58) +} + +pub fn primary_button_bg() -> egui::Color32 { + egui::Color32::from_rgb(242, 242, 242) +} + +pub fn primary_button_text() -> egui::Color32 { + egui::Color32::from_rgb(28, 28, 28) +} + +pub fn accent() -> egui::Color32 { + egui::Color32::from_rgb(240, 122, 50) +} + +pub fn success_fg() -> egui::Color32 { + egui::Color32::from_rgb(102, 192, 133) +} + +pub fn text_primary() -> egui::Color32 { + egui::Color32::from_rgb(242, 242, 242) +} + +pub fn text_secondary() -> egui::Color32 { + egui::Color32::from_rgb(168, 168, 168) +} + +pub fn muted_text() -> egui::Color32 { + egui::Color32::from_rgb(126, 126, 126) +} + +pub fn warning_fg() -> egui::Color32 { + egui::Color32::from_rgb(240, 173, 78) +} + +pub fn error_fg() -> egui::Color32 { + egui::Color32::from_rgb(238, 107, 107) +} + +pub fn success_chip_bg() -> egui::Color32 { + egui::Color32::from_rgb(31, 48, 38) +} + +pub fn warning_chip_bg() -> egui::Color32 { + egui::Color32::from_rgb(56, 44, 30) +} + +pub fn error_chip_bg() -> egui::Color32 { + egui::Color32::from_rgb(58, 36, 36) +} + +pub fn danger_button_bg() -> egui::Color32 { + egui::Color32::from_rgb(70, 40, 40) +} + +pub fn apply_theme(ctx: &egui::Context) { + let mut style = (*ctx.style()).clone(); + let mut visuals = egui::Visuals::dark(); + let rounding = egui::Rounding::same(8.0); + + visuals.panel_fill = app_bg(); + visuals.window_fill = panel_bg(); + visuals.extreme_bg_color = input_bg(); + visuals.faint_bg_color = elevated_bg(); + visuals.code_bg_color = input_bg(); + visuals.selection.bg_fill = egui::Color32::from_rgb(70, 70, 70); + visuals.selection.stroke = egui::Stroke::new(1.0, text_primary()); + visuals.hyperlink_color = accent(); + visuals.warn_fg_color = warning_fg(); + visuals.error_fg_color = error_fg(); + visuals.window_rounding = rounding; + visuals.menu_rounding = rounding; + + for widgets in [ + &mut visuals.widgets.noninteractive, + &mut visuals.widgets.inactive, + &mut visuals.widgets.hovered, + &mut visuals.widgets.active, + &mut visuals.widgets.open, + ] { + widgets.rounding = rounding; + } + + visuals.widgets.noninteractive.bg_fill = panel_bg(); + visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, border()); + visuals.widgets.noninteractive.fg_stroke = egui::Stroke::new(1.0, text_primary()); + visuals.widgets.inactive.bg_fill = input_bg(); + visuals.widgets.inactive.weak_bg_fill = elevated_bg(); + visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, border()); + visuals.widgets.inactive.fg_stroke = egui::Stroke::new(1.0, text_primary()); + visuals.widgets.hovered.bg_fill = egui::Color32::from_rgb(52, 52, 52); + visuals.widgets.hovered.weak_bg_fill = egui::Color32::from_rgb(48, 48, 48); + visuals.widgets.hovered.bg_stroke = egui::Stroke::new(1.0, egui::Color32::from_rgb(82, 82, 82)); + visuals.widgets.active.bg_fill = egui::Color32::from_rgb(58, 58, 58); + visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, text_secondary()); + + style.visuals = visuals; + style.spacing.item_spacing = egui::vec2(8.0, 8.0); + style.spacing.button_padding = egui::vec2(12.0, 6.0); + style.spacing.window_margin = egui::Margin::same(0.0); + ctx.set_style(style); +} diff --git a/src/bin/hf-mount-gui/util.rs b/src/bin/hf-mount-gui/util.rs new file mode 100644 index 0000000..4f27150 --- /dev/null +++ b/src/bin/hf-mount-gui/util.rs @@ -0,0 +1,180 @@ +//! Small shared helpers: text normalization, timestamps, atomic file writes. + +use std::fs::OpenOptions; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::Once; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; + +static BACKEND_INIT: Once = Once::new(); + +/// Initialize tracing and fd limits exactly once per process. Both the GUI +/// window and the `--background-worker` entry point go through this. +pub fn init_backend_once() { + BACKEND_INIT.call_once(|| { + hf_mount::setup::raise_fd_limit(); + hf_mount::setup::init_tracing(false); + }); +} + +/// `Some(trimmed)` when the input has non-whitespace content. +pub fn optional_text(text: &str) -> Option { + let trimmed = text.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + +pub fn non_empty_or_default(text: &str, default: &str) -> String { + let trimmed = text.trim(); + if trimmed.is_empty() { + default.to_string() + } else { + trimmed.to_string() + } +} + +pub fn parse_path(text: &str, label: &str) -> Result { + let trimmed = text.trim(); + if trimmed.is_empty() { + return Err(format!("{label} is required.")); + } + Ok(PathBuf::from(trimmed)) +} + +pub fn current_env_hf_token() -> Option { + std::env::var("HF_TOKEN").ok().and_then(|token| optional_text(&token)) +} + +pub fn current_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} + +fn current_unix_nanos() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default() +} + +pub fn panic_message(payload: Box) -> String { + if let Some(message) = payload.downcast_ref::<&str>() { + (*message).to_string() + } else if let Some(message) = payload.downcast_ref::() { + message.clone() + } else { + "unknown panic".to_string() + } +} + +/// Compact `1h 02m` / `5m 12s` / `42s` rendering for the status bar. +pub fn format_elapsed(secs: u64) -> String { + if secs >= 3600 { + format!("{}h {:02}m", secs / 3600, (secs % 3600) / 60) + } else if secs >= 60 { + format!("{}m {:02}s", secs / 60, secs % 60) + } else { + format!("{secs}s") + } +} + +/// Write `bytes` to `path` atomically: temp sibling + rename. The temp file is +/// owner-private on Unix. +pub fn write_file_replace(path: &Path, bytes: &[u8]) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create {}: {e}", parent.display()))?; + } + + let temp_path = temp_sibling_path(path); + write_private_file(&temp_path, bytes).map_err(|e| format!("Failed to write {}: {e}", temp_path.display()))?; + + // std::fs::rename replaces an existing destination atomically on Unix and + // via MoveFileExW(REPLACE_EXISTING) on Windows. Don't pre-delete the + // destination: a crash between delete and rename would lose the old file. + std::fs::rename(&temp_path, path).map_err(|e| { + let _ = std::fs::remove_file(&temp_path); + format!("Failed to replace {} with {}: {e}", path.display(), temp_path.display()) + }) +} + +fn write_private_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + #[cfg(unix)] + { + let mut file = OpenOptions::new().write(true).create_new(true).mode(0o600).open(path)?; + file.write_all(bytes) + } + #[cfg(not(unix))] + { + let mut file = OpenOptions::new().write(true).create_new(true).open(path)?; + file.write_all(bytes) + } +} + +fn temp_sibling_path(path: &Path) -> PathBuf { + let file_name = path + .file_name() + .map(|name| name.to_string_lossy()) + .unwrap_or_else(|| "hf-mount".into()); + let unique = format!(".{file_name}.{}.{}.tmp", std::process::id(), current_unix_nanos()); + path.with_file_name(unique) +} + +/// Per-user config directory for the GUI (profile, worker status, logs). +pub fn app_config_dir() -> Result { + #[cfg(windows)] + { + let base = std::env::var_os("APPDATA") + .map(PathBuf::from) + .ok_or_else(|| "APPDATA is not set.".to_string())?; + Ok(base.join("hf-mount")) + } + #[cfg(target_os = "macos")] + { + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .ok_or_else(|| "HOME is not set.".to_string())?; + Ok(home.join("Library").join("Application Support").join("hf-mount")) + } + #[cfg(all(not(windows), not(target_os = "macos")))] + { + if let Some(base) = std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from) { + return Ok(base.join("hf-mount")); + } + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .ok_or_else(|| "HOME is not set.".to_string())?; + Ok(home.join(".config").join("hf-mount")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn optional_text_trims_and_filters() { + assert_eq!(optional_text(" "), None); + assert_eq!(optional_text(" x "), Some("x".to_string())); + } + + #[test] + fn format_elapsed_renders_each_magnitude() { + assert_eq!(format_elapsed(42), "42s"); + assert_eq!(format_elapsed(312), "5m 12s"); + assert_eq!(format_elapsed(3720), "1h 02m"); + } + + #[test] + fn write_file_replace_is_atomic_and_overwrites() { + let dir = std::env::temp_dir().join(format!("hf-mount-gui-test-{}", std::process::id())); + let path = dir.join("settings.json"); + write_file_replace(&path, b"one").unwrap(); + write_file_replace(&path, b"two").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"two"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/bin/hf-mount-gui/widgets.rs b/src/bin/hf-mount-gui/widgets.rs new file mode 100644 index 0000000..5495e58 --- /dev/null +++ b/src/bin/hf-mount-gui/widgets.rs @@ -0,0 +1,155 @@ +//! Reusable UI primitives: tab bar, chips, labeled field rows, buttons. + +use eframe::egui::{self, RichText}; + +use crate::app::MountState; +use crate::theme::*; + +/// Underline-style tab button. Returns `true` when clicked. +pub fn tab_button(ui: &mut egui::Ui, label: &str, active: bool) -> bool { + let color = if active { text_primary() } else { text_secondary() }; + let text = RichText::new(label).size(14.0).strong().color(color); + let response = ui.add(egui::Label::new(text).sense(egui::Sense::click())); + let response = response.on_hover_cursor(egui::CursorIcon::PointingHand); + + let underline = if active { + Some(accent()) + } else if response.hovered() { + Some(border()) + } else { + None + }; + if let Some(color) = underline { + let rect = response.rect; + ui.painter() + .hline(rect.x_range(), rect.bottom() + 6.0, egui::Stroke::new(2.0, color)); + } + response.clicked() +} + +pub fn chip(ui: &mut egui::Ui, text: &str, fg: egui::Color32, bg: egui::Color32) { + egui::Frame::none() + .fill(bg) + .rounding(egui::Rounding::same(6.0)) + .inner_margin(egui::Margin::symmetric(8.0, 3.0)) + .show(ui, |ui| { + ui.label(RichText::new(text).small().strong().color(fg)); + }); +} + +pub fn status_chip(ui: &mut egui::Ui, state: &MountState) { + let (label, fg, bg) = match state { + MountState::Ready => ("Ready", text_secondary(), elevated_bg()), + MountState::Mounting => ("Mounting", warning_fg(), warning_chip_bg()), + MountState::Mounted => ("Mounted", success_fg(), success_chip_bg()), + MountState::Stopping => ("Stopping", warning_fg(), warning_chip_bg()), + MountState::Stopped => ("Stopped", text_secondary(), elevated_bg()), + MountState::Failed => ("Error", error_fg(), error_chip_bg()), + }; + chip(ui, label, fg, bg); +} + +/// A form row: fixed-width label column on the left, control on the right. +/// Collapses to stacked label/control when the panel is narrow. +pub fn field_row(ui: &mut egui::Ui, label: &str, add_field: impl FnOnce(&mut egui::Ui)) { + if ui.available_width() < 420.0 { + ui.vertical(|ui| { + ui.label(RichText::new(label).size(13.0).color(text_secondary())); + add_field(ui); + }); + ui.add_space(2.0); + } else { + ui.horizontal_top(|ui| { + ui.allocate_ui_with_layout( + egui::vec2(120.0, 30.0), + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + ui.label(RichText::new(label).size(13.0).color(text_secondary())); + }, + ); + ui.vertical(|ui| { + ui.set_width(ui.available_width()); + add_field(ui); + }); + }); + ui.add_space(2.0); + } +} + +pub fn text_field(ui: &mut egui::Ui, value: &mut String, hint: &str, password: bool) -> egui::Response { + ui.add_sized( + [ui.available_width(), 30.0], + egui::TextEdit::singleline(value) + .desired_width(f32::INFINITY) + .hint_text(hint) + .password(password), + ) +} + +pub fn field_hint(ui: &mut egui::Ui, text: &str) { + ui.label(RichText::new(text).size(11.0).color(muted_text())); +} + +pub fn field_error(ui: &mut egui::Ui, text: &str) { + ui.label(RichText::new(text).size(11.0).color(error_fg())); +} + +pub fn primary_button(ui: &mut egui::Ui, label: &str, enabled: bool, width: f32) -> egui::Response { + let (fill, fg) = if enabled { + (primary_button_bg(), primary_button_text()) + } else { + (input_bg(), muted_text()) + }; + let button = egui::Button::new(RichText::new(label).strong().color(fg)) + .fill(fill) + .min_size(egui::vec2(width, 32.0)); + ui.add_enabled(enabled, button) +} + +pub fn danger_button(ui: &mut egui::Ui, label: &str, enabled: bool, width: f32) -> egui::Response { + let fg = if enabled { text_primary() } else { muted_text() }; + let button = egui::Button::new(RichText::new(label).strong().color(fg)) + .fill(danger_button_bg()) + .min_size(egui::vec2(width, 32.0)); + ui.add_enabled(enabled, button) +} + +pub fn secondary_button(ui: &mut egui::Ui, label: &str, enabled: bool, width: f32) -> egui::Response { + let fg = if enabled { text_primary() } else { muted_text() }; + let button = egui::Button::new(RichText::new(label).color(fg)).min_size(egui::vec2(width, 32.0)); + ui.add_enabled(enabled, button) +} + +/// Two-option segmented control. Returns `true` when the selection changed. +pub fn segmented_pair(ui: &mut egui::Ui, value: &mut T, options: [(T, &str); 2]) -> bool { + let mut changed = false; + egui::Frame::none() + .fill(input_bg()) + .stroke(egui::Stroke::new(1.0, border())) + .rounding(8.0) + .inner_margin(egui::Margin::same(3.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + let spacing = ui.spacing().item_spacing.x; + let width = ((ui.available_width() - spacing) / 2.0).max(90.0); + for (option, label) in options { + let selected = *value == option; + let fg = if selected { text_primary() } else { text_secondary() }; + let fill = if selected { + egui::Color32::from_rgb(58, 58, 58) + } else { + egui::Color32::TRANSPARENT + }; + let button = egui::Button::new(RichText::new(label).strong().color(fg)) + .fill(fill) + .stroke(egui::Stroke::NONE) + .min_size(egui::vec2(width, 26.0)); + if ui.add(button).clicked() && !selected { + *value = option; + changed = true; + } + } + }); + }); + changed +} diff --git a/src/bin/hf-mount-gui/worker.rs b/src/bin/hf-mount-gui/worker.rs new file mode 100644 index 0000000..b895521 --- /dev/null +++ b/src/bin/hf-mount-gui/worker.rs @@ -0,0 +1,546 @@ +//! Detached background worker: the `--background-worker` process entry point, +//! the status-file IPC between worker and GUI, and the poller thread that +//! watches worker state without ever blocking the UI thread. + +use std::fs::OpenOptions; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; +use std::time::Duration; + +use hf_mount::nfs::NfsMountEvent; +use serde::{Deserialize, Serialize}; + +use crate::platform; +use crate::profile::{load_mount_profile, profile_mount_options, profile_mount_source}; +use crate::util::{app_config_dir, current_unix_secs, panic_message, write_file_replace}; + +pub const BACKGROUND_WORKER_ARG: &str = "--background-worker"; +const WORKER_STATUS_STALE_AFTER_SECS: u64 = 120; +const POLL_INTERVAL: Duration = Duration::from_secs(2); + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum WorkerState { + Mounting, + Mounted, + Stopping, + Stopped, + Failed, +} + +impl WorkerState { + pub fn is_active(&self) -> bool { + matches!( + self, + WorkerState::Mounting | WorkerState::Mounted | WorkerState::Stopping + ) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkerStatus { + pub state: WorkerState, + pub headline: String, + pub detail: String, + #[serde(default)] + pub mount_point: Option, + #[serde(default)] + pub pid: Option, + #[serde(default)] + pub updated_at_secs: u64, +} + +// ── Status file IPC ─────────────────────────────────────────────────── + +pub fn worker_status_path() -> Result { + Ok(app_config_dir()?.join("background-status.json")) +} + +pub fn worker_log_path() -> Result { + Ok(app_config_dir()?.join("background.log")) +} + +pub fn read_worker_status() -> Result, String> { + let path = worker_status_path()?; + // The worker replaces the file atomically, but a read can still race the + // (non-atomic) replace on Windows — retry briefly before giving up. + for attempt in 0..3 { + if !path.exists() { + if attempt < 2 { + std::thread::sleep(Duration::from_millis(10)); + continue; + } + return Ok(None); + } + + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + // NotFound/PermissionDenied can surface mid-replace on Windows even + // though path.exists() just succeeded — treat as a transient race. + Err(e) + if attempt < 2 + && matches!( + e.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::PermissionDenied + ) => + { + std::thread::sleep(Duration::from_millis(10)); + continue; + } + Err(e) => return Err(format!("Failed to read {}: {e}", path.display())), + }; + match serde_json::from_slice(&bytes) { + Ok(status) => return Ok(Some(status)), + Err(_) if attempt < 2 => std::thread::sleep(Duration::from_millis(10)), + Err(e) => return Err(format!("Failed to parse {}: {e}", path.display())), + } + } + + Ok(None) +} + +fn write_worker_status( + state: WorkerState, + headline: impl Into, + detail: impl Into, + mount_point: Option<&Path>, + pid: Option, +) -> Result<(), String> { + let path = worker_status_path()?; + let status = WorkerStatus { + state, + headline: headline.into(), + detail: detail.into(), + mount_point: mount_point.map(|mount_point| mount_point.to_string_lossy().into_owned()), + pid, + updated_at_secs: current_unix_secs(), + }; + let json = serde_json::to_vec_pretty(&status).map_err(|e| format!("Failed to serialize worker status: {e}"))?; + write_file_replace(&path, &json) +} + +pub fn clear_worker_status() { + if let Ok(path) = worker_status_path() { + let _ = std::fs::remove_file(path); + } +} + +/// Overwrite the status file after the GUI terminates a worker that never +/// reached `Mounted`, so later launches don't see a stale `Mounting` claim. +pub fn mark_worker_stopped(mount_point: Option<&Path>) { + let _ = write_worker_status( + WorkerState::Stopped, + "Background mount stopped", + "The worker was stopped before the mount completed.", + mount_point, + None, + ); +} + +/// Whether `pid` is alive and still identifies as an hf-mount background +/// worker (guards against PID reuse after a crash or reboot). +pub fn worker_process_matches(pid: u32) -> bool { + platform::worker_process_alive(pid, BACKGROUND_WORKER_ARG) +} + +/// Whether a reported worker status corresponds to something actually alive: +/// its process exists and identifies as our worker, its mount answers, or the +/// heartbeat is recent. Blocking (process probes, filesystem stat) — poller +/// thread only. +pub fn worker_status_is_live(status: &WorkerStatus, mount_point: Option<&Path>) -> bool { + // Terminal states (written by `mark_worker_stopped` and the early-startup + // failure path, both with `pid: None`) are dead regardless of how fresh the + // heartbeat is — never let them keep stale UI state alive. + if !status.state.is_active() { + return false; + } + + // A mount visible in the OS mount table is authoritative regardless of the + // (reuse-prone) recorded PID — but only where the check truly inspects the + // mount table. On Windows `mount_point_appears_active` is a best-effort + // directory probe that a leftover directory from a crashed worker still + // passes, so there we fall through to the PID/heartbeat check instead of + // trusting it. + #[cfg(not(windows))] + if status.state == WorkerState::Mounted && mount_point.is_some_and(platform::mount_point_appears_active) { + return true; + } + #[cfg(windows)] + let _ = mount_point; + + match status.pid { + // Once a worker has recorded its own PID, trust only that process: a + // dead or recycled PID means it is gone. Falling back to the heartbeat + // here would keep a crashed worker "live" for the staleness window and + // wrongly block starting a replacement. + Some(pid) => worker_process_matches(pid), + // Provisional pid-less launch status (written by the GUI before the + // detached worker has published its own): trust the recent heartbeat. + None => worker_status_heartbeat_fresh(status), + } +} + +/// Cheap, non-blocking liveness heuristic: heartbeat freshness only. Used by +/// the GUI's synchronous startup reconcile, which must not run the full +/// (mount-stat / process-probe) check before the first frame — either can wedge +/// on a dead NFS mount and keep the window from ever opening. The poller +/// re-confirms with [`worker_status_is_live`] on its own thread within one +/// interval and corrects the UI if the worker is actually gone. +pub fn worker_status_heartbeat_fresh(status: &WorkerStatus) -> bool { + status.updated_at_secs != 0 + && current_unix_secs().saturating_sub(status.updated_at_secs) <= WORKER_STATUS_STALE_AFTER_SECS +} + +// ── Worker log ──────────────────────────────────────────────────────── + +fn append_worker_log(message: impl AsRef) { + let Ok(mut file) = worker_log_file() else { + return; + }; + let _ = writeln!(file, "{}", message.as_ref()); +} + +fn worker_log_file() -> Result { + let path = worker_log_path()?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create {}: {e}", parent.display()))?; + } + + let mut options = OpenOptions::new(); + options.create(true).append(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + let file = options + .open(&path) + .map_err(|e| format!("Failed to open {}: {e}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = file + .metadata() + .map_err(|e| format!("Failed to inspect {}: {e}", path.display()))? + .permissions() + .mode() + & 0o777; + if mode & 0o077 != 0 { + let mut perms = file + .metadata() + .map_err(|e| format!("Failed to inspect {}: {e}", path.display()))? + .permissions(); + perms.set_mode(0o600); + file.set_permissions(perms) + .map_err(|e| format!("Failed to chmod {}: {e}", path.display()))?; + } + } + Ok(file) +} + +// ── Spawning ────────────────────────────────────────────────────────── + +/// Launch a detached `--background-worker` process for the saved profile. +pub fn spawn_background_worker(mount_point: &Path) -> Result { + let exe = std::env::current_exe().map_err(|e| format!("Could not locate current executable: {e}"))?; + clear_worker_status(); + write_worker_status( + WorkerState::Mounting, + "Background worker launching", + "Starting detached process.", + Some(mount_point), + None, + )?; + let result = (|| { + let log = worker_log_file()?; + let log_for_stderr = log + .try_clone() + .map_err(|e| format!("Failed to duplicate background log handle: {e}"))?; + let mut command = Command::new(exe); + command + .arg(BACKGROUND_WORKER_ARG) + .stdin(Stdio::null()) + .stdout(Stdio::from(log)) + .stderr(Stdio::from(log_for_stderr)); + platform::detach_command(&mut command); + command + .spawn() + .map_err(|e| format!("Failed to launch background worker: {e}")) + })(); + if result.is_err() { + // Remove the provisional "launching" status so the poller doesn't + // treat a worker that never existed as live for the staleness window. + clear_worker_status(); + } + result +} + +// ── Worker process entry point ──────────────────────────────────────── + +/// Body of the detached `--background-worker` process: load the saved +/// profile, run the NFS mount, and mirror progress into the status file. +pub fn run_background_worker() -> Result<(), String> { + crate::util::init_backend_once(); + + append_worker_log("Background worker starting"); + + // These run before any worker-owned status exists, so a failure here would + // otherwise leave the GUI's provisional `Mounting` claim live for the whole + // staleness window. Overwrite it with a `Failed` record on any early error. + let early_setup = || -> Result<_, String> { + let profile = load_mount_profile()?.ok_or_else(|| "No saved mount settings were found.".to_string())?; + let source = profile_mount_source(&profile)?; + let options = profile_mount_options(&profile)?; + Ok((source, options)) + }; + let (source, options) = match early_setup() { + Ok(parts) => parts, + Err(e) => { + append_worker_log(format!("Background worker failed to start: {e}")); + let _ = write_worker_status( + WorkerState::Failed, + "Background worker failed to start", + &e, + None, + Some(std::process::id()), + ); + return Err(e); + } + }; + let worker_mount_point = source.mount_point().to_path_buf(); + let mount_label = worker_mount_point.display().to_string(); + write_worker_status( + WorkerState::Mounting, + "Background worker starting", + format!("Target: {mount_label}"), + Some(&worker_mount_point), + Some(std::process::id()), + )?; + + // catch_unwind is a last resort for panics deep inside the backend; setup + // and mount errors arrive as plain Results. + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let setup = hf_mount::setup::build(source, options, true).map_err(|e| e.to_string())?; + let virtual_fs = setup.virtual_fs.clone(); + let mount_point = setup.mount_point.clone(); + let params = hf_mount::nfs::NfsMountParams { + metadata_ttl_ms: setup.metadata_ttl_ms, + read_only: setup.read_only, + security: setup.nfs_security.clone(), + shutdown: None, + }; + let event_mount_point = mount_point.clone(); + setup + .runtime + .block_on(hf_mount::nfs::mount_nfs_with_callback( + virtual_fs, + &mount_point, + params, + None, + move |event| handle_background_mount_event(&event_mount_point, event), + )) + .map_err(|e| e.to_string()) + })); + + match result { + Ok(Ok(())) => { + append_worker_log("Background worker stopped cleanly"); + write_worker_status( + WorkerState::Stopped, + "Background mount stopped", + "The background worker exited cleanly.", + Some(&worker_mount_point), + Some(std::process::id()), + )?; + Ok(()) + } + Ok(Err(message)) => { + append_worker_log(format!("Mount failed: {message}")); + let _ = write_worker_status( + WorkerState::Failed, + "Mount failed", + &message, + Some(&worker_mount_point), + Some(std::process::id()), + ); + Err(message) + } + Err(payload) => { + let message = panic_message(payload); + append_worker_log(format!("Mount crashed: {message}")); + let _ = write_worker_status( + WorkerState::Failed, + "Mount crashed", + &message, + Some(&worker_mount_point), + Some(std::process::id()), + ); + Err(message) + } + } +} + +fn handle_background_mount_event(default_mount_point: &Path, event: NfsMountEvent) { + match event { + NfsMountEvent::ServerListening { port } => { + append_worker_log(format!("Local NFS server is listening on 127.0.0.1:{port}")); + let _ = write_worker_status( + WorkerState::Mounting, + "Local NFS server is listening", + format!("127.0.0.1:{port}"), + Some(default_mount_point), + Some(std::process::id()), + ); + } + NfsMountEvent::MountCommand { command } => { + append_worker_log(format!("Running {command}")); + } + NfsMountEvent::Mounted { mount_point } => { + append_worker_log(format!("Mounted at {mount_point}")); + let event_mount_point = Path::new(&mount_point); + let _ = write_worker_status( + WorkerState::Mounted, + "Mounted", + format!("Mounted at {mount_point}"), + Some(event_mount_point), + Some(std::process::id()), + ); + } + NfsMountEvent::ShuttingDown { reason } => { + append_worker_log(format!("Shutting down: {reason}")); + let _ = write_worker_status( + WorkerState::Stopping, + "Shutting down", + reason, + Some(default_mount_point), + Some(std::process::id()), + ); + } + } +} + +// ── Poller thread ───────────────────────────────────────────────────── + +/// A poll result: the parsed status file plus a liveness verdict. +#[derive(Clone, Debug)] +pub struct WorkerSnapshot { + /// Bumped on every completed poll so the UI can skip stale reads. + pub generation: u64, + pub status: Option, + pub live: bool, + pub error: Option, +} + +/// Watches the background worker from a dedicated thread so the UI thread +/// never touches the status file, `tasklist.exe`, or a possibly-wedged NFS +/// mount. The previous implementation did all of that on every frame. +pub struct WorkerPoller { + snapshot: Arc>, + stop: Arc, + wake: Arc, + wake_lock: Arc>, + handle: Option>, +} + +impl WorkerPoller { + /// Start polling. `repaint` is invoked after every poll so the UI wakes + /// up promptly. + pub fn start(repaint: impl Fn() + Send + 'static) -> Self { + let snapshot = Arc::new(Mutex::new(WorkerSnapshot { + generation: 0, + status: None, + live: false, + error: None, + })); + let stop = Arc::new(AtomicBool::new(false)); + let wake = Arc::new(std::sync::Condvar::new()); + let wake_lock = Arc::new(Mutex::new(())); + + let thread_snapshot = snapshot.clone(); + let thread_stop = stop.clone(); + let thread_wake = wake.clone(); + let thread_wake_lock = wake_lock.clone(); + let handle = std::thread::Builder::new() + .name("worker-status-poller".to_string()) + .spawn(move || { + let mut generation = 0u64; + while !thread_stop.load(Ordering::SeqCst) { + let (status, live, error) = match read_worker_status() { + Ok(Some(status)) => { + // Liveness probes (process lookup, mount stat) are + // only worth their cost while the file claims an + // active worker; terminal states are simply dead. + let live = status.state.is_active() && { + let mount_point = status + .mount_point + .as_deref() + .map(str::trim) + .filter(|mount_point| !mount_point.is_empty()) + .map(PathBuf::from); + worker_status_is_live(&status, mount_point.as_deref()) + }; + (Some(status), live, None) + } + Ok(None) => (None, false, None), + Err(e) => (None, false, Some(e)), + }; + + { + let mut shared = thread_snapshot.lock().expect("worker snapshot poisoned"); + generation += 1; + *shared = WorkerSnapshot { + generation, + status, + live, + error, + }; + } + repaint(); + + let guard = thread_wake_lock.lock().expect("poller wake lock poisoned"); + let _unused = thread_wake + .wait_timeout(guard, POLL_INTERVAL) + .expect("poller wake lock poisoned"); + } + }) + .expect("failed to spawn worker poller thread"); + + Self { + snapshot, + stop, + wake, + wake_lock, + handle: Some(handle), + } + } + + pub fn snapshot(&self) -> WorkerSnapshot { + self.snapshot.lock().expect("worker snapshot poisoned").clone() + } +} + +impl Drop for WorkerPoller { + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + { + let _guard = self.wake_lock.lock().expect("poller wake lock poisoned"); + self.wake.notify_all(); + } + // The poller may be blocked inside a liveness probe on a wedged NFS + // mount; joining unconditionally would hang window close. Reap it if + // it winds down promptly, otherwise detach — the process is exiting + // and the thread holds only Arcs. + if let Some(handle) = self.handle.take() { + let deadline = std::time::Instant::now() + Duration::from_millis(250); + while !handle.is_finished() && std::time::Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + if handle.is_finished() { + let _ = handle.join(); + } + } + } +} diff --git a/src/daemon.rs b/src/daemon.rs index ab88885..4fc7c69 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -20,14 +20,22 @@ fn trusted_state_dir_from_home(home: &Path) -> std::io::Result { if meta.file_type().is_symlink() || !meta.is_dir() { return Err(std::io::Error::new( std::io::ErrorKind::PermissionDenied, - format!("HOME {} must be a trusted directory, not a symlink or file", home.display()), + format!( + "HOME {} must be a trusted directory, not a symlink or file", + home.display() + ), )); } let uid = unsafe { libc::getuid() }; if meta.uid() != uid { return Err(std::io::Error::new( std::io::ErrorKind::PermissionDenied, - format!("HOME {} is owned by uid {}, expected {}", home.display(), meta.uid(), uid), + format!( + "HOME {} is owned by uid {}, expected {}", + home.display(), + meta.uid(), + uid + ), )); } if meta.mode() & 0o022 != 0 { @@ -120,7 +128,12 @@ fn ensure_private_dir(path: &Path) -> std::io::Result<()> { if meta.uid() != uid { return Err(std::io::Error::new( std::io::ErrorKind::PermissionDenied, - format!("state directory {} is owned by uid {}, expected {}", path.display(), meta.uid(), uid), + format!( + "state directory {} is owned by uid {}, expected {}", + path.display(), + meta.uid(), + uid + ), )); } let mode = meta.mode() & 0o777; @@ -139,10 +152,7 @@ fn prepare_state_dir(path: &Path) -> std::io::Result<()> { } fn open_no_follow_read(path: &Path) -> std::io::Result { - OpenOptions::new() - .read(true) - .custom_flags(libc::O_NOFOLLOW) - .open(path) + OpenOptions::new().read(true).custom_flags(libc::O_NOFOLLOW).open(path) } fn read_to_string_no_follow(path: &Path) -> std::io::Result { diff --git a/src/error.rs b/src/error.rs index db83217..7947783 100644 --- a/src/error.rs +++ b/src/error.rs @@ -2,11 +2,17 @@ use std::fmt; #[derive(Debug)] pub enum Error { - Hub { message: String, status: Option }, + Hub { + message: String, + status: Option, + }, Xet(String), Io(std::io::Error), Json(serde_json::Error), Http(reqwest::Error), + /// Mount configuration or initialization failure (bad CLI/GUI input, + /// cache-dir preparation, storage client bootstrap, ...). + Setup(String), } impl Error { @@ -37,6 +43,7 @@ impl fmt::Display for Error { Self::Io(err) => write!(f, "IO error: {err}"), Self::Json(err) => write!(f, "JSON error: {err}"), Self::Http(err) => write!(f, "HTTP error: {err}"), + Self::Setup(msg) => write!(f, "Setup error: {msg}"), } } } diff --git a/src/lib.rs b/src/lib.rs index b72da27..cb1a572 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,6 +25,7 @@ pub mod nfs; pub mod overlay; pub mod setup; pub mod virtual_fs; +pub mod windows; pub mod xet; #[cfg(test)] diff --git a/src/nfs.rs b/src/nfs.rs index dc5c7e1..4240db3 100644 --- a/src/nfs.rs +++ b/src/nfs.rs @@ -566,16 +566,13 @@ pub async fn mount_nfs( security: NfsSecurity, daemon_guard: Option<&mut DaemonGuard>, ) -> std::io::Result<()> { - mount_nfs_with_callback( - virtual_fs, - mount_point, + let params = NfsMountParams { metadata_ttl_ms, read_only, security, - daemon_guard, - |_| {}, - ) - .await + shutdown: None, + }; + mount_nfs_with_callback(virtual_fs, mount_point, params, daemon_guard, |_| {}).await } #[derive(Clone, Debug, PartialEq, Eq)] @@ -586,18 +583,86 @@ pub enum NfsMountEvent { ShuttingDown { reason: String }, } +/// Cooperative shutdown handle for an NFS mount started with +/// [`mount_nfs_with_callback`]. Cloneable; `request()` may be called from any +/// thread at any point of the mount lifecycle — including while the mount +/// command is still being retried — and makes the mount unmount and return. +#[derive(Clone, Default)] +pub struct MountShutdown { + inner: Arc, +} + +#[derive(Default)] +struct MountShutdownInner { + requested: std::sync::atomic::AtomicBool, + notify: tokio::sync::Notify, +} + +impl MountShutdown { + pub fn new() -> Self { + Self::default() + } + + /// Ask the mount to stop. Idempotent and thread-safe. + pub fn request(&self) { + self.inner.requested.store(true, std::sync::atomic::Ordering::SeqCst); + self.inner.notify.notify_waiters(); + } + + pub fn is_requested(&self) -> bool { + self.inner.requested.load(std::sync::atomic::Ordering::SeqCst) + } + + /// Resolve once shutdown has been requested. Race-free against `request` + /// calls that happen before or while the future is being created. + async fn wait(&self) { + loop { + if self.is_requested() { + return; + } + let mut notified = std::pin::pin!(self.inner.notify.notified()); + notified.as_mut().enable(); + if self.is_requested() { + return; + } + notified.await; + } + } +} + +/// Resolve when `shutdown` fires; pend forever when no handle was provided. +async fn wait_for_shutdown(shutdown: Option<&MountShutdown>) { + match shutdown { + Some(shutdown) => shutdown.wait().await, + None => std::future::pending().await, + } +} + +/// Mount parameters for [`mount_nfs_with_callback`]. +pub struct NfsMountParams { + pub metadata_ttl_ms: u64, + pub read_only: bool, + pub security: NfsSecurity, + /// Optional cooperative stop handle; see [`MountShutdown`]. + pub shutdown: Option, +} + pub async fn mount_nfs_with_callback( virtual_fs: Arc, mount_point: &Path, - metadata_ttl_ms: u64, - read_only: bool, - security: NfsSecurity, + params: NfsMountParams, daemon_guard: Option<&mut DaemonGuard>, mut on_event: F, ) -> std::io::Result<()> where F: FnMut(NfsMountEvent) + Send, { + let NfsMountParams { + metadata_ttl_ms, + read_only, + security, + shutdown, + } = params; let vfs_for_shutdown = virtual_fs.clone(); let adapter = NFSAdapter::new(virtual_fs, read_only, security.clone()); let pool_for_shutdown = adapter.handle_pool.clone(); @@ -653,6 +718,17 @@ where // Convert ms to seconds (rounding up so 100ms → 1s, not 0s which disables caching entirely) let actimeo = metadata_ttl_ms.div_ceil(1000); + // A stop request that lands before the mount command runs aborts cleanly + // instead of mounting and immediately tearing down. + if shutdown.as_ref().is_some_and(MountShutdown::is_requested) { + server_handle.abort(); + on_event(NfsMountEvent::ShuttingDown { + reason: "stop requested".to_string(), + }); + vfs_for_shutdown.shutdown(); + return Ok(()); + } + // Platform-specific mount command #[cfg(target_os = "macos")] { @@ -672,9 +748,28 @@ where on_event(NfsMountEvent::MountCommand { command: format!("{} -o {} {} {}", mount_cmd.display(), opts, nfs_export, mount_point_str), }); - let status = std::process::Command::new(mount_cmd) + let mut command = tokio::process::Command::new(mount_cmd); + command .args(["-o", &opts, &nfs_export, mount_point_str]) - .status()?; + .kill_on_drop(true); + // Race the mount command against a stop request so a hung mount_nfs + // cannot pin the shutdown; kill_on_drop reaps the child on cancel. + let status = tokio::select! { + // A mount command that completed in the same poll must win over a + // concurrent stop, otherwise we could tear the server down while the + // client mount it just established stays behind. When it wins here + // the still-pending stop is handled by the wait loop, which unmounts. + biased; + status = command.status() => status?, + _ = wait_for_shutdown(shutdown.as_ref()) => { + server_handle.abort(); + on_event(NfsMountEvent::ShuttingDown { + reason: "stop requested".to_string(), + }); + vfs_for_shutdown.shutdown(); + return Ok(()); + } + }; if !status.success() { server_handle.abort(); return Err(std::io::Error::other(format!("mount command failed with {status}"))); @@ -690,14 +785,33 @@ where on_event(NfsMountEvent::MountCommand { command: format!("mount.nfs -o {mount_opts} {nfs_export} {mount_point_str}"), }); - let output = if unsafe { libc::getuid() } == 0 { - std::process::Command::new("mount.nfs") - .args(["-o", &mount_opts, &nfs_export, mount_point_str]) - .output()? + let mut command = if unsafe { libc::getuid() } == 0 { + let mut command = tokio::process::Command::new("mount.nfs"); + command.args(["-o", &mount_opts, &nfs_export, mount_point_str]); + command } else { - std::process::Command::new("sudo") - .args(["-n", "mount.nfs", "-o", &mount_opts, &nfs_export, mount_point_str]) - .output()? + let mut command = tokio::process::Command::new("sudo"); + command.args(["-n", "mount.nfs", "-o", &mount_opts, &nfs_export, mount_point_str]); + command + }; + command.kill_on_drop(true); + // Race the mount command against a stop request so a hung mount.nfs + // cannot pin the shutdown; kill_on_drop reaps the child on cancel. + let output = tokio::select! { + // A mount command that completed in the same poll must win over a + // concurrent stop, otherwise we could tear the server down while the + // client mount it just established stays behind. When it wins here + // the still-pending stop is handled by the wait loop, which unmounts. + biased; + output = command.output() => output?, + _ = wait_for_shutdown(shutdown.as_ref()) => { + server_handle.abort(); + on_event(NfsMountEvent::ShuttingDown { + reason: "stop requested".to_string(), + }); + vfs_for_shutdown.shutdown(); + return Ok(()); + } }; if !output.status.success() { server_handle.abort(); @@ -740,7 +854,19 @@ where ); } else { info!("Running: {cmd}"); - let output = mount_windows_nfs_with_retry(&mount_cmd, &opts, &share, &mount_target).await?; + // `None` means a stop request cancelled the mount — a clean stop, + // not a failure. + let Some(output) = + mount_windows_nfs_with_retry(&mount_cmd, &opts, &share, &mount_target, shutdown.as_ref()).await? + else { + server_handle.abort(); + portmapper_handle.abort(); + on_event(NfsMountEvent::ShuttingDown { + reason: "stop requested".to_string(), + }); + vfs_for_shutdown.shutdown(); + return Ok(()); + }; if !output.status.success() { server_handle.abort(); portmapper_handle.abort(); @@ -757,14 +883,21 @@ where } } - info!("NFS mount active at {}", mount_point_str); - on_event(NfsMountEvent::Mounted { - mount_point: mount_point_str.to_string(), - }); + if skip_auto_mount { + // The server and portmapper are up but no client mount exists yet (the + // user mounts manually), so we must not claim Mounted or signal + // readiness — both would advertise a live mount that isn't there. + info!("NFS server is ready; waiting for a manual mount of {}", mount_point_str); + } else { + info!("NFS mount active at {}", mount_point_str); + on_event(NfsMountEvent::Mounted { + mount_point: mount_point_str.to_string(), + }); - // Signal the parent process that the mount is live (daemon mode). - if let Some(guard) = daemon_guard { - guard.notify_ready(); + // Signal the parent process that the mount is live (daemon mode). + if let Some(guard) = daemon_guard { + guard.notify_ready(); + } } // Wait for unmount signal, server exit, or Ctrl+C. @@ -782,6 +915,13 @@ where let mut server_handle = server_handle; tokio::pin!(sigterm_fut); let mut server_exited = false; + // The liveness probe runs as its own select arm: a probe that wedges on a + // dead mount must not keep the shutdown/signal/UMNT branches from running. + let mut probe_handle: Option> = None; + // Shutdown-time unmounts are offloaded to a blocking task and awaited + // (bounded) after the loop, so a wedged umount can't delay the break and + // the subsequent server/portmapper/VFS teardown. + let mut unmount_task: Option> = None; loop { tokio::select! { msg = mount_rx.recv() => { @@ -806,22 +946,25 @@ where } _ = tokio::signal::ctrl_c() => { info!("Received Ctrl+C, unmounting..."); - on_event(NfsMountEvent::ShuttingDown { - reason: "Ctrl+C received".to_string(), - }); - unmount_nfs(mount_point_str); + on_event(NfsMountEvent::ShuttingDown { reason: "Ctrl+C received".to_string() }); + unmount_task = Some(spawn_unmount(mount_point_str)); break; } _ = &mut sigterm_fut => { info!("Received SIGTERM, unmounting..."); - on_event(NfsMountEvent::ShuttingDown { - reason: "termination signal received".to_string(), - }); - unmount_nfs(mount_point_str); + on_event(NfsMountEvent::ShuttingDown { reason: "termination signal received".to_string() }); + unmount_task = Some(spawn_unmount(mount_point_str)); + break; + } + _ = wait_for_shutdown(shutdown.as_ref()) => { + info!("Shutdown requested, unmounting..."); + on_event(NfsMountEvent::ShuttingDown { reason: "stop requested".to_string() }); + unmount_task = Some(spawn_unmount(mount_point_str)); break; } - _ = tokio::time::sleep(std::time::Duration::from_secs(2)) => { - if !skip_auto_mount && !is_mounted(mount_point_str) { + mounted = async { probe_handle.as_mut().expect("probe arm is guarded").await }, if probe_handle.is_some() => { + probe_handle = None; + if !skip_auto_mount && !mounted.unwrap_or(false) { info!("NFS mount disappeared, shutting down"); on_event(NfsMountEvent::ShuttingDown { reason: "mount disappeared".to_string(), @@ -829,6 +972,33 @@ where break; } } + _ = tokio::time::sleep(std::time::Duration::from_secs(2)), if probe_handle.is_none() => { + // The probe touches the (possibly wedged) mount, so it runs on + // the blocking pool and is awaited by the arm above. + let probe_path = mount_point_str.to_string(); + probe_handle = Some(tokio::task::spawn_blocking(move || is_mounted(&probe_path))); + } + } + } + // An in-flight probe is abandoned here; its blocking thread finishes (or + // unwedges) on its own and only returns a bool nobody reads. + drop(probe_handle); + + // Await the shutdown-time unmount (if any) before tearing the server down, + // so the server can still service the UMNT RPC — but bound the wait so a + // wedged umount can't pin teardown indefinitely. + if let Some(task) = unmount_task { + match tokio::time::timeout(std::time::Duration::from_secs(10), task).await { + Ok(Ok(true)) => {} + Ok(Ok(false)) => tracing::warn!( + "unmount of {} failed during shutdown; manual cleanup (umount -f) may be required", + mount_point_str + ), + Ok(Err(e)) => tracing::warn!("unmount task for {} failed: {}", mount_point_str, e), + Err(_) => tracing::warn!( + "unmount of {} timed out during shutdown; proceeding with teardown", + mount_point_str + ), } } @@ -1027,8 +1197,17 @@ fn system_time_to_nfstime(t: SystemTime) -> nfstime3 { } } -/// Check if a path is still an active mount point. -fn unmount_nfs(mount_point: &str) { +/// Run the (blocking) unmount on the blocking pool so a wedged external +/// `umount` can't stall the async shutdown path. Returns a handle the caller +/// awaits with a bounded timeout. +fn spawn_unmount(mount_point: &str) -> tokio::task::JoinHandle { + let mount_point = mount_point.to_string(); + tokio::task::spawn_blocking(move || unmount_nfs(&mount_point)) +} + +/// Unmount `mount_point`, trying the platform syscall first and an external +/// command as fallback. Returns `true` when one of them reported success. +fn unmount_nfs(mount_point: &str) -> bool { #[cfg(unix)] use std::ffi::CString; @@ -1038,49 +1217,87 @@ fn unmount_nfs(mount_point: &str) { #[cfg(target_os = "linux")] { if unsafe { libc::umount2(c_path.as_ptr(), libc::MNT_DETACH) } == 0 { - return; + return true; } } #[cfg(target_os = "macos")] { if unsafe { libc::unmount(c_path.as_ptr(), libc::MNT_FORCE) } == 0 { - return; + return true; } } } // Fallback: external command. #[cfg(target_os = "macos")] - if let Err(e) = std::process::Command::new("/sbin/umount").arg(mount_point).status() { - tracing::warn!("NFS unmount fallback failed for {}: {}", mount_point, e); - } + let result = std::process::Command::new("/sbin/umount").arg(mount_point).status(); #[cfg(target_os = "linux")] - { - let result = if unsafe { libc::getuid() } == 0 { - std::process::Command::new("umount").arg(mount_point).status() - } else { - std::process::Command::new("sudo") - .args(["-n", "umount", mount_point]) - .status() - }; - if let Err(e) = result { + let result = if unsafe { libc::getuid() } == 0 { + std::process::Command::new("umount").arg(mount_point).status() + } else { + std::process::Command::new("sudo") + .args(["-n", "umount", mount_point]) + .status() + }; + #[cfg(windows)] + let result = std::process::Command::new(umount_command_path()) + .args(["-f", &windows_nfs_mount_target(mount_point)]) + .status(); + + match result { + Ok(status) if status.success() => true, + Ok(status) => { + tracing::warn!("NFS unmount fallback for {} exited with {}", mount_point, status); + false + } + Err(e) => { tracing::warn!("NFS unmount fallback failed for {}: {}", mount_point, e); + false } } - #[cfg(windows)] - if let Err(e) = std::process::Command::new(umount_command_path()) - .args(["-f", &windows_nfs_mount_target(mount_point)]) - .status() - { - tracing::warn!("NFS unmount fallback failed for {}: {}", mount_point, e); +} + +/// Whether `path` currently has an active mount: checked against the mount +/// table on Linux, the statfs filesystem type on macOS, and a drive/directory +/// probe on Windows. A bare existing directory does not count. Blocking on a +/// wedged mount — keep it off latency-sensitive threads. +#[cfg(target_os = "linux")] +fn unescape_proc_mounts(raw: &str) -> String { + raw.replace("\\040", " ") + .replace("\\011", "\t") + .replace("\\012", "\n") + .replace("\\134", "\\") +} + +#[cfg(target_os = "linux")] +fn normalize_mount_path(path: &str) -> String { + let trimmed = path.trim_end_matches('/'); + if trimmed.is_empty() { + "/".to_string() + } else { + trimmed.to_string() } } -fn is_mounted(path: &str) -> bool { +pub fn is_mounted(path: &str) -> bool { #[cfg(target_os = "linux")] { + // /proc/mounts octal-escapes spaces/tabs in the mount point and may + // differ from `path` only by a trailing slash. Normalize both before + // comparing so an active mount isn't misread as "disappeared" (which + // would fire the shutdown path). String-only — no canonicalize, which + // could block on a wedged mount. + let wanted = normalize_mount_path(path); std::fs::read_to_string("/proc/mounts") - .map(|s| s.lines().any(|line| line.split_whitespace().nth(1) == Some(path))) + .map(|contents| { + contents.lines().any(|line| { + line.split_whitespace() + .nth(1) + .map(unescape_proc_mounts) + .map(|mount| normalize_mount_path(&mount)) + .is_some_and(|mount| mount == wanted) + }) + }) .unwrap_or(false) } #[cfg(target_os = "macos")] @@ -1112,6 +1329,66 @@ fn is_mounted(path: &str) -> bool { } } +/// Whether `path` is mounted by *our* local loopback NFS export (device +/// `127.0.0.1:`), as opposed to an unrelated filesystem mounted at the same +/// location. Used to confirm ownership before a speculative cleanup unmount so +/// a stop never detaches a pre-existing mount. Blocking on a wedged mount — +/// keep it off latency-sensitive threads. +pub fn is_loopback_nfs_mount(path: &str) -> bool { + #[cfg(target_os = "linux")] + { + let wanted = normalize_mount_path(path); + std::fs::read_to_string("/proc/mounts") + .map(|contents| { + contents.lines().any(|line| { + let mut fields = line.split_whitespace(); + let device = fields.next(); + let mount = fields + .next() + .map(unescape_proc_mounts) + .map(|mount| normalize_mount_path(&mount)); + mount.as_deref() == Some(wanted.as_str()) + && device.is_some_and(|device| device.starts_with("127.0.0.1:")) + }) + }) + .unwrap_or(false) + } + #[cfg(target_os = "macos")] + { + use std::ffi::{CStr, CString}; + use std::mem::MaybeUninit; + let c_path = match CString::new(path) { + Ok(p) => p, + Err(_) => return false, + }; + unsafe { + let mut buf = MaybeUninit::::uninit(); + if libc::statfs(c_path.as_ptr(), buf.as_mut_ptr()) != 0 { + return false; + } + let buf = buf.assume_init(); + let fstype = CStr::from_ptr(buf.f_fstypename.as_ptr()); + if fstype.to_bytes() != b"nfs" { + return false; + } + let from = CStr::from_ptr(buf.f_mntfromname.as_ptr()); + from.to_bytes().starts_with(b"127.0.0.1:") + } + } + #[cfg(windows)] + { + // Windows drive-letter mounts don't expose the NFS source cheaply; fall + // back to the best-effort mount probe. A drive letter the user assigned + // to our mount is unlikely to collide with an unrelated mount. + is_mounted(path) + } + #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] + { + let _ = path; + false + } +} + #[cfg(target_os = "macos")] fn mount_nfs_command_path() -> std::path::PathBuf { std::path::PathBuf::from("/sbin/mount_nfs") @@ -1132,30 +1409,47 @@ const WINDOWS_ERROR_53_RETRY_ATTEMPTS: usize = 6; #[cfg(windows)] const WINDOWS_ERROR_53_RETRY_DELAY_MS: u64 = 300; +/// Run `mount.exe`, retrying transient Network Error 53. Returns `Ok(None)` +/// when a stop request cancelled the mount (a clean stop, not a failure); +/// both the command itself and the backoff sleeps are interruptible. #[cfg(windows)] async fn mount_windows_nfs_with_retry( mount_cmd: &Path, opts: &str, share: &str, mount_target: &str, -) -> std::io::Result { - for attempt in 1..=WINDOWS_ERROR_53_RETRY_ATTEMPTS { - let output = tokio::process::Command::new(mount_cmd) - .args(["-o", opts, share, mount_target]) - .output() - .await?; + shutdown: Option<&MountShutdown>, +) -> std::io::Result> { + let mut attempt = 1; + loop { + if shutdown.is_some_and(MountShutdown::is_requested) { + return Ok(None); + } + + let mut command = tokio::process::Command::new(mount_cmd); + command.args(["-o", opts, share, mount_target]).kill_on_drop(true); + let output = tokio::select! { + // A successful mount.exe in the same poll must win over a concurrent + // stop; the caller's wait loop then unmounts it. Otherwise the + // attempt could be reported as cancelled while the mount stayed live. + biased; + output = command.output() => output?, + _ = wait_for_shutdown(shutdown) => return Ok(None), + }; if output.status.success() || !windows_mount_output_is_network_error_53(&output) || attempt == WINDOWS_ERROR_53_RETRY_ATTEMPTS { - return Ok(output); + return Ok(Some(output)); } - tokio::time::sleep(std::time::Duration::from_millis(WINDOWS_ERROR_53_RETRY_DELAY_MS)).await; + attempt += 1; + tokio::select! { + _ = tokio::time::sleep(std::time::Duration::from_millis(WINDOWS_ERROR_53_RETRY_DELAY_MS)) => {} + _ = wait_for_shutdown(shutdown) => return Ok(None), + } } - - unreachable!("mount retry loop always returns on the final attempt") } #[cfg(windows)] @@ -1191,13 +1485,7 @@ fn windows_nfs_share(export_name: &str) -> String { } #[cfg(windows)] -fn windows_system32_exe(name: &str) -> std::path::PathBuf { - std::env::var_os("SystemRoot") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::PathBuf::from(r"C:\Windows")) - .join("System32") - .join(name) -} +use crate::windows::{drive_letter as windows_drive_letter, system32_exe as windows_system32_exe}; #[cfg(windows)] fn windows_nfs_mount_target(path: &str) -> String { @@ -1215,20 +1503,6 @@ fn windows_nfs_probe_path(path: &str) -> String { } } -#[cfg(windows)] -fn windows_drive_letter(path: &str) -> Option { - let mut chars = path.chars(); - let drive = chars.next()?; - if !drive.is_ascii_alphabetic() || chars.next() != Some(':') { - return None; - } - match (chars.next(), chars.next()) { - (None, None) => Some(drive), - (Some('\\' | '/'), None) => Some(drive), - _ => None, - } -} - #[cfg(all(test, windows))] mod windows_nfs_path_tests { use super::*; @@ -1322,6 +1596,37 @@ mod tests { } } + #[cfg(target_os = "linux")] + #[test] + fn proc_mounts_unescape_handles_octal_escapes() { + // /proc/mounts octal-escapes space, tab, newline and backslash; the + // liveness probe must decode them or an active mount reads as gone. + assert_eq!(unescape_proc_mounts(r"/mnt/with\040space"), "/mnt/with space"); + assert_eq!(unescape_proc_mounts(r"/mnt/tab\011here"), "/mnt/tab\there"); + assert_eq!(unescape_proc_mounts(r"/mnt/nl\012here"), "/mnt/nl\nhere"); + assert_eq!(unescape_proc_mounts(r"/mnt/back\134slash"), r"/mnt/back\slash"); + assert_eq!(unescape_proc_mounts(r"/a\040b\011c\134d"), "/a b\tc\\d"); + // Unescaped paths pass through unchanged. + assert_eq!(unescape_proc_mounts("/plain/path"), "/plain/path"); + } + + #[cfg(target_os = "linux")] + #[test] + fn proc_mounts_normalize_trims_trailing_slashes() { + assert_eq!(normalize_mount_path("/foo/"), "/foo"); + assert_eq!(normalize_mount_path("/foo///"), "/foo"); + assert_eq!(normalize_mount_path("/foo"), "/foo"); + // Root must survive rather than collapse to an empty string. + assert_eq!(normalize_mount_path("/"), "/"); + assert_eq!(normalize_mount_path("///"), "/"); + // A path differing from the table only by a trailing slash compares + // equal after normalization — the false-disappearance regression. + assert_eq!( + normalize_mount_path("/tmp/hf-mount/"), + normalize_mount_path("/tmp/hf-mount") + ); + } + #[test] fn nfs_name_rejects_path_components() { for bytes in [b"".as_slice(), b".", b"..", b"a/b", b"a\0b"] { diff --git a/src/setup.rs b/src/setup.rs index 802a8e1..60ff015 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -10,12 +10,17 @@ use xet_data::processing::{CacheConfig, FileDownloadSession, create_remote_clien use xet_runtime::core::XetContext; use crate::cached_xet_client::CachedXetClient; +use crate::error::{Error, Result}; use crate::file_cache::FileCache; use crate::hub_api::{HubApiClient, HubTokenRefresher, SourceKind, parse_repo_id, split_path_prefix}; use crate::overlay::OverlayBacking; use crate::virtual_fs::{VfsConfig, VirtualFs}; use crate::xet::{StagingDir, XetSessions}; +fn setup_err(message: impl Into) -> Error { + Error::Setup(message.into()) +} + #[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)] pub enum CacheMode { /// xet-core's chunk_cache: caches xorb byte ranges on disk. @@ -228,13 +233,29 @@ pub struct Args { pub options: MountOptions, } +/// Owns a runtime and bounds its teardown. Runtime::drop waits for +/// blocking-pool tasks; a probe wedged in a stat on a dead NFS mount would +/// stall teardown forever, so detach stragglers after a grace period. +/// Implementing Drop here (not on MountSetup) keeps MountSetup's fields +/// movable. +#[derive(Default)] +struct OwnedRuntime(Option); + +impl Drop for OwnedRuntime { + fn drop(&mut self) { + if let Some(runtime) = self.0.take() { + runtime.shutdown_timeout(std::time::Duration::from_secs(5)); + } + } +} + /// Everything needed to run a mount backend (FUSE or NFS). pub struct MountSetup { pub runtime: tokio::runtime::Handle, - /// Owned runtime, kept alive for the lifetime of this MountSetup. `None` + /// Owned runtime, kept alive for the lifetime of this MountSetup. Empty /// when the runtime is owned externally (sidecar mode shares one runtime /// across all volumes — see `build_with_runtime`). - _owned_runtime: Option, + _owned_runtime: OwnedRuntime, pub virtual_fs: Arc, pub mount_point: PathBuf, pub read_only: bool, @@ -318,12 +339,11 @@ pub fn init_tracing(daemon: bool) { /// Async tasks live on the heap, so the per-thread stack only needs to fit /// the deepest sync call. 512 KB is ample and shrinks the per-worker virtual /// reservation from the 2 MB default. -pub fn build_runtime() -> tokio::runtime::Runtime { +pub fn build_runtime() -> std::io::Result { tokio::runtime::Builder::new_multi_thread() .thread_stack_size(512 * 1024) .enable_all() .build() - .expect("Failed to create tokio runtime") } /// Build tokio runtime, storage client, Hub client, and VFS. @@ -331,11 +351,11 @@ pub fn build_runtime() -> tokio::runtime::Runtime { /// /// Owns the runtime it creates. Use `build_with_runtime` to share one runtime /// across multiple volumes (sidecar mode). -pub fn build(source: Source, options: MountOptions, is_nfs: bool) -> MountSetup { - let runtime = build_runtime(); - let mut setup = build_with_runtime(source, options, is_nfs, runtime.handle().clone()); - setup._owned_runtime = Some(runtime); - setup +pub fn build(source: Source, options: MountOptions, is_nfs: bool) -> Result { + let runtime = build_runtime().map_err(|e| setup_err(format!("failed to create tokio runtime: {e}")))?; + let mut setup = build_with_runtime(source, options, is_nfs, runtime.handle().clone())?; + setup._owned_runtime = OwnedRuntime(Some(runtime)); + Ok(setup) } /// Like `build`, but reuses an externally-owned runtime. The caller must keep @@ -346,10 +366,11 @@ pub fn build_with_runtime( options: MountOptions, is_nfs: bool, runtime: tokio::runtime::Handle, -) -> MountSetup { +) -> Result { let (mount_point, source_kind, path_prefix) = match source { Source::Bucket { bucket_id, mount_point } => { - let (id, prefix) = split_path_prefix(&bucket_id).unwrap_or_else(|e| panic!("invalid bucket path: {e}")); + let (id, prefix) = + split_path_prefix(&bucket_id).map_err(|e| setup_err(format!("invalid bucket path: {e}")))?; ( mount_point, SourceKind::Bucket { @@ -364,7 +385,7 @@ pub fn build_with_runtime( revision, } => { let (repo_type, rest) = parse_repo_id(&repo_id); - let (id, prefix) = split_path_prefix(&rest).unwrap_or_else(|e| panic!("invalid repo path: {e}")); + let (id, prefix) = split_path_prefix(&rest).map_err(|e| setup_err(format!("invalid repo path: {e}")))?; ( mount_point, SourceKind::Repo { @@ -378,48 +399,48 @@ pub fn build_with_runtime( }; if options.overlay && options.read_only { - panic!( - "--overlay with --read-only is pointless: overlay enables local writes, --read-only disables them. Use --read-only alone instead." - ); + return Err(setup_err( + "--overlay with --read-only is pointless: overlay enables local writes, --read-only disables them. Use --read-only alone instead.", + )); } #[cfg(windows)] if options.overlay { - panic!("--overlay is not supported on Windows. Use a regular NFS mount without --overlay."); + return Err(setup_err( + "--overlay is not supported on Windows. Use a regular NFS mount without --overlay.", + )); } #[cfg(not(unix))] let private_nfs_credentials = is_nfs && (options.hf_token.is_some() || options.token_file.is_some()); #[cfg(not(unix))] if private_nfs_credentials && !options.nfs_allow_unsafe_loopback { - panic!( - "credential-backed NFS mounts require --nfs-allow-unsafe-loopback on this platform because local NFS caller authorization cannot be enforced" - ); + return Err(setup_err( + "credential-backed NFS mounts require --nfs-allow-unsafe-loopback on this platform because local NFS caller authorization cannot be enforced", + )); } - ensure_private_cache_dir(&options.cache_dir) - .unwrap_or_else(|e| panic!("Failed to prepare private cache dir {:?}: {e}", options.cache_dir)); + ensure_private_cache_dir(&options.cache_dir).map_err(|e| { + setup_err(format!( + "failed to prepare private cache dir {:?}: {e}", + options.cache_dir + )) + })?; let backend = if is_nfs { "nfs" } else { "fuse" }; - let hub_client = runtime.block_on(async { - HubApiClient::from_source( + let hub_client = runtime + .block_on(HubApiClient::from_source( &options.hub_endpoint, options.hf_token.as_deref(), options.token_file.clone(), source_kind, path_prefix, backend, - ) - .await - .unwrap_or_else(|e| panic!("Failed to initialize Hub client: {e}")) - }); + )) + .map_err(|e| setup_err(format!("failed to initialize Hub client: {e}")))?; // Validate that the subfolder exists on the remote. if !hub_client.path_prefix().is_empty() { - runtime.block_on(async { - hub_client.validate_path_prefix().await.unwrap_or_else(|e| { - panic!("{e}"); - }); - }); + runtime.block_on(hub_client.validate_path_prefix())?; } let read_only = (options.read_only || hub_client.is_repo()) && !options.overlay; @@ -430,8 +451,8 @@ pub fn build_with_runtime( // Overlay: local writes allowed, but no remote write token/upload. let remote_read_only = read_only || options.overlay; let refresher = hub_client.token_refresher(remote_read_only); - let xet_ctx = XetContext::default().expect("Failed to create XetContext"); - let cas_config = build_cas_config(&xet_ctx, &runtime, &refresher); + let xet_ctx = XetContext::default().map_err(|e| setup_err(format!("failed to create XetContext: {e}")))?; + let cas_config = build_cas_config(&xet_ctx, &runtime, &refresher)?; // The chunk cache and the whole-file cache are mutually exclusive: when // `cache_mode=file` we explicitly disable xet-core's chunk_cache so we @@ -443,7 +464,10 @@ pub fn build_with_runtime( ); } let file_cache = if options.cache_mode == CacheMode::File && !options.no_disk_cache { - Some(FileCache::new(&options.cache_dir, options.cache_size).expect("Failed to create file cache")) + Some( + FileCache::new(&options.cache_dir, options.cache_size) + .map_err(|e| setup_err(format!("failed to create file cache: {e}")))?, + ) } else { None }; @@ -453,12 +477,12 @@ pub fn build_with_runtime( } else { let xorbs_dir = options.cache_dir.join("xorbs"); std::fs::create_dir_all(&xorbs_dir) - .unwrap_or_else(|e| panic!("Failed to create xorbs dir {:?}: {e}", xorbs_dir)); + .map_err(|e| setup_err(format!("failed to create xorbs dir {xorbs_dir:?}: {e}")))?; let config = CacheConfig { cache_directory: xorbs_dir, cache_size: options.cache_size, }; - Some(get_cache(&xet_ctx.config, &config).expect("Failed to create chunk cache")) + Some(get_cache(&xet_ctx.config, &config).map_err(|e| setup_err(format!("failed to create chunk cache: {e}")))?) }; let raw_client = runtime @@ -467,7 +491,7 @@ pub fn build_with_runtime( &uuid::Uuid::new_v4().to_string(), false, )) - .expect("Failed to create storage client"); + .map_err(|e| setup_err(format!("failed to create storage client: {e}")))?; let cached_client = CachedXetClient::new(raw_client); let download_session = FileDownloadSession::from_client(&xet_ctx, cached_client.clone(), xorb_cache.clone()); let upload_config = if remote_read_only { None } else { Some(cas_config) }; @@ -480,10 +504,10 @@ pub fn build_with_runtime( // rooted at the covered directory after mount. let overlay_backing = if options.overlay { std::fs::create_dir_all(&mount_point) - .unwrap_or_else(|e| panic!("Failed to create mount point {:?} for overlay: {e}", mount_point)); + .map_err(|e| setup_err(format!("failed to create mount point {mount_point:?} for overlay: {e}")))?; Some( OverlayBacking::open_dir(&mount_point) - .unwrap_or_else(|e| panic!("Failed to open mount point {:?} for overlay: {e}", mount_point)), + .map_err(|e| setup_err(format!("failed to open mount point {mount_point:?} for overlay: {e}")))?, ) } else { None @@ -492,7 +516,7 @@ pub fn build_with_runtime( // Repos need a staging dir for HTTP download cache (open_readonly), // even when advanced_writes is disabled. let staging_dir = if advanced_writes || hub_client.is_repo() { - Some(StagingDir::new(&options.cache_dir, options.max_staging_size)) + Some(StagingDir::new(&options.cache_dir, options.max_staging_size)?) } else { None }; @@ -510,7 +534,7 @@ pub fn build_with_runtime( && let Err(e) = std::fs::create_dir_all(&mount_point) && e.kind() != std::io::ErrorKind::AlreadyExists { - panic!("Failed to create mount point {:?}: {e}", mount_point); + return Err(setup_err(format!("failed to create mount point {mount_point:?}: {e}"))); } if is_nfs && options.direct_io { @@ -594,9 +618,9 @@ pub fn build_with_runtime( }, ); - MountSetup { + Ok(MountSetup { runtime, - _owned_runtime: None, + _owned_runtime: OwnedRuntime::default(), virtual_fs, mount_point, read_only, @@ -607,7 +631,7 @@ pub fn build_with_runtime( metadata_ttl_ms: options.metadata_ttl_ms, fuse_owner_only: effective_fuse_owner_only(&options), nfs_security: NfsSecurity::new(uid, options.nfs_allow_unsafe_loopback), - } + }) } fn effective_fuse_owner_only(options: &MountOptions) -> bool { @@ -618,11 +642,15 @@ fn effective_fuse_owner_only(options: &MountOptions) -> bool { /// Parse CLI args, build VFS and all dependencies. /// `is_nfs` controls whether advanced writes are forced (NFS has no open/close). +/// Exits the process with an error message when setup fails. pub fn setup(is_nfs: bool) -> MountSetup { raise_fd_limit(); let args = Args::parse(); init_tracing(false); - build(args.source, args.options, is_nfs) + build(args.source, args.options, is_nfs).unwrap_or_else(|e| { + eprintln!("Error: {e}"); + std::process::exit(1); + }) } /// Try to raise the soft file descriptor limit to avoid "Too many open files" @@ -656,10 +684,7 @@ pub fn default_cache_dir() -> PathBuf { #[cfg(target_os = "macos")] { if let Some(home) = std::env::var_os("HOME") { - return PathBuf::from(home) - .join("Library") - .join("Caches") - .join("hf-mount"); + return PathBuf::from(home).join("Library").join("Caches").join("hf-mount"); } } #[cfg(all(unix, not(target_os = "macos")))] @@ -683,13 +708,13 @@ fn ensure_private_dir(path: &Path) -> std::io::Result<()> { { use std::os::unix::fs::{MetadataExt, PermissionsExt}; - if let Ok(meta) = std::fs::symlink_metadata(path) { - if meta.file_type().is_symlink() { - return Err(std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - "path must not be a symlink", - )); - } + if let Ok(meta) = std::fs::symlink_metadata(path) + && meta.file_type().is_symlink() + { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "path must not be a symlink", + )); } std::fs::create_dir_all(path)?; @@ -737,19 +762,7 @@ fn should_create_mount_point(mount_point: &Path, is_nfs: bool) -> bool { #[cfg(windows)] fn is_windows_drive_mount_point(path: &Path) -> bool { - let text = path.as_os_str().to_string_lossy(); - let mut chars = text.chars(); - let Some(drive) = chars.next() else { - return false; - }; - if !drive.is_ascii_alphabetic() || chars.next() != Some(':') { - return false; - } - match (chars.next(), chars.next()) { - (None, None) => true, - (Some('\\' | '/'), None) => true, - _ => false, - } + crate::windows::drive_letter(&path.as_os_str().to_string_lossy()).is_some() } #[cfg(unix)] @@ -778,21 +791,20 @@ fn build_cas_config( ctx: &XetContext, runtime: &tokio::runtime::Handle, refresher: &Arc, -) -> Arc { +) -> Result> { let jwt = runtime .block_on(refresher.fetch_initial()) - .unwrap_or_else(|e| panic!("Failed to get storage token: {e}")); + .map_err(|e| setup_err(format!("failed to get storage token: {e}")))?; info!("Got storage token for endpoint: {}", jwt.cas_url); - Arc::new( - default_config( - ctx, - jwt.cas_url, - Some((jwt.access_token, jwt.exp)), - Some(refresher.clone()), - None, - ) - .unwrap_or_else(|e| panic!("Failed to build TranslatorConfig: {e}")), + let config = default_config( + ctx, + jwt.cas_url, + Some((jwt.access_token, jwt.exp)), + Some(refresher.clone()), + None, ) + .map_err(|e| setup_err(format!("failed to build TranslatorConfig: {e}")))?; + Ok(Arc::new(config)) } #[cfg(all(test, windows))] diff --git a/src/test_mocks.rs b/src/test_mocks.rs index 1a47953..843be3e 100644 --- a/src/test_mocks.rs +++ b/src/test_mocks.rs @@ -594,7 +594,7 @@ pub fn make_test_vfs( // even when advanced_writes is disabled (mirrors setup.rs logic). let staging_dir = if effective_advanced_writes || hub.is_repo() { let path = fresh_test_dir("hf_mount_test"); - Some(StagingDir::new(&path, opts.max_staging_size)) + Some(StagingDir::new(&path, opts.max_staging_size).expect("failed to create test staging dir")) } else { None }; @@ -638,7 +638,7 @@ pub fn make_overlay_test_vfs_with_root( .build() .unwrap(); let cache_dir = fresh_test_dir("hf_mount_test"); - let staging_dir = Some(StagingDir::new(&cache_dir, 0)); + let staging_dir = Some(StagingDir::new(&cache_dir, 0).expect("failed to create test staging dir")); let overlay_backing = Some(OverlayBacking::open_dir(&overlay_root).expect("failed to open overlay root dir")); let vfs = crate::virtual_fs::VirtualFs::new( diff --git a/src/windows.rs b/src/windows.rs new file mode 100644 index 0000000..fbd8f9e --- /dev/null +++ b/src/windows.rs @@ -0,0 +1,87 @@ +//! Helpers for Windows drive-letter mount targets and System32 tools. +//! +//! Shared by the NFS backend, mount setup, and the GUI so the parsing rules +//! stay in one place. The module compiles on every platform — the functions +//! are pure (or env-var based), which lets Linux CI cover the logic — but the +//! semantics only matter on Windows. + +use std::path::PathBuf; + +/// Parse a bare drive-letter target such as `Z:`, `Z:\` or `z:/`. +/// +/// Returns the drive letter for drive-letter targets and `None` for anything +/// longer (e.g. `C:\hf-mounts\repo`), which is treated as a directory path. +pub fn drive_letter(path: &str) -> Option { + let mut chars = path.chars(); + let drive = chars.next()?; + if !drive.is_ascii_alphabetic() || chars.next() != Some(':') { + return None; + } + match (chars.next(), chars.next()) { + (None, None) => Some(drive), + (Some('\\' | '/'), None) => Some(drive), + _ => None, + } +} + +/// Absolute path to a System32 executable (`mount.exe`, `umount.exe`, ...). +/// +/// Resolving through `%SystemRoot%` avoids PATH hijacking for the privileged +/// helper tools the NFS backend shells out to. +pub fn system32_exe(name: &str) -> PathBuf { + std::env::var_os("SystemRoot") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\Windows")) + .join("System32") + .join(name) +} + +/// Drive letters that are not currently assigned, from a `GetLogicalDrives` +/// style bitmask (bit 0 = `A:`, bit 25 = `Z:`). +/// +/// Returned in reverse alphabetical order (`Z`, `Y`, ...) because high letters +/// are the conventional choice for removable/network mounts and the least +/// likely to collide with local disks. +pub fn free_drive_letters(assigned_mask: u32) -> Vec { + ('D'..='Z') + .rev() + .filter(|letter| assigned_mask & (1 << (*letter as u8 - b'A')) == 0) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn drive_letter_accepts_bare_targets() { + assert_eq!(drive_letter("Z:"), Some('Z')); + assert_eq!(drive_letter("Z:\\"), Some('Z')); + assert_eq!(drive_letter("z:/"), Some('z')); + } + + #[test] + fn drive_letter_rejects_paths_and_garbage() { + assert_eq!(drive_letter(r"C:\hf-mounts\repo"), None); + assert_eq!(drive_letter("Z:x"), None); + assert_eq!(drive_letter("ZZ:"), None); + assert_eq!(drive_letter("/tmp/mount"), None); + assert_eq!(drive_letter(""), None); + assert_eq!(drive_letter("1:"), None); + } + + #[test] + fn free_drive_letters_skips_assigned_bits_and_reserved_letters() { + // C: and D: assigned -> D excluded, A/B never offered, Z first. + let mask = (1 << 2) | (1 << 3); + let free = free_drive_letters(mask); + assert_eq!(free.first(), Some(&'Z')); + assert!(!free.contains(&'D')); + assert!(!free.contains(&'A')); + assert!(!free.contains(&'B')); + assert!(!free.contains(&'C')); + + // All assigned -> nothing free. + assert!(free_drive_letters(u32::MAX).is_empty()); + } +} diff --git a/src/xet.rs b/src/xet.rs index a3019f8..ca01bba 100644 --- a/src/xet.rs +++ b/src/xet.rs @@ -206,17 +206,40 @@ impl Drop for StagingRoot { } impl StagingDir { - pub fn new(cache_dir: &Path, max_bytes: u64) -> Self { + pub fn new(cache_dir: &Path, max_bytes: u64) -> crate::error::Result { + std::fs::create_dir_all(cache_dir) + .map_err(|e| crate::error::Error::Setup(format!("failed to create cache dir {cache_dir:?}: {e}")))?; + // Random per-mount subdir so two mounts sharing cache_dir, or a mount // started after a crashed previous one, never see each other's files. - let dir = cache_dir.join(format!("staging-{:016x}", rand_u64())); - std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("Failed to create staging dir {:?}: {e}", dir)); + // Create it exclusively (not create_dir_all) so a name collision never + // silently shares one staging root between mounts — StagingRoot::drop + // would otherwise delete another mount's live files. + let mut dir = None; + for _ in 0..16 { + let candidate = cache_dir.join(format!("staging-{:016x}", rand_u64())); + match std::fs::create_dir(&candidate) { + Ok(()) => { + dir = Some(candidate); + break; + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => { + return Err(crate::error::Error::Setup(format!( + "failed to create staging dir {candidate:?}: {e}" + ))); + } + } + } + let dir = dir.ok_or_else(|| { + crate::error::Error::Setup("failed to create a unique staging dir after 16 attempts".to_string()) + })?; - Self { + Ok(Self { root: Arc::new(StagingRoot { dir }), bytes_used: Arc::new(AtomicU64::new(0)), max_bytes, - } + }) } /// Root directory of the staging area.