From 8201fc18937c371530653ae723f374d888c05cf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tales=20A=2E=20Mendon=C3=A7a?= Date: Mon, 7 Sep 2026 23:14:12 -0300 Subject: [PATCH] fix: detect installed browsers and restore Chrome dock icons Reject unrelated PATH shims and cross-channel binary matches. Preserve explicit commands, Nix store discovery, and Flatpak detection. Align Chrome app IDs and regenerate existing desktop entries without renaming profiles or replacing icons. Validate regression tests, full local gate, GTK dialogs, GNOME Wayland association, and release build. --- .../usr/share/biglinux-webapps/browsers.toml | 6 +- crates/webapps-core/src/browsers.rs | 65 ++++++------ crates/webapps-core/src/browsers/native.rs | 88 +++++++++++++++ .../webapps-core/src/browsers/native/tests.rs | 100 ++++++++++++++++++ crates/webapps-core/src/desktop/wm_class.rs | 6 +- .../src/service/migration/mod.rs | 2 +- .../tests/crud/chrome_migration.rs | 51 +++++++++ .../webapps-manager/tests/crud_integration.rs | 7 ++ 8 files changed, 285 insertions(+), 40 deletions(-) create mode 100644 crates/webapps-core/src/browsers/native.rs create mode 100644 crates/webapps-core/src/browsers/native/tests.rs create mode 100644 crates/webapps-manager/tests/crud/chrome_migration.rs diff --git a/biglinux-webapps/usr/share/biglinux-webapps/browsers.toml b/biglinux-webapps/usr/share/biglinux-webapps/browsers.toml index 5ddbec9d..d04791f0 100644 --- a/biglinux-webapps/usr/share/biglinux-webapps/browsers.toml +++ b/biglinux-webapps/usr/share/biglinux-webapps/browsers.toml @@ -63,7 +63,7 @@ flatpak_id = "flatpak-librewolf" id = "google-chrome-stable" display_name = "Google Chrome" native_paths = ["/usr/bin/google-chrome-stable", "/opt/google/chrome/google-chrome"] -wm_class_prefix = "google-chrome" +wm_class_prefix = "chrome" desktop_pattern = "google-chrome-stable" desktop_aliases = ["google-chrome"] firefox_like = false @@ -75,7 +75,7 @@ legacy_flatpak_ids = ["flatpak-chrome"] id = "google-chrome-beta" display_name = "Google Chrome Beta" native_paths = ["/usr/bin/google-chrome-beta", "/opt/google/chrome-beta/google-chrome"] -wm_class_prefix = "google-chrome" +wm_class_prefix = "chrome" desktop_pattern = "google-chrome-beta" firefox_like = false @@ -83,7 +83,7 @@ firefox_like = false id = "google-chrome-unstable" display_name = "Google Chrome Dev" native_paths = ["/usr/bin/google-chrome-unstable", "/opt/google/chrome-unstable/google-chrome"] -wm_class_prefix = "google-chrome" +wm_class_prefix = "chrome" desktop_pattern = "google-chrome-unstable" firefox_like = false flatpak_app_id = "com.google.ChromeDev" diff --git a/crates/webapps-core/src/browsers.rs b/crates/webapps-core/src/browsers.rs index c5c4b923..857c2c41 100644 --- a/crates/webapps-core/src/browsers.rs +++ b/crates/webapps-core/src/browsers.rs @@ -8,6 +8,8 @@ use std::sync::OnceLock; use serde::Deserialize; +mod native; + /// Definition of one supported browser, loaded from `browsers.toml`. #[derive(Debug, Clone, Deserialize)] pub struct BrowserDef { @@ -16,7 +18,7 @@ pub struct BrowserDef { pub id: String, /// Human-readable label shown in the manager UI. pub display_name: String, - /// Candidate binary paths for native detection; first existing path wins. + /// Candidate binary paths for native detection; first executable path wins. #[serde(default)] pub native_paths: Vec, /// WM_CLASS prefix set by Chromium-family browsers. @@ -99,42 +101,41 @@ fn load_browser_defs() -> Vec { parse_defs(DEFAULT_TOML).expect("embedded browsers.toml must be valid TOML") } -pub fn native_browser_path(definition: &BrowserDef) -> Option { - for candidate in &definition.native_paths { - if crate::config::is_flatpak() { - if crate::config::host_command("test") - .args(["-x", candidate]) - .status() - .is_ok_and(|status| status.success()) - { - return Some(candidate.clone()); - } - } else if std::path::Path::new(candidate).is_file() { - return Some(candidate.clone()); - } - let name = std::path::Path::new(candidate).file_name()?; - if crate::config::is_flatpak() { - if let Ok(output) = crate::config::host_command("which").arg(name).output() { - if output.status.success() { - return Some(String::from_utf8_lossy(&output.stdout).trim().to_owned()); - } - } - } else if let Some(paths) = std::env::var_os("PATH") { - for directory in std::env::split_paths(&paths) { - let path = directory.join(name); - if path.is_file() { - return Some(path.to_string_lossy().into_owned()); - } - } - } - } - None -} +pub use native::native_browser_path; #[cfg(test)] mod tests { use super::*; + #[test] + fn chrome_channels_use_the_browser_process_name_for_wayland() { + let definitions = parse_defs(DEFAULT_TOML).unwrap(); + for id in [ + "google-chrome-stable", + "google-chrome-beta", + "google-chrome-unstable", + "flatpak-chrome", + ] { + let browser = definitions + .iter() + .find(|browser| { + browser.id == id + || browser.flatpak_id.as_deref() == Some(id) + || browser.legacy_flatpak_ids.iter().any(|alias| alias == id) + }) + .unwrap(); + assert_eq!(browser.wm_class_prefix, "chrome", "{id}"); + assert_eq!( + crate::desktop::chromium_browser_app_id( + &browser.wm_class_prefix, + "https://open.spotify.com/intl-pt/", + "Default" + ), + "chrome-open.spotify.com__intl-pt_-Default" + ); + } + } + #[test] fn every_legacy_flatpak_id_keeps_its_application_mapping() { let definitions = parse_defs(DEFAULT_TOML).unwrap(); diff --git a/crates/webapps-core/src/browsers/native.rs b/crates/webapps-core/src/browsers/native.rs new file mode 100644 index 00000000..6a131614 --- /dev/null +++ b/crates/webapps-core/src/browsers/native.rs @@ -0,0 +1,88 @@ +use std::{os::unix::fs::PermissionsExt, path::Path}; + +use super::BrowserDef; +use crate::config; + +pub fn native_browser_path(definition: &BrowserDef) -> Option { + definition + .native_paths + .iter() + .find_map(|candidate| registered_path(candidate)) + .or_else(|| nix_browser_path(definition)) +} + +fn registered_path(candidate: &str) -> Option { + let path = Path::new(candidate); + if path.is_absolute() { + return executable(path).then(|| candidate.to_owned()); + } + if path.components().count() == 1 { + return command_path(candidate); + } + None +} + +fn executable(path: &Path) -> bool { + if config::is_flatpak() { + return config::host_command("test") + .arg("-f") + .arg(path) + .arg("-a") + .arg("-x") + .arg(path) + .status() + .is_ok_and(|status| status.success()); + } + path.metadata() + .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0) +} + +fn command_path(name: &str) -> Option { + if config::is_flatpak() { + let output = config::host_command("which").arg(name).output().ok()?; + let path = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + return (output.status.success() && executable(Path::new(&path))).then_some(path); + } + std::env::split_paths(&std::env::var_os("PATH")?) + .map(|directory| directory.join(name)) + .find(|path| executable(path)) + .map(|path| path.to_string_lossy().into_owned()) +} + +fn nix_browser_path(definition: &BrowserDef) -> Option { + let names = definition + .native_paths + .iter() + .filter(|path| Path::new(path).parent() == Some(Path::new("/usr/bin"))) + .filter_map(|path| Path::new(path).file_name()?.to_str()) + .chain(definition.desktop_aliases.iter().map(String::as_str)); + for name in names { + let Some(path) = command_path(name) else { + continue; + }; + // Nix profiles contain real installations; distro PATH shims may exist without a browser. + if canonical_path(&path).is_some_and(|path| path.starts_with("/nix/store/")) { + return Some(path); + } + } + None +} + +fn canonical_path(path: &str) -> Option { + if config::is_flatpak() { + let output = config::host_command("readlink") + .args(["-f", path]) + .output() + .ok()?; + return output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned()); + } + std::fs::canonicalize(path) + .ok() + .map(|path| path.to_string_lossy().into_owned()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/webapps-core/src/browsers/native/tests.rs b/crates/webapps-core/src/browsers/native/tests.rs new file mode 100644 index 00000000..1fa3eb79 --- /dev/null +++ b/crates/webapps-core/src/browsers/native/tests.rs @@ -0,0 +1,100 @@ +use std::{ffi::OsString, fs, os::unix::fs::symlink, path::PathBuf}; + +use serial_test::serial; + +use super::*; + +struct SearchPath { + directory: tempfile::TempDir, + previous: Option, +} + +impl SearchPath { + fn new() -> Self { + let directory = tempfile::tempdir().unwrap(); + let previous = std::env::var_os("PATH"); + std::env::set_var("PATH", directory.path()); + Self { + directory, + previous, + } + } + + fn program(&self, name: &str, mode: u32) -> PathBuf { + let path = self.directory.path().join(name); + fs::write(&path, "#!/bin/sh\nexit 1\n").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(mode)).unwrap(); + path + } +} + +impl Drop for SearchPath { + fn drop(&mut self) { + match &self.previous { + Some(value) => std::env::set_var("PATH", value), + None => std::env::remove_var("PATH"), + } + } +} + +fn definition(paths: Vec) -> BrowserDef { + let mut definition = crate::browsers::find_def("brave").unwrap().clone(); + definition.native_paths = paths; + definition.desktop_aliases.clear(); + definition +} + +#[test] +#[serial] +fn path_shims_do_not_prove_a_registered_browser_is_installed() { + let search = SearchPath::new(); + let wrapper = search.program("browser-tweaks-chromium-base", 0o755); + symlink(wrapper, search.directory.path().join("brave-beta")).unwrap(); + let browser = definition(vec!["/missing/bin/brave-beta".into()]); + assert!(native_browser_path(&browser).is_none()); +} + +#[test] +#[serial] +fn internal_binary_basename_does_not_select_another_channel() { + let search = SearchPath::new(); + search.program("google-chrome", 0o755); + let browser = definition(vec!["/missing/chrome-beta/google-chrome".into()]); + assert!(native_browser_path(&browser).is_none()); +} + +#[test] +#[serial] +fn installed_alternative_wins_over_a_path_shim() { + let search = SearchPath::new(); + search.program("brave", 0o755); + let installed = search.program("actual-brave", 0o755); + let browser = definition(vec![ + "/missing/bin/brave".into(), + installed.display().to_string(), + ]); + assert_eq!(native_browser_path(&browser).as_deref(), installed.to_str()); +} + +#[test] +#[serial] +fn explicit_command_names_support_custom_installations() { + let search = SearchPath::new(); + let installed = search.program("custom-browser", 0o755); + assert_eq!( + native_browser_path(&definition(vec!["custom-browser".into()])).as_deref(), + installed.to_str() + ); +} + +#[test] +#[serial] +fn directories_and_non_executable_files_are_not_browsers() { + let search = SearchPath::new(); + let file = search.program("browser", 0o644); + let browser = definition(vec![ + search.directory.path().display().to_string(), + file.display().to_string(), + ]); + assert!(native_browser_path(&browser).is_none()); +} diff --git a/crates/webapps-core/src/desktop/wm_class.rs b/crates/webapps-core/src/desktop/wm_class.rs index 87a1148f..7adb049f 100644 --- a/crates/webapps-core/src/desktop/wm_class.rs +++ b/crates/webapps-core/src/desktop/wm_class.rs @@ -54,9 +54,7 @@ pub fn chromium_browser_app_id(short: &str, url: &str, profile: &str) -> String format!("{short}-{host}{path_separator}{path_part}-{profile}") } -/// Browser-id prefix used by each Chromium fork. Mirrors `argv[0]` of the -/// binary the user runs — the value Chromium injects as the leading segment -/// of its synthesized Wayland `app_id`. +/// Chromium's compiled browser process name, which prefixes its Wayland app ID. pub(super) fn chromium_short_name(browser_id: &str) -> String { if let Some(definition) = crate::browsers::find_def(browser_id) { if !definition.wm_class_prefix.is_empty() { @@ -76,7 +74,7 @@ pub(super) fn chromium_short_name(browser_id: &str) -> String { } else if lower.contains("chromium") { "chromium".to_string() } else if lower.contains("chrom") { - "google-chrome".to_string() + "chrome".to_string() } else { // Fall back to the raw browser id; unknown forks will still produce a // unique-but-app_id-mismatched value, which is no worse than the diff --git a/crates/webapps-manager/src/service/migration/mod.rs b/crates/webapps-manager/src/service/migration/mod.rs index 3e1b2471..5c591777 100644 --- a/crates/webapps-manager/src/service/migration/mod.rs +++ b/crates/webapps-manager/src/service/migration/mod.rs @@ -31,7 +31,7 @@ const WMCLASS_MIGRATION_MARKER: &str = ".desktop-wmclass-aligned-v3"; /// caused taskbars to group webapp windows under the host browser's own /// `.desktop` entry. This marker records that the one-shot regeneration with /// the corrected derivation has run. -const BROWSER_WMCLASS_MIGRATION_MARKER: &str = ".desktop-wmclass-browser-v1"; +const BROWSER_WMCLASS_MIGRATION_MARKER: &str = ".desktop-wmclass-browser-v2"; /// Marker for the icon-persistence migration. /// diff --git a/crates/webapps-manager/tests/crud/chrome_migration.rs b/crates/webapps-manager/tests/crud/chrome_migration.rs new file mode 100644 index 00000000..290f0c76 --- /dev/null +++ b/crates/webapps-manager/tests/crud/chrome_migration.rs @@ -0,0 +1,51 @@ +use super::*; + +#[test] +#[serial] +fn chrome_wmclass_migration_preserves_existing_profiles_and_icons() { + let _sandbox = XdgSandbox::new(); + let mut app = make_browser_app("Spotify", "https://open.spotify.com/intl-pt/"); + app.browser = "google-chrome-stable".into(); + app.app_profile = "Default".into(); + app.app_file = "google-chrome-open.spotify.com__intl-pt_-Default.desktop".into(); + let icon = config::data_dir().join("spotify.png"); + app.app_icon = icon.to_string_lossy().into_owned(); + let profile = config::profiles_dir() + .join(&app.browser) + .join(app.app_file.trim_end_matches(".desktop")); + fs::create_dir_all(&profile).unwrap(); + fs::write(profile.join("Cookies"), b"existing session").unwrap(); + fs::create_dir_all(config::data_dir()).unwrap(); + fs::write(&icon, b"existing icon").unwrap(); + fs::write(config::data_dir().join(".desktop-wmclass-browser-v1"), b"").unwrap(); + webapps_manager::service::save_webapps(&WebAppCollection { + webapps: vec![app.clone()], + }) + .unwrap(); + + assert_eq!( + webapps_manager::service::regenerate_browser_mode_desktops(), + 1 + ); + let saved = webapps_manager::service::load_webapps().webapps.remove(0); + assert_eq!( + serde_json::to_value(&saved).unwrap(), + serde_json::to_value(&app).unwrap() + ); + let desktop = fs::read_to_string(config::applications_dir().join(&app.app_file)).unwrap(); + assert!(desktop.contains("StartupWMClass=chrome-open.spotify.com__intl-pt_-Default\n")); + assert!(desktop.contains(&format!("filename=\"{}\"", app.app_file))); + assert!(desktop.contains(&format!("Icon={}\n", app.app_icon))); + assert_eq!( + fs::read(profile.join("Cookies")).unwrap(), + b"existing session" + ); + assert_eq!(fs::read(&icon).unwrap(), b"existing icon"); + assert!(config::data_dir() + .join(".desktop-wmclass-browser-v2") + .exists()); + assert_eq!( + webapps_manager::service::regenerate_browser_mode_desktops(), + 0 + ); +} diff --git a/crates/webapps-manager/tests/crud_integration.rs b/crates/webapps-manager/tests/crud_integration.rs index 125afb2b..5d178853 100644 --- a/crates/webapps-manager/tests/crud_integration.rs +++ b/crates/webapps-manager/tests/crud_integration.rs @@ -17,6 +17,9 @@ use tempfile::TempDir; use webapps_core::config; use webapps_core::models::{AppMode, BrowserId, WebApp, WebAppCollection}; +#[path = "crud/chrome_migration.rs"] +mod chrome_migration; + // Single mutex held across the body of any test that mutates env vars — protects // against parallel test runners that ignore the `serial` attribute (e.g. cargo // nextest with `--test-threads`). @@ -32,6 +35,10 @@ impl XdgSandbox { let guard = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner()); let dir = TempDir::new().expect("create tempdir"); let root = dir.path(); + std::env::set_var( + "BIGLINUX_WEBAPPS_PREFIX", + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../biglinux-webapps/usr"), + ); std::env::set_var("HOME", root); std::env::set_var("XDG_DATA_HOME", root.join("data")); std::env::set_var("XDG_CONFIG_HOME", root.join("config"));