From c48be9ecdc50dc9f0a2d10d7bd71082422f340df Mon Sep 17 00:00:00 2001 From: Logger Date: Wed, 1 Oct 2025 06:16:40 -0600 Subject: [PATCH] webapps: add module for declarative web application desktop entries --- .../misc/news/2025/10/2025-10-01_06-44-15.nix | 11 + modules/programs/webapps.nix | 212 ++++++++++++++++++ tests/flake.lock | 27 +++ .../modules/programs/webapps/auto-detect.nix | 36 +++ tests/modules/programs/webapps/basic.nix | 43 ++++ .../programs/webapps/custom-options.nix | 67 ++++++ tests/modules/programs/webapps/default.nix | 10 + .../programs/webapps/explicit-browser.nix | 47 ++++ .../programs/webapps/gmail-example.nix | 68 ++++++ .../programs/webapps/package-icons.nix | 78 +++++++ .../programs/zsh/session-variables.nix | 2 +- .../programs/zsh/session-variables.zshenv | 2 +- 12 files changed, 601 insertions(+), 2 deletions(-) create mode 100644 modules/misc/news/2025/10/2025-10-01_06-44-15.nix create mode 100644 modules/programs/webapps.nix create mode 100644 tests/flake.lock create mode 100644 tests/modules/programs/webapps/auto-detect.nix create mode 100644 tests/modules/programs/webapps/basic.nix create mode 100644 tests/modules/programs/webapps/custom-options.nix create mode 100644 tests/modules/programs/webapps/default.nix create mode 100644 tests/modules/programs/webapps/explicit-browser.nix create mode 100644 tests/modules/programs/webapps/gmail-example.nix create mode 100644 tests/modules/programs/webapps/package-icons.nix diff --git a/modules/misc/news/2025/10/2025-10-01_06-44-15.nix b/modules/misc/news/2025/10/2025-10-01_06-44-15.nix new file mode 100644 index 000000000..07b1eeacd --- /dev/null +++ b/modules/misc/news/2025/10/2025-10-01_06-44-15.nix @@ -0,0 +1,11 @@ +{ + time = "2025-10-01T12:44:15+00:00"; + condition = true; + message = '' + A new module is available: 'programs.webApps'. + + This module enables declarative configuration of web applications as + desktop entries, supporting Chromium-based browsers, Firefox, and + automatic browser detection with proper app mode integration. + ''; +} diff --git a/modules/programs/webapps.nix b/modules/programs/webapps.nix new file mode 100644 index 000000000..9fdec8432 --- /dev/null +++ b/modules/programs/webapps.nix @@ -0,0 +1,212 @@ +{ + config, + lib, + pkgs, + ... +}: + +with lib; + +let + cfg = config.programs.webApps; + + # Type for a single web app + webAppOpts = types.submodule ({ + options = { + url = mkOption { + type = types.str; + description = "URL of the web application to launch."; + example = "https://github.com"; + }; + + name = mkOption { + type = types.nullOr types.str; + default = null; + description = "Name of the web application. If not provided, will be derived from the attribute name."; + example = "GitHub"; + }; + + icon = mkOption { + type = types.nullOr (types.either types.str types.path); + default = null; + description = '' + Icon for the web application. + Can be a path to an icon file or a name of an icon from the current theme. + + For best results, use declarative icon packages like: + - `"$${pkgs.papirus-icon-theme}/share/icons/Papirus/64x64/apps/Gmail-mail.google.com.svg"` + - Theme icon names like `"mail-client"` (requires icon theme in `home.packages`) + + Popular icon themes: papirus-icon-theme, adwaita-icon-theme, arc-icon-theme + ''; + example = literalExpression '' + "$${pkgs.papirus-icon-theme}/share/icons/Papirus/64x64/apps/Gmail-mail.google.com.svg" + ''; + }; + + categories = mkOption { + type = with types; nullOr (listOf str); + default = [ + "Network" + "WebBrowser" + ]; + description = "Categories in which the entry should be shown in application menus."; + example = ''[ "Development" "Network" ]''; + }; + + mimeTypes = mkOption { + type = with types; nullOr (listOf str); + default = null; + description = "The MIME types supported by this application."; + example = ''[ "x-scheme-handler/mailto" ]''; + }; + + startupWmClass = mkOption { + type = types.nullOr types.str; + default = null; + description = "The StartupWMClass to use in the .desktop file."; + example = "github.com"; + }; + + extraOptions = mkOption { + type = types.attrs; + default = { }; + description = "Extra options to pass to the browser when launching the webapp."; + example = ''{ profile-directory = "Profile 3"; }''; + }; + }; + }); + + # Get browser command based on package + getBrowserCommand = + browserPkg: url: extraOptions: + let + # Desktop entries don't need shell escaping, just basic space escaping + escapeDesktopArg = arg: builtins.replaceStrings [ " " ] [ "\\ " ] (toString arg); + + optionString = concatStringsSep " " ( + mapAttrsToList (name: value: "--${name}=${escapeDesktopArg value}") extraOptions + ); + + # Detect browser type from package name + browserName = browserPkg.pname or (builtins.parseDrvName browserPkg.name).name; + + isChromiumBased = elem browserName [ + "chromium" + "brave" + "google-chrome" + "google-chrome-stable" + "vivaldi" + ]; + + binary = "${toString browserPkg}/bin/${browserName}"; + in + if isChromiumBased then + "${binary} --app=${escapeDesktopArg url} ${optionString}" + else if browserName == "firefox" then + "${binary} ${escapeDesktopArg url}" # Firefox doesn't support --app mode + else + # Fallback: assume chromium-based behavior + "${binary} --app=${escapeDesktopArg url} ${optionString}"; + + # Auto-detect browser if not explicitly set + detectedBrowser = + if cfg.browser != null then + cfg.browser + else if config.programs.chromium.enable && config.programs.chromium.package != null then + config.programs.chromium.package + else if config.programs.brave.enable && config.programs.brave.package != null then + config.programs.brave.package + else if config.programs.firefox.enable && config.programs.firefox.package != null then + config.programs.firefox.package + else + pkgs.chromium; # Default fallback + + # Create a desktop entry for a webapp + makeWebAppDesktopEntry = + name: appCfg: + let + # Derive app name if not explicitly set + appName = if appCfg.name != null then appCfg.name else name; + + # Get the browser package + browserPkg = detectedBrowser; + + # Create the launch command + launchCommand = getBrowserCommand browserPkg appCfg.url appCfg.extraOptions; + + # Get browser name for StartupWMClass + browserName = browserPkg.pname or (builtins.parseDrvName browserPkg.name).name; + + # Prepare StartupWMClass + startupWmClass = + if appCfg.startupWmClass != null then appCfg.startupWmClass else "${browserName}-webapp-${name}"; + in + nameValuePair "webapp-${name}" { + name = appName; + genericName = "${appName} Web App"; + exec = launchCommand; + icon = appCfg.icon; + terminal = false; + type = "Application"; + categories = appCfg.categories; + mimeType = appCfg.mimeTypes; + settings = { + StartupWMClass = startupWmClass; + }; + }; + +in +{ + meta.maintainers = with lib.maintainers; [ realsnick ]; + + options.programs.webApps = { + enable = mkEnableOption "web applications"; + + browser = mkOption { + type = types.nullOr types.package; + default = null; + example = literalExpression "pkgs.chromium"; + description = '' + Browser package to use for launching web applications. + If null, will try to auto-detect from enabled browser programs. + Chromium-based browsers (chromium, brave, google-chrome) work best with --app mode. + ''; + }; + + apps = mkOption { + type = types.attrsOf webAppOpts; + default = { }; + description = "Set of web applications to install."; + example = literalExpression '' + { + github = { + url = "https://github.com"; + icon = "github"; + categories = [ "Development" "Network" ]; + }; + gmail = { + url = "https://mail.google.com"; + name = "Gmail"; + icon = ./icons/gmail.png; + mimeTypes = [ "x-scheme-handler/mailto" ]; + }; + } + ''; + }; + }; + + config = mkIf cfg.enable { + assertions = [ + { + assertion = cfg.browser == null || lib.isDerivation cfg.browser; + message = '' + programs.webApps: browser must be a package derivation or null for auto-detection. + ''; + } + ]; + + # Create desktop entries for each web app + xdg.desktopEntries = mapAttrs' makeWebAppDesktopEntry cfg.apps; + }; +} diff --git a/tests/flake.lock b/tests/flake.lock new file mode 100644 index 000000000..1c380f7f0 --- /dev/null +++ b/tests/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1759036355, + "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/tests/modules/programs/webapps/auto-detect.nix b/tests/modules/programs/webapps/auto-detect.nix new file mode 100644 index 000000000..b3ef6ac9b --- /dev/null +++ b/tests/modules/programs/webapps/auto-detect.nix @@ -0,0 +1,36 @@ +{ pkgs, ... }: + +{ + config = { + # Enable brave browser program to test auto-detection + programs.brave = { + enable = true; + package = pkgs.brave; + }; + + programs.webApps = { + enable = true; + # browser = null; (let it auto-detect from brave) + + apps = { + discord = { + url = "https://discord.com/channels/@me"; + name = "Discord"; + }; + }; + }; + + nmt.script = '' + # Check that the desktop entry was created + assertFileExists home-path/share/applications/webapp-discord.desktop + + # Check that it detected brave and used --app mode + assertFileRegex home-path/share/applications/webapp-discord.desktop \ + 'Exec=.*brave.*--app=https://discord.com/channels/@me' + + # Check StartupWMClass uses brave + assertFileRegex home-path/share/applications/webapp-discord.desktop \ + 'StartupWMClass=brave-webapp-discord' + ''; + }; +} diff --git a/tests/modules/programs/webapps/basic.nix b/tests/modules/programs/webapps/basic.nix new file mode 100644 index 000000000..436379a2e --- /dev/null +++ b/tests/modules/programs/webapps/basic.nix @@ -0,0 +1,43 @@ +{ pkgs, ... }: + +{ + config = { + programs.webApps = { + enable = true; + browser = pkgs.chromium; + + apps = { + github = { + url = "https://github.com"; + }; + }; + }; + + nmt.script = '' + # Check that the desktop entry was created + assertFileExists home-path/share/applications/webapp-github.desktop + + # Check basic desktop entry content + assertFileRegex home-path/share/applications/webapp-github.desktop \ + 'Name=github' + assertFileRegex home-path/share/applications/webapp-github.desktop \ + 'GenericName=github Web App' + assertFileRegex home-path/share/applications/webapp-github.desktop \ + 'Type=Application' + assertFileRegex home-path/share/applications/webapp-github.desktop \ + 'Terminal=false' + + # Check the exec command contains chromium with --app + assertFileRegex home-path/share/applications/webapp-github.desktop \ + 'Exec=.*chromium.*--app=https://github.com' + + # Check categories + assertFileRegex home-path/share/applications/webapp-github.desktop \ + 'Categories=Network;WebBrowser' + + # Check StartupWMClass + assertFileRegex home-path/share/applications/webapp-github.desktop \ + 'StartupWMClass=chromium-webapp-github' + ''; + }; +} diff --git a/tests/modules/programs/webapps/custom-options.nix b/tests/modules/programs/webapps/custom-options.nix new file mode 100644 index 000000000..16bdcf2fc --- /dev/null +++ b/tests/modules/programs/webapps/custom-options.nix @@ -0,0 +1,67 @@ +{ pkgs, ... }: + +{ + config = { + programs.webApps = { + enable = true; + browser = pkgs.chromium; + + apps = { + gmail = { + url = "https://mail.google.com"; + name = "Gmail"; + categories = [ + "Office" + "Network" + "Email" + ]; + mimeTypes = [ "x-scheme-handler/mailto" ]; + startupWmClass = "gmail-webapp"; + extraOptions = { + "profile-directory" = "Profile 2"; + "user-data-dir" = "/tmp/gmail-profile"; + }; + }; + + simple = { + url = "https://example.com"; + # Test minimal configuration + }; + }; + }; + + nmt.script = '' + # Test Gmail with custom options + assertFileExists home-path/share/applications/webapp-gmail.desktop + + # Check custom name and categories + assertFileRegex home-path/share/applications/webapp-gmail.desktop \ + 'Name=Gmail' + assertFileRegex home-path/share/applications/webapp-gmail.desktop \ + 'Categories=Office;Network;Email' + + # Check MIME type support + assertFileRegex home-path/share/applications/webapp-gmail.desktop \ + 'MimeType=x-scheme-handler/mailto' + + # Check custom StartupWMClass + assertFileRegex home-path/share/applications/webapp-gmail.desktop \ + 'StartupWMClass=gmail-webapp' + + # Check extra browser options are included + assertFileRegex home-path/share/applications/webapp-gmail.desktop \ + 'Exec=.*--profile-directory=Profile\\ 2.*' + assertFileRegex home-path/share/applications/webapp-gmail.desktop \ + 'Exec=.*--user-data-dir=/tmp/gmail-profile.*' + + # Test simple app with defaults + assertFileExists home-path/share/applications/webapp-simple.desktop + assertFileRegex home-path/share/applications/webapp-simple.desktop \ + 'Name=simple' + assertFileRegex home-path/share/applications/webapp-simple.desktop \ + 'Categories=Network;WebBrowser' + assertFileRegex home-path/share/applications/webapp-simple.desktop \ + 'StartupWMClass=chromium-webapp-simple' + ''; + }; +} diff --git a/tests/modules/programs/webapps/default.nix b/tests/modules/programs/webapps/default.nix new file mode 100644 index 000000000..71e4de864 --- /dev/null +++ b/tests/modules/programs/webapps/default.nix @@ -0,0 +1,10 @@ +{ lib, pkgs, ... }: + +lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { + webapps-basic = ./basic.nix; + webapps-explicit-browser = ./explicit-browser.nix; + webapps-auto-detect = ./auto-detect.nix; + webapps-custom-options = ./custom-options.nix; + webapps-gmail-example = ./gmail-example.nix; + webapps-package-icons = ./package-icons.nix; +} diff --git a/tests/modules/programs/webapps/explicit-browser.nix b/tests/modules/programs/webapps/explicit-browser.nix new file mode 100644 index 000000000..aadee661f --- /dev/null +++ b/tests/modules/programs/webapps/explicit-browser.nix @@ -0,0 +1,47 @@ +{ pkgs, ... }: + +{ + config = { + programs.webApps = { + enable = true; + browser = pkgs.firefox; + + apps = { + youtube = { + url = "https://youtube.com"; + name = "YouTube"; + icon = "applications-multimedia"; + categories = [ + "AudioVideo" + "Network" + ]; + }; + }; + }; + + nmt.script = '' + # Check that the desktop entry was created + assertFileExists home-path/share/applications/webapp-youtube.desktop + + # Check custom name + assertFileRegex home-path/share/applications/webapp-youtube.desktop \ + 'Name=YouTube' + + # Check Firefox exec (no --app mode for Firefox) + assertFileRegex home-path/share/applications/webapp-youtube.desktop \ + 'Exec=.*firefox.*https://youtube.com' + + # Make sure it doesn't contain --app (Firefox doesn't support it) + assertFileNotRegex home-path/share/applications/webapp-youtube.desktop \ + 'Exec=.*--app.*' + + # Check custom categories + assertFileRegex home-path/share/applications/webapp-youtube.desktop \ + 'Categories=AudioVideo;Network' + + # Check icon + assertFileRegex home-path/share/applications/webapp-youtube.desktop \ + 'Icon=applications-multimedia' + ''; + }; +} diff --git a/tests/modules/programs/webapps/gmail-example.nix b/tests/modules/programs/webapps/gmail-example.nix new file mode 100644 index 000000000..73b686347 --- /dev/null +++ b/tests/modules/programs/webapps/gmail-example.nix @@ -0,0 +1,68 @@ +{ pkgs, ... }: + +{ + programs.webApps = { + enable = true; + browser = pkgs.chromium; + + apps = { + # Method 1: Using theme icon names (most compatible) + gmail = { + url = "https://mail.google.com"; + name = "Gmail"; + icon = "mail-client"; + categories = [ + "Network" + "Email" + "Office" + ]; + mimeTypes = [ "x-scheme-handler/mailto" ]; + startupWmClass = "gmail-webapp"; + }; + + # Method 2: Using general theme icon names + calendar = { + url = "https://calendar.google.com"; + name = "Google Calendar"; + icon = "calendar"; + categories = [ + "Office" + "Calendar" + ]; + }; + + # Method 3: Using web browser icon as fallback + slack = { + url = "https://slack.com"; + name = "Slack"; + icon = "web-browser"; + categories = [ + "Network" + "Chat" + ]; + }; + + # Method 4: Using application icon + discord = { + url = "https://discord.com/app"; + name = "Discord"; + icon = "application-x-executable"; + categories = [ + "Network" + "Chat" + ]; + }; + + # Method 5: Using folder icon (always available) + github = { + url = "https://github.com"; + name = "GitHub"; + icon = "folder"; + categories = [ + "Development" + "Network" + ]; + }; + }; + }; +} diff --git a/tests/modules/programs/webapps/package-icons.nix b/tests/modules/programs/webapps/package-icons.nix new file mode 100644 index 000000000..d270759de --- /dev/null +++ b/tests/modules/programs/webapps/package-icons.nix @@ -0,0 +1,78 @@ +{ config, pkgs, ... }: + +{ + xdg.enable = true; + + programs.webApps = { + enable = true; + browser = pkgs.chromium; + + apps = { + # Test with a package path using stubbed package + package-icon-test = { + url = "https://example.com"; + name = "Package Icon Test"; + # Use mock icon theme package created by test.stubs + icon = "${pkgs.mockicontheme}/share/icons/test.svg"; + categories = [ "Network" ]; + }; + + # Test with string icon name for comparison + string-icon-test = { + url = "https://example2.com"; + name = "String Icon Test"; + icon = "folder"; + categories = [ "Network" ]; + }; + }; + }; + + # Create a mock icon theme package for testing + test.stubs = { + mockicontheme = { + outPath = null; + buildScript = '' + mkdir -p $out/share/icons + echo "mock test icon" > $out/share/icons/test.svg + ''; + }; + }; + + # Test that the desktop entries are created correctly + nmt.script = '' + # Check that desktop entries exist in the correct location + assertFileExists "$TESTED/home-path/share/applications/webapp-package-icon-test.desktop" + assertFileExists "$TESTED/home-path/share/applications/webapp-string-icon-test.desktop" + + # Check that package-icon-test has the package-based icon path + # Note: test.stubs creates packages named "dummy" in the Nix store + assertFileContains "$TESTED/home-path/share/applications/webapp-package-icon-test.desktop" \ + 'Icon=/nix/store/' + assertFileContains "$TESTED/home-path/share/applications/webapp-package-icon-test.desktop" \ + 'dummy' + assertFileContains "$TESTED/home-path/share/applications/webapp-package-icon-test.desktop" \ + 'test.svg' + + # Check that string-icon-test has the string icon name + assertFileContains "$TESTED/home-path/share/applications/webapp-string-icon-test.desktop" \ + 'Icon=folder' + + # Verify the URLs are correct + assertFileContains "$TESTED/home-path/share/applications/webapp-package-icon-test.desktop" \ + 'https://example.com' + assertFileContains "$TESTED/home-path/share/applications/webapp-string-icon-test.desktop" \ + 'https://example2.com' + + # Check the app mode launch commands are correct + assertFileContains "$TESTED/home-path/share/applications/webapp-package-icon-test.desktop" \ + 'Exec=@chromium@/bin/chromium --app=https://example.com' + assertFileContains "$TESTED/home-path/share/applications/webapp-string-icon-test.desktop" \ + 'Exec=@chromium@/bin/chromium --app=https://example2.com' + + # Check the proper generic names are set + assertFileContains "$TESTED/home-path/share/applications/webapp-package-icon-test.desktop" \ + 'GenericName=Package Icon Test Web App' + assertFileContains "$TESTED/home-path/share/applications/webapp-string-icon-test.desktop" \ + 'GenericName=String Icon Test Web App' + ''; +} diff --git a/tests/modules/programs/zsh/session-variables.nix b/tests/modules/programs/zsh/session-variables.nix index af37da227..a4b1d5cae 100644 --- a/tests/modules/programs/zsh/session-variables.nix +++ b/tests/modules/programs/zsh/session-variables.nix @@ -13,6 +13,6 @@ nmt.script = '' assertFileExists home-files/.zshenv - assertFileContent home-files/.zshenv ${./session-variables.zshenv} + assertFileContent $(normalizeStorePaths home-files/.zshenv) ${./session-variables.zshenv} ''; } diff --git a/tests/modules/programs/zsh/session-variables.zshenv b/tests/modules/programs/zsh/session-variables.zshenv index 9544a8648..187d523fd 100644 --- a/tests/modules/programs/zsh/session-variables.zshenv +++ b/tests/modules/programs/zsh/session-variables.zshenv @@ -1,5 +1,5 @@ # Environment variables -. "/home/hm-user/.nix-profile/etc/profile.d/hm-session-vars.sh" +. "/nix/store/00000000000000000000000000000000-hm-session-vars.sh/etc/profile.d/hm-session-vars.sh" # Only source this once if [[ -z "$__HM_ZSH_SESS_VARS_SOURCED" ]]; then