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, addDRY_RUN=1as a make argument to preview)duo/— zenduo hardware tooling;duo doctoris 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.mdhosts/+flake.nix— per-machine home-manager profiles (home-manager switch --flake path:.#genericorpath:.#zenbook-duo)install.sh— one-command setup on a fresh Ubuntu machine
| 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 |
- Go to GitHub Settings → Codespaces
- Enable "Automatically install dotfiles"
- Set repository to your fork or clone of this repo
- Create a new Codespace - setup runs automatically!
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 installsetup.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,cloudThe 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).
# 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.shcp -r hosts/generic hosts/<name>and edit it (import machine modules, set options)- Add
homeConfigurations.<name> = mkHome "<name>";inflake.nix ./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).
- 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
- Python 3.13 with pip
- pyenv for version management
- VS Code extensions: Python, Pylance, Black, Flake8
- Node.js v22 LTS (global) with npm and pnpm
- nodenv for version management
- VS Code extensions: ESLint, Prettier, TailwindCSS
- JDK 21, Maven, Gradle
- VS Code Java extensions
-
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 realskillscommand 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 installsnodejs_22itself whenmodules.nodeis 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 = falsecovers the terminal's own version of that.The first three are
settings.jsonkeys;copyOnSelectis not in that schema and lives in~/.claude.jsonwith 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 withjqinstead, 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/configchange to one of them lasts until then. Seeding only-if-missing was tried first and does not work — Claude Code materialises its own defaults intosettings.jsonas you use it, so the key is usually already there holding the value you wanted changed.
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-keysresolves the shortcut throughorg.gnome.desktop.default-applications.terminal), and what Nautilus's "Open in Terminal" uses. Turn that off withmodules.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 ofxterm-ghosttyand 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.
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) andmid: - 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 configappsSkip 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--classon Wayland (an X11 flag) and derives the id from the URL —brave-www.notion.so__-Default, captured withWAYLAND_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-maximizedfixes 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.
# user-config.nix
hostName = "LAPTOP-JA"; # empty (the default) leaves the current name aloneEdit 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.
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.targetwaits onnetwork-online.targetwaits 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: annfs/cifs/_netdeventry in/etc/fstabmeans 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 inlspci, or a loadednvidia/nouveau/amdgpu/radeonmodule, 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 rebootdocker.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.
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 emptyThe 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.
Off by default. gameMode = true; in user-config.nix turns on both halves:
-
system/86-gamemode.shwrites/etc/gamemode.ini. Ubuntu'sgamemodepackage ships no config at all, so every setting is otherwise a compiled-in default. The important ones here areigpu_desiredgov/igpu_power_threshold: this machine's CPU and Arc graphics share one power budget, so pinning the cores toperformancetakes 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.nixmakes the CurseForge launcher start throughgamemoderun. GameMode does nothing until a process asks for it, and nothing on a stock Ubuntu ever does — so an installed-but-unwiredgamemode(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.nixalready writes that same file to appendchromiumFlagsto apt-installed Electron apps. Two modules declaring one.desktopdoes not fail loudly — apps.nix writes a real file from an activation script, which silently overwrites the symlink anxdg.dataFileputs there, so the last writer wins and gamemode loses with nothing reported. Ordering afterappsDesktopIntegrationand prepending the wrapper keeps both features on the same Exec line:Exec=/usr/games/gamemoderun /opt/CurseForge/curseforge --enable-features=… %UTurning
gameModeoff 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_governorThe trade is heat, fan noise and battery while a game runs. That is why it is opt-in.
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-loginThe 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.serviceDocker 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-desktopThe 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-desktopThese 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 policyEach has a matching one-run override on system/run.sh — --no-brave,
--no-brave-policy, --no-gecko-policy, --no-openwhispr,
--no-claude-desktop.
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.
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
Preferencesfile 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/etcat 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.
/etcis untouched by the.deb. - It applies to both installs, since
/etc/brave/policiesis 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.
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) |
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:
MiddelButtonClickAutoscrollis Brave's, typo and all. Enablingbrave://flags#middle-button-autoscrollin 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
MiddleClickAutoscrollis 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.json5That 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.
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 putNot 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 pasteExcluding 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.
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.
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-onlySo 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.
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.jspointing atmozilla.cfg.extraPrefsinmodules/apps.nixappendslockPref("middlemouse.paste", false)to it.lockPrefso 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-pastemarker so the block can be removed with onegrep -vand 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.
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 realInstalled-Sizebefore unpacking — and declines with a message rather than filling/. - Every run costs a few hundred bytes, not 433 MB. electron-builder
publishes
latest-linux.ymlbeside the binaries; the script reads the version and sha512 out of it, compares withdpkg --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.
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.
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 luksAddKeycommand 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.
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.
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.
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 tallysystem/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.
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.
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
};
}# 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 daysNote 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.
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-activatemake 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.
- 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
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
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.
Nix not found after install:
source ~/.nix-profile/etc/profile.d/nix.shFile conflicts during setup:
home-manager switch --flake path:.#generic -b backup # or your host profileLicense: MIT - Feel free to clone, fork, make it your own. I tried to make it easy to setup with just 1 script.