commit f1ac976a12d234389725a84023668613c156442f
parent db287114ebdd6e6019c4fa1dddd535aa6f62e6ca
Author: DAEMON <zer0sec.xp@icloud.com>
Date: Fri, 9 Oct 2026 07:44:44 +0100
feat: add Zen browser, patched Gradia screenshots, and default apps
- Add zen.nix next to Floorp, sharing the encrypted bookmarks export; Floorp stays the default browser
- Route Print and Shift+Print into Gradia, and bind Ctrl+Print to the region-to-clipboard grab
- Patch Gradia's censor tool into a secure blur whose size slider sets strength (upstream pixelates in fixed 8 px blocks)
- Set zathura for PDFs and Loupe for images via xdg.mimeApps, and source hm-session-vars.sh from the zsh shim since programs.zsh is off
- Add git-crypt, git-remote-gcrypt and glab for the NetrunnerVault sync tooling
Commit-Date: 2026-10-09T07:44:52+01:00
Commit-Host: daemonsec@nixos
Diffstat:
9 files changed, 340 insertions(+), 7 deletions(-)
diff --git a/flake.lock b/flake.lock
@@ -921,7 +921,8 @@
"nvf": "nvf",
"sops-nix": "sops-nix",
"stylix": "stylix",
- "wrapper-modules": "wrapper-modules"
+ "wrapper-modules": "wrapper-modules",
+ "zen-browser": "zen-browser"
}
},
"sops-nix": {
@@ -1177,6 +1178,29 @@
"repo": "xdg-desktop-portal-hyprland",
"type": "github"
}
+ },
+ "zen-browser": {
+ "inputs": {
+ "home-manager": [
+ "home-manager"
+ ],
+ "nixpkgs": [
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1791521685,
+ "narHash": "sha256-S4ZBR4y3cQ6NbLDJqoQR48718TWCM1ofpjt8Mzo83Hg=",
+ "owner": "0xc000022070",
+ "repo": "zen-browser-flake",
+ "rev": "841dba01deda5e994a9838da9d2216f493fbaea9",
+ "type": "github"
+ },
+ "original": {
+ "owner": "0xc000022070",
+ "repo": "zen-browser-flake",
+ "type": "github"
+ }
}
},
"root": "root",
diff --git a/flake.nix b/flake.nix
@@ -55,6 +55,14 @@
inputs.nixpkgs.follows = "nixpkgs";
};
+ # Zen Browser, which is not in nixpkgs (modules/home/zen.nix).
+ # `packages.default` is the general release channel; `twilight` is nightly.
+ zen-browser = {
+ url = "github:0xc000022070/zen-browser-flake";
+ inputs.nixpkgs.follows = "nixpkgs";
+ inputs.home-manager.follows = "home-manager";
+ };
+
# Neovim, configured in Nix (modules/home/neovim.nix).
nvf = {
url = "github:notashelf/nvf";
diff --git a/modules/features/desktop/_gradia-secure-blur.patch b/modules/features/desktop/_gradia-secure-blur.patch
@@ -0,0 +1,132 @@
+--- a/gradia/backend/tool_config.py
++++ b/gradia/backend/tool_config.py
+@@ -344,7 +344,7 @@
+ icon="checkerboard-big-symbolic",
+ column=3,
+ row=1,
+- has_scale=False,
++ has_scale=True,
+ has_primary_color=False,
+ ),
+ ToolConfig(
+--- a/gradia/overlay/drawing_actions.py
++++ b/gradia/overlay/drawing_actions.py
+@@ -21,6 +21,7 @@
+ from gi.repository import Gtk, Gdk, Gio, Pango, PangoCairo, GdkPixbuf
+ from enum import Enum
+ import math
++import random
+ from gradia.backend.logger import Logger
+ from gradia.utils.colors import has_visible_color
+ import time
+@@ -640,19 +641,25 @@
+
+
+ class CensorAction(RectAction):
++ # Secure blur: the area is averaged down to cells (4x the size slider, in
++ # screen pixels), every cell gets random noise, then it is scaled back up
++ # smoothly. Averaging throws the detail away; the noise means the cells no
++ # longer equal the true averages, which is what depixelation tools match on.
++ NOISE_SIGMA = 10
++
+ def __init__(self, start: tuple[int, int], end: tuple[int, int], background_pixbuf: GdkPixbuf.Pixbuf, options):
+ super().__init__(start, end, False, options)
+
+ self.original_scale = 1.0
+- self.base_block_size = 8
+ self.background_pixbuf = background_pixbuf
++ self.noise_seed = random.SystemRandom().getrandbits(64)
+
+ def set_original_scale(self, scale: float):
+ self.original_scale = scale
+
+ def _get_scaled_block_size(self, current_scale: float) -> float:
+ scale_ratio = current_scale / self.original_scale
+- return self.base_block_size * scale_ratio
++ return max(3, self.options.size) * 4 * scale_ratio
+
+ def draw(self, cr: cairo.Context, image_to_widget_coords: Callable[[int, int], tuple[float, float]], scale: float):
+ crop = self._get_image_crop()
+@@ -675,37 +682,59 @@
+ return
+
+ scaled_block_size = self._get_scaled_block_size(scale)
+- self._draw_pixelation(cr, crop, x, y, width, height, scaled_block_size)
++ self._draw_secure_blur(cr, crop, x, y, width, height, scaled_block_size)
++
++ def _draw_secure_blur(self, cr: cairo.Context, crop: dict, x: float, y: float, width: float, height: float, block_size: float):
++ blocks_x = max(2, round(width / block_size))
++ blocks_y = max(2, round(height / block_size))
++
++ cells = cairo.ImageSurface(cairo.FORMAT_ARGB32, blocks_x, blocks_y)
++ cells_cr = cairo.Context(cells)
++ cells_cr.scale(blocks_x / crop['width'], blocks_y / crop['height'])
++ cells_cr.translate(-crop['x'], -crop['y'])
++ Gdk.cairo_set_source_pixbuf(cells_cr, self.background_pixbuf, 0, 0)
++ cells_cr.get_source().set_filter(cairo.FILTER_GOOD) # box-average when shrinking
++ cells_cr.paint()
++ cells.flush()
++ self._add_noise(cells)
++ cells.mark_dirty()
++
++ # Two smooth upscales (cells -> 4x -> area) read as a blur, not a mosaic.
++ mid_w, mid_h = blocks_x * 4, blocks_y * 4
++ mid = cairo.ImageSurface(cairo.FORMAT_ARGB32, mid_w, mid_h)
++ mid_cr = cairo.Context(mid)
++ mid_cr.scale(4, 4)
++ self._set_smooth_source(mid_cr, cells)
++ mid_cr.paint()
+
+- def _draw_pixelation(self, cr: cairo.Context, crop: dict, x: float, y: float, width: float, height: float, block_size: float):
+ cr.save()
+ cr.rectangle(x, y, width, height)
+ cr.clip()
+-
+- blocks_x = max(1, int(width / block_size))
+- blocks_y = max(1, int(height / block_size))
+-
+- tiny_surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, blocks_x, blocks_y)
+- tiny_cr = cairo.Context(tiny_surface)
+-
+- tiny_cr.set_operator(cairo.OPERATOR_CLEAR)
+- tiny_cr.paint()
+- tiny_cr.set_operator(cairo.OPERATOR_OVER)
+-
+- tiny_cr.scale(blocks_x / crop['width'], blocks_y / crop['height'])
+- tiny_cr.translate(-crop['x'], -crop['y'])
+- Gdk.cairo_set_source_pixbuf(tiny_cr, self.background_pixbuf, 0, 0)
+- tiny_cr.paint()
+-
+ cr.translate(x, y)
+- cr.scale(width / blocks_x, height / blocks_y)
+- pattern = cairo.SurfacePattern(tiny_surface)
+- pattern.set_filter(cairo.FILTER_NEAREST)
+- cr.set_source(pattern)
++ cr.scale(width / mid_w, height / mid_h)
++ self._set_smooth_source(cr, mid)
+ cr.paint()
+-
+ cr.restore()
+
++ @staticmethod
++ def _set_smooth_source(cr: cairo.Context, surface: cairo.ImageSurface):
++ pattern = cairo.SurfacePattern(surface)
++ pattern.set_filter(cairo.FILTER_BILINEAR)
++ pattern.set_extend(cairo.EXTEND_PAD) # no see-through fringe at the edges
++ cr.set_source(pattern)
++
++ def _add_noise(self, surface: cairo.ImageSurface):
++ rng = random.Random(self.noise_seed) # fixed per box, so it does not flicker
++ data = surface.get_data()
++ stride = surface.get_stride()
++ for row in range(surface.get_height()):
++ for col in range(surface.get_width()):
++ i = row * stride + col * 4
++ alpha = data[i + 3]
++ shift = rng.gauss(0, self.NOISE_SIGMA) # brightness only, so it reads as grain
++ for c in range(3): # premultiplied B, G, R stay within alpha
++ data[i + c] = min(alpha, max(0, round(data[i + c] + shift)))
++
+ def _get_image_crop(self) -> dict | None:
+ img_w, img_h = self.background_pixbuf.get_width(), self.background_pixbuf.get_height()
+ x1 = int(self.start[0] + img_w / 2)
diff --git a/modules/features/desktop/niri.nix b/modules/features/desktop/niri.nix
@@ -21,6 +21,7 @@
nautilus = lib.getExe pkgs.nautilus;
grim = lib.getExe pkgs.grim;
slurp = lib.getExe pkgs.slurp;
+ gradia = lib.getExe self'.packages.gradia;
wl-copy = "${pkgs.wl-clipboard}/bin/wl-copy";
wpctl = "${pkgs.wireplumber}/bin/wpctl";
brightnessctl = lib.getExe pkgs.brightnessctl;
@@ -43,6 +44,20 @@
}) { } (lib.range 1 9);
in
{
+ # Gradia, the screenshot editor the Print binds open, with its Censor tool
+ # replaced by a secure blur (_gradia-secure-blur.patch). Upstream
+ # pixelates in fixed 8 px blocks with the size slider greyed out, which
+ # leaves large text readable. Patched, the slider sets the strength: the
+ # area is averaged into cells 4× the slider value (12–100 px, 56 by
+ # default), each cell gets random brightness noise so depixelation tools
+ # can't match the true averages, and it is scaled back up smoothly.
+ # Still not zero-leak: for secrets, a filled rectangle is the safe choice.
+ # Shared with home-manager (modules/home/session.nix) so the launcher
+ # entry runs the same build.
+ packages.gradia = pkgs.gradia.overrideAttrs (old: {
+ patches = (old.patches or [ ]) ++ [ ./_gradia-secure-blur.patch ];
+ });
+
packages.niri = inputs.wrapper-modules.wrappers.niri.wrap {
inherit pkgs;
settings = {
@@ -157,9 +172,16 @@
"Mod+O".toggle-overview = _: { };
"Mod+Shift+Slash".show-hotkey-overlay = _: { };
- # screenshots
- "Print".spawn-sh = "${grim} -g \"$(${slurp})\" - | ${wl-copy}";
- "Shift+Print".spawn-sh = "${grim} - | ${wl-copy}";
+ # screenshots: Print and Shift+Print open the shot in Gradia to
+ # annotate (arrows, text, blur, background) then copy or save;
+ # Ctrl+Print is the quick region-to-clipboard grab
+ "Print" = titled "Screenshot region → editor" {
+ spawn-sh = "region=$(${slurp}) && ${grim} -g \"$region\" - | ${gradia}";
+ };
+ "Shift+Print" = titled "Screenshot screen → editor" { spawn-sh = "${grim} - | ${gradia}"; };
+ "Ctrl+Print" = titled "Screenshot region → clipboard" {
+ spawn-sh = "region=$(${slurp}) && ${grim} -g \"$region\" - | ${wl-copy}";
+ };
# media and hardware keys, also on the lock screen
"XF86AudioRaiseVolume" = locked "${wpctl} set-volume -l 1.4 @DEFAULT_AUDIO_SINK@ 5%+";
diff --git a/modules/home/default.nix b/modules/home/default.nix
@@ -29,6 +29,7 @@
yazi # yazi with previews, Rosé Pine, plugins
gtk # Yaru-purple icons, cursor, prefer-dark
floorp # Floorp as the main browser: ShyFox (Rosé Pine), the Dia bookmarks
+ zen # Zen Browser alongside Floorp, sharing the one encrypted bookmarks export
];
home = {
diff --git a/modules/home/session.nix b/modules/home/session.nix
@@ -1,21 +1,49 @@
# modules/home/session.nix — what every Wayland session needs, whichever
# compositor is running: the polkit agent (privilege prompts), clipboard
# history, auto-mounting, and the screenshot / clipboard tools the Niri binds
-# and the toolbox scripts expect on PATH. Not gated on a desktop switch, so a
+# and the toolbox scripts expect on PATH. Gradia (patched, see niri.nix) is the
+# screenshot editor the Print binds pipe into (arrows, text, censor, gradient
+# backgrounds); Loupe opens images and zathura opens PDFs by default. Not gated on a desktop switch, so a
# Niri-only system keeps working. Everything hangs off graphical-session.target,
# which uwsm (Hyprland) and niri-session both manage.
-{ ... }:
+{ self, ... }:
{
flake.homeModules.session =
- { pkgs, ... }:
+ { lib, pkgs, ... }:
{
home.packages = with pkgs; [
grim
slurp
wl-clipboard
libnotify
+ self.packages.${pkgs.stdenv.hostPlatform.system}.gradia # patched: modules/features/desktop/niri.nix
+ loupe
];
+ xdg.mimeApps = {
+ enable = true;
+ defaultApplications =
+ lib.genAttrs [
+ "application/pdf"
+ "application/postscript"
+ "image/vnd.djvu"
+ "application/vnd.comicbook+zip"
+ ] (_: [ "org.pwmt.zathura.desktop" ])
+ // lib.genAttrs [
+ "image/png"
+ "image/jpeg"
+ "image/gif"
+ "image/webp"
+ "image/avif"
+ "image/heif"
+ "image/jxl"
+ "image/bmp"
+ "image/tiff"
+ "image/svg+xml"
+ "image/x-icon"
+ ] (_: [ "org.gnome.Loupe.desktop" ]);
+ };
+
systemd.user.services.hyprpolkitagent = {
Unit = {
Description = "Hyprland polkit authentication agent";
diff --git a/modules/home/shell.nix b/modules/home/shell.nix
@@ -70,6 +70,10 @@
# Managed by home-manager (NixDaemon modules/home/shell.nix). The shell
# configuration lives in ~/.dotfiles (→ ~/git/daemon-sec-dotfiles).
export ZDOTDIR="$HOME/.dotfiles"
+ # home.sessionVariables (PASSAGE_*, XDG_*, …); programs.zsh is off, so
+ # nothing else sources this.
+ [[ -r "${config.home.profileDirectory}/etc/profile.d/hm-session-vars.sh" ]] \
+ && . "${config.home.profileDirectory}/etc/profile.d/hm-session-vars.sh"
[[ -r "$HOME/.cargo/env" ]] && . "$HOME/.cargo/env"
'';
".fzf.zsh".text = ''
diff --git a/modules/home/tools.nix b/modules/home/tools.nix
@@ -47,6 +47,17 @@
perl
git
jujutsu
+ # `vaultx` syncs NetrunnerVault to GitLab with file contents
+ # encrypted. git-crypt does that through git's clean/smudge filters,
+ # which is only safe because the vault is plain git now -- jj does not
+ # run those filters and would commit plaintext (Vault-Encryption.md).
+ # git-remote-gcrypt stays for the older whole-repo scheme the vault
+ # notes still describe; it cannot carry this vault to gitlab.com
+ # because it packs each push into one blob and the cap is 100 MiB.
+ # glab is how vaultx creates and purges the project.
+ git-crypt
+ git-remote-gcrypt
+ glab
gnutar
zstd
pigz
diff --git a/modules/home/zen.nix b/modules/home/zen.nix
@@ -0,0 +1,103 @@
+# modules/home/zen.nix — Zen Browser alongside Floorp, sharing one bookmarks file.
+#
+# Floorp stays the default browser: this module deliberately sets neither
+# xdg.mimeApps nor $BROWSER, so links keep opening in Floorp
+# (modules/home/floorp.nix owns both). Zen is here to be tried with the real
+# bookmarks; promote it by moving those two settings over, or drop this module
+# to back out.
+#
+# Zen is not in nixpkgs, so the package comes from the community flake
+# (inputs.zen-browser, pinned in flake.nix). `packages.default` is the general
+# release channel — the binary is literally `zen-beta` and versions carry a `b`,
+# which is how upstream labels its releases; `twilight` is the nightly.
+#
+# The profile is written by hand under ~/.zen, for the same reason as Floorp:
+# Zen's application.ini declares `Profile=zen`, so Gecko reads ~/.zen rather
+# than anywhere home-manager's Firefox-family modules would look.
+#
+# Bookmarks are NOT duplicated. The one encrypted export at
+# secrets/floorp/bookmarks.html (111 links, 19 folders, matching the live Floorp
+# profile) is declared here a second time under its own name so this module does
+# not depend on floorp.nix being imported. Edit it once with
+# `TMPDIR=/dev/shm sops secrets/floorp/bookmarks.html` and both browsers see it.
+#
+# Prefs live in Nix here, not sops, because nothing in them is private yet —
+# only the start page and the bookmark-file path. If this grows personal prefs,
+# move it to secrets/zen/user.js and mirror the sops.secrets block floorp.nix
+# uses.
+{ ... }:
+{
+ flake.homeModules.zen =
+ { config, lib, pkgs, inputs, ... }:
+ let
+ profile = "daemon";
+ profileDir = ".zen/${profile}";
+ zen = inputs.zen-browser.packages.${pkgs.stdenv.hostPlatform.system}.default;
+ bookmarksPath = config.sops.secrets."zen-bookmarks.html".path;
+ in
+ {
+ home.packages = [ zen ];
+
+ # The same encrypted export Floorp imports, under its own secret name so
+ # neither module needs the other. sops-nix is happy for two secrets to come
+ # from one sopsFile; they decrypt to separate runtime paths.
+ sops.secrets."zen-bookmarks.html" = {
+ sopsFile = ../../secrets/floorp/bookmarks.html;
+ format = "binary";
+ };
+
+ home.file = {
+ # Written here rather than left to Zen so the Nix-built profile is the
+ # default one. Trade-off, as with Floorp: profile changes made in Zen's
+ # own profile manager are reverted on the next `nh os switch`.
+ ".zen/profiles.ini".text = ''
+ [Profile0]
+ Name=${profile}
+ IsRelative=1
+ Path=${profile}
+ Default=1
+
+ [General]
+ StartWithLastProfile=1
+ Version=2
+ '';
+
+ # browser.places.importBookmarksHTML is deliberately absent: see the
+ # activation script below for why it cannot live in user.js.
+ "${profileDir}/user.js".text = ''
+ // Managed by modules/home/zen.nix. Zen reads this only at startup.
+ user_pref("browser.bookmarks.file", "${bookmarksPath}");
+ // Don't let Zen's shipped default bookmarks compete with the import.
+ user_pref("browser.bookmarks.restore_default_bookmarks", false);
+ user_pref("browser.startup.homepage", "https://startpage.daemon-sec.xyz/");
+ // No first-run tour or import wizard racing the seeded import.
+ user_pref("browser.aboutwelcome.enabled", false);
+ user_pref("browser.startup.firstrunSkipsHomepage", true);
+ user_pref("datareporting.policy.firstRunURL", "");
+ '';
+ };
+
+ # One-time bookmark import, same mechanism floorp.nix documents.
+ #
+ # `browser.places.importBookmarksHTML` is what makes Gecko read
+ # browser.bookmarks.file into the places database. It cannot live in
+ # user.js: the browser consumes it and sets it back to false, but user.js
+ # re-applies every pref at every startup, so the import would run again on
+ # each launch and duplicate all 111 bookmarks. Seeding it into prefs.js
+ # instead, only while the profile has no places.sqlite yet, imports once on
+ # first launch and never again.
+ home.activation.zenSeedBookmarkImport = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
+ zenProfile="$HOME/${profileDir}"
+ zenPrefs="$zenProfile/prefs.js"
+ if [ ! -e "$zenProfile/places.sqlite" ] \
+ && ! grep -qs 'browser.places.importBookmarksHTML' "$zenPrefs"; then
+ verboseEcho "Zen: seeding the one-time bookmark import in $zenPrefs"
+ if [ -z "''${DRY_RUN:-}" ]; then
+ mkdir -p "$zenProfile"
+ printf '%s\n' 'user_pref("browser.places.importBookmarksHTML", true);' >> "$zenPrefs"
+ fi
+ fi
+ '';
+ }
+ ;
+}