Skip to content

JowiAoun/dome

Repository files navigation

Dotfiles

Here's my development environment that works in WSL, GitHub Codespaces, and local environment. It includes many near-native functionalities previously not supported on Asus Zenbook Duo (the 2024 edition)!

⚠️ I found out that this is a very strong breakthrough for Asus Zenbook Duo support on Ubuntu which many people struggled with for a while, and as such I will be soon working on a repository that will have all the support possible to aim for near-native support of Asus Zenbook Duo, hopefully for the largest set possible! Linux is an amazing experience which i'm finding out now finally. Make an issue or something if you have any concerns or suggestions meanwhile! ⚠️ ⚠️ Although development and results are extremely solid, this repository will be getting pushes directly to main during development and can be very unstable, use at your own risk ⚠️

ZenDuo project: this repo also provisions a full Ubuntu 24.04 dual-boot on the ASUS Zenbook Duo (2024) UX8406MA. Start at docs/PLAN.md — the exhaustive, phase-gated master plan (research archive in docs/research/). The moving parts:

  • system/ — idempotent root-layer scripts (sudo make system HOST=zenbook-duo, add DRY_RUN=1 as a make argument to preview)
  • duo/zenduo hardware tooling; duo doctor is the read-only probe used as the live-USB install gate. Day to day it keeps the bottom panel in step with the keyboard (off while docked, back on when you lift it off) and makes the Fn/media row work — see duo/README.md
  • hosts/ + flake.nix — per-machine home-manager profiles (home-manager switch --flake path:.#generic or path:.#zenbook-duo)
  • install.sh — one-command setup on a fresh Ubuntu machine

Quick Start — pick your machine's path

Machine Path
GitHub Codespaces Enable dotfiles (below) — bootstrap.sh runs automatically
WSL / existing Linux that already has (or wants only) Nix ./bootstrap.sh (interactive)
Fresh Ubuntu LTS machine (24.04 or 26.04) — any hardware ./install.sh --host generic
ASUS Zenbook Duo (2024) UX8406MA Follow docs/PLAN.md + docs/CHECKLIST.md, then ./install.sh --host zenbook-duo

GitHub Codespaces

  1. Go to GitHub Settings → Codespaces
  2. Enable "Automatically install dotfiles"
  3. Set repository to your fork or clone of this repo
  4. Create a new Codespace - setup runs automatically!

Fresh Ubuntu machine (any hardware)

sudo apt install -y git   # the only manual prerequisite (dome's base layer also installs it)
git clone https://github.com/JowiAoun/dome.git ~/.dotfiles
cd ~/.dotfiles
./setup.sh                # guided TUI: detects the machine, picks the host
                          # profile, toggles modules, writes user-config.nix,
                          # and offers to run the full install

setup.sh detects distro/hardware/WSL/Codespaces, suggests the right hosts/<name> profile (e.g. the Zenbook Duo is auto-recognized by DMI model), seeds the module checklist from that host's committed defaults (hosts/<name>/setup-defaults.env — the Duo ships with cloud off), and preserves your saved choices on re-runs.

Headless/scripted alternative (same result, no prompts):

./setup.sh --defaults zenbook-duo         # write user-config.nix from host seeds
./install.sh --host zenbook-duo           # flags: --enable/--disable python,node,java,ai,cloud

The install runs, in order: user-config.nix environment re-detection, the system layer (apt basics, HWE+GA kernels, GRUB os-prober — duo-only steps are skipped on generic hosts), the official Nix installer, and home-manager for the chosen host. Re-run any time; every step is idempotent. Preview root-level changes with sudo bash system/run.sh --host <profile> --dry-run (or sudo make system DRY_RUN=1, once build-essential is installed). Use the flag rather than DRY_RUN=1 sudo …: sudo's default env_reset drops the variable before the script sees it, so that form would really apply the changes. The system layer accepts the two LTS releases it has been checked against, 24.04 (noble) and 26.04 (resolute). Interim releases and other distros need FORCE=1: they are supported for nine months, which is shorter than the gap between provisions of this machine. Non-Ubuntu distros: only bootstrap.sh applies (the system layer refuses to run without FORCE=1).

WSL / existing Linux

# Install Nix
sh <(curl -L https://nixos.org/nix/install) --daemon

# Clone and setup
git clone https://github.com/JowiAoun/dome.git ~/.dotfiles
cd ~/.dotfiles
./bootstrap.sh

Adding a new machine profile

  1. cp -r hosts/generic hosts/<name> and edit it (import machine modules, set options)
  2. Add homeConfigurations.<name> = mkHome "<name>"; in flake.nix
  3. ./install.sh --host <name>

Machine-specific code lives in modules/<name>/ and is imported only by that host's profile — every other machine never evaluates it (that's how the Zenbook Duo tooling stays out of generic setups).

What You Get

Core Tools

  • Shell: Zsh with oh-my-zsh and sensible defaults, and a nice theme using starship
  • Editor: VSCode suggested extensions for each module chosen
  • Git: Pre-configured with your details
  • Utils: fzf, ripgrep, bat, tree, lazygit

Development Modules (Choose During Setup)

Python (modules.python = true)

  • Python 3.13 with pip
  • pyenv for version management
  • VS Code extensions: Python, Pylance, Black, Flake8

Node.js (modules.node = true)

  • Node.js v22 LTS (global) with npm and pnpm
  • nodenv for version management
  • VS Code extensions: ESLint, Prettier, TailwindCSS

Java (modules.java = true)

  • JDK 21, Maven, Gradle
  • VS Code Java extensions

AI Tools (modules.ai = true)

  • Claude Code: AI coding assistant, from the official installer so it is always current and self-updating

  • skills (vercel-labs/skills): the open agent-skills CLI. Upstream documents npx skills …; this installs it globally instead, so it is a real skills command that starts instantly and works offline. skills find, skills add <source>, skills ls, skills update. It is an npm package needing Node ≥ 22.20, so this module installs nodejs_22 itself when modules.node is off — the same derivation, so enabling both gives one Node, not two

  • Claude Code keybindings (~/.claude/keybindings.json): Shift+Enter inserts a newline. That needs a terminal that can encode a modified Enter — see the terminal module below

  • Claude Code defaults: theme dark (so a fresh machine never opens on the theme picker), Show tips off, Use auto mode during plan off, and Copy on select off — in the fullscreen TUI Claude Code runs its own mouse selection, and highlighting anything replaced the clipboard. Ghostty's copy-on-select = false covers the terminal's own version of that.

    The first three are settings.json keys; copyOnSelect is not in that schema and lives in ~/.claude.json with the app's auth and history. Both files are written by Claude Code constantly, so neither can be a home-manager symlink — that would make the target read-only and break the app. They are merged with jq instead, every other key passed through untouched, and only when the file does not already agree.

    These values are declared, not seeded: they are re-applied on each make home, so a /config change to one of them lasts until then. Seeding only-if-missing was tried first and does not work — Claude Code materialises its own defaults into settings.json as you use it, so the key is usually already there holding the value you wanted changed.

Terminal (always on, not part of modules.apps)

Ghostty, installed on every machine with a desktop — deliberately outside the optional apps bundle, because it is what everything else here runs inside.

  • Set as the default terminal, which is also what Ctrl+Alt+T opens (gsd-media-keys resolves the shortcut through org.gnome.desktop.default-applications.terminal), and what Nautilus's "Open in Terminal" uses. Turn that off with modules.terminal.setDefault = false
  • Pinned to the dash; GNOME Terminal is left installed as a fallback
  • Its terminfo is installed to ~/.terminfo, because Ubuntu's ncurses has never heard of xterm-ghostty and without it every system tool (vim, htop, less) dies with "unknown terminal type" inside it

The reason it is Ghostty and not GNOME Terminal is Shift+Enter. VTE implements neither the Kitty keyboard protocol nor modifyOtherKeys, so Shift+Enter and Enter reach applications as the same bare CR and nothing can tell them apart. That is still true on the newer LTS — checked by strings on both shipped libraries, 0.76.0 on 24.04 and 0.84.0 on 26.04, neither of which mentions either mechanism. Ghostty speaks the Kitty protocol, so the keypress arrives as CSI 13;2u and Claude Code's binding fires.

Desktop Apps (modules.apps = true)

GUI software plus the desktop wiring that makes it usable — desktop entries, the default browser, and the GNOME dash:

  • Brave — pinned to the dash (in Firefox's old slot) and set as the default browser for http/https/text/html; the Firefox pin is removed (the snap itself is left installed)
  • Thunderbird — pinned, and set as the default mail client for mailto:, message/rfc822 (.eml) and mid:
  • Discord — pinned to the dash
  • Joplin — pinned to the dash
  • draw.io, LocalSend, Bruno, OBS Studio, Zoom — installed, not pinned
  • Notion, YouTube Music — pinned web apps, see below

Budget roughly 5 GiB of disk for the set. To hold one back without removing it from the module, name it in appsSkip — that is the same switch the already-installed detection uses, so it also skips the pin and the desktop entry:

appsSkip = [ "bruno" "obs-studio" "joplin" ];   # install later, keep the config

appsSkip lives in user-config.nix, which is gitignored and regenerated on a fresh machine — so anything parked there is held back on this machine only and installs normally on a clean run.

Web apps. Some services ship no Linux client at all: Notion publishes macOS and Windows builds only (nixpkgs' notion-app is macOS-only, and notion-app-enhanced is a third-party repackage rather than a Notion build), and YouTube Music has never had a desktop app. For those the vendor's web app is the Linux client, so the module writes a launcher that opens it with --app=: its own window, no tabs or address bar, its own icon and dash pin, using the normal browser profile so logins persist. It costs a .desktop file and an icon, since the browser is already installed.

Two details that are easy to get wrong, both settled by measurement rather than guesswork:

  • Window identity. GNOME matches a window to its launcher via StartupWMClass. Chromium ignores --class on Wayland (an X11 flag) and derives the id from the URL — brave-www.notion.so__-Default, captured with WAYLAND_DEBUG=1. Get it wrong and the window shows a generic cog icon.
  • Window size. An --app= window has no saved geometry the first time it opens, so Chromium falls back to a small default; --start-maximized fixes it.

Add anything else from nixpkgs by name, no module edits needed:

# hosts/<name>/default.nix
modules.apps.extras = [ "obsidian" "vlc" "dbeaver-bin" ];

Worth knowing: on Ubuntu the GNOME session does not have the Nix profile on XDG_DATA_DIRS or PATH, so a plain home.packages GUI app is invisible to the dash and app grid. The module works around this by copying each app's .desktop entry into ~/.local/share/applications with Exec= and Icon= rewritten to absolute /nix/store paths — which is why the apps show up immediately, with icons, and with no re-login. extras are installed as plain packages and skip that rewrite.

GPU: Nix packages look for DRI drivers under /run/opengl-driver, which Ubuntu does not have, so out of the box a Nix GUI app gets no GL driver at all — Flutter apps (LocalSend) refuse to start with "No GL Implementation Available", and Electron apps fall back to software rendering. system/80-nix-gpu.sh fixes it by installing home-manager's non-nixos-gpu systemd unit, which points /run/opengl-driver at a Mesa build matching the Nix closure on every boot (/run is a tmpfs, so a plain symlink would not survive). It runs as part of sudo make system and at the end of ./install.sh. Re-run it after make update: the bundle's store path changes, and home-manager will say "GPU drivers require an update" on the next switch.

VS Code gets the same treatment, even though programs.vscode (not this module) installs it — it has the same bare Exec=code and themed Icon=vscode, so before this it was installed but absent from the app grid entirely. Its entries are written whenever programs.vscode.enable is on, apps module or not.

The pins and default browser are merged, not overwritten (anything you pinned yourself survives), by ~/.local/bin/apps-setup. It runs during make home; re-run it by hand if you activate from a TTY or over SSH, where there is no D-Bus session to write to.

Already have one of these from apt, snap or flatpak? It is left completely alone — no second copy, no pin, no change to its defaults:

./setup.sh --audit-apps       # full report: what is installed where, and what collides
./setup.sh --detect-apps      # just the names of apps already installed elsewhere
./setup.sh --sync-apps-skip   # record them in user-config.nix (install.sh does this too)

That writes appsSkip = [ "brave" ];, which drops the app from the module entirely — no package, no desktop entry, no pin, never the default browser. vscode is skippable the same way, and turns programs.vscode off with it. Names you add by hand are kept. Detection only looks at system locations (/usr/share/applications, snap and flatpak exports, /usr/bin, /snap/bin) — deliberately never at the Nix profile, or the module would detect its own installs and remove them on the next run.

--audit-apps also sweeps generically for the underlying failure mode: the same .desktop id existing in two places, which is what puts two icons of one app in the app grid. It reports what gnome-shell can actually see, so it can tell a real duplicate from a harmless one.

Not enabled for the generic host profile — it also covers WSL and headless machines.

Renaming the machine

# user-config.nix
hostName = "LAPTOP-JA";   # empty (the default) leaves the current name alone

Edit the field, then apply with sudo make system or ./setup.sh. There is no prompt for it — renaming is a one-off, and setup.sh carries whatever is already there through untouched. Renaming means three things, and doing only the first is how machines end up half-renamed:

where what it affects
static /etc/hostname hostname, mDNS <name>.local, shell prompts
pretty PRETTY_HOSTNAME in /etc/machine-info GNOME Settings → About "Device Name", the Bluetooth name
hosts the 127.0.1.1 line in /etc/hosts local name resolution — miss it and every sudo prints "unable to resolve host" after a DNS timeout

The /etc/hosts edit keeps any other aliases on that line (and anything Docker Desktop added), refuses to write a file that lost its localhost entry, and leaves the previous copy at /etc/hosts.dome.bak.

Not to be confused with hostProfile, which selects hosts/<name>/ — the per-machine configuration profile, nothing to do with the machine's name.

Boot time

Measured on this machine before any tuning:

$ systemd-analyze
7.820s (firmware) + 5.906s (loader) + 5.347s (kernel) + 7.417s (userspace) = 26.491s

The firmware 7.8s is ASUS's and not ours. system/35-boot-services.sh takes ~5.1s off the userspace phase by disabling two services this machine gets nothing from — each behind a guard, so a machine where they do matter keeps them:

  • NetworkManager-wait-online (3.78s) sits at the top of the critical chain: graphical.target waits on network-online.target waits on this, which blocks until DHCP finishes. What actually wants that target here is printer discovery, firmware metadata, crash reporting and Docker — all of which cope with a network arriving later, which is the normal condition for a laptop that roams. Guard: an nfs/cifs/_netdev entry in /etc/fstab means something really does need the network first, and the wait is kept.
  • gpu-manager (1.32s) is Ubuntu's hybrid-graphics arbiter, for NVIDIA Optimus and switchable AMD. This machine has one Intel iGPU. Guard: more than one GPU in lspci, or a loaded nvidia/nouveau/amdgpu/radeon module, and it is kept.

Both use disable, never disable --now and never mask: nothing running is stopped, and anything that genuinely needs them can still start them. Undo:

sudo systemctl enable NetworkManager-wait-online.service gpu-manager.service
systemd-analyze blame | head        # after a reboot

docker.service was a further 914ms and is already gone — see Docker, which is now socket-activated.

Still on the table, deliberately not done: the initramfs is 89 MB because Ubuntu ships MODULES=most. MODULES=dep typically takes it to 20–30 MB and is worth another 3–5s across the loader and kernel phases. It is not automated here because a wrong dep set is an unbootable machine, and that trade wants a deliberate decision plus a verified reboot on both the HWE and GA kernel — not a provisioning script doing it on your behalf.

Memory, swap, and what happens when RAM runs out

The machine has 15 GiB of RAM shared with an iGPU that has no VRAM of its own. Ubuntu's stock answer to filling that up is systemd-oomd, and its stock answer is wrong. From this machine's journal:

systemd-oomd: Killed …/user@1000.service/session.slice/org.gnome.Shell@wayland.service
  due to memory pressure for /user.slice/…/user@1000.service
  being 58.86% > 50.00% for > 20s with reclaim activity

On Wayland gnome-shell is the session, so killing it takes every open application with it and drops you at the login screen. It reads exactly like a crash. It was reproducible by playing a heavily modded Minecraft (8 GB Java heap, ~10 GB resident) with a browser open and then alt-tabbing out — which is the tell: systemd-oomd picks the descendant cgroup with the highest pressure, not the biggest one, and leaving a game makes the compositor allocate (overview, thumbnails, compositing resumes) at the exact moment nothing is free. The 10 GB game sits quiet and the compositor elects itself.

system/25-memory.sh fixes this in four parts. It is unconditional — there is no switch, because it is a correctness fix rather than a preference.

Two swap tiers. /etc/systemd/zram-generator.conf adds a zstd-compressed swap device in RAM at priority 100, sized min(ram / 2, 8192) MB. Anonymous pages compress about 3:1, so it holds several GB for a fraction of that in real memory, at RAM latency rather than a round trip through LUKS to NVMe. Behind it, /swap.img grows from 4 GB to 16 GB as genuine overflow for pages cold enough that compressing them in RAM would be a waste of RAM.

NAME       TYPE       SIZE PRIO
/dev/zram0 partition  7.5G  100   <- fills first, compressed, in RAM
/swap.img  file        16G   -1   <- overflow, on the encrypted disk

Reclaim tuning in /etc/sysctl.d/90-dome-memory.conf, because the defaults assume swapping means a disk: vm.swappiness=180, vm.page-cluster=0 (no pointless readahead out of RAM), vm.watermark_boost_factor=0 (its reclaim bursts are latency spikes) and vm.watermark_scale_factor=125 (~190 MB of runway ahead of an allocation instead of ~15 MB). vm.max_map_count is left alone: Ubuntu already defaults it to 1048576.

A ceiling on apps, and a floor under the desktop. /etc/systemd/user/app.slice.d/ sets MemoryHigh=12G on app.slice, which is where gnome-shell puts everything you launch. It is a throttle, not a limit: past 12 GB the kernel reclaims hard from inside app.slice — into zram — instead of taking pages from the session. Nothing is killed; a game that wants more just gets slower. It also fixes oomd's aim for free, because the pressure now shows up on an app rather than on the compositor.

And oomd can no longer choose the desktop. Ubuntu's 50% pressure limit is raised to 75% (/etc/systemd/system/user@.service.d/), and gnome-shell is marked ManagedOOMPreference=omit (/etc/systemd/user/org.gnome.Shell@*.service.d/), which removes it from candidate selection entirely.

Check the result:

swapon --show      # zram0 at prio 100, /swap.img at 16G
zramctl            # ALGORITHM zstd, plus the live compression ratio
oomctl             # user@1000.service should read "Memory Pressure Limit: 75.00%"
systemctl --user show app.slice -p MemoryHigh          # 12G
journalctl -u systemd-oomd | grep Killed               # should stay empty

The one number worth tuning is MemoryHigh. If games feel throttled, either raise it in system/25-memory.sh or close the browser first — a 10 GB game plus ~4 GB of browser and chat genuinely does not fit under 12 GB, and the throttle is what stops that from taking the session down with it.

Games (GameMode)

Off by default. gameMode = true; in user-config.nix turns on both halves:

  • system/86-gamemode.sh writes /etc/gamemode.ini. Ubuntu's gamemode package ships no config at all, so every setting is otherwise a compiled-in default. The important ones here are igpu_desiredgov / igpu_power_threshold: this machine's CPU and Arc graphics share one power budget, so pinning the cores to performance takes watts away from the GPU that is actually the bottleneck. GameMode backs the governor off again once the iGPU is doing the work, which is the correct behaviour on an integrated part.

  • modules/gaming.nix makes the CurseForge launcher start through gamemoderun. GameMode does nothing until a process asks for it, and nothing on a stock Ubuntu ever does — so an installed-but-unwired gamemode (which is what you get by default) never actually runs.

    It does this by amending the launcher at activation rather than declaring its own, because modules/apps.nix already writes that same file to append chromiumFlags to apt-installed Electron apps. Two modules declaring one .desktop does not fail loudly — apps.nix writes a real file from an activation script, which silently overwrites the symlink an xdg.dataFile puts there, so the last writer wins and gamemode loses with nothing reported. Ordering after appsDesktopIntegration and prepending the wrapper keeps both features on the same Exec line:

    Exec=/usr/games/gamemoderun /opt/CurseForge/curseforge --enable-features=… %U
    

    Turning gameMode off is self-healing: apps.nix rewrites the entry from the package's own on every switch, so the prefix just stops being re-applied.

gamemoderun works by setting LD_PRELOAD=libgamemodeauto.so.0, which children inherit, so wrapping the launcher reaches the game's JVM. [filter] whitelist=java in the config is what stops the Electron launcher itself from counting — without it, leaving the modpack browser open would hold the governor at performance all day. Add another game by adding its binary name to that list.

sudo bash system/run.sh --gamemode   # or --no-gamemode, for a single run
gamemodelist                         # while a game is running
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor

The trade is heat, fan noise and battery while a game runs. That is why it is opt-in.

Docker

docker is not a Nix package here: the client is useless without a root-owned daemon, and nixpkgs cannot give you a systemd unit, a socket, or the docker group. So Docker Engine comes from the system layer instead — system/60-docker.sh adds Docker's official apt repository and installs docker-ce, docker-ce-cli, containerd.io and the buildx/compose plugins, enables docker.socket, and adds you to the docker group (root-equivalent, by design — log out and back in for it to apply):

sudo make system            # includes Docker Engine when dockerEngine = true
docker run --rm hello-world # after a re-login

The user layer keeps docker-compose (a standalone binary, works against any reachable daemon) and adds lazydocker as a TUI.

The daemon is socket-activated, not started at boot. dockerd plus containerd sit on ~45 MB and two always-on services for a machine that usually has no containers running, so only docker.socket is enabled; the first client connection to /run/docker.sock starts the engine. Nothing about using Docker changes except that your first command after a boot takes about a second longer.

The one real consequence: a container with restart: always or unless-stopped no longer comes back by itself after a reboot, because nothing has touched the socket yet. If you want a container running from boot, put the eager units back:

sudo systemctl enable --now docker.service containerd.service

Docker Desktop is off by default — it is a ~450 MB download that runs its own KVM virtual machine under a separate docker context (desktop-linux), alongside rather than instead of the native engine. Turn it on with dockerDesktop = true; in user-config.nix, or for one run:

sudo bash system/run.sh --docker-desktop

Claude Desktop

The Claude desktop app (Linux beta) is on by default. It is not a Nix package — Anthropic publishes it through their own signed apt repository — so system/75-claude-desktop.sh installs it at the root layer and it updates with the rest of the system on apt upgrade.

The signing key's fingerprint is pinned and verified before the repository is registered: downloading a key over TLS only proves it came from that host, whereas a mismatch here fails loudly instead of silently becoming trusted for every later upgrade.

It is pinned to the dash by the apps module, which is why that module has a systemPins list: the app comes from apt rather than Nix, so it is pinned only once the .desktop file is really on the machine (absent = quietly not pinned). install.sh runs the system layer before home-manager, so on a clean provision it is already installed by the time the pin is written. The lookup tries the known filenames and then falls back to whichever system entry actually launches claude-desktop, so a renamed entry in a future .deb still gets found.

Turn it off with claudeDesktop = false;, or for one run:

sudo bash system/run.sh --no-claude-desktop

These switches live outside modules in user-config.nix, because the root layer reads them with sed rather than through Nix:

dockerEngine = true;        # Docker Engine (CE)
dockerDesktop = false;      # Docker Desktop GUI
claudeDesktop = true;       # Claude desktop app (beta)
openWhispr = true;          # OpenWhispr dictation, from its GitHub release (~1 GB)
braveBrowser = true;        # Brave from Brave's apt repo, not nixpkgs
braveManagedPolicy = true;  # Leo, Wallet, Rewards, VPN, News, Web Discovery off
geckoPolicy = true;         # Firefox + Thunderbird: middle-click autoscroll, as policy

Each has a matching one-run override on system/run.sh--no-brave, --no-brave-policy, --no-gecko-policy, --no-openwhispr, --no-claude-desktop.

Brave, and why it is not a Nix package

flake.lock freezes nixpkgs at whatever commit it was last bumped to, so every Nix package here is exactly as old as that pin. For most tools that is a feature. For a browser it is a liability: this machine sat on Brave 143 — a Chromium seven months old — until sites began refusing it, and no amount of make home could have changed that. Only make update moves the pin, and it moves everything at once.

So Brave comes from Brave's own signed apt repository instead (system/78-brave.sh), and every sudo make system upgrades it to the newest build Brave has published. braveBrowser = true; (the default) turns it on, and the apps module reacts by installing no Nix Brave, pinning the apt one through systemPins, and pointing $BROWSER and the web app launchers at /usr/bin/brave-browser.

Registering the repository is not enough on its own, which is the mistake the first version of this made: nothing on an Ubuntu desktop runs apt upgrade on a schedule, so "it will update with apt" quietly means "whenever someone remembers". That is how a browser gets seven releases behind. The script now refreshes Brave's index — and only Brave's, a one-second request rather than a full apt-get update — and runs apt-get install --only-upgrade on every run, reporting the version change.

Brave's settings, as policy

system/79-brave-policy.sh writes /etc/brave/policies/managed/dome.json, turning off Leo (the AI assistant), the Wallet, Rewards/BAT, the VPN upsell, Brave News and Web Discovery.

Policy, not preferences, and the distinction is the whole point:

  • The profile's Preferences file is the browser's own live state — it rewrites it on exit, so an edit there is either clobbered or fights the running browser. Policy is read fresh from /etc at every launch, applies to every profile, and shows in the UI as managed by your organisation with the control greyed out.
  • It survives updates. Nothing in the file names a Brave version, a Chromium version or an install path, and Chromium ignores policy keys it does not recognise — so a key a future Brave retires becomes a silent no-op instead of an error. /etc is untouched by the .deb.
  • It applies to both installs, since /etc/brave/policies is compiled into the binary — the apt Brave and the nixpkgs one alike.

Adding another setting is one line in the script. Check the spelling against the shipped binary rather than a support page, because the polarity is not consistent upstream (some keys are *Disabled, some *Enabled):

strings /opt/brave.com/brave/brave | grep -xE 'Brave[A-Za-z]+(Disabled|Enabled)'

braveManagedPolicy = false; removes the file again and hands the settings back to the browser UI. Verify what is in force at brave://policy.

Middle-click autoscroll, everywhere

Hold the scroll wheel down and move the mouse to pan, the way Windows does. Both engines on this machine ship the feature and both default it off on Linux, where middle click pastes the primary selection instead. Two engines, so two mechanisms — but one behaviour, so middle click means the same thing in every app:

engine apps mechanism
Chromium Brave, Discord, VS Code, Joplin, draw.io, Bruno, Claude Desktop, OpenWhispr, CurseForge modules.apps.chromiumFlags (a command-line switch)
Gecko Firefox, Thunderbird system/77-gecko-policy.sh (general.autoScroll, as policy)

Chromium: modules.apps.chromiumFlags

chromiumFlags = [
  "--enable-features=MiddelButtonClickAutoscroll,MiddleClickAutoscroll"
  "--blink-settings=middleClickPasteAllowed=false"
];

Two switches, because the first one alone is not enough. Turning autoscroll on does not turn the Linux middle-click paste off — Chromium does both at once, so a middle click pans the page and dumps the clipboard into whatever is under the cursor. GNOME cannot fix it either: Chromium subscribes to notify::gtk-enable-primary-paste and pastes anyway with that key already false. middleClickPasteAllowed is a Blink setting (settings.json5, initial true) and --blink-settings= reads that exact table. It is per-app, so the terminal keeps its middle-click paste, which is what you want there.

Watch out: Chromium ignores an unknown --blink-settings key silently, with no message even at ERROR level (checked with a deliberately bogus key). A typo here fails quietly — check names against settings.json5, not against the absence of an error.

Electron is Chromium, so one list covers the browser and every Electron app. Adding a future Electron app is one line — chromium = true; on its entry in desktopApps (Nix-installed) or an entry in systemChromiumApps (apt).

Both spellings are there on purpose, and neither came from a support page:

  • MiddelButtonClickAutoscroll is Brave's, typo and all. Enabling brave://flags#middle-button-autoscroll in a throwaway profile and reading what Brave then handed its own child processes is what settled it:
    brave-browser --user-data-dir=/tmp/probe &
    tr '\0' '\n' < /proc/<a-renderer-pid>/cmdline | grep enable-features
    #   --enable-features=MiddelButtonClickAutoscroll
  • MiddleClickAutoscroll is upstream Chromium/Electron's, found in Electron binaries that contain no trace of Brave's spelling (strings /usr/lib/claude-desktop/claude-desktop).

An unrecognised feature name is ignored rather than rejected, so the pair is safe everywhere and survives Brave fixing its typo.

--enable-features is used because that is what Brave's own flag entry injects, and it demonstrably works for Electron too. The --enable-blink-features form most guides give would very likely work as well — MiddleClickAutoscroll is a Blink runtime-enabled feature:

// This is enabled by default on Windows only. The only part that's
// "experimental" is the support on other platforms.
{ name: "MiddleClickAutoscroll", status: "test" },   // runtime_enabled_features.json5

That comment is also the clearest statement of what this whole section is working around: upstream considers autoscroll a Windows feature, and everything else is opt-in.

The caret moves too, and that part is not fixable

In an editable area a middle click also drags the text caret to the click point. There is no switch for it, and it is worth knowing why, because it looks like something middleClickPasteAllowed should have covered.

Blink places the caret in SelectionController::HandleMousePressEvent, which MouseEventManager calls for every button with no gate — it collapses the selection at the hit-test position. middleClickPasteAllowed only suppresses the paste that follows on mouse-up; the caret has already moved, deliberately:

// Ignore handled, since we want to paste to where the caret was placed anyway.
handled = HandlePasteGlobalSelection(event.Event()) || handled;

And a page cannot work around it either. Autoscroll starts in Node::DefaultEventHandler, i.e. as the mousedown's default action — the same thing the caret placement is downstream of — so preventDefault() on middle mousedown throws away the scrolling along with the caret. They are one behaviour.

So in a text editor it is autoscroll-with-a-moving-caret, or neither. To choose neither for one app, drop the switch for it:

chromiumFlagsExclude = [ "--enable-features" ];   # no autoscroll, caret stays put

When an app rejects a switch

Not every Electron app forwards what it does not recognise. Most hand argv straight to Chromium, but one that parses its own can refuse to start. Joplin ends its parser with

if (arg.length && arg[0] === "-") throw new Error(_("Unknown flag: %s", arg))

having allowlisted specific Chromium switches by name — --enable-features= is on that list, which is why autoscroll works there; --blink-settings= is not, so it put up a modal "An error occurred: Unknown flag" instead of a window. (Joplin is unusual this way: its desktop app shares CLI parsing with joplin-cli.)

An app drops individual switches with chromiumFlagsExclude, matched by prefix:

chromiumFlagsExclude = [ "--blink-settings" ];   # Joplin keeps autoscroll, keeps paste

Excluding one costs only what that switch bought, which beats the app not opening. There is no cheap way to test for this: the failure is a GUI dialog, so the app keeps running and prints nothing to stdout or stderr — launching it and grepping the output finds nothing even when it is broken. If an app ever shows that dialog, add one line to its entry.

A switch reaches a Chromium process only through the command line that started it — no config file, no policy key, no environment variable. So every launch path has to carry it, or the setting holds in some windows and not others:

launch path where the switches come from
a Nix app's own launcher patchDesktop, for chromium = true apps
an apt app's own launcher chromium_flag_overrides in apps-setup
web app launchers (Notion, YouTube Music) webAppEntry
$BROWSER browserOpener
a terminal brave-browser not covered — nothing owns that argv

The apt case is the interesting one: the .deb owns /usr/share/applications/brave-browser.desktop, so apps-setup writes an override of the same id into ~/.local/share/applications, which XDG resolves first. It is regenerated from the system entry on every make home, not frozen as a copy — that is why an upgrade that adds a launcher action shows up instead of being masked by a stale override, the failure mode of hand-copying a .desktop file once. Every Exec= is rewritten, not just the first: Brave's New Window and New Incognito Window, and VS Code's New Empty Window, are separate desktop actions with their own.

Each generated override carries a marker comment on line 1 (legal before the first group — checked with desktop-file-validate) holding two things:

# dome: generated [modules.apps.chromiumFlags:b133ee672d61] — edits are overwritten by `make home`.
#                  ^ whose file this is              ^ hash of (system entry + flags)

The id lets the sweep tell its own files from a launcher you wrote by hand, so an app you later uninstall, drop from the list, or set chromiumFlags = [ ]; for does not leave a dead entry in the app grid.

The stamp is what makes it safe for another module to own the same Exec line afterwards. modules/gaming.nix legitimately prepends gamemoderun to CurseForge's, running after this by design. Comparing file content would see "different" on every switch, rewrite the file and drop gamemoderun until gaming.nix's activation put it back — and apps-setup run on its own (it is on PATH) would drop it with nothing to restore it. Comparing the stamp asks the question that actually matters instead: have the source entry or the flag list changed since this was generated? If not, the file is left alone, whatever has since been layered onto it.

Apps must be fully quit to pick up a switch change; brave://version shows the command line actually in force.

Gecko: system/77-gecko-policy.sh

Firefox and Thunderbird take a preference, not a switch, and it goes in a policy file for the same reason Brave's settings do — prefs.js is live state the app rewrites on exit. general.autoScroll is one of the few preferences Gecko names individually in its policy allowlist, so this works at all.

The /etc path is real but conditional, and was verified rather than assumed — the lookup lives in JavaScript inside omni.ja, so strings libxul.so shows nothing:

unzip -p <install>/omni.ja modules/EnterprisePoliciesParent.sys.mjs |
  sed -n '/_getConfigurationFile/,/^  }/p'
#   if (platform == "linux" && AppConstants.MOZ_SYSTEM_POLICIES) { SysConfD/policies/... }
unzip -p <install>/omni.ja modules/AppConstants.sys.mjs | grep -o 'MOZ_SYSTEM_POLICIES: *[a-z]*'
#   MOZ_SYSTEM_POLICIES: true      (Firefox snap AND nixpkgs Thunderbird)

That path is checked first and returns immediately, which is why it reaches inside the Firefox snap (whose own tree is read-only, and whose AppArmor profile already grants /etc/firefox{,/,/**} rk).

"Returns immediately" is also the trap: the system file replaces a shipped distribution/policies.json, it does not merge with it. nixpkgs' Thunderbird ships one containing DisableAppUpdate, so the policy written here carries that key too — without it, installing this would quietly re-enable Thunderbird's self-updater for a copy living in a read-only Nix store. The script also compares its own keys against whatever the installed app ships and warns if it would shadow one it does not carry, so that cannot go unnoticed if nixpkgs adds another.

geckoPolicy = false; removes both files. Verify at about:policies.

Ghostty: not possible, and why

The terminal is the one app that cannot join in. Ghostty 1.3.1 has no autoscroll and no way to express one — checked three ways rather than inferred from the docs:

strings $(readlink -f $(which ghostty)) | grep -i autoscroll   # nothing
ghostty +list-actions | grep -iE 'scroll|pan|drag'             # only keyboard scroll actions
ghostty +validate-config --config-file=<(echo 'keybind = mouse_middle=scroll_page_down')
#   keybind: unknown error error.InvalidFormat                 # triggers are keyboard-only

So there is no config to set and nothing to bind a mouse button to. Scrolling there stays shift+page_up/shift+page_down (plus shift+home/shift+end) and the scrollbar. Ghostty also handles its own middle-click paste — it never reads gtk-enable-primary-paste — so the Chromium switch above leaves the terminal's paste alone, which is the behaviour you want in a shell anyway.

Middle click never pastes

There is no single switch for this. Middle-click paste is the X11/Wayland PRIMARY selection, and every toolkit implements it separately — so "turn it off everywhere" is four settings, one per rendering stack, and an app is only covered by the one that draws its text box:

Stack Where it is set Covers
GTK 3/4 gtk-enable-primary-paste dconf key, modules/desktop-shell.nix Nautilus, Text Editor, Settings, GTK file dialogs, and the GTK chrome of Firefox/Thunderbird
Blink --blink-settings=middleClickPasteAllowed=false in modules.apps.chromiumFlags Brave and every Electron app
Gecko middlemouse.paste, modules/apps.nix Thunderbird's compose window, Firefox page content
Ghostty not possible — see below

The GTK key is set explicitly rather than trusted, because its default is not the same everywhere and gsettings get in a Nix shell will lie to you about it: nixpkgs' gsettings-desktop-schemas defaults it to false, Ubuntu's /usr/share/glib-2.0/schemas defaults it to true, and which one an app sees depends on the XDG_DATA_DIRS it was launched with. An app started from the GNOME shell resolves Ubuntu's copy and pastes. A dconf value outranks every schema default, which is what makes the answer the same for all of them.

Gecko cannot be done with the policy in system/77-gecko-policy.sh, which is the trap worth knowing about, because that file already sets a mouse pref (general.autoScroll) and adding one more next to it looks obvious. Gecko's Preferences policy only accepts prefs matching an allowlist compiled into the app — allowedPrefixes in modules/policies/Policies.sys.mjs, readable out of the shipped omni.ja:

accessibility. app.update. browser. calendar. chat. datareporting.policy. dom.
extensions. general.autoScroll general.smoothScroll geo. gfx. intl. layers.
layout. mail. mailnews. media. network. pdfjs. places. print. signon.
spellchecker. ui. widget.

middlemouse. is not on it. Note general.autoScroll is there by name, which is exactly why the autoscroll policy works and why this one cannot join it — a middlemouse pref in policies.json is dropped with "Preference not allowed for stability reasons" and silently does nothing.

So Gecko uses autoconfig, the supported route for an arbitrary pref, by two different paths depending on who packaged the app:

  • Thunderbird comes from nixpkgs, whose Gecko wrapper already ships defaults/pref/autoconfig.js pointing at mozilla.cfg. extraPrefs in modules/apps.nix appends lockPref("middlemouse.paste", false) to it. lockPref so it cannot drift back on, and because autoconfig applies to every profile, including ones created later.
  • Firefox is the Ubuntu snap, and a snap's install tree is a read-only image — autoconfig is the packager's to set, not ours. The only mechanism that works regardless of packaging is a per-profile user.js, which a home-manager activation step writes into each Firefox profile it finds. Each line carries a // dome:no-middle-click-paste marker so the block can be removed with one grep -v and hand-written prefs are left alone. Being per-profile is the reason the Thunderbird we do build uses autoconfig instead.

Changes land at the app's next start — Gecko reads both files at startup.

The terminal is the one place this is not possible. Ghostty handles middle click itself and never reads gtk-enable-primary-paste, it exposes no setting for it, and its keybind triggers are keyboard-only (keybind = mouse_middle=...error.InvalidFormat), so there is nothing to set and nothing to bind. Middle-click paste stays in Ghostty.

OpenWhispr

system/76-openwhispr.sh installs OpenWhispr — voice-to-text dictation with local Whisper/Parakeet models — from the vendor's GitHub release, exactly as their Linux docs prescribe (apt install ./OpenWhispr-*.deb, so the ydotool and libpipewire dependencies resolve; dpkg -i would not).

It is in the root layer rather than modules/apps.nix because nixpkgs does not have it (checked) and the vendor ships a .deb — which installs its own /usr/share/applications entry, so it needs none of the desktop patching the Nix apps require. It is not pinned to the dash: it is hotkey-driven.

Two details worth knowing:

  • It is a ~1 GB install (a 433 MB .deb, Installed-Size ≈ 985 MB). The script checks free space twice — once before spending the download, once against the real Installed-Size before unpacking — and declines with a message rather than filling /.
  • Every run costs a few hundred bytes, not 433 MB. electron-builder publishes latest-linux.yml beside the binaries; the script reads the version and sha512 out of it, compares with dpkg --compare-versions, and downloads only when something newer exists. That checksum is also verified before installing.

Their docs also list a paste helper as a post-install step — xdotool for X11, wtype for Wayland — so both are installed. Without one, dictation silently pastes nothing.

The launchers adapt on their own: the Wayland app_id prefix is the basename of the binary Chromium was started as, so it derives from browserBin rather than being hardcoded — brave-www.notion.so__-Default under nixpkgs, brave-browser-www.notion.so__-Default under the .deb.

Order matters when switching an existing machine over. The system layer must install Brave before home-manager drops the Nix copy:

sudo make system   # installs Brave from the apt repo
make home          # drops the Nix copy, repoints the web apps

./install.sh already runs the two in that order.

One caveat on the key pin. Anthropic documents Claude Desktop's fingerprint, so 75-claude-desktop.sh verifies against a published value. Brave publishes no machine-readable fingerprint for its apt keyring — brave.com/linux points at brave.com/signing-keys, whose static HTML contains none of the three keys the keyring actually ships (checked). 78-brave.sh therefore pins the fingerprint set it observed, cross-checked against the signature on the live dists/stable/InRelease. That is trust-on-first-use: it cannot prove the first fetch was honest, but it turns any later key swap into a loud failure rather than silent trust for every future upgrade.

Reinstalling this machine

Most of a rebuild is already free: the repo is on GitHub and ./install.sh re-derives the rest. migrate/ covers the narrow set of things it cannot — what lives only on this disk.

make preflight-wipe DEST=/media/$USER/STICK   # read-only: what would be lost
make backup DEST=/media/$USER/STICK           # capture it, browsers closed
# … wipe, install, first boot …
bash /media/$USER/STICK/dome-backup/restore.sh

--preflight-wipe answers one question: if this disk were erased in five minutes, what would be gone that no clone could bring back? Hard failures are unrecoverable-after-the-fact (unpushed commits, a missing user-config.nix, a destination on the disk being erased); warnings merely cost time. What it cannot know — whether your only TOTP seed lives here — it names instead of pretending to check.

backup.sh captures five things git cannot: ~/.ssh and ~/.gnupg, the gitignored user-config.nix, the login keyring, the browser profiles, and /etc/NetworkManager/system-connections. That last one is easy to forget and the most annoying to lose: it is root-owned, so a $HOME backup misses it, and without it you cannot get online on the fresh install to fetch anything else. It needs sudo; if sudo is unavailable the script says plainly that WiFi was not captured rather than failing silently.

Three refusals, all for the same reason — a backup you cannot restore is worse than none, because you will not find out until it matters:

  • a destination on the same physical disk as /
  • Brave or Firefox running (their profiles are live SQLite; copying one open yields a profile that looks fine and is corrupt)
  • any archive that does not read back after being written

restore.sh is copied onto the media by backup.sh. That is deliberate, not duplication. The restore happens on a fresh install before the repo has been cloned — you need the SSH key to clone over SSH — so a restore tool that lived only in the repo could never run when it is needed. Splitting user-config.nix into its own archive is part of the same bootstrap: it means the home restore never creates ~/.dotfiles, which would make git clone refuse a non-empty directory.

Each restore phase is idempotent and logs why it is skipping, so re-running after installing a missing app picks up what could not apply the first time — Firefox's profile needs ~/snap/firefox to exist, so restore.sh --only firefox after the first launch finishes the job.

Disk encryption (LUKS)

Nothing in dome can encrypt a disk. LUKS has to be created while the disk is being partitioned — Ubuntu installer → Advanced features… → Use LVM and encryption — and a running root filesystem cannot be retrofitted safely. What dome does is close the two gaps the installer leaves behind.

The passphrase is unrecoverable. This is the part that catches people arriving from Windows. BitLocker generates a recovery key for you and escrows it to your Microsoft account, so you can look it up later. LUKS has no equivalent: you choose the passphrase, it is never displayed again, and it cannot be extracted from the disk — the master key is stored wrapped by an Argon2id derivation of it, which is one-way. Forget it and the data is gone. There is no support line and no backdoor.

So ./setup.sh refuses to be quiet about it. When it detects that / sits on dm-crypt (it walks the chain upward, so it still sees LUKS through LVM) it shows a full-screen warning and then asks where to keep a header backup, storing the answer as luksHeaderBackupDir in user-config.nix. On an unencrypted machine — WSL, Codespaces, a generic box — it asks nothing.

system/95-luks.sh then runs last in the system layer, so its warning is still on screen when the run ends. It is read-only unless luksHeaderBackupDir is set, and never fails the run:

  • Single-keyslot volumes. The installer enrols exactly one passphrase and stops. The script counts enabled keyslots (LUKS1 and LUKS2 dump formats differ) and, if there is only one, prints the cryptsetup luksAddKey command for the specific device. Put a long random string in that second slot and keep it in a password manager — that is your recovery key.
  • Header backups. The LUKS header holds the wrapped master key in the first few MB of the partition. Corrupt it and the correct passphrase stops working. The script writes luks-header-<uuid>.img, chmod 600, and verifies it reads back as a valid header.

Two refusals worth knowing. It will not write the backup to the same physical disk the header protects — you could never mount the filesystem holding it without the header you are trying to recover — and it will not silently overwrite an existing backup, because that file is a snapshot of the keyslots: after a passphrase change the old file still opens the disk with the old passphrase. Delete and re-take it when you change one.

The destination is read from user-config.nix rather than an environment variable for the reason lib.sh documents: sudo's env_reset would drop a LUKS_BACKUP_DIR=… prefix, and a backup that silently never happens is worse than none.

On the Duo specifically: the passphrase prompt runs in the initramfs, which has no Bluetooth stack and no on-screen keyboard. The keyboard must be physically attached at boot — detached, it is a Bluetooth device and you cannot type the passphrase at all. Worth proving to yourself on the first boot rather than discovering it later.

Skipping the passphrase with the TPM (opt-in)

system/96-tpm-unlock.sh can enrol the encrypted root into the TPM so the disk unlocks from the chip and no passphrase is typed at boot. It is off by default and gated on tpmAutoUnlock = true in user-config.nix, because it is a genuine trade-off: the disk stays encrypted against a pulled drive or a powered-off theft, but anyone who can power the machine on reaches the login screen without the disk passphrase. The passphrase keyslot is never removed, so it remains the automatic fallback.

It uses Clevis, not systemd-cryptenroll. The systemd-native tpm2-device=auto option is honoured only by sd-cryptsetup, which is not in Ubuntu's initramfs-tools initramfs (Launchpad #1980018) — on stock Noble the option is silently ignored and the disk still prompts. Getting it would mean replacing initramfs-tools with dracut, a large change to the boot pipeline the rest of this repo assumes; Clevis plugs into the initramfs Ubuntu already ships, so /etc/crypttab is left untouched. The script installs the Clevis packages, a small tss-user initramfs hook (24.04's clevis-initramfs omits the tss user the TPM tooling drops to — without it the unseal fails and boot falls back to the prompt), binds the volume to PCR 7, rebuilds the initramfs, and verifies the result before you ever reboot.

PCR 7 measures Secure Boot state, so the binding survives ordinary signed kernel and initramfs updates but re-locks after a Secure Boot or firmware-key change (a BIOS dbx update, a MOK enrolment, or turning Secure Boot off). When that happens you simply get the passphrase prompt again — expected and safe — and re-enrol to capture the new state. The script prints the exact clevis luks unbind/bind commands for both re-enrolling and turning it off. The enrol step needs your existing passphrase, so on a non-interactive run it prints the one command to run rather than failing.

Logging in without pressing Enter (opt-in)

loginPinLength = 4; in user-config.nix makes the login screen and the lock screen submit the password by themselves the moment it reaches that many characters — a Windows Hello PIN, on GNOME. 0 (the default) leaves both screens asking for Enter. loginRateLimit = true; is the lockout that makes a short PIN defensible; the two are separate switches and either works alone.

It does not check the password after every character, and neither does Windows. A Hello PIN is a fixed-length credential and Windows submits at that length. Per- character checking is not merely slow here, it is impossible, and gnome-shell's own gdm/authPrompt.js says so in three places: every check is a full PAM conversation and pam_unix on Ubuntu has no nodelay, so a wrong answer costs ~2 s; _onVerificationFailed() calls clear(), which does _entry.set_text(''), so the box is wiped after every wrong answer; and _activateNext() calls updateSensitivity(false), which drops the entry's reactive and moves key focus away until the answer comes back. Character 1 would be erased before character 2 could be typed.

So system/gnome-extensions/pin-unlock@dome.local submits at a known length instead. It hooks nothing private: authPrompt.js wires the entry up with entry.clutter_text.connect('activate', …), so emitting activate on that ClutterText is pressing Enter, and the entry is found through the stage's key focus rather than by importing the dialog — the same code covers both screens and a shell that rearranges either one quietly stops matching instead of breaking.

The extension is installed to /usr/share/gnome-shell/extensions, not into ~/.local/share, because the login screen is not your session: it is gnome-shell running as the gdm user, which cannot read anything under /home. One system-wide copy serves both screens. Enabling it is per-user and therefore happens twice — system/87-login-pin.sh writes the greeter's dconf drop-in and re-runs Ubuntu's own /usr/share/gdm/generate-config, and modules/desktop-shell.nix adds the uuid to enabled-extensions for the lock screen (that list is authoritative, so an extension merely switched on in the Extensions app is switched back off at the next make home).

If the length is wrong you are not locked out. A loginPinLength shorter than the real password would otherwise submit a prefix forever with no way to finish typing, so the extension disarms after 3 auto-submits and the prompt falls back to type-it-and-press-Enter. The counter resets whenever the auth screen is re-entered, so a correct PIN always rearms it.

Two things it genuinely costs. Auto-submitting at a known length tells anyone holding the machine how long your password is — they can type characters until it submits itself. And a typo becomes a failed attempt with no Enter pressed. It does not otherwise weaken anything: guessing was already unlimited, and Enter is not a speed bump to an attacker with a script.

metadata.json declares shell-version: ["46"] only. A different GNOME lists it as outdated and does not load it, which is the safe direction; the script says so out loud rather than leaving a feature that silently does nothing.

Rate-limiting password guesses (opt-in)

Ubuntu ships no lockout at all. pam_faillock is installed and /etc/security/faillock.conf exists with every line commented out, but nothing in /etc/pam.d references the module — so guessing is unlimited, at the ~30 tries a minute pam_unix's failure delay allows. A 4-digit PIN is 10,000 combinations, i.e. under six hours. loginRateLimit = true; cuts that to 8 tries per 15 minutes, or several days.

It covers the login and lock screen only, deliberately. The usual advice is to put faillock in /etc/pam.d/common-auth, which catches every service at once — including sudo and the TTYs. That is exactly what makes it wrong here: a lockout would then take the recovery tools with it, and the only way back into the machine is a rescue boot. Confined to gdm-password, a lockout is always fixable from the machine itself:

sudo faillock --user "$USER" --reset     # clear it now
faillock --user "$USER"                  # see the current tally

system/88-faillock.sh replaces @include common-auth in /etc/pam.d/gdm-password rather than wrapping it. faillock has to bracket the module that checks the password — preauth before it, authfail after it to count the failure, authsucc after that to clear the count — and common-auth ends in auth requisite pam_deny.so, which aborts the stack the instant authentication fails. An authfail line placed after the include is therefore unreachable, and no failure is ever counted. The widely-copied Debian faillock recipes have this bug: the stack looks right, logs nothing, and locks nobody out. The block is common-auth's own module list with the three phases woven through it, and the script refuses to touch anything — restoring the stock @include — if common-auth ever stops being the list it was copied from (pam-auth-update running for a new module, fingerprint login, a domain join).

authsucc is optional, not the customary sufficient: sufficient returns success immediately and would skip the auth optional pam_gnome_keyring.so line below it, so the login keyring would stop unlocking itself. The control flag only decides how the result is combined; the counter is cleared either way.

Turning the switch off restores the byte-identical stock file, and a stock copy is kept at /var/backups/dome/gdm-password.orig before the first change.

No icon flashing in the dash on copy/paste

Claude Code checks the clipboard on every paste to see whether you pasted an image. On Wayland it reaches for wl-clipboard, and that used to make an icon blink in the dash on every copy and paste.

GNOME 46 does not implement wlr-data-control, so wl-paste/wl-copy have no headless way to read the clipboard. They map a real (1x1) toplevel purely to receive a keyboard-focus serial — visible in WAYLAND_DEBUG=1 wl-paste:

-> xdg_toplevel@15.set_title("wl-clipboard")
-> wl_surface@13.attach(wl_buffer@18, 0, 0)   # mapped, so the dash draws it
   wl_keyboard@12.enter(...)                  # the serial it was after
-> xdg_toplevel@15.destroy()                  # ~20ms later

It never calls set_app_id, so the shell cannot match it to a .desktop and draws a generic placeholder — the flash. xclip does the same job through Xwayland with a window it never maps, so nothing is drawn.

modules/ai.nix therefore installs xclip and puts tiny wl-paste/wl-copy shims in front of Claude Code only, via a claude shell function. They are deliberately not installed into the profile: they cover just the flags Claude Code passes, so they must not shadow the real wl-clipboard system-wide. With no DISPLAY to borrow they hand back to the real tool — a brief window beats a broken clipboard.

Installing xclip matters on its own: Ubuntu ships neither xclip nor wl-clipboard by default (they arrive only as Recommends of packages like pass), so without it Claude Code has no clipboard helper at all and pasting a screenshot into it silently does nothing.

Configuration

Your personal settings are stored in user-config.nix (git-ignored):

{
  name = "Your Name";
  email = "your@email.com";
  
  modules = {
    python = true;   # Enable Python tools
    node = true;     # Enable Node.js tools  
    java = false;    # Disable Java tools
    ai = true;       # Enable AI tools
  };
}

Common Commands

# Version management
pyenv install 3.12.0    # Install Python version
pyenv global 3.12.0     # Set global Python
nodenv install 20.0.0   # Install Node version  
nodenv global 20.0.0    # Set global Node

# Update dotfiles
cd ~/.dotfiles && git pull
home-manager switch --flake path:.#generic -b backup   # or your host profile

# Update packages
nix flake update

# Tests and housekeeping
make test               # unit-test system/lib.sh (sudo make test covers the root paths)
make gc                 # delete Nix generations older than 30 days

Note the path:. — a plain . flake ref copies only git-tracked files, which would silently skip your git-ignored user-config.nix.

make test runs tests/test-lib.sh against the helpers in system/lib.sh — plain bash and coreutils, so it works on a fresh machine before Nix exists. It exists because shellcheck and nix eval both pass happily on a script whose detection helper returns the wrong answer, which is how a | grep -q pipeline under pipefail came to silently disable a whole section of system/25-memory.sh. See the note at the top of CLAUDE.md.

Nothing runs nix-collect-garbage automatically: every make home leaves the previous generation behind and each one pins its whole closure, but deleting the generation you were about to roll back to is not a surprise a provisioning run should spring on you. make gc is the deliberate version.

Updating & recovering

make update bumps the pinned inputs in flake.lock — nixpkgs, home-manager, and therefore every pkgs.* package. The repo tracks nixos-unstable, which is newest but occasionally ships a mid-transition snapshot (e.g. a glibc mass-rebuild in progress) where core tools like git or vscode fail at runtime with GLIBC_ABI_* not found errors.

If an update misbehaves, undo it with one command:

make rollback   # restore flake.lock to the committed pin and re-activate

make rollback reverts the lockfile and rebuilds via nix run home-manager/master, which works even when the bad update left the profile's git/home-manager broken (nix's own fetcher doesn't depend on those). It's just git restore flake.lock + a re-activation — safe to run any time.

Prefer stability over newest? Point the flake at a stable release instead of unstable. Claude (official installer) and Gemini (npm) stay on the latest either way, so the only cost is slightly older system packages — no more mid-rebuild breakage:

# flake.nix — use the current stable release (25.05 as of mid-2026)
nixpkgs.url      = "github:NixOS/nixpkgs/nixos-25.05";
home-manager.url = "github:nix-community/home-manager/release-25.05";

Then nix flake update && make home to re-lock onto the stable channel.

Why This Setup?

  • Declarative: Everything defined in code
  • Reproducible: Same setup, everywhere
  • Modular: Only install what you need
  • Cross-platform: Works on any Linux/macOS
  • Version Control: Your entire dev environment in git

Structure

dome/
├── install.sh             # Full-machine setup (system layer + Nix + home-manager)
├── bootstrap.sh           # User-layer-only setup (Codespaces/WSL)
├── Makefile               # make system / home / doctor / update
├── user-config.nix        # Your settings (git-ignored, never committed)
├── user-config.template.nix # Template for user-config.nix
├── flake.nix              # Nix flake definition (host-profile outputs)
├── home.nix               # Main home-manager configuration
├── hosts/                 # Per-machine profiles (generic, zenbook-duo, ...)
├── modules/               # Development environments + machine modules
│   ├── python.nix         # Python + pyenv
│   ├── node.nix           # Node.js + nodenv
│   ├── java.nix           # Java development
│   ├── ai.nix             # AI tools (Claude Code, skills CLI, keybindings)
│   ├── terminal.nix       # Ghostty + default-terminal wiring (not under apps)
│   ├── gaming.nix         # GameMode-wrapped game launchers (see system/86-)
│   ├── cloud.nix          # Terraform/Pulumi/cloud CLIs/k8s
│   └── zenbook-duo/       # Duo-only home-manager wiring
├── system/                # Idempotent root-layer scripts (Ubuntu)
│   └── gnome-extensions/  # Shell extensions the GDM greeter needs, so they
│                          # install to /usr/share, not to ~/.local/share
├── tests/                 # Unit tests for system/lib.sh (`make test`)
├── duo/                   # zenduo hardware tooling (self-contained, MIT)
└── docs/                  # PLAN.md, CHECKLIST.md, research archive

Secrets policy

This is a public repository — treat it accordingly:

  • user-config.nix (name, email, host, module choices) is git-ignored; only the neutral template is tracked, and CI fails if the real file ever becomes tracked.
  • No keys, tokens, or passphrases belong anywhere in the tree. CI runs a gitleaks scan on every push/PR as a backstop.
  • Future encrypted secrets (API tokens etc.) are planned via sops-nix (age-encrypted, safe to commit — see docs/PLAN.md G6). Until then, keep secrets out entirely.

Troubleshooting

Nix not found after install:

source ~/.nix-profile/etc/profile.d/nix.sh

File conflicts during setup:

home-manager switch --flake path:.#generic -b backup   # or your host profile

License: MIT - Feel free to clone, fork, make it your own. I tried to make it easy to setup with just 1 script.

About

Dotfiles + full Ubuntu 24.04 dual-boot provisioning for the ASUS Zenbook Duo (2024) UX8406MA

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages