Skip to content

Conversation

@i-am-logger
Copy link

@i-am-logger i-am-logger commented Oct 1, 2025

Description

This PR adds a new webapps module for Home Manager that allows users to declaratively configure web applications as desktop entries. This enables creating application launcher entries for web services like Gmail, Slack, Discord, GitHub, etc., with proper browser integration and desktop environment support.

Inspiration: This module was inspired by the webapps functionality in omarchy. Special thanks to @dhh for the inspiration and approach.

Features

  • Declarative Configuration: Define web applications with clean Nix syntax
  • Auto-Browser Detection: Automatically detects and uses available browsers (Chromium, Brave, Firefox)
  • App Mode Support: Creates proper --app= flags for Chromium-based browsers for true webapp experience
  • Icon Management: Supports both declarative icon packages and theme icon names
  • Desktop Integration: Creates proper .desktop entries that integrate with application launchers
  • MIME Type Support: Handles MIME types for protocol association (e.g., mailto: links)
  • Window Management: Sets StartupWMClass for proper application grouping

Usage Example

programs.webApps = {
  enable = true;
  apps = {
    gmail = {
      url = "https://mail.google.com";
      name = "Gmail";
      icon = "${pkgs.papirus-icon-theme}/share/icons/Papirus/64x64/apps/gmail.svg";
      categories = [ "Network" "Email" "Office" ];
      mimeTypes = [ "x-scheme-handler/mailto" ];
      startupWmClass = "gmail-webapp";
    };
    github = {
      url = "https://github.com";
      name = "GitHub";
      icon = "github";
      categories = [ "Development" "Network" ];
    };
  };
};

Checklist

  • Change is backwards compatible.
  • Code formatted with nix fmt.
  • Test cases updated/added - comprehensive test suite included.
  • Commit messages are formatted correctly.
  • Added myself as module maintainer.
  • Basic tests added covering various use cases.

Tests Included

  • Basic functionality test
  • Auto-detection of browsers
  • Explicit browser configuration
  • Custom options and extraOptions
  • Gmail example (real-world use case)

The module has been thoroughly tested and is working correctly with Hyprland + Rofi, creating proper desktop entries that appear in application launchers and launch web applications in app mode.

Special thanks to @dhh and the omarchy project for the inspiration!

@i-am-logger i-am-logger force-pushed the feature/webapps-module branch 9 times, most recently from d8ba7b9 to d0b2c07 Compare October 1, 2025 12:41
@i-am-logger i-am-logger force-pushed the feature/webapps-module branch from d0b2c07 to c48be9e Compare October 1, 2025 13:26
@github-actions github-actions bot added the shell label Oct 1, 2025
...
}:

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;

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.

Comment on lines +94 to +123
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
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.

Comment on lines +22 to +27
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";
};
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 = "...";
  };
  // ...
}));

"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?

Comment on lines +84 to +89
# 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
);
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.

Comment on lines +94 to +110
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}";
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.

Comment on lines +94 to +123
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
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.

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.

config = {
programs.webApps = {
enable = true;
browser = pkgs.firefox;
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.

{
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.


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.

};

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.

@i-am-logger
Copy link
Author

Thanks for the review, will update it towards weekend hopefully as i'm traveling

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants