about summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2025-06-06 21:06:07 +0200
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2025-06-06 21:06:07 +0200
commit0ec2002665ce7bbf3238dc225d348e1c74a00cef (patch)
tree5e943763e77494ca764d87cd769b617ef761aff0
parentpkgs/fupdate: Move cli test into a `tests` module (diff)
downloadnixos-config-0ec2002665ce7bbf3238dc225d348e1c74a00cef.zip
{modules,pkgs}/qutebrowser: Enable qutebrowser support
-rw-r--r--modules/by-name/gp/gpg/module.nix3
-rw-r--r--modules/by-name/qu/qutebrowser/include/redirects.py64
-rw-r--r--modules/by-name/qu/qutebrowser/module.hm.nix490
-rw-r--r--modules/by-name/qu/qutebrowser/module.nix87
-rw-r--r--modules/by-name/qu/qutebrowser/settings/default.nix534
-rw-r--r--modules/by-name/qu/qutebrowser/settings/keybindings.nix289
-rw-r--r--modules/by-name/ts/tskm/module.nix13
-rw-r--r--modules/common/default.nix4
-rw-r--r--pkgs/by-name/qu/qutebrowser-patched/0001-fix-standardpaths-Continue-to-work-with-xdg-while-ba.patch54
-rw-r--r--pkgs/by-name/qu/qutebrowser-patched/package.nix6
-rwxr-xr-xupdate.sh1
11 files changed, 1538 insertions, 7 deletions
diff --git a/modules/by-name/gp/gpg/module.nix b/modules/by-name/gp/gpg/module.nix
index 89d7b356..d5136e92 100644
--- a/modules/by-name/gp/gpg/module.nix
+++ b/modules/by-name/gp/gpg/module.nix
@@ -22,6 +22,8 @@ in {
   };
 
   config = lib.mkIf cfg.enable {
+    soispha.programs.qutebrowser.key = "8321 ED3A 8DB9 99A5 1F3B  F80F F268 2914 EA42 DE26";
+
     home-manager.users.soispha = {
       programs.gpg = {
         enable = true;
@@ -45,6 +47,7 @@ in {
           }
         ];
       };
+
       services = {
         gpg-agent = {
           enable = true;
diff --git a/modules/by-name/qu/qutebrowser/include/redirects.py b/modules/by-name/qu/qutebrowser/include/redirects.py
new file mode 100644
index 00000000..23456a25
--- /dev/null
+++ b/modules/by-name/qu/qutebrowser/include/redirects.py
@@ -0,0 +1,64 @@
+# Based on the redirect scripts from here:
+# - https://github.com/Phantop/dotfiles/blob/9f6256820dbb99cdf5afd373b74d93fe8b8972d7/qutebrowser/include/redirects.py
+# - https://gitlab.com/jgkamat/dotfiles/-/blob/0e5f05d347079a9839cb2c7878fd81d9850928ce/qutebrowser/.config/qutebrowser/pyconfig/redirectors.py
+
+from qutebrowser.api import interceptor, message
+
+import operator
+import typing
+
+from PyQt6.QtCore import QUrl
+
+
+def get_host(url: QUrl, base: bool = False) -> str:
+    host = url.host()
+    if base:
+        # www.someSite.com -> someSite.com
+        return ".".join(host.split(".")[-2:])
+    else:
+        return host
+
+
+def partial(func, *part_args):
+    def wrapper(*extra_args):
+        return func(*part_args, *extra_args)
+
+    return wrapper
+
+
+def farside_redir(target: str, url: QUrl) -> bool:
+    url.setHost("farside.link")
+    url.setPath("/" + target + url.path())
+    return True
+
+
+# Any return value other than a literal 'False' means we redirect
+REDIRECT_MAP: typing.Dict[str, typing.Callable[..., typing.Optional[bool]]] = {
+    "reddit.com": operator.methodcaller("setHost", "redlib.vhack.eu"),
+    # Source: https://libredirect.github.io/
+    "medium.com": partial(farside_redir, "scribe"),
+    "stackoverflow.com": partial(farside_redir, "anonymousoverflow"),
+    "goodreads.com": partial(farside_redir, "biblioreads"),
+}
+
+
+def rewrite(info: interceptor.Request):
+    if (
+        info.resource_type != interceptor.ResourceType.main_frame
+        or info.request_url.scheme() in {"data", "blob"}
+    ):
+        return
+
+    url = info.request_url
+
+    redir = REDIRECT_MAP.get(get_host(url, base=False))
+    if redir is None:
+        # Try again, but now only with the base host.
+        redir = REDIRECT_MAP.get(get_host(url, base=True))
+
+    if redir is not None and redir(url) is not False:
+        message.info("Redirecting to " + url.toString())
+        info.redirect(url)
+
+
+interceptor.register(rewrite)
diff --git a/modules/by-name/qu/qutebrowser/module.hm.nix b/modules/by-name/qu/qutebrowser/module.hm.nix
new file mode 100644
index 00000000..3f0f0d52
--- /dev/null
+++ b/modules/by-name/qu/qutebrowser/module.hm.nix
@@ -0,0 +1,490 @@
+{
+  config,
+  lib,
+  pkgs,
+  ...
+}: let
+  cfg = config.programs.qutebrowser;
+
+  /*
+  Flattens an attribute set of key values to stringified keys and their values.
+
+  This is necessary, as qutebrowser only understands key value settings.
+
+  ## Determining the keys and values
+
+  Currently, this function will recursively iterate through the `configAttrs`, whilst
+  adding up the attribute keys, until one of the following conditions apply:
+  - The value is not an attribute set.
+  - The value is “marked” as being a raw attribute set. This is done automatically via the
+    `apply` functions in the option that take attribute sets (i.e., dictionaries) as
+    values.
+
+  ## Marking an attribute set as value
+  Currently, this function assumes that a attribute set is meant as value, if it's key is
+  `__raw`. For example:
+  ```nix
+    {
+      bindings.command.__raw = {
+          normal.L = "close";
+      };
+    }
+  ```
+
+  # Type
+
+  flattenSettings :: AttrSet -> [{key :: String; value :: Any}]
+
+  # Arguments
+
+  configAttrs
+  : The configuration attribute set to flatten.
+
+  # Examples
+
+  flattenSettings {aliases.__raw = {c = "close";}; colors.background = "0xffffff00";}
+  => [
+    {key = "aliases"; value = {c = "close";};}
+    {key = "colors.background"; value = "0xffffff00";}
+  ]
+  */
+  flattenSettings = let
+    isValue = aS: (builtins.attrNames aS) == ["__raw"];
+  in
+    configAttrs:
+      lib.collect (aS: (builtins.attrNames aS) == ["key" "value"]) (
+        lib.attrsets.mapAttrsRecursiveCond (a: !(isValue a)) (
+          path: value:
+            assert builtins.isList path; {
+              key = lib.concatStringsSep "." path;
+              inherit value;
+            }
+        )
+        configAttrs
+      );
+
+  pythonize = identCount: v: let
+    nextIdentCount = identCount + 2;
+    prevIdentCount = identCount - 2;
+    ident = builtins.concatStringsSep "" (builtins.genList (_: " ") identCount);
+    prevIdent = builtins.concatStringsSep "" (builtins.genList (_: " ") prevIdentCount);
+  in
+    if v == null
+    then "None"
+    else if builtins.isBool v
+    then
+      (
+        if v
+        then "True"
+        else "False"
+      )
+    else if builtins.isString v
+    then ''"${lib.escape ["\"" "\\"] v}"''
+    else if builtins.isInt v
+    then builtins.toString v
+    else if builtins.isList v
+    then
+      (
+        let
+          items = builtins.map (pythonize nextIdentCount) v;
+        in
+          if (builtins.length items) < 2
+          then "[${builtins.concatStringsSep "," items}]"
+          else "[\n${ident}${builtins.concatStringsSep ",\n${ident}" items}\n${prevIdent}]"
+      )
+    else if builtins.isAttrs v
+    then
+      if (v ? __raw && v.__raw == null)
+      # Make it possible to skip values, as we would otherwise unconditional override the
+      # non-set default values with `{}`.
+      then null
+      else if (v ? __raw && builtins.isList v.__raw)
+      # The raw value is already pre-rendered (e.g., used by the bindings.commands to use
+      # the `config.bind` functions instead of setting the keys directly.)
+      then v.__raw
+      else
+        (
+          # {key = "value"; other = "other";} -> "{'key': 'value', 'other': 'other'}"
+          let
+            items =
+              lib.attrsets.mapAttrsToList
+              (key: value: ''${pythonize nextIdentCount key}: ${pythonize nextIdentCount value}'')
+              (v.__raw or v);
+          in
+            if (builtins.length items) == 0
+            then "{}"
+            else "{\n${ident}${builtins.concatStringsSep ",\n${ident}" items}\n${prevIdent}}"
+        )
+    else builtins.throw "Cannot serialize a '${builtins.typeOf v}' as python value.";
+
+  configSet = keyValue: maybe_url: let
+    value = pythonize 2 keyValue.value;
+  in
+    if value == null
+    then ""
+    else if builtins.isList value
+    then builtins.concatStringsSep "\n" value
+    else "config.set(${pythonize 2 keyValue.key}, ${value}${
+      if (maybe_url != null)
+      then ", ${maybe_url}"
+      else ""
+    })";
+  urlConfigSet = url: conf: builtins.map (c: configSet c url) (flattenSettings conf);
+  extraConfigSet = name: path_or_string: ''config.source("${
+      if (builtins.isPath path_or_string)
+      then path_or_string
+      else pkgs.writeText name path_or_string
+    }")'';
+
+  mkRaw = value: {__raw = value;};
+  formatQuickmarks = n: s: "${n} ${s}";
+
+  settingsType = lib.types.submodule {
+    freeformType = lib.types.attrsOf lib.types.anything;
+    options = {
+      # A list of config keys, that take dicts and need special handling:
+      # (See their configdata.yml for the source of this list)
+      #    aliases
+      #    bindings.commands
+      #    bindings.default
+      #    bindings.key_mappings
+      #    content.headers.custom
+      #    content.javascript.log
+      #    hints.selectors
+      #    statusbar.padding
+      #    url.searchengines
+
+      aliases = lib.mkOption {
+        type = lib.types.nullOr (lib.types.attrsOf lib.types.str);
+        default = null;
+        apply = mkRaw;
+      };
+
+      bindings = let
+        keyBindType = lib.types.nullOr (lib.types.attrsOf (
+          lib.types.attrsOf (
+            lib.types.nullOr (
+              lib.types.separatedString " ;; "
+            )
+          )
+        ));
+      in {
+        key_mappings = lib.mkOption {
+          type = lib.types.nullOr (lib.types.attrsOf lib.types.str);
+          default = null;
+          apply = mkRaw;
+        };
+
+        # This is special, as we need to use the `config.bind` command, instead of the
+        # normal one.
+        commands = lib.mkOption {
+          type = keyBindType;
+          default = null;
+          apply = v:
+            if v == null
+            then null
+            else {
+              __raw =
+                lib.lists.flatten
+                (lib.mapAttrsToList
+                  (mode: mappings:
+                    lib.mapAttrsToList
+                    (key: value: "config.bind(${pythonize 0 key}, ${pythonize 0 value}, mode=${pythonize 0 mode})")
+                    mappings)
+                  v);
+            };
+          example = lib.literalExpression ''
+            {
+              normal = {
+                "<Ctrl-v>" = "spawn mpv {url}";
+                ",p" = "spawn --userscript qute-pass";
+                ",l" = '''config-cycle spellcheck.languages ["en-GB"] ["en-US"]''';
+                "<F1>" = mkMerge [
+                  "config-cycle tabs.show never always"
+                  "config-cycle statusbar.show in-mode always"
+                  "config-cycle scrolling.bar never always"
+                ];
+              };
+              prompt = {
+                "<Ctrl-y>" = "prompt-yes";
+              };
+            }
+          '';
+        };
+
+        default = lib.mkOption {
+          type = keyBindType;
+          default = null;
+          apply = mkRaw;
+        };
+      };
+
+      content = {
+        headers = {
+          custom = lib.mkOption {
+            type = lib.types.nullOr (lib.types.attrsOf lib.types.str);
+            default = null;
+            apply = mkRaw;
+          };
+        };
+        javascript = {
+          log = lib.mkOption {
+            type = lib.types.nullOr (lib.types.attrsOf lib.types.str);
+            default = null;
+            apply = mkRaw;
+          };
+        };
+      };
+
+      hints = {
+        selectors = lib.mkOption {
+          type = lib.types.nullOr (lib.types.attrsOf lib.types.str);
+          default = null;
+          apply = mkRaw;
+        };
+      };
+
+      qt = {
+        environ = lib.mkOption {
+          type = lib.types.nullOr (lib.types.attrsOf lib.types.str);
+          default = null;
+          apply = mkRaw;
+        };
+      };
+
+      url = {
+        searchengines = lib.mkOption {
+          type = lib.types.nullOr (lib.types.attrsOf lib.types.str);
+          default = null;
+          apply = mkRaw;
+          example = lib.literalExpression ''
+            {
+              w = "https://en.wikipedia.org/wiki/Special:Search?search={}&go=Go&ns0=1";
+              aw = "https://wiki.archlinux.org/?search={}";
+              nw = "https://wiki.nixos.org/index.php?search={}";
+              g = "https://www.google.com/search?hl=en&q={}";
+            }
+          '';
+        };
+      };
+      statusbar = {
+        padding = lib.mkOption {
+          type = lib.types.nullOr (lib.types.attrsOf lib.types.int);
+          default = null;
+          apply = mkRaw;
+        };
+      };
+    };
+  };
+in {
+  options.programs.qutebrowser = {
+    enable = lib.mkEnableOption "qutebrowser";
+
+    package = lib.mkPackageOption pkgs "qutebrowser" {};
+
+    loadAutoconfig = lib.mkOption {
+      type = lib.types.bool;
+      default = false;
+      description = ''
+        Load settings configured via the GUI.
+      '';
+    };
+
+    settings = lib.mkOption {
+      type = settingsType;
+
+      default = {};
+      description = ''
+        Options to add to qutebrowser {file}`config.py` file.
+        See <https://qutebrowser.org/doc/help/settings.html>
+        for options.
+      '';
+      example = lib.literalExpression ''
+        {
+          colors = {
+            hints = {
+              bg = "#000000";
+              fg = "#ffffff";
+            };
+            tabs.bar.bg = "#000000";
+          };
+          tabs.tabs_are_windows = true;
+        }
+      '';
+    };
+
+    quickmarks = lib.mkOption {
+      type = lib.types.attrsOf lib.types.str;
+      default = {};
+      description = ''
+        Quickmarks to add to qutebrowser's {file}`quickmarks` file.
+        Note that when Home Manager manages your quickmarks, you cannot edit them at runtime.
+      '';
+      example = lib.literalExpression ''
+        {
+          nixpkgs = "https://github.com/NixOS/nixpkgs";
+          home-manager = "https://github.com/nix-community/home-manager";
+        }
+      '';
+    };
+
+    greasemonkey = lib.mkOption {
+      type = lib.types.listOf lib.types.package;
+      default = [];
+      example = lib.literalExpression ''
+        [
+          (pkgs.fetchurl {
+            url = "https://raw.githubusercontent.com/afreakk/greasemonkeyscripts/1d1be041a65c251692ee082eda64d2637edf6444/youtube_sponsorblock.js";
+            sha256 = "sha256-e3QgDPa3AOpPyzwvVjPQyEsSUC9goisjBUDMxLwg8ZE=";
+          })
+          (pkgs.writeText "some-script.js" '''
+            // ==UserScript==
+            // @name  Some Greasemonkey script
+            // ==/UserScript==
+          ''')
+        ]
+      '';
+      description = ''
+        Greasemonkey userscripts to add to qutebrowser's {file}`greasemonkey`
+        directory.
+      '';
+    };
+
+    perUrlSettings = lib.mkOption {
+      type = lib.types.attrsOf settingsType;
+      default = {};
+      description = ''
+        Options to set, as in {option}`settings` but per url pattern.
+
+        See <https://qutebrowser.org/doc/help/settings.html> for options,
+        but beware, that not every option supports being set per-url.
+      '';
+      example = lib.literalExpression ''
+        {
+          "zoom.us" = {
+            content = {
+              autoplay = true;
+              media.audio_capture = true;
+              media.video_capture = true;
+            };
+          };
+          "github.com".colors.webpage.darkmode.enabled = false;
+        };
+      '';
+    };
+
+    extraConfig = lib.mkOption {
+      type = lib.types.attrsOf (lib.types.either lib.types.str lib.types.pathInStore);
+      default = {};
+      description = ''
+        Extra config snippets. The attribute name is their name, and the value their
+        value.
+      '';
+      example = lib.literalExpression ''
+        {
+          "redirects" = ' '
+            from PyQt5.QtCore import QUrl
+            from qutebrowser.api import interceptor
+
+            def intercept(info: interceptor.Request):
+                if info.request_url.host() == 'www.youtube.com':
+                    new_url = QUrl(info.request_url)
+                    new_url.setHost('www.invidio.us')
+                    try:
+                        info.redirect(new_url)
+                    except interceptors.RedirectFailedException:
+                        pass
+
+            interceptor.register(intercept)
+          ' '
+        }
+      '';
+    };
+  };
+
+  config = let
+    qutebrowserConfig = lib.concatStringsSep "\n" (
+      [
+        (
+          if cfg.loadAutoconfig
+          then "config.load_autoconfig()"
+          else "config.load_autoconfig(False)"
+        )
+      ]
+      ++ builtins.map (c: configSet c null) (flattenSettings cfg.settings)
+      ++ lib.lists.flatten (lib.mapAttrsToList urlConfigSet cfg.perUrlSettings)
+      ++ lib.mapAttrsToList extraConfigSet cfg.extraConfig
+    );
+
+    quickmarksFile = lib.optionals (cfg.quickmarks != {}) lib.concatStringsSep "\n" (
+      lib.mapAttrsToList formatQuickmarks cfg.quickmarks
+    );
+
+    greasemonkeyDir =
+      lib.optionals (
+        cfg.greasemonkey != []
+      )
+      pkgs.linkFarmFromDrvs "greasemonkey-userscripts"
+      cfg.greasemonkey;
+  in
+    lib.mkIf cfg.enable {
+      home = {
+        packages = [cfg.package];
+
+        file = {
+          ".qutebrowser/config.py" = lib.mkIf pkgs.stdenv.hostPlatform.isDarwin {
+            text = qutebrowserConfig;
+          };
+
+          ".qutebrowser/quickmarks" =
+            lib.mkIf (cfg.quickmarks != {} && pkgs.stdenv.hostPlatform.isDarwin)
+            {
+              text = quickmarksFile;
+            };
+
+          ".qutebrowser/greasemonkey" =
+            lib.mkIf (cfg.greasemonkey != [] && pkgs.stdenv.hostPlatform.isDarwin)
+            {
+              source = greasemonkeyDir;
+            };
+        };
+      };
+
+      xdg = {
+        configFile = {
+          "qutebrowser/config.py" = lib.mkIf pkgs.stdenv.hostPlatform.isLinux {
+            text = qutebrowserConfig;
+            onChange = ''
+              hash="$(echo -n "$USER" | md5sum | cut -d' ' -f1)"
+              socket="''${XDG_RUNTIME_DIR:-/run/user/$UID}/qutebrowser/ipc-$hash"
+              if [[ -S $socket ]]; then
+                command=${
+                lib.escapeShellArg (
+                  builtins.toJSON {
+                    args = [":config-source"];
+                    target_arg = null;
+                    protocol_version = 1;
+                  }
+                )
+              }
+                echo "$command" | ${pkgs.socat}/bin/socat -lf /dev/null - UNIX-CONNECT:"$socket"
+              fi
+              unset hash socket command
+            '';
+          };
+
+          "qutebrowser/quickmarks" =
+            lib.mkIf (cfg.quickmarks != {} && pkgs.stdenv.hostPlatform.isLinux)
+            {
+              text = quickmarksFile;
+            };
+
+          "qutebrowser/greasemonkey" =
+            lib.mkIf (cfg.greasemonkey != [] && pkgs.stdenv.hostPlatform.isLinux)
+            {
+              source = greasemonkeyDir;
+            };
+        };
+      };
+    };
+}
diff --git a/modules/by-name/qu/qutebrowser/module.nix b/modules/by-name/qu/qutebrowser/module.nix
new file mode 100644
index 00000000..20ab8e4b
--- /dev/null
+++ b/modules/by-name/qu/qutebrowser/module.nix
@@ -0,0 +1,87 @@
+{
+  config,
+  lib,
+  pkgs,
+  self,
+  ...
+}: let
+  cfg = config.soispha.programs.qutebrowser;
+
+  qutebrowsersWithProfiles = let
+    xdg_data_home = config.home-manager.users.soispha.xdg.dataHome;
+    xdg_config_home = config.home-manager.users.soispha.xdg.configHome;
+
+    mkQutebrowser = name:
+      pkgs.runCommandLocal "qutebrowser-${name}" {
+        nativeBuildInputs = [
+          pkgs.makeBinaryWrapper
+        ];
+      }
+      ''
+        makeWrapper ${pkgs.qutebrowser-patched} "$out/bin/qutebrowser-${name}" \
+          --add-flags --no-err-windows \
+          --add-flags --basedir \
+          --add-flags "${xdg_data_home}/qutebrowser/${name}" \
+          --add-flags --config \
+          --add-flags "${xdg_config_home}/qutebrowser/config.py" \
+      '';
+  in
+    builtins.filter (val: val != null) (lib.mapAttrsToList (name: value:
+      if value.enable
+      then mkQutebrowser name
+      else null)
+    cfg.profiles);
+in {
+  options.soispha.programs.qutebrowser = {
+    enable = lib.mkEnableOption "qutebrowser";
+
+    key = lib.mkOption {
+      type = lib.types.str;
+      description = ''
+        The gpg key, used when decrypting the keepassxc connection key.
+      '';
+    };
+
+    profiles = lib.mkOption {
+      type = lib.types.attrsOf (lib.types.submodule {
+        options = {
+          enable =
+            (lib.mkEnableOption "this profile")
+            // {
+              default = true;
+            };
+        };
+      });
+
+      description = "A name enable map of profies to create besides the default `default` profile.";
+      default = {};
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    home-manager.users.soispha = {
+      disabledModules = [
+        "${self.inputs.home-manager}/modules/programs/qutebrowser.nix"
+      ];
+      imports = [
+        ./module.hm.nix
+      ];
+
+      home.packages = qutebrowsersWithProfiles;
+
+      programs.qutebrowser = {
+        enable = true;
+        package = pkgs.hello; # TODO: Set to null, once supported <2025-06-06>
+
+        settings = import ./settings {
+          inherit lib pkgs;
+          cfg = config.soispha.programs.qutebrowser;
+        };
+
+        extraConfig = {
+          "redirects" = ./include/redirects.py;
+        };
+      };
+    };
+  };
+}
diff --git a/modules/by-name/qu/qutebrowser/settings/default.nix b/modules/by-name/qu/qutebrowser/settings/default.nix
new file mode 100644
index 00000000..20e02e93
--- /dev/null
+++ b/modules/by-name/qu/qutebrowser/settings/default.nix
@@ -0,0 +1,534 @@
+{
+  lib,
+  pkgs,
+  cfg,
+}: let
+  millisecond = 1;
+  second = 1000 * millisecond;
+
+  wordlist =
+    pkgs.runCommandNoCCLocal "wordlist" {
+      nativeBuildInputs = [pkgs.python3];
+    }
+    ''
+      python ${
+        pkgs.writeText "normalize-words"
+        # python
+        ''
+          import string
+          import os
+
+          # Perform qutebrowser's normalizations, to reduce our dependency closure.
+          # See <//qutebrowser/qutebrowser/browser/hints.py> for them.
+          with open("${pkgs.scowl}/share/dict/words.txt", encoding = "latin1") as wordfile:
+            alphabet = set(string.ascii_lowercase)
+            hints = set()
+            lines = (line.rstrip().lower() for line in wordfile)
+            for word in lines:
+                if len(word) > 4:
+                    # we don't need words longer than 4
+                    continue
+                if set(word) - alphabet:
+                    # contains none-alphabetic chars
+                    continue
+                for i in range(len(word)):
+                    # remove all prefixes of this word
+                    hints.discard(word[:i + 1])
+                hints.add(word)
+
+          with open(os.environ["out"], "w") as out:
+            for word in hints:
+              out.write(word)
+              out.write("\n")
+        ''
+      }
+    '';
+in {
+  aliases = {
+    q = "close";
+    qa = "quit";
+    w = "session-save";
+    wq = "quit --save";
+    wqa = "quit --save";
+    read = "spawn --userscript readability";
+  };
+
+  auto_save = {
+    interval = 10 * second;
+    session = true; # Safe/restore the session
+  };
+
+  backend = "webengine";
+
+  bindings = {
+    default = {}; # Disable the default key bindings
+
+    commands = import ./keybindings.nix {inherit cfg lib;};
+
+    key_mappings = {
+      "<Ctrl-6>" = "<Ctrl-^>";
+      "<Ctrl-Enter>" = "<Ctrl-Return>";
+      "<Ctrl-I>" = "<Tab>";
+      "<Ctrl-J>" = "<Return>";
+      "<Ctrl-M>" = "<Return>";
+      "<Ctrl-[>" = "<Escape>";
+      "<Enter>" = "<Return>";
+      "<Shift-Enter>" = "<Return>";
+      "<Shift-Return>" = "<Return>";
+    };
+  };
+
+  changelog_after_upgrade = "minor";
+
+  fonts = {};
+  colors = {
+    # TODO(@bpeetz): Use a nord color scheme (or stylix). <2025-05-31>
+
+    # Make things readable.
+    messages.error.fg = "black";
+    completion.match.fg = "black";
+
+    webpage = {
+      darkmode = {
+        enabled = true;
+        algorithm = "lightness-cielab";
+        policy = {
+          images = "smart";
+          page = "smart";
+        };
+        threshold = {
+          background = 0; # 0 -> always
+          foreground = 256; # 256 -> always
+        };
+      };
+      preferred_color_scheme = "auto";
+    };
+  };
+
+  completion = {
+    cmd_history_max_items = -1; # No limit.
+    delay = 0; # delay before updating completions in milliseconds.
+    favorite_paths = [];
+    height = "50%";
+    min_chars = 1;
+    open_categories = ["searchengines" "quickmarks" "bookmarks" "history" "filesystem"];
+
+    quick = true; # Select, when only one left.
+
+    scrollbar = {
+      padding = 0;
+      width = 0;
+    };
+
+    show = "auto"; # Only show, when selected
+
+    shrink = true;
+
+    use_best_match = false;
+    web_history = {
+      exclude = [];
+      max_items = -1; # unlimited.
+    };
+  };
+
+  confirm_quit = [
+    "downloads"
+  ];
+
+  content = {
+    autoplay = false;
+
+    blocking = {
+      enabled = true;
+
+      # Sources for brave's adblocker
+      adblock = {
+        lists = [
+          "https://easylist.to/easylist/easylist.txt"
+          "https://easylist.to/easylist/easyprivacy.txt"
+        ];
+      };
+
+      # Sources for the (simpler) /etc/hosts based one.
+      hosts = {
+        block_subdomains = true;
+        lists = [];
+      };
+
+      method = "adblock"; # Only brave's
+      whitelist = [];
+    };
+
+    # Allow websites to read canvas elements.
+    canvas_reading = true;
+
+    cookies = {
+      store = true;
+      accept = "no-3rdparty";
+    };
+
+    default_encoding = "utf-8";
+
+    dns_prefetch = true;
+
+    fullscreen = {
+      # Fullscreen is limited to the browser window.
+      window = true;
+      overlay_timeout = 3 * second;
+    };
+
+    headers = {
+      accept_language = "en-CA,sv,en;q=0.9";
+      custom = {};
+      do_not_track = true;
+      referer = "same-domain";
+      user_agent = "Mozilla/5.0 ({os_info}) AppleWebKit/{webkit_version} (KHTML, like Gecko) {upstream_browser_key}/{upstream_browser_version_short} Safari/{webkit_version}";
+    };
+
+    hyperlink_auditing = false;
+
+    # Auto-load images.
+    images = true;
+
+    javascript = {
+      enabled = true;
+
+      can_open_tabs_automatically = false;
+      alert = true;
+      clipboard = "ask";
+      legacy_touch_events = "never";
+
+      log = {
+        error = "debug";
+        warning = "debug";
+        info = "debug";
+        unknown = "debug";
+      };
+
+      modal_dialog = false;
+      prompt = true;
+    };
+
+    local_content_can_access_file_urls = true;
+    local_content_can_access_remote_urls = false;
+
+    local_storage = true;
+
+    desktop_capture = "ask";
+    geolocation = "ask";
+    persistent_storage = "ask";
+    mouse_lock = "ask";
+    register_protocol_handler = "ask";
+    media = {
+      audio_capture = "ask";
+      audio_video_capture = "ask";
+      video_capture = "ask";
+    };
+
+    mute = false;
+
+    notifications = {
+      # TODO(@bpeetz): I might change that. <2025-05-31>
+      enabled = true;
+
+      presenter = "libnotify";
+      show_origin = true;
+    };
+
+    pdfjs = false;
+
+    # TODO(@bpeetz): What are “plugins in Web pages”? <2025-05-31>
+    plugins = false;
+
+    prefers_reduced_motion = false;
+    print_element_backgrounds = true;
+    private_browsing = false;
+    proxy = "system";
+
+    site_specific_quirks = {
+      enabled = true;
+      skip = [];
+    };
+
+    tls = {
+      certificate_errors = "ask";
+    };
+
+    unknown_url_scheme_policy = "allow-from-user-interaction";
+    user_stylesheets = [];
+
+    webgl = true;
+
+    webrtc_ip_handling_policy = "all-interfaces";
+    xss_auditing = false;
+  };
+
+  downloads = {
+    location = {
+      # Use OS default.
+      directory = null;
+      prompt = true;
+      remember = true;
+      suggestion = "path";
+    };
+
+    # TODO(@bpeetz): I might want to set that. <2025-05-31>
+    open_dispatcher = null;
+
+    position = "top";
+    prevent_mixed_content = true;
+
+    # Never remove downloads without my intervention.
+    remove_finished = -1;
+  };
+
+  editor = {
+    command = [
+      "${lib.getExe pkgs.alacritty}"
+      "--title"
+      "floating please"
+      "--command"
+      "nvim"
+      "-c"
+      "normal {line}G{column0}|"
+      "{file}"
+    ];
+    encoding = "utf-8";
+    remove_file = true;
+  };
+
+  fileselect = {
+    handler = "default";
+  };
+
+  hints = {
+    auto_follow = "unique-match";
+    auto_follow_timeout = 0;
+
+    chars = "asdfghjkl";
+    dictionary = "${wordlist}";
+
+    hide_unmatched_rapid_hints = true;
+    leave_on_load = false;
+
+    min_chars = 1;
+    next_regexes = ["\\bnext\\b" "\\bmore\\b" "\\bnewer\\b" "\\b[>→≫]\\b" "\\b(>>|»)\\b" "\\bcontinue\\b"];
+    prev_regexes = ["\\bprev(ious)?\\b" "\\bback\\b" "\\bolder\\b" "\\b[<←≪]\\b" "\\b(<<|«)\\b"];
+
+    scatter = true;
+    uppercase = false;
+    mode = "word";
+  };
+
+  input = {
+    escape_quits_reporter = false;
+    forward_unbound_keys = "auto";
+
+    insert_mode = {
+      auto_enter = true;
+      auto_leave = true;
+      auto_load = false;
+      leave_on_load = true;
+      plugins = false;
+    };
+
+    links_included_in_focus_chain = true;
+    match_counts = true;
+
+    media_keys = true;
+
+    mode_override = null;
+
+    mouse = {
+      back_forward_buttons = true;
+      rocker_gestures = false;
+    };
+
+    partial_timeout = 0;
+    spatial_navigation = false;
+  };
+
+  keyhint = {
+    blacklist = [];
+    delay = 250 * millisecond;
+  };
+
+  logging = {
+    level = {
+      console = "info";
+      ram = "debug";
+    };
+  };
+
+  messages = {
+    timeout = 3 * second;
+  };
+
+  new_instance_open_target = "tab";
+  new_instance_open_target_window = "last-focused";
+
+  prompt = {
+    filebrowser = true;
+  };
+
+  qt = {
+    args = [];
+
+    chromium = {
+      experimental_web_platform_features = "auto";
+      low_end_device_mode = "auto";
+      process_model = "process-per-site-instance";
+      sandboxing = "enable-all";
+    };
+
+    environ = {};
+    force_platform = null;
+    force_platformtheme = null;
+    force_software_rendering = "none";
+
+    highdpi = true;
+
+    workarounds = {
+      disable_accelerated_2d_canvas = "auto";
+      disable_hangouts_extension = false;
+      locale = false;
+      remove_service_workers = false;
+    };
+  };
+
+  scrolling = {
+    bar = "never";
+    smooth = false;
+  };
+
+  search = {
+    ignore_case = "smart";
+    incremental = true;
+    wrap = true;
+    wrap_messages = true;
+  };
+
+  session = {
+    default_name = null;
+    # TODO(@bpeetz): See https://github.com/qutebrowser/qutebrowser/issues/67 <2025-06-04>
+    lazy_restore = false;
+  };
+
+  spellcheck = {
+    # TODO(@bpeetz): Installing them (reproducibly) is simply not worth the hassle, as
+    # qutebrowser already provides quick access to an editor. <2025-06-04>
+    # languages = ["en-GB" "sv-SE" "de-DE"];
+  };
+
+  statusbar = {
+    padding = {
+      bottom = 5;
+      left = 5;
+      right = 5;
+      top = 5;
+    };
+
+    position = "top";
+    show = "always";
+    widgets = ["keypress" "search_match" "url" "scroll" "history" "tabs" "progress"];
+  };
+
+  tabs = {
+    background = true;
+    close_mouse_button = "middle";
+    close_mouse_button_on_bar = "new-tab";
+
+    favicons = {
+      scale = 1;
+      show = "always";
+    };
+
+    focus_stack_size = 10;
+
+    last_close = "default-page";
+
+    max_width = -1;
+    min_width = -1;
+
+    mode_on_change = "normal";
+
+    mousewheel_switching = true;
+
+    new_position = {
+      related = "next";
+      unrelated = "next";
+      stacking = true;
+    };
+
+    pinned = {
+      frozen = true;
+      shrink = true;
+    };
+
+    position = "top";
+
+    select_on_remove = "last-used";
+
+    show = "multiple";
+    show_switching_delay = 2 * second;
+
+    tabs_are_windows = false;
+    title = {
+      alignment = "center";
+      elide = "middle";
+      format = "{audio}{index}: {current_title}";
+      format_pinned = "{index}";
+    };
+
+    tooltips = true;
+    undo_stack_size = 100;
+    width = "15%";
+    wrap = true;
+  };
+
+  url = {
+    auto_search = "naive";
+    default_page = "qute://start";
+    incdec_segments = ["path" "query"];
+
+    open_base_url = false; # Of search engine.
+    searchengines = rec {
+      DEFAULT = leta;
+
+      leta = "https://leta.mullvad.net/search?q={}";
+      "@ls" = leta;
+
+      # NIX
+      "@np" = "https://search.nixos.org/packages?type=packages&query={}"; # Nix packages
+
+      "@ng" = "https://noogle.dev/q?term={}"; # Nix functions
+
+      "@no" = "https://search.nixos.org/options?type=options&query={}"; # NixOS options
+      "@nh" = "https://home-manager-options.extranix.com/?query={}&release=master"; # Home-Manager options
+
+      "@ni" = "https://github.com/NixOS/nixpkgs/issues?q=is%3Aissue+is%3Aopen+{}"; # Nixpkgs issues
+      "@nr" = "https://github.com/NixOS/nixpkgs/pulls?q=is%3Apr+is%3Aopen+{}"; # Nixpkgs pull requests
+
+      "@nt" = "https://nixpk.gs/pr-tracker.html?pr={}"; # Nixpkgs pull requests tracker
+      "@nw" = "https://wiki.nixos.org/w/index.php?search={}"; # NixOS Wiki
+
+      # RUST
+      "@rs" = "https://doc.rust-lang.org/std/?search={}"; # Rust std
+      "@rt" = "https://docs.rs/tokio/latest/tokio/index.html?search={}"; # Rust tokio
+
+      # OTHER
+      "@gs" = "https://scholar.google.com/scholar?hl=en&q={}"; # Google Scholar
+      "@wp" = "https://en.wikipedia.org/wiki/{}"; # Wikipedia
+      "@aw" = "https://wiki.archlinux.org/index.php?search={}"; # Arch Wiki
+    };
+
+    start_pages = ["qute://start"];
+    yank_ignored_parameters = ["ref" "utm_source" "utm_medium" "utm_campaign" "utm_term" "utm_content" "utm_name"];
+  };
+
+  window = {
+    hide_decoration = true;
+    title_format = "{perc}{current_title}{title_sep}qutebrowser";
+    transparent = false;
+  };
+}
diff --git a/modules/by-name/qu/qutebrowser/settings/keybindings.nix b/modules/by-name/qu/qutebrowser/settings/keybindings.nix
new file mode 100644
index 00000000..fde78457
--- /dev/null
+++ b/modules/by-name/qu/qutebrowser/settings/keybindings.nix
@@ -0,0 +1,289 @@
+{
+  cfg,
+  lib,
+}: {
+  caret = {
+    "<Escape>" = "mode-leave";
+
+    H = "scroll left";
+    T = "scroll down";
+    N = "scroll up";
+    S = "scroll right";
+    h = "move-to-prev-char";
+    t = "move-to-next-line";
+    n = "move-to-prev-line";
+    s = "move-to-next-char";
+
+    "$" = "move-to-end-of-line";
+    "0" = "move-to-start-of-line";
+    G = "move-to-end-of-document";
+    gg = "move-to-start-of-document";
+
+    b = "move-to-prev-word";
+    c = "mode-enter normal";
+    e = "move-to-end-of-word";
+    w = "move-to-next-word";
+
+    "{" = "move-to-end-of-prev-block";
+    "}" = "move-to-end-of-next-block";
+    "[" = "move-to-start-of-prev-block";
+    "]" = "move-to-start-of-next-block";
+
+    y = "yank selection";
+    Y = "yank selection -s";
+    V = "selection-toggle --line";
+    v = "selection-toggle";
+    "<Ctrl-Space>" = "selection-drop";
+    "<Return>" = "yank selection";
+    "<Space>" = "selection-toggle";
+    o = "selection-reverse";
+  };
+
+  command = {
+    "<Alt-B>" = "rl-backward-word";
+    "<Alt-Backspace>" = "rl-backward-kill-word";
+    "<Alt-D>" = "rl-kill-word";
+    "<Alt-F>" = "rl-forward-word";
+    "<Ctrl-?>" = "rl-delete-char";
+    "<Ctrl-A>" = "rl-beginning-of-line";
+    "<Ctrl-B>" = "rl-backward-char";
+    "<Ctrl-C>" = "completion-item-yank";
+    "<Ctrl-D>" = "completion-item-del";
+    "<Ctrl-E>" = "rl-end-of-line";
+    "<Ctrl-F>" = "rl-forward-char";
+    "<Ctrl-H>" = "rl-backward-delete-char";
+    "<Ctrl-K>" = "rl-kill-line";
+    "<Ctrl-N>" = "command-history-next";
+    "<Ctrl-P>" = "command-history-prev";
+    "<Ctrl-Return>" = "command-accept --rapid";
+    "<Ctrl-Shift-C>" = "completion-item-yank --sel";
+    "<Ctrl-Shift-Tab>" = "completion-item-focus prev-category";
+    "<Ctrl-Shift-W>" = "rl-filename-rubout";
+    "<Ctrl-Tab>" = "completion-item-focus next-category";
+    "<Ctrl-U>" = "rl-unix-line-discard";
+    "<Ctrl-W>" = "rl-rubout \" \"";
+    "<Ctrl-Y>" = "rl-yank";
+    "<Down>" = "completion-item-focus --history next";
+    "<Escape>" = "mode-leave";
+    "<PgDown>" = "completion-item-focus next-page";
+    "<PgUp>" = "completion-item-focus prev-page";
+    "<Return>" = "command-accept";
+    "<Shift-Delete>" = "completion-item-del";
+    "<Shift-Tab>" = "completion-item-focus prev";
+    "<Tab>" = "completion-item-focus next";
+    "<Up>" = "completion-item-focus --history prev";
+  };
+
+  hint = {
+    "<Ctrl-B>" = "hint all tab-bg";
+    "<Ctrl-F>" = "hint links";
+    "<Ctrl-R>" = "hint --rapid links tab-bg";
+    "<Escape>" = "mode-leave";
+    "<Return>" = "hint-follow";
+  };
+
+  insert = {
+    "<Ctrl-E>" = "edit-text";
+    "<Escape>" = "mode-leave";
+    "<Shift-Escape>" = "fake-key <Escape>";
+    "<Shift-Ins>" = "insert-text -- {primary}";
+    "<Ctrl-k>" = "spawn --userscript qute-keepassxc --key ${lib.escapeShellArg cfg.key}";
+  };
+
+  normal = {
+    #  a
+    #  b
+    #  c
+    #  d -> download management
+    #  e -> [e]merge tab
+    #  f -> `hint all`
+    #  g -> tabs [g]oing
+    #  h -> `back`
+    #  i -> `mode-enter insert`
+    #  j -> inputs
+    #  k
+    #  l -> `search-next`
+    #  m -> in page movement
+    #  n -> `scroll up`
+    #  o
+    #  p
+    #  q -> `macro-record`
+    #  r
+    #  s -> `forward`
+    #  t -> `scroll down`
+    #  u -> `undo`
+    #  v -> `mode-enter caret`
+    #  w -> theme switching
+    #  x -> hinting
+    #  y -> yanking
+    #  z
+
+    # Theme switching
+    wd = "set --temp colors.webpage.darkmode.enabled true";
+    wl = "set --temp colors.webpage.darkmode.enabled false";
+
+    # Tab [g]oing
+    "g$" = "tab-focus -1";
+    g0 = "tab-focus 1";
+    gh = "home";
+    gH = "history";
+    gs = "cmd-set-text --space :open";
+    gP = "open -- {primary}";
+    gp = "open -- {clipboard}";
+    go = "cmd-set-text :open {url:pretty}";
+    gt = "cmd-set-text --space :tab-select";
+    # TODO(@bpeetz): Make this to a lf tab listing. <2025-05-31>
+    "\\f" = "cmd-set-text --space --relative :tab-focus";
+
+    eP = "open --tab -- {primary}";
+    ep = "open --tab -- {clipboard}";
+    ea = "open --tab";
+    es = "cmd-set-text --space :open --tab";
+    eo = "cmd-set-text :open --tab --related {url:pretty}";
+    eh = "back --tab";
+    en = "forward --tab";
+    ec = "tab-clone";
+    em = "tab-move";
+    ed = "tab-close";
+    eR = "reload --force";
+    er = "reload";
+
+    # Download management
+    dm = "download";
+    dc = "download-cancel";
+    dp = "download-clear";
+    do = "download-open";
+    dr = "download-retry";
+
+    # In page hierarchy [m]ovement
+    mi = "navigate increment";
+    md = "navigate increment";
+    mp = "navigate prev";
+    mn = "navigate next";
+    mu = "navigate up";
+    mh = "navigate strip";
+
+    # Page movement
+    H = "scroll left";
+    t = "scroll down";
+    n = "scroll up";
+    S = "scroll right";
+    h = "back";
+    T = "tab-prev";
+    N = "tab-next";
+    s = "forward";
+    G = "scroll-to-perc";
+    gg = "scroll-to-perc 0";
+
+    l = "search-next";
+    L = "search-prev";
+
+    "+" = "zoom-in";
+    "-" = "zoom-out";
+    "=" = "zoom";
+
+    "." = "cmd-repeat-last";
+    "/" = "cmd-set-text /";
+    "?" = "cmd-set-text ?";
+    ":" = "cmd-set-text :";
+
+    # Hinting
+    xI = "hint images tab";
+    xi = "hint images";
+    xO = "hint links fill :open --tab --related {hint-url}";
+    xo = "hint links fill :open {hint-url}";
+    xY = "hint links yank-primary";
+    xy = "hint links yank";
+    xb = "hint all tab-bg";
+    xf = "hint all tab-fg";
+    xd = "hint links download";
+    xh = "hint all hover";
+    xr = "hint --rapid links tab-bg";
+    xt = "hint inputs";
+    f = "hint all";
+
+    # Inputs
+    jf = "hint inputs --first";
+    jk = "spawn --userscript qute-keepassxc --key ${lib.escapeShellArg cfg.key}";
+
+    # Yanking
+    yD = "yank domain --sel";
+    yM = "yank inline [{title}]({url:yank}) --sel";
+    yP = "yank pretty-url --sel";
+    yT = "yank title --sel";
+    yY = "yank --sel";
+    yd = "yank domain";
+    ym = "yank inline [{title}]({url:yank})";
+    yp = "yank pretty-url";
+    yt = "yank title";
+    yy = "yank";
+
+    "<Escape>" = "clear-keychain ;; search ;; fullscreen --leave ;; clear-messages";
+    "<Shift-Space>" = "scroll-page 0 -1";
+    "<Space>" = "scroll-page 0 1";
+    "<Ctrl-Return>" = "selection-follow --tab";
+    "<Return>" = "selection-follow";
+    "<back>" = "back";
+    "<forward>" = "forward";
+
+    i = "mode-enter insert";
+    "'" = "mode-enter jump_mark";
+    "`" = "mode-enter set_mark";
+    v = "mode-enter caret";
+    V = "mode-enter caret ;; selection-toggle --line";
+    "<Ctrl-V>" = "mode-enter passthrough";
+
+    q = "macro-record";
+    "@" = "macro-run";
+    u = "undo";
+    U = "undo --window";
+  };
+
+  passthrough = {
+    "<Shift-Escape>" = "mode-leave";
+  };
+
+  prompt = {
+    "<Alt-B>" = "rl-backward-word";
+    "<Alt-Backspace>" = "rl-backward-kill-word";
+    "<Alt-D>" = "rl-kill-word";
+    "<Alt-E>" = "prompt-fileselect-external";
+    "<Alt-F>" = "rl-forward-word";
+    "<Alt-Shift-Y>" = "prompt-yank --sel";
+    "<Alt-Y>" = "prompt-yank";
+    "<Ctrl-?>" = "rl-delete-char";
+    "<Ctrl-A>" = "rl-beginning-of-line";
+    "<Ctrl-B>" = "rl-backward-char";
+    "<Ctrl-E>" = "rl-end-of-line";
+    "<Ctrl-F>" = "rl-forward-char";
+    "<Ctrl-H>" = "rl-backward-delete-char";
+    "<Ctrl-K>" = "rl-kill-line";
+    "<Ctrl-P>" = "prompt-open-download --pdfjs";
+    "<Ctrl-Shift-W>" = "rl-filename-rubout";
+    "<Ctrl-U>" = "rl-unix-line-discard";
+    "<Ctrl-W>" = "rl-rubout \" \"";
+    "<Ctrl-X>" = "prompt-open-download";
+    "<Ctrl-Y>" = "rl-yank";
+    "<Down>" = "prompt-item-focus next";
+    "<Escape>" = "mode-leave";
+    "<Return>" = "prompt-accept";
+    "<Shift-Tab>" = "prompt-item-focus prev";
+    "<Tab>" = "prompt-item-focus next";
+    "<Up>" = "prompt-item-focus prev";
+  };
+
+  register = {
+    "<Escape>" = "mode-leave";
+  };
+
+  yesno = {
+    "<Alt-Shift-Y>" = "prompt-yank --sel";
+    "<Alt-Y>" = "prompt-yank";
+    "<Escape>" = "mode-leave";
+    "<Return>" = "prompt-accept";
+    N = "prompt-accept --save no";
+    Y = "prompt-accept --save yes";
+    n = "prompt-accept no";
+    y = "prompt-accept yes";
+  };
+}
diff --git a/modules/by-name/ts/tskm/module.nix b/modules/by-name/ts/tskm/module.nix
index 9bf2b389..6f6517f8 100644
--- a/modules/by-name/ts/tskm/module.nix
+++ b/modules/by-name/ts/tskm/module.nix
@@ -23,13 +23,13 @@
   in
     [name] ++ subprojects;
 
-  firefoxProfiles = builtins.listToAttrs (lib.imap0 (index: name:
-    lib.attrsets.nameValuePair name {
+  qutebrowserProfiles = builtins.listToAttrs (
+    builtins.map (name: {
       inherit name;
-      # Add one here, so that we can have the default profile at id 0.
-      id = index + 1;
+      value = {};
     })
-  allProjectNames);
+    allProjectNames
+  );
 
   contexts =
     builtins.concatStringsSep "\n"
@@ -99,7 +99,8 @@ in {
 
   config = lib.mkIf cfg.enable {
     soispha.programs = {
-      firefox.profiles = firefoxProfiles;
+      qutebrowser.profiles = qutebrowserProfiles;
+
       taskwarrior = {
         includeFiles = {
           tskm-contexts = contextsFile;
diff --git a/modules/common/default.nix b/modules/common/default.nix
index 4ac4f6c7..d5dfb7a3 100644
--- a/modules/common/default.nix
+++ b/modules/common/default.nix
@@ -179,6 +179,10 @@
       i3bar-river.enable = true;
       i3status-rust.enable = true;
 
+      qutebrowser = {
+        enable = true;
+      };
+
       nvim = {
         enable = true;
         shell = pkgs.zsh;
diff --git a/pkgs/by-name/qu/qutebrowser-patched/0001-fix-standardpaths-Continue-to-work-with-xdg-while-ba.patch b/pkgs/by-name/qu/qutebrowser-patched/0001-fix-standardpaths-Continue-to-work-with-xdg-while-ba.patch
new file mode 100644
index 00000000..fa2e2482
--- /dev/null
+++ b/pkgs/by-name/qu/qutebrowser-patched/0001-fix-standardpaths-Continue-to-work-with-xdg-while-ba.patch
@@ -0,0 +1,54 @@
+From 8a0aa0e244fa565b8c55aab38cc5e84323c3b481 Mon Sep 17 00:00:00 2001
+From: Benedikt Peetz <benedikt.peetz@b-peetz.de>
+Date: Tue, 3 Jun 2025 12:43:44 +0200
+Subject: [PATCH] fix(standardpaths): Continue to work with xdg, while
+ `--basedir` is set
+
+This can be used to simulate firefox's profiles feature (i.e., completely separated
+data/runtime/cache dirs), while still keeping to the xdg basedir standard.
+---
+ qutebrowser/utils/standarddir.py | 19 +++++++++++--------
+ 1 file changed, 11 insertions(+), 8 deletions(-)
+
+diff --git a/qutebrowser/utils/standarddir.py b/qutebrowser/utils/standarddir.py
+index b82845a96..61319daed 100644
+--- a/qutebrowser/utils/standarddir.py
++++ b/qutebrowser/utils/standarddir.py
+@@ -285,12 +285,11 @@ def _from_args(
+         The overridden path, or None if there is no override.
+     """
+     basedir_suffix = {
+-        QStandardPaths.StandardLocation.ConfigLocation: 'config',
+-        QStandardPaths.StandardLocation.AppDataLocation: 'data',
+-        QStandardPaths.StandardLocation.AppLocalDataLocation: 'data',
+-        QStandardPaths.StandardLocation.CacheLocation: 'cache',
+-        QStandardPaths.StandardLocation.DownloadLocation: 'download',
+-        QStandardPaths.StandardLocation.RuntimeLocation: 'runtime',
++        QStandardPaths.StandardLocation.ConfigLocation: ('config', False),
++        QStandardPaths.StandardLocation.AppDataLocation: ('data', False),
++        QStandardPaths.StandardLocation.AppLocalDataLocation: ('data', False),
++        QStandardPaths.StandardLocation.CacheLocation: ('cache', True),
++        QStandardPaths.StandardLocation.RuntimeLocation: ('runtime', True),
+     }
+ 
+     if getattr(args, 'basedir', None) is None:
+@@ -298,10 +297,14 @@ def _from_args(
+     assert args is not None
+ 
+     try:
+-        suffix = basedir_suffix[typ]
++        (suffix, extend) = basedir_suffix[typ]
+     except KeyError:  # pragma: no cover
+         return None
+-    return os.path.abspath(os.path.join(args.basedir, suffix))
++
++    if extend:
++        return os.path.abspath(os.path.join(_writable_location(typ), os.path.basename(args.basedir)))
++    else:
++        return os.path.abspath(os.path.join(args.basedir, suffix))
+ 
+ 
+ def _create(path: str) -> None:
+-- 
+2.49.0
+
diff --git a/pkgs/by-name/qu/qutebrowser-patched/package.nix b/pkgs/by-name/qu/qutebrowser-patched/package.nix
new file mode 100644
index 00000000..1f2ea889
--- /dev/null
+++ b/pkgs/by-name/qu/qutebrowser-patched/package.nix
@@ -0,0 +1,6 @@
+{qutebrowser}:
+qutebrowser.overrideAttrs (final: prev: {
+  pname = "${prev.pname}-patched";
+
+  patches = (prev.patches or []) ++ [./0001-fix-standardpaths-Continue-to-work-with-xdg-while-ba.patch];
+})
diff --git a/update.sh b/update.sh
index ff10f870..8f7d338e 100755
--- a/update.sh
+++ b/update.sh
@@ -22,7 +22,6 @@ __update_sh_run() {
 }
 
 __update_sh_run nix flake update
-__update_sh_run ./modules/by-name/fi/firefox/update_extensions.sh "$@"
 __update_sh_run ./pkgs/update_pkgs.sh "$@"
 
 # __update_sh_run nix flake check