-
-
Notifications
You must be signed in to change notification settings - Fork 2.2k
webapps: add module for declarative web application desktop entries #7917
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
| ''; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,212 @@ | ||
| { | ||
| config, | ||
| lib, | ||
| pkgs, | ||
| ... | ||
| }: | ||
|
|
||
| with lib; | ||
|
|
||
| let | ||
| cfg = config.programs.webApps; | ||
|
|
||
| # Type for a single web app | ||
| webAppOpts = types.submodule ({ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Prefer in-lining submodule usage or scoping in a let block closer to usage. |
||
| 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"; | ||
| }; | ||
|
Comment on lines
+22
to
+27
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
type = lib.types.attrsOf (lib.types.submodule ({ name, ... }: {
// ...
name = lib.mkOption {
type = lib.types.str;
default = name;
description = "...";
};
// ...
})); |
||
|
|
||
| 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."; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a canonical list of categories we can link to? |
||
| 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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd use the usual |
||
| default = { }; | ||
| description = "Extra options to pass to the browser when launching the webapp."; | ||
| example = ''{ profile-directory = "Profile 3"; }''; | ||
| }; | ||
| }; | ||
| }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I could imagine wanting to use different browsers for different apps, rather than the same browser for all of them. |
||
|
|
||
| # 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 | ||
| ); | ||
|
Comment on lines
+84
to
+89
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note: I feel like |
||
|
|
||
| # 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}"; | ||
|
Comment on lines
+94
to
+110
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. An |
||
|
|
||
| # 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 | ||
|
Comment on lines
+94
to
+123
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Might be cleaner to create an attrset used for configuring these default / known browsers instead of multiple if else if blocks There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In my opinion we should make it so |
||
|
|
||
| # 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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not needed if using the module-argument-based default I outlined above. |
||
|
|
||
| # 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}"; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why not simply |
||
| 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. | ||
| ''; | ||
| } | ||
|
Comment on lines
+201
to
+206
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Isn't this redundant with its |
||
| ]; | ||
|
|
||
| # Create desktop entries for each web app | ||
| xdg.desktopEntries = mapAttrs' makeWebAppDesktopEntry cfg.apps; | ||
| }; | ||
| } | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| { pkgs, ... }: | ||
|
|
||
| { | ||
| config = { | ||
| # Enable brave browser program to test auto-detection | ||
| programs.brave = { | ||
| enable = true; | ||
| package = pkgs.brave; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You should use a dummy package to reduce the closure size of tests. |
||
| }; | ||
|
|
||
| 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' | ||
| ''; | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| { pkgs, ... }: | ||
|
|
||
| { | ||
| config = { | ||
| programs.webApps = { | ||
| enable = true; | ||
| browser = pkgs.chromium; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dummy package. |
||
|
|
||
| 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' | ||
| ''; | ||
| }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.