diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b5b6deb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..3384dce --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Tests for webapp-manager diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..79dda42 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,51 @@ +"""Pytest configuration and fixtures for webapp-manager tests.""" + +import os +import sys +from contextlib import contextmanager + +import pytest + +# Add webapp-manager library to Python path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "usr", "lib", "webapp-manager")) + + +@contextmanager +def mock_environment(**env_vars): + """Context manager to temporarily set environment variables.""" + original = {} + for key, value in env_vars.items(): + original[key] = os.environ.get(key) + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + try: + yield + finally: + for key, orig_value in original.items(): + if orig_value is None: + os.environ.pop(key, None) + else: + os.environ[key] = orig_value + + +@pytest.fixture +def wayland_env(): + """Fixture to simulate a Wayland session environment.""" + with mock_environment(XDG_SESSION_TYPE="wayland", WAYLAND_DISPLAY="wayland-0"): + yield + + +@pytest.fixture +def x11_env(): + """Fixture to simulate an X11 session environment.""" + with mock_environment(XDG_SESSION_TYPE="x11", WAYLAND_DISPLAY=None): + yield + + +@pytest.fixture +def unset_env(): + """Fixture with display environment variables unset.""" + with mock_environment(XDG_SESSION_TYPE=None, WAYLAND_DISPLAY=None): + yield diff --git a/tests/test_wayland.py b/tests/test_wayland.py new file mode 100644 index 0000000..442d036 --- /dev/null +++ b/tests/test_wayland.py @@ -0,0 +1,223 @@ +"""Tests for Wayland support in webapp-manager.""" + +import os +import sys +from contextlib import contextmanager +from unittest.mock import MagicMock + +import pytest + +# Mock gi.repository before importing common (requires GTK) +sys.modules['gi'] = MagicMock() +sys.modules['gi.repository'] = MagicMock() +sys.modules['PIL'] = MagicMock() +sys.modules['PIL.Image'] = MagicMock() +sys.modules['requests'] = MagicMock() + +# Add webapp-manager library to Python path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "usr", "lib", "webapp-manager")) + +# Import the module under test +import common + + +@contextmanager +def mock_environment(**env_vars): + """Context manager to temporarily set environment variables.""" + original = {} + for key, value in env_vars.items(): + original[key] = os.environ.get(key) + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + try: + yield + finally: + for key, orig_value in original.items(): + if orig_value is None: + os.environ.pop(key, None) + else: + os.environ[key] = orig_value + + +class TestIsWaylandSession: + """Tests for is_wayland_session() function.""" + + def test_wayland_session_type(self, wayland_env): + """Should return True when XDG_SESSION_TYPE is wayland.""" + assert common.is_wayland_session() is True + + def test_wayland_display_set(self): + """Should return True when WAYLAND_DISPLAY is set.""" + with mock_environment(XDG_SESSION_TYPE="", WAYLAND_DISPLAY="wayland-0"): + assert common.is_wayland_session() is True + + def test_x11_session(self, x11_env): + """Should return False for X11 session.""" + assert common.is_wayland_session() is False + + def test_unset_environment(self, unset_env): + """Should return False when environment variables are unset.""" + assert common.is_wayland_session() is False + + def test_wayland_session_type_case_insensitive(self): + """Should handle case-insensitive session type.""" + with mock_environment(XDG_SESSION_TYPE="WAYLAND", WAYLAND_DISPLAY=None): + assert common.is_wayland_session() is True + + def test_wayland_display_only(self): + """Should detect Wayland from WAYLAND_DISPLAY alone.""" + with mock_environment(XDG_SESSION_TYPE="tty", WAYLAND_DISPLAY="wayland-1"): + assert common.is_wayland_session() is True + + +class TestGetChromiumWaylandClass: + """Tests for get_chromium_wayland_class() function.""" + + def test_simple_url_isolated_profile(self): + """Should generate correct class for simple URL with isolated profile.""" + result = common.get_chromium_wayland_class( + url="https://calendar.google.com", + custom_parameters="", + isolate_profile=True + ) + assert result == "chrome-calendar.google.com__-Default" + + def test_url_with_path(self): + """Path should be included with slashes converted to underscores.""" + result = common.get_chromium_wayland_class( + url="https://example.com/some/path/here", + custom_parameters="", + isolate_profile=True + ) + assert result == "chrome-example.com__some_path_here-Default" + + def test_url_with_port(self): + """Port should be stripped from domain, path included.""" + result = common.get_chromium_wayland_class( + url="https://localhost:8080/app", + custom_parameters="", + isolate_profile=True + ) + assert result == "chrome-localhost__app-Default" + + def test_custom_profile_with_equals(self): + """Should extract profile from --profile-directory=ProfileName.""" + result = common.get_chromium_wayland_class( + url="https://example.com", + custom_parameters='--profile-directory="Profile 1"', + isolate_profile=False + ) + assert result == "chrome-example.com__-Profile_1" + + def test_custom_profile_with_space(self): + """Should extract profile from --profile-directory ProfileName.""" + result = common.get_chromium_wayland_class( + url="https://example.com", + custom_parameters="--profile-directory 'My Profile'", + isolate_profile=False + ) + assert result == "chrome-example.com__-My_Profile" + + def test_profile_spaces_converted_to_underscores(self): + """Spaces in profile names should be replaced with underscores.""" + result = common.get_chromium_wayland_class( + url="https://example.com", + custom_parameters='--profile-directory="Work Profile"', + isolate_profile=False + ) + assert result == "chrome-example.com__-Work_Profile" + + def test_subdomain_preserved(self): + """Subdomains should be preserved in the class name.""" + result = common.get_chromium_wayland_class( + url="https://mail.google.com", + custom_parameters="", + isolate_profile=True + ) + assert result == "chrome-mail.google.com__-Default" + + def test_isolated_profile_ignores_custom_parameters(self): + """When isolate_profile is True, custom profile directory is ignored.""" + result = common.get_chromium_wayland_class( + url="https://example.com", + custom_parameters='--profile-directory="Custom"', + isolate_profile=True + ) + assert result == "chrome-example.com__-Default" + + def test_http_url(self): + """Should work with http:// URLs.""" + result = common.get_chromium_wayland_class( + url="http://insecure.example.com", + custom_parameters="", + isolate_profile=True + ) + assert result == "chrome-insecure.example.com__-Default" + + def test_empty_custom_parameters(self): + """Should handle empty custom parameters gracefully.""" + result = common.get_chromium_wayland_class( + url="https://example.com", + custom_parameters="", + isolate_profile=False + ) + assert result == "chrome-example.com__-Default" + + def test_custom_parameters_without_profile(self): + """Should use Default when custom params don't include profile.""" + result = common.get_chromium_wayland_class( + url="https://example.com", + custom_parameters="--disable-extensions --start-maximized", + isolate_profile=False + ) + assert result == "chrome-example.com__-Default" + + +class TestWaylandChromiumIntegration: + """Tests for Wayland + Chromium behavior.""" + + def test_wayland_chromium_uses_chrome_class(self): + """On Wayland, Chromium browsers should use chrome-{domain}__-{profile} format.""" + with mock_environment(XDG_SESSION_TYPE="wayland", WAYLAND_DISPLAY="wayland-0"): + codename = "TestApp1234" + browser_type = common.BROWSER_TYPE_CHROMIUM + url = "https://calendar.google.com" + + if common.is_wayland_session() and browser_type == common.BROWSER_TYPE_CHROMIUM: + wm_class = common.get_chromium_wayland_class(url, "", True) + else: + wm_class = "WebApp-%s" % codename + + assert wm_class == "chrome-calendar.google.com__-Default" + + def test_wayland_chromium_with_custom_profile(self): + """On Wayland, Chromium with custom profile should reflect in class.""" + with mock_environment(XDG_SESSION_TYPE="wayland", WAYLAND_DISPLAY="wayland-0"): + codename = "WorkApp5678" + browser_type = common.BROWSER_TYPE_CHROMIUM + url = "https://docs.google.com" + custom_params = '--profile-directory="Work Profile"' + + if common.is_wayland_session() and browser_type == common.BROWSER_TYPE_CHROMIUM: + wm_class = common.get_chromium_wayland_class(url, custom_params, False) + else: + wm_class = "WebApp-%s" % codename + + assert wm_class == "chrome-docs.google.com__-Work_Profile" + + def test_chrome_prefix_detected_as_webapp(self): + """Verify that 'chrome-' prefix is recognized as a webapp.""" + test_lines = [ + "StartupWMClass=chrome-calendar.google.com__-Default", + "StartupWMClass=chrome-mail.google.com__-Profile_1", + "StartupWMClass=chrome-localhost__-Default", + ] + + for line in test_lines: + is_webapp = ("StartupWMClass=WebApp" in line or + "StartupWMClass=Chromium" in line or + "StartupWMClass=ICE-SSB" in line or + "StartupWMClass=chrome-" in line) + assert is_webapp is True, f"Failed to detect webapp for: {line}" diff --git a/tests/test_x11.py b/tests/test_x11.py new file mode 100644 index 0000000..ae0ec1d --- /dev/null +++ b/tests/test_x11.py @@ -0,0 +1,188 @@ +"""Tests for X11 and non-Chromium browser behavior.""" + +import os +import sys +from contextlib import contextmanager +from unittest.mock import MagicMock + +import pytest + +# Mock gi.repository before importing common (requires GTK) +sys.modules['gi'] = MagicMock() +sys.modules['gi.repository'] = MagicMock() +sys.modules['PIL'] = MagicMock() +sys.modules['PIL.Image'] = MagicMock() +sys.modules['requests'] = MagicMock() + +# Add webapp-manager library to Python path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "usr", "lib", "webapp-manager")) + +# Import the module under test +import common + + +@contextmanager +def mock_environment(**env_vars): + """Context manager to temporarily set environment variables.""" + original = {} + for key, value in env_vars.items(): + original[key] = os.environ.get(key) + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + try: + yield + finally: + for key, orig_value in original.items(): + if orig_value is None: + os.environ.pop(key, None) + else: + os.environ[key] = orig_value + + +class TestWebappDetection: + """Tests for webapp detection formats.""" + + def test_all_webapp_formats_detected(self): + """Verify all StartupWMClass formats are detected.""" + formats = [ + ("StartupWMClass=WebApp-MyApp1234", "WebApp- prefix"), + ("StartupWMClass=WebApp-Gmail5678", "WebApp- with name"), + ("StartupWMClass=ICE-SSB-oldapp", "ICE-SSB format"), + ("StartupWMClass=Chromium-webapp", "Chromium format"), + ] + + for line, description in formats: + is_webapp = ("StartupWMClass=WebApp" in line or + "StartupWMClass=Chromium" in line or + "StartupWMClass=ICE-SSB" in line or + "StartupWMClass=chrome-" in line) + assert is_webapp is True, f"Failed to detect format ({description}): {line}" + + def test_non_webapp_not_detected(self): + """Non-webapp StartupWMClass should not be detected.""" + test_lines = [ + "StartupWMClass=Firefox", + "StartupWMClass=Nautilus", + "StartupWMClass=gnome-terminal", + ] + + for line in test_lines: + is_webapp = ("StartupWMClass=WebApp" in line or + "StartupWMClass=Chromium" in line or + "StartupWMClass=ICE-SSB" in line or + "StartupWMClass=chrome-" in line) + assert is_webapp is False, f"Incorrectly detected as webapp: {line}" + + +class TestX11Behavior: + """Tests for X11 session behavior.""" + + def test_x11_chromium_uses_webapp_class(self): + """On X11, Chromium browsers should use WebApp-{codename} format.""" + with mock_environment(XDG_SESSION_TYPE="x11", WAYLAND_DISPLAY=None): + codename = "TestApp1234" + browser_type = common.BROWSER_TYPE_CHROMIUM + + if common.is_wayland_session() and browser_type == common.BROWSER_TYPE_CHROMIUM: + wm_class = common.get_chromium_wayland_class("https://example.com", "", True) + else: + wm_class = "WebApp-%s" % codename + + assert wm_class == "WebApp-TestApp1234" + + def test_unset_env_chromium_uses_webapp_class(self): + """With unset env vars, Chromium browsers should use WebApp-{codename} format.""" + with mock_environment(XDG_SESSION_TYPE=None, WAYLAND_DISPLAY=None): + codename = "Gmail5678" + browser_type = common.BROWSER_TYPE_CHROMIUM + + if common.is_wayland_session() and browser_type == common.BROWSER_TYPE_CHROMIUM: + wm_class = common.get_chromium_wayland_class("https://mail.google.com", "", True) + else: + wm_class = "WebApp-%s" % codename + + assert wm_class == "WebApp-Gmail5678" + + def test_firefox_uses_webapp_class_on_x11(self): + """Firefox browsers should use WebApp-{codename} on X11.""" + with mock_environment(XDG_SESSION_TYPE="x11", WAYLAND_DISPLAY=None): + codename = "FirefoxApp5678" + browser_type = common.BROWSER_TYPE_FIREFOX + + if common.is_wayland_session() and browser_type == common.BROWSER_TYPE_CHROMIUM: + wm_class = common.get_chromium_wayland_class("https://example.com", "", True) + else: + wm_class = "WebApp-%s" % codename + + assert wm_class == "WebApp-FirefoxApp5678" + + +class TestNonChromiumBrowsers: + """Tests for non-Chromium browsers (always use WebApp- format).""" + + def test_firefox_always_uses_webapp_class(self): + """Firefox browsers should always use WebApp-{codename}, even on Wayland.""" + with mock_environment(XDG_SESSION_TYPE="wayland", WAYLAND_DISPLAY="wayland-0"): + codename = "FirefoxApp1234" + browser_type = common.BROWSER_TYPE_FIREFOX + + if common.is_wayland_session() and browser_type == common.BROWSER_TYPE_CHROMIUM: + wm_class = common.get_chromium_wayland_class("https://example.com", "", True) + else: + wm_class = "WebApp-%s" % codename + + assert wm_class == "WebApp-FirefoxApp1234" + + def test_epiphany_always_uses_webapp_class(self): + """Epiphany browser should always use WebApp-{codename}.""" + with mock_environment(XDG_SESSION_TYPE="wayland", WAYLAND_DISPLAY="wayland-0"): + codename = "EpiphanyApp1234" + browser_type = common.BROWSER_TYPE_EPIPHANY + + if common.is_wayland_session() and browser_type == common.BROWSER_TYPE_CHROMIUM: + wm_class = common.get_chromium_wayland_class("https://example.com", "", True) + else: + wm_class = "WebApp-%s" % codename + + assert wm_class == "WebApp-EpiphanyApp1234" + + def test_falkon_always_uses_webapp_class(self): + """Falkon browser should always use WebApp-{codename}.""" + with mock_environment(XDG_SESSION_TYPE="wayland", WAYLAND_DISPLAY="wayland-0"): + codename = "FalkonApp1234" + browser_type = common.BROWSER_TYPE_FALKON + + if common.is_wayland_session() and browser_type == common.BROWSER_TYPE_CHROMIUM: + wm_class = common.get_chromium_wayland_class("https://example.com", "", True) + else: + wm_class = "WebApp-%s" % codename + + assert wm_class == "WebApp-FalkonApp1234" + + def test_all_non_chromium_browsers_use_webapp_class(self): + """All non-Chromium browser types should use WebApp-{codename}.""" + non_chromium_types = [ + (common.BROWSER_TYPE_FIREFOX, "Firefox"), + (common.BROWSER_TYPE_FIREFOX_FLATPAK, "Firefox Flatpak"), + (common.BROWSER_TYPE_FIREFOX_SNAP, "Firefox Snap"), + (common.BROWSER_TYPE_LIBREWOLF_FLATPAK, "LibreWolf Flatpak"), + (common.BROWSER_TYPE_WATERFOX_FLATPAK, "Waterfox Flatpak"), + (common.BROWSER_TYPE_FLOORP_FLATPAK, "Floorp Flatpak"), + (common.BROWSER_TYPE_EPIPHANY, "Epiphany"), + (common.BROWSER_TYPE_FALKON, "Falkon"), + (common.BROWSER_TYPE_ZEN_FLATPAK, "Zen Flatpak"), + ] + + with mock_environment(XDG_SESSION_TYPE="wayland", WAYLAND_DISPLAY="wayland-0"): + for browser_type, browser_name in non_chromium_types: + codename = f"TestApp{browser_type}" + + if common.is_wayland_session() and browser_type == common.BROWSER_TYPE_CHROMIUM: + wm_class = common.get_chromium_wayland_class("https://example.com", "", True) + else: + wm_class = "WebApp-%s" % codename + + assert wm_class == f"WebApp-TestApp{browser_type}", \ + f"{browser_name} should use WebApp- format, got: {wm_class}" diff --git a/usr/lib/webapp-manager/common.py b/usr/lib/webapp-manager/common.py index 58a6b80..fdf3ad2 100644 --- a/usr/lib/webapp-manager/common.py +++ b/usr/lib/webapp-manager/common.py @@ -64,6 +64,50 @@ def wrapper(*args): ICONS_DIR = os.path.join(ICE_DIR, "icons") BROWSER_TYPE_FIREFOX, BROWSER_TYPE_FIREFOX_FLATPAK, BROWSER_TYPE_FIREFOX_SNAP, BROWSER_TYPE_LIBREWOLF_FLATPAK, BROWSER_TYPE_WATERFOX_FLATPAK, BROWSER_TYPE_FLOORP_FLATPAK, BROWSER_TYPE_CHROMIUM, BROWSER_TYPE_EPIPHANY, BROWSER_TYPE_FALKON, BROWSER_TYPE_ZEN_FLATPAK = range(10) + +def is_wayland_session(): + """Detect if the current session is running on Wayland.""" + session_type = os.environ.get("XDG_SESSION_TYPE", "").lower() + wayland_display = os.environ.get("WAYLAND_DISPLAY", "") + return session_type == "wayland" or bool(wayland_display) + + +def get_chromium_wayland_class(url, custom_parameters, isolate_profile): + """ + Generate the correct StartupWMClass for Chromium on Wayland. + + Format: chrome-{domain}__{path}-{profile} + Examples: + https://calendar.google.com -> chrome-calendar.google.com__-Default + https://mail.google.com/mail/u/0/ -> chrome-mail.google.com__mail_u_0_-Default + """ + import re + + # Extract domain and path from URL + parsed = urllib.parse.urlparse(url) + domain = parsed.netloc + if ':' in domain: + domain = domain.split(':')[0] + + # Convert path: remove leading slash, replace remaining slashes with underscores + path = parsed.path + if path.startswith('/'): + path = path[1:] + path = path.replace('/', '_') + + # Determine profile name + profile_name = "Default" + if not isolate_profile and custom_parameters: + match = re.search(r'--profile-directory[=\s]+["\']?([^"\']+)["\']?', custom_parameters) + if match: + profile_name = match.group(1).strip() + + # Chrome replaces spaces with underscores in app_id + profile_name = profile_name.replace(" ", "_") + + return f"chrome-{domain}__{path}-{profile_name}" + + class Browser: def __init__(self, browser_type, name, exec_path, test_path): @@ -98,7 +142,10 @@ def __init__(self, path, codename): line = line.strip() # Identify if the app is a webapp - if "StartupWMClass=WebApp" in line or "StartupWMClass=Chromium" in line or "StartupWMClass=ICE-SSB" in line: + if ("StartupWMClass=WebApp" in line or + "StartupWMClass=Chromium" in line or + "StartupWMClass=ICE-SSB" in line or + "StartupWMClass=chrome-" in line): is_webapp = True continue @@ -286,7 +333,11 @@ def create_webapp(self, name, desc, url, icon, category, browser, custom_paramet desktop_file.write("Icon=%s\n" % icon) desktop_file.write("Categories=GTK;%s;\n" % category) desktop_file.write("MimeType=text/html;text/xml;application/xhtml_xml;\n") - desktop_file.write("StartupWMClass=WebApp-%s\n" % codename) + if is_wayland_session() and browser.browser_type == BROWSER_TYPE_CHROMIUM: + wm_class = get_chromium_wayland_class(url, custom_parameters, isolate_profile) + else: + wm_class = "WebApp-%s" % codename + desktop_file.write("StartupWMClass=%s\n" % wm_class) desktop_file.write("StartupNotify=true\n") desktop_file.write("X-WebApp-Browser=%s\n" % browser.name) desktop_file.write("X-WebApp-URL=%s\n" % url) @@ -409,18 +460,23 @@ def get_exec_string(self, browser, codename, custom_parameters, icon, isolate_pr exec_string += " --no-remote " + url else: # Chromium based + if is_wayland_session(): + wm_class = get_chromium_wayland_class(url, custom_parameters, isolate_profile) + else: + wm_class = "WebApp-" + codename + if isolate_profile: profile_path = os.path.join(PROFILES_DIR, codename) exec_string = (browser.exec_path + " --app=" + "\"" + url + "\"" + - " --class=WebApp-" + codename + - " --name=WebApp-" + codename + + " --class=" + wm_class + + " --name=" + wm_class + " --user-data-dir=" + profile_path) else: exec_string = (browser.exec_path + " --app=" + "\"" + url + "\"" + - " --class=WebApp-" + codename + - " --name=WebApp-" + codename) + " --class=" + wm_class + + " --name=" + wm_class) if privatewindow: if browser.name == "Microsoft Edge": @@ -463,6 +519,13 @@ def edit_webapp(self, path, name, desc, browser, url, icon, category, custom_par config.set("Desktop Entry", "X-WebApp-Navbar", bool_to_string(navbar)) config.set("Desktop Entry", "X-WebApp-PrivateWindow", bool_to_string(privatewindow)) + # Update StartupWMClass for Wayland compatibility + if is_wayland_session() and browser.browser_type == BROWSER_TYPE_CHROMIUM: + wm_class = get_chromium_wayland_class(url, custom_parameters, isolate_profile) + else: + wm_class = "WebApp-%s" % codename + config.set("Desktop Entry", "StartupWMClass", wm_class) + except: print("This WebApp was created with an old version of WebApp Manager. Its URL cannot be edited.")