diff --git a/src/app.rs b/src/app.rs index 814adfe..6b8978b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -48,6 +48,9 @@ type RefreshResult = Result; /// launcher runs as `PioneerGame.exe`; once it hands off, the running game is /// `PioneerGame-e.exe` (EAC) or `PioneerGame-d.exe`. const GAME_PROCESS_NAMES: &[&str] = &["PioneerGame.exe", "PioneerGame-e.exe", "PioneerGame-d.exe"]; +/// Room left for a settings row's label and sub-label when the control next to +/// it wants the rest of the width. +const LABEL_COLUMN_WIDTH: f32 = 220.0; const HELP_URL: &str = "https://arctracker.io/help/sync"; /// Where a synced user goes to view their inventory on the web app. const STASH_URL: &str = "https://arctracker.io/stash"; @@ -1347,22 +1350,42 @@ impl ArcTrackerSyncApp { } fn browse_game_executable(&mut self) { + // Reached from the hub's "choose game" button as well as Settings. The + // picker would return None here rather than fail, so without this the + // button looks broken. + if !elevation::portal_dialogs_available() { + self.push_message( + "File dialogs cannot open while this binary carries CAP_NET_RAW \ + (the desktop portal cannot identify the process). Type the game \ + path under Settings instead." + .to_string(), + ); + return; + } + if let Some(path) = rfd::FileDialog::new() .set_title("Choose ARC Raiders") .add_filter("Game file", &["exe"]) .pick_file() { self.game_path_text = path.display().to_string(); - self.config.game_executable_path = Some(path.clone()); - if self.config.platform == LauncherPlatform::Auto { - self.config.platform = - launch::resolve_platform(LauncherPlatform::Auto, Some(&path)); - } - self.save_config(); - self.refresh_launcher_readiness(); + self.apply_game_path(); } } + /// Adopt whatever `game_path_text` currently holds, whether it arrived from + /// the file picker or was typed in because the picker is unavailable. + fn apply_game_path(&mut self) { + let path = self.selected_game_path(); + self.config.game_executable_path = path.clone(); + if self.config.platform == LauncherPlatform::Auto { + self.config.platform = + launch::resolve_platform(LauncherPlatform::Auto, path.as_deref()); + } + self.save_config(); + self.refresh_launcher_readiness(); + } + /// Switching to Epic with no game path set auto-fills it from the Epic /// manifests so the user isn't stuck on the "Choose ARC Raiders" picker. fn set_launcher(&mut self, platform: LauncherPlatform) { @@ -2845,12 +2868,31 @@ impl ArcTrackerSyncApp { .map(|path| path.display().to_string()) .unwrap_or_else(|| tr!("SyncApp.settings.autoDetected")); settings_row(ui, &tr!("SyncApp.settings.arcLocation"), &location, |ui| { - if ui - .button(tr!("SyncApp.settings.change")) - .on_hover_cursor(egui::CursorIcon::PointingHand) - .clicked() - { - self.browse_game_executable(); + // A file picker cannot open while this process holds a permitted + // capability, so offer a text field there rather than a button + // that would silently do nothing. + if elevation::portal_dialogs_available() { + if ui + .button(tr!("SyncApp.settings.change")) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { + self.browse_game_executable(); + } + } else { + // Take the row's width apart from a reserve for the label + // column, rather than a fixed size: game paths run past 70 + // characters, and the row lays this control out first, so + // whatever is claimed here is taken from the label. + let width = (ui.available_width() - LABEL_COLUMN_WIDTH) + .max(theme::SPACE_XL * 8.0); + if ui + .add(egui::TextEdit::singleline(&mut self.game_path_text) + .desired_width(width)) + .changed() + { + self.apply_game_path(); + } } }); }); diff --git a/src/elevation.rs b/src/elevation.rs index ff09879..4eb235a 100644 --- a/src/elevation.rs +++ b/src/elevation.rs @@ -8,6 +8,49 @@ pub use imp::{is_elevated, relaunch_elevated}; #[cfg(not(windows))] pub use stub::{is_elevated, relaunch_elevated}; +/// Whether desktop-portal dialogs — the file picker, in practice — can work in +/// this process. +/// +/// `xdg-desktop-portal` identifies the process asking for a dialog by opening +/// `/proc//root`. The kernel gates that behind `ptrace_may_access`, which +/// refuses unless the reader holds a superset of the target's *permitted* +/// capabilities. The portal holds none, so once this binary carries +/// `CAP_NET_RAW` from `setcap` it can never be identified, and every request +/// comes back as: +/// +/// ```text +/// Portal operation not allowed: Unable to open /proc//root +/// ``` +/// +/// There is no way to satisfy both: capture needs the capability for as long as +/// the app can reopen a socket, and a permitted capability can only be dropped, +/// never regained. So the picker is reported as unavailable and the path is +/// typed instead — better than a button that silently does nothing. +/// +/// Elevation on Windows works through the process token, not file +/// capabilities, so dialogs are unaffected there. +pub fn portal_dialogs_available() -> bool { + #[cfg(target_os = "linux")] + { + // Any permitted capability blocks the portal, not just CAP_NET_RAW; a + // zero mask is the only case that lets it identify us. Unreadable or + // unparseable status means we are not in the setcap situation this + // guards against, so keep the picker. + let Ok(status) = std::fs::read_to_string("/proc/self/status") else { + return true; + }; + status + .lines() + .find_map(|line| line.strip_prefix("CapPrm:")) + .and_then(|value| u64::from_str_radix(value.trim(), 16).ok()) + .is_none_or(|permitted| permitted == 0) + } + #[cfg(not(target_os = "linux"))] + { + true + } +} + #[cfg(windows)] mod imp { use std::os::windows::ffi::OsStrExt;