Skip to content
Merged
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
6 changes: 3 additions & 3 deletions biglinux-webapps/usr/share/biglinux-webapps/browsers.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -75,15 +75,15 @@ 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

[[browser]]
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"
Expand Down
65 changes: 33 additions & 32 deletions crates/webapps-core/src/browsers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<String>,
/// WM_CLASS prefix set by Chromium-family browsers.
Expand Down Expand Up @@ -99,42 +101,41 @@ fn load_browser_defs() -> Vec<BrowserDef> {
parse_defs(DEFAULT_TOML).expect("embedded browsers.toml must be valid TOML")
}

pub fn native_browser_path(definition: &BrowserDef) -> Option<String> {
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();
Expand Down
88 changes: 88 additions & 0 deletions crates/webapps-core/src/browsers/native.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
definition
.native_paths
.iter()
.find_map(|candidate| registered_path(candidate))
.or_else(|| nix_browser_path(definition))
}

fn registered_path(candidate: &str) -> Option<String> {
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<String> {
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<String> {
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<String> {
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;
100 changes: 100 additions & 0 deletions crates/webapps-core/src/browsers/native/tests.rs
Original file line number Diff line number Diff line change
@@ -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<OsString>,
}

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<String>) -> 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());
}
6 changes: 2 additions & 4 deletions crates/webapps-core/src/desktop/wm_class.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/webapps-manager/src/service/migration/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down
51 changes: 51 additions & 0 deletions crates/webapps-manager/tests/crud/chrome_migration.rs
Original file line number Diff line number Diff line change
@@ -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
);
}
Loading
Loading