Skip to content
Open
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
11 changes: 11 additions & 0 deletions modules/misc/news/2025/10/2025-10-01_06-44-15.nix
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.
'';
}
212 changes: 212 additions & 0 deletions modules/programs/webapps.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
{
config,
lib,
pkgs,
...
}:

with lib;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
with lib;


let
cfg = config.programs.webApps;

# Type for a single web app
webAppOpts = types.submodule ({
Copy link
Collaborator

Choose a reason for hiding this comment

The 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
Copy link
Contributor

@ambroisie ambroisie Oct 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

types.submodule can use a function to get the attribute name, this avoids the nullOr which doesn't really want null:

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.";
Copy link
Contributor

Choose a reason for hiding this comment

The 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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd use the usual attrsOf (oneOf [ str number boolean ]) type for command line options.

default = { };
description = "Extra options to pass to the browser when launching the webapp.";
example = ''{ profile-directory = "Profile 3"; }'';
};
};
});
Copy link
Contributor

Choose a reason for hiding this comment

The 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: I feel like toGNUCommandLine should be able to be used in this circumstance. Perhaps a PR to add a way to customize the escaping function would be good.


# 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An isFirefoxBased filter would lead to less duplication.


# 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
Copy link
Collaborator

Choose a reason for hiding this comment

The 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

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my opinion we should make it so cfg.browser is type = types.package (i.e: not-nullable) and set an appropriate default in the module option. We should also document the default choice.


# 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;
Copy link
Contributor

Choose a reason for hiding this comment

The 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}";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not simply webapp-${name}? That way we could make it non-nullable and use that as the default in the module option definition.

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this redundant with its type = types.nullOr types.package?

];

# Create desktop entries for each web app
xdg.desktopEntries = mapAttrs' makeWebAppDesktopEntry cfg.apps;
};
}
27 changes: 27 additions & 0 deletions tests/flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

36 changes: 36 additions & 0 deletions tests/modules/programs/webapps/auto-detect.nix
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;
Copy link
Contributor

Choose a reason for hiding this comment

The 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'
'';
};
}
43 changes: 43 additions & 0 deletions tests/modules/programs/webapps/basic.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{ pkgs, ... }:

{
config = {
programs.webApps = {
enable = true;
browser = pkgs.chromium;
Copy link
Contributor

Choose a reason for hiding this comment

The 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'
'';
};
}
Loading