Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 55 additions & 13 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ type RefreshResult = Result<String, SubmitError>;
/// 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";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
}
}
});
});
Expand Down
43 changes: 43 additions & 0 deletions src/elevation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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/<pid>/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/<pid>/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;
Expand Down