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"));