Compare commits

..

4 commits

Author SHA1 Message Date
420cbd770f
desktop: never sleep; bump nixpkgs to 2026-10-05
Carried over from local master. The trusted-users setting made there
was already present on simplified, so it was dropped in favor of the
upstream block.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 11:39:17 +02:00
ddca4e1d72
hyprland: virtual desktops, and stop OOM kills from killing the session
Workspaces were pinned one-per-monitor, so switching only ever moved one
screen. The virtual-desktops plugin replaces them with vdesks that span
every monitor, and SUPER+1..5 now switches all screens together. Hyprland
plugins are ABI-locked to the compositor, so the input's nixpkgs follows
ours to compile against the hyprland we actually run. cycleworkspaces
stays off: upstream says it breaks above 2 monitors, and pc has 3.

The session had also been dying several times a day, which looked like a
Hyprland crash and was not one. There was no swap at all, and a
nixos-rebuild forks ~60 nix evaluators; stacked on a browser and a few
editor sessions that exhausted all 15G, leaving page cache squeezed to
40M while 13G sat in anon and nothing was reclaimable. The kernel then
OOM-killed 1Password (highest oom_score_adj), and because every GUI app
shares the wayland-wm@hyprland.service cgroup, systemd's default
OOMPolicy=stop tore down the whole unit -- compositor included, with
uwsm's Restart=no meaning nothing came back.

Two independent fixes: zram swap (50% of RAM, zstd) with vm.swappiness
raised to 180, so the kernel compresses anon pages instead of evicting
cache, which raises the ceiling; and OOMPolicy=continue, so that hitting
it costs one app instead of the desktop. zram rather than a swapfile
because root is compress=zstd, and a btrfs swapfile needs its own
nodatacow, uncompressed subvolume.

Also lands the pending work in this tree: hyprshot replaces grimblast for
screenshots (it reads HYPRSHOT_DIR, not XDG_SCREENSHOTS_DIR, so shots were
landing loose in ~/Pictures), hyprlock at boot so login lands on the lock
screen and unlocks the keyring, the battery popup moves to SUPER+R, and
SUPER+W/E open floating wifi and bluetooth panels.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 20:19:30 +02:00
d04b17fe52
mbp: move root to btrfs on p8, reclaiming 256G from macOS
Shrinking the macOS APFS container freed ~256G, but it landed at a lower
disk offset than the Linux partitions, so the ext4 root could never be
grown into it — a partition only ever extends into free space that follows
it. Rather than relocate the Asahi stub and ESP (the reliable way to brick
the boot chain), the root moved into the gap as a new partition:

  p8  29424896..96687621  256.6G  btrfs, subvol @, compress=zstd

zstd puts ~70G of data in ~37G, and free space went from 7.3G to 220G.
The old ext4 p5 is left intact as a rollback; generations <= 48 still boot
it, since each generation carries its own root UUID in its initrd.

Also lands the pending work in this tree: postgresql in home.packages,
the deepvis cachix substituter with a root-owned netrc, nixbuild.net as a
builder for both arches, the caffeine stay-awake unit with its SUPER+A
bind, and monthly btrfs autoScrub on the new root.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 14:35:28 +02:00
1ac7506a87
add comments and simplify 2026-09-07 23:40:24 +02:00
5 changed files with 481 additions and 121 deletions

View file

@ -2,6 +2,20 @@
# Monitor layout is host-specific; written by the flake per host.
source = ~/.config/hypr/monitors.conf
# Virtual desktops plugin. A vdesk spans every monitor, so SUPER+1..5 switches
# all screens together instead of workspaces being pinned per monitor.
source = ~/.config/hypr/plugins.conf
plugin {
virtual-desktops {
# The startup toast would fire on every login now that mako exists.
notifyinit = 0
rememberlayout = size
# Deliberately NOT enabling cycleworkspaces: upstream states it does
# not work with more than 2 monitors, and pc has 3.
}
}
device {
name = apple-spi-keyboard
kb_options = caps:ctrl_modifier,grp:win_space_toggle
@ -63,9 +77,15 @@ ecosystem {
env = QT_QPA_PLATFORMTHEME,qt6ct
env = QT_STYLE_OVERRIDE,Adwaita-Dark
env = XDG_SCREENSHOTS_DIR,$HOME/Pictures/Screenshots
# hyprshot ignores XDG_SCREENSHOTS_DIR: it reads HYPRSHOT_DIR, else XDG_PICTURES_DIR,
# else $HOME. Without this, screenshots land loose in ~/Pictures.
env = HYPRSHOT_DIR,$HOME/Pictures/Screenshots
# -- Autostart --
exec-once = uwsm finalize
# Boot lands on the lock screen rather than an unlocked desktop. Unlocking it
# also unlocks the keyring (see security.pam.services.hyprlock in flake.nix).
exec-once = hyprlock
exec-once = swaybg -c '#16181a'
exec-once = GDK_BACKEND=wayland eww daemon
exec-once = systemctl --user start hyprpolkitagent
@ -73,13 +93,27 @@ exec-once = systemctl --user start hyprpolkitagent
# -- Core --
bind = SUPER, Return, exec, kitty
bind = SUPER, D, exec, fuzzel
bind = SUPER, B, exec, eww open battery-popup
bindr = SUPER, B, exec, eww close battery-popup
bind = SUPER, R, exec, eww open battery-popup
bindr = SUPER, R, exec, eww close battery-popup
bind = SUPER, Q, killactive
bind = SUPER, F, fullscreen, 1
bind = SUPER SHIFT, Space, togglefloating
bind = SUPER, Escape, exec, hyprlock
# Amphetamine-style stay-awake toggle: idle + sleep + lid. See flake.nix.
bind = SUPER, A, exec, caffeine
# -- Floating panels --
# Each gets its own app_id so the rule below floats only these and not every
# terminal. Quit with the TUI's own `q`; no toggle wrapper.
#
# The float/size rule is in monitors.conf, generated per host: Hyprland 0.56
# `size` takes ONLY absolute pixels -- percentages are silently ignored (they
# parse fine and then do nothing), so the value depends on the resolution.
bind = SUPER, W, exec, kitty --class panel-wifi impala
bind = SUPER, E, exec, kitty --class panel-bt bluetui
# -- Focus --
bind = SUPER, h, movefocus, l
bind = SUPER, j, movefocus, d
@ -92,18 +126,18 @@ bind = SUPER SHIFT, j, movewindow, d
bind = SUPER SHIFT, k, movewindow, u
bind = SUPER SHIFT, l, movewindow, r
# -- Workspaces --
bind = SUPER, 1, workspace, 1
bind = SUPER, 2, workspace, 2
bind = SUPER, 3, workspace, 3
bind = SUPER, 4, workspace, 4
bind = SUPER, 5, workspace, 5
# -- Virtual desktops (plugin dispatchers, not Hyprland workspaces) --
bind = SUPER, 1, vdesk, 1
bind = SUPER, 2, vdesk, 2
bind = SUPER, 3, vdesk, 3
bind = SUPER, 4, vdesk, 4
bind = SUPER, 5, vdesk, 5
bind = SUPER SHIFT, 1, movetoworkspace, 1
bind = SUPER SHIFT, 2, movetoworkspace, 2
bind = SUPER SHIFT, 3, movetoworkspace, 3
bind = SUPER SHIFT, 4, movetoworkspace, 4
bind = SUPER SHIFT, 5, movetoworkspace, 5
bind = SUPER SHIFT, 1, movetodesk, 1
bind = SUPER SHIFT, 2, movetodesk, 2
bind = SUPER SHIFT, 3, movetodesk, 3
bind = SUPER SHIFT, 4, movetodesk, 4
bind = SUPER SHIFT, 5, movetodesk, 5
# -- Move current workspace to another monitor --
bind = SUPER ALT, h, movecurrentworkspacetomonitor, l
@ -135,10 +169,8 @@ bindl = , XF86AudioNext, exec, playerctl next
bindl = , XF86AudioPrev, exec, playerctl previous
# -- Screenshots --
# Super+S: region -> clipboard
bind = SUPER, S, exec, grimblast --notify copy area
# Super+Ctrl+S: full screen -> file + clipboard
bind = SUPER CTRL, S, exec, grimblast --notify copysave screen
# Super+Shift+S: region -> file + clipboard + open in GIMP
bind = SUPER SHIFT, S, exec, bash -c 'mkdir -p "$HOME/Pictures/Screenshots"; f="$HOME/Pictures/Screenshots/$(date +%Y-%m-%dT%H-%M-%S).png"; grimblast --notify --freeze save area "$f" && wl-copy --type image/png < "$f" && gimp "$f"'
# Super+Ctrl+S: focused monitor -> file + clipboard
bind = SUPER CTRL, S, exec, hyprshot -m output
# Super+Shift+S: region (screen frozen while selecting) -> file + clipboard
bind = SUPER SHIFT, S, exec, hyprshot -m region -z

View file

@ -1,114 +1,141 @@
vim.o.winborder = "rounded"
vim.o.tabstop = 2
vim.o.ignorecase = true
vim.o.shiftwidth = 2
vim.o.smartindent = true
vim.opt.wrap = false
vim.opt.number = true
vim.o.relativenumber = true
vim.o.termguicolors = true
vim.o.undofile = true
vim.o.incsearch = true
vim.o.signcolumn = "yes"
vim.opt.clipboard = "unnamedplus"
vim.opt.expandtab = true
vim.o.winborder = "rounded" -- floating windows (hover docs, pickers) get rounded borders
vim.o.tabstop = 2 -- a tab character is rendered 2 columns wide
vim.o.ignorecase = true -- searching is case insensitive
vim.o.shiftwidth = 2 -- >> and << indent by 2
vim.o.smartindent = true -- indent new lines based on the line above
vim.opt.wrap = false -- long lines run off the screen instead of wrapping
vim.opt.number = true -- show line numbers
vim.o.relativenumber = true -- ...but relative to the cursor, so 5j / 5k are easy to aim
vim.o.termguicolors = true -- 24 bit color, the colorscheme needs this
vim.o.undofile = true -- keep undo history on disk, so you can undo after reopening a file
vim.o.incsearch = true -- jump to the match while you are still typing the search
vim.o.signcolumn = "yes" -- always show the left gutter (diagnostics live there) so text never jumps
vim.opt.clipboard = "unnamedplus" -- yank and paste use the system clipboard
vim.opt.expandtab = true -- pressing tab inserts spaces, not a tab character
-- On NixOS the flake provides plugins (vim.g.nix_managed set in the flake);
-- on Arch, vim.pack fetches them at runtime. Both paths keep working.
if not vim.g.nix_managed then
vim.pack.add({
{ src = "https://github.com/nvim-tree/nvim-web-devicons" },
{ src = "https://github.com/scottmckendry/cyberdream.nvim" },
{ src = "https://github.com/stevearc/oil.nvim" },
{ src = "https://github.com/neovim/nvim-lspconfig" },
{ src = "https://github.com/chomosuke/typst-preview.nvim" },
{ src = "https://github.com/saghen/blink.cmp" },
{ src = "https://github.com/dynamotn/Navigator.nvim" },
{ src = "https://github.com/folke/snacks.nvim" },
{ src = "https://github.com/folke/todo-comments.nvim" },
{ src = "https://github.com/folke/trouble.nvim" },
{ src = "https://github.com/nvim-treesitter/nvim-treesitter" },
{ src = "https://github.com/j-hui/fidget.nvim" },
{ src = "https://github.com/nvim-lualine/lualine.nvim" },
})
end
-- vim.pack is neovim's built in plugin manager. it clones these repos into
-- stdpath("data")/site/pack the first time you start. `vim.pack.update()`
-- updates them.
vim.pack.add({
{ src = "https://github.com/nvim-tree/nvim-web-devicons" }, -- filetype icons (needs a nerd font in your terminal)
{ src = "https://github.com/scottmckendry/cyberdream.nvim" }, -- the colorscheme
{ src = "https://github.com/stevearc/oil.nvim" }, -- file manager that is just an editable buffer
{ src = "https://github.com/neovim/nvim-lspconfig" }, -- ready made configs for language servers
{ src = "https://github.com/saghen/blink.cmp" }, -- the autocomplete popup
{ src = "https://github.com/folke/snacks.nvim" }, -- grab bag of small tools. we use the picker, terminal and lazygit
{ src = "https://github.com/folke/todo-comments.nvim" }, -- highlights TODO/FIXME/HACK and can list them
{ src = "https://github.com/folke/trouble.nvim" }, -- a readable list of errors and warnings
{ src = "https://github.com/nvim-treesitter/nvim-treesitter" },-- real syntax parsing, better highlighting
{ src = "https://github.com/j-hui/fidget.nvim" }, -- little lsp progress spinner in the corner
{ src = "https://github.com/nvim-lualine/lualine.nvim" }, -- the statusline at the bottom
})
-- most plugins only need setup() called with no arguments to turn them on.
require("blink.cmp").setup()
require("Navigator").setup()
require("todo-comments").setup()
require("trouble").setup()
require("fidget").setup()
require("lualine").setup({
options = {
-- plain | instead of the default powerline arrows, so it looks right in any font
component_separators = { left = "|", right = "|" },
section_separators = { left = "|", right = "|" },
},
})
require("oil").setup({
default_file_explorer = true,
delete_to_trash = true,
skip_confirm_for_simple_edits = true,
view_options = { show_hidden = true },
default_file_explorer = true, -- opening a directory opens oil instead of netrw
delete_to_trash = true, -- deleting a file in oil sends it to the trash, not the void
skip_confirm_for_simple_edits = true, -- don't ask "are you sure" for renames and single deletes
view_options = { show_hidden = true }, -- show dotfiles
-- oil's own keymaps, inside an oil buffer only.
keymaps = {
["g?"] = { "actions.show_help", mode = "n" },
["<CR>"] = "actions.select",
["-"] = { "actions.parent", mode = "n" },
["_"] = { "actions.open_cwd", mode = "n" },
["`"] = { "actions.cd", mode = "n" },
["g?"] = { "actions.show_help", mode = "n" }, -- everything oil can do
["<CR>"] = "actions.select", -- open the file/directory under the cursor
["-"] = { "actions.parent", mode = "n" }, -- go up one directory
["_"] = { "actions.open_cwd", mode = "n" }, -- jump to the directory nvim was started in
["`"] = { "actions.cd", mode = "n" }, -- make this directory nvim's working directory
},
use_default_keymaps = false,
use_default_keymaps = false, -- only the five above, nothing else
})
require("snacks").setup({ picker = { ui_select = true } })
require("snacks").setup({ picker = { ui_select = true } }) -- any "pick one" prompt uses the snacks picker
vim.cmd("colorscheme cyberdream")
-- briefly flash whatever you just yanked, so you can see what got copied
vim.api.nvim_create_autocmd("TextYankPost", {
callback = function()
vim.highlight.on_yank()
end,
})
vim.api.nvim_create_autocmd("FileType", {
pattern = "typst",
callback = function()
vim.opt_local.wrap = true
vim.opt_local.linebreak = true
vim.opt_local.breakindent = true
end,
})
vim.lsp.enable({ "lua_ls", "svelte", "tinymist", "ts_ls", "rust_analyzer", "yamlls", "html", "cssls", "jsonls", "nixd", "zls" })
-- These are the language servers we turn on. the server programs themselves are
-- installed by nix (see programs.neovim.extraPackages in flake.nix), this line
-- only tells neovim to start them for the right filetypes.
vim.lsp.enable({ "lua_ls", "ts_ls", "rust_analyzer", "html", "cssls", "jsonls", "nixd" })
-- This means that we should run the callback when the event LspAttach happens
-- aka. when a language server attaches to the buffer. that is when these
-- keymaps start doing something useful, so we only set them then.
vim.api.nvim_create_autocmd("LspAttach", {
callback = function(ev)
vim.keymap.set('n', 'gd', vim.lsp.buf.definition)
vim.keymap.set('n', 'gD', vim.lsp.buf.declaration)
vim.keymap.set('n', 'gr', Snacks.picker.lsp_references)
vim.keymap.set('n', 'gt', Snacks.picker.lsp_type_definitions)
vim.keymap.set('n', '<leader>ss', Snacks.picker.lsp_symbols)
vim.keymap.set('n', '<leader>sS', Snacks.picker.lsp_workspace_symbols)
vim.keymap.set('n', '<leader>ca', vim.lsp.buf.code_action)
vim.keymap.set('n', '<leader>cr', vim.lsp.buf.rename)
vim.keymap.set('n', 'gd', vim.lsp.buf.definition) -- go to where the thing under the cursor is defined
vim.keymap.set('n', 'gD', vim.lsp.buf.declaration) -- go to its declaration (mostly a C/C++ thing)
vim.keymap.set('n', 'gr', Snacks.picker.lsp_references) -- list every place it is used
vim.keymap.set('n', 'gt', Snacks.picker.lsp_type_definitions) -- go to the definition of its type
vim.keymap.set('n', '<leader>ss', Snacks.picker.lsp_symbols) -- search symbols in this file
vim.keymap.set('n', '<leader>sS', Snacks.picker.lsp_workspace_symbols) -- search symbols in the whole project
vim.keymap.set('n', '<leader>ca', vim.lsp.buf.code_action) -- offer the fixes/refactors the server suggests
vim.keymap.set('n', '<leader>cr', vim.lsp.buf.rename) -- rename it everywhere at once
end
})
-- This is your leader key. set it to whatever you like. but space is nice i think
vim.g.mapleader = " "
vim.keymap.set('n', '<leader>o', ':update<CR> :source<CR>')
-- space+o in normal mode saves this file and re-runs it, so config changes apply
-- without restarting neovim. (to update the plugins themselves: :lua vim.pack.update())
vim.keymap.set('n', '<leader>o', ':update<CR>:source<CR>')
-- space+w in normal mode writes (saves) the current buffer
vim.keymap.set('n', '<leader>w', ':write<CR>')
-- space+q in normal mode quits
vim.keymap.set('n', '<leader>q', ':quit<CR>')
-- space+f in normal mode opens a file picker where you can fuzzy search for files by their name
vim.keymap.set('n', '<leader>f', function() Snacks.picker.files() end)
-- space+sg in normal mode opens a fuzzy searcher that searches through the contents of all files
vim.keymap.set('n', '<leader>sg', function() Snacks.picker.grep() end)
-- space+e in normal mode opens a new buffer with Oil. this is the file manager or what you would call it
-- The cool thing about oil is that you can edit all of your files like neovim buffers
vim.keymap.set('n', '<leader>e', ':Oil<CR>')
vim.keymap.set({ 'n', 't' }, '<C-h>', ':NavigatorLeft<CR>')
vim.keymap.set({ 'n', 't' }, '<C-l>', ':NavigatorRight<CR>')
vim.keymap.set({ 'n', 't' }, '<C-k>', ':NavigatorUp<CR>')
vim.keymap.set({ 'n', 't' }, '<C-j>', ':NavigatorDown<CR>')
vim.keymap.set({ "n", "t" }, "<C-t>", function() Snacks.terminal.toggle("zsh") end) -- Open Snacks picker
-- ctrl+h/j/k/l move to the split left/down/up/right. <C-w>h is neovim's own
-- "go to the window on the left", these just put it on an easier key.
-- normal mode only: inside the terminal these keys belong to the shell
-- (ctrl+l clears it), and you leave that terminal with ctrl+t anyway.
vim.keymap.set('n', '<C-h>', '<C-w>h')
vim.keymap.set('n', '<C-l>', '<C-w>l')
vim.keymap.set('n', '<C-k>', '<C-w>k')
vim.keymap.set('n', '<C-j>', '<C-w>j')
-- ctrl+t toggles a floating zsh terminal. it works both in normal mode and from
-- inside the terminal itself, so the same key opens and closes it
vim.keymap.set({ "n", "t" }, "<C-t>", function() Snacks.terminal.toggle("zsh") end)
-- space+g in normal mode opens lazygit in floating window (tui for git)
vim.keymap.set('n', '<leader>g', function() Snacks.lazygit() end)
-- space+t in normal mode opens a picker of todo comments where you can search through them
vim.keymap.set('n', '<leader>t', function() Snacks.picker.todo_comments() end)
-- space+x in normal mode opens a bottom bar that shows warning and errors from your LSP in the current buffer
vim.keymap.set('n', '<leader>x', ":Trouble diagnostics toggle filter.buf=0<CR>")
-- space+shift+X does the same but for every file in the project
vim.keymap.set('n', '<leader>X', ":Trouble diagnostics<CR>")
-- space+| in normal mode vertically splits the current buffer
vim.keymap.set('n', '<leader>|', ":vsplit<CR>")
-- space+_ in normal mode horizontally splits the current buffer
vim.keymap.set('n', '<leader>_', ":split<CR>")
-- ESC in normal mode clears the highlighting left over from your last search
vim.keymap.set('n', '<ESC>', ":noh<CR>")
-- > in visual mode indents the selected lines (gv reselects them so you can keep pressing >)
vim.keymap.set("v", ">", ">gv", { silent = true, desc = "Indent and reselect" })
-- < in visual mode unindents the selected lines
vim.keymap.set("v", "<", "<gv", { silent = true, desc = "Unindent and reselect" })
-- space+lf in normal mode calls the lsp's formatter on the current buffer
vim.keymap.set('n', '<leader>lf', vim.lsp.buf.format)

View file

@ -78,13 +78,50 @@
"type": "github"
}
},
"hyprland-virtual-desktops": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1790338297,
"narHash": "sha256-iJP+CyrS/fw/dqysRN9GtVB8GlJZcja4rkjBeJsXFsg=",
"owner": "levnikmyskin",
"repo": "hyprland-virtual-desktops",
"rev": "74b88ee5209c4dcea74eedc4646e185b02a8f946",
"type": "github"
},
"original": {
"owner": "levnikmyskin",
"repo": "hyprland-virtual-desktops",
"type": "github"
}
},
"linear-tui-src": {
"flake": false,
"locked": {
"lastModified": 1790503843,
"narHash": "sha256-FS0w8a53N+XN0tnxUzpu4FNG0rYVycv/ZlnB7bPgd+I=",
"owner": "k1-c",
"repo": "linear-tui",
"rev": "fa79ffed365c2deff1916022abe8652989682216",
"type": "github"
},
"original": {
"owner": "k1-c",
"ref": "v0.13.0",
"repo": "linear-tui",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1786106723,
"narHash": "sha256-zDSUbpoeo/9ZmD2+wXnzxoo1+uhL8vxc0b8yuYMKYq0=",
"lastModified": 1791179034,
"narHash": "sha256-Ni0LBydzaCi8oek12r4mVreuFHqYlM1eTD6SfiENKfw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "f13ff45afd1bb73e640eaa08a7066dbed07e3238",
"rev": "494ce7fd23ff6a5dff39e1fb11e9b6f2ac74bf25",
"type": "github"
},
"original": {
@ -99,6 +136,8 @@
"apple-silicon": "apple-silicon",
"firefox-addons": "firefox-addons",
"home-manager": "home-manager",
"hyprland-virtual-desktops": "hyprland-virtual-desktops",
"linear-tui-src": "linear-tui-src",
"nixpkgs": "nixpkgs",
"zen-browser": "zen-browser"
}

316
flake.nix
View file

@ -24,10 +24,46 @@
inputs.nixpkgs.follows = "nixpkgs";
inputs.home-manager.follows = "home-manager";
};
# linear-tui has no flake of its own, so take the source and build it below.
# Pinned to a tag; `nix flake update` moves it and records the hash in
# flake.lock, so there is no sha256 to maintain by hand.
linear-tui-src = {
url = "github:k1-c/linear-tui/v0.13.0";
flake = false;
};
# Virtual desktops: one desktop spans all monitors, replacing Hyprland's
# workspace-pinned-to-monitor model. Its own nixpkgs pin is older than
# ours, and Hyprland plugins are ABI-locked to the compositor, so it must
# follow ours to compile against the hyprland we actually run.
hyprland-virtual-desktops = {
url = "github:levnikmyskin/hyprland-virtual-desktops";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, apple-silicon, home-manager, firefox-addons, zen-browser, ... }:
outputs = { self, nixpkgs, apple-silicon, home-manager, firefox-addons, zen-browser, linear-tui-src, hyprland-virtual-desktops, ... }:
let
# linear-tui, built from the pinned source input above. Upstream ships no
# flake and it is not in nixpkgs, but it has a committed Cargo.lock, so
# cargoLock.lockFile covers the dependency hashes with nothing to vendor.
#
# No OpenSSL/pkg-config needed: the crate selects reqwest's rustls
# backend on purpose, and takes its trust roots from the OS store.
linearTui = pkgs: pkgs.rustPlatform.buildRustPackage {
pname = "linear-tui";
version = "0.13.0";
src = linear-tui-src;
cargoLock.lockFile = "${linear-tui-src}/Cargo.lock";
# One test (a_worktree_belongs_to_its_repository) shells out to `git`,
# which the build sandbox lacks -- it panics on NotFound unwrapping
# Command::new("git"). Supplying git lets all 512 tests run.
nativeCheckInputs = [ pkgs.git ];
meta.mainProgram = "linear-tui";
};
# ───────────────────── shared home-manager user ─────────────────────
home = { pkgs, osConfig, ... }: {
imports = [ zen-browser.homeModules.beta ];
@ -47,19 +83,76 @@
if osConfig.networking.hostName == "mbp"
then ''
monitor = , preferred, auto, 2.0
# Floating TUI panels: 70% wide, full height. Absolute px because
# Hyprland 0.56 ignores percentages in `size`. 1728x1080 logical.
windowrule = match:class ^(panel-wifi|panel-bt)$, float on, size 1210 1080, center on
''
else ''
monitor = desc:Lenovo Group Limited P27q-20 V909LRFR, 2560x1440@60, 0x0, 1.0
monitor = desc:Lenovo Group Limited P27q-20 V909LPNY, 2560x1440@60, 2560x0, 1.0
monitor = desc:Lenovo Group Limited P27q-20 V909LRDD, 2560x1440@60, 5120x0, 1.0
workspace = 1, monitor:desc:Lenovo Group Limited P27q-20 V909LRFR, default:true
workspace = 2, monitor:desc:Lenovo Group Limited P27q-20 V909LPNY, default:true
workspace = 3, monitor:desc:Lenovo Group Limited P27q-20 V909LRDD, default:true
# Same panels, sized for 2560x1440 logical.
windowrule = match:class ^(panel-wifi|panel-bt)$, float on, size 1792 1440, center on
'';
# Plugin load line, as a store path. Sourced from hyprland.conf, the
# same pattern as monitors.conf. Upstream installs the library as
# libvirtual-desktops.so (not lib<pname>.so).
"hypr/plugins.conf".text = ''
plugin = ${hyprland-virtual-desktops.packages.${pkgs.stdenv.hostPlatform.system}.virtual-desktops}/lib/libvirtual-desktops.so
'';
"eww".source = ./config/eww;
"fuzzel".source = ./config/fuzzel;
};
# Launcher hygiene. These .desktop entries ship with packages we do want,
# but they are noise in fuzzel. A NoDisplay entry in XDG_DATA_HOME
# shadows the package's own copy, which is the only way to hide one
# without patching the package.
#
# Deliberately home.file and NOT xdg.desktopEntries: that option only
# emits anything when xdg.enable = true, which we don't set, so it
# silently produces no files at all.
# nvim - neovim's "Neovim wrapper" entry; we only launch it from a terminal
# uuctl - uwsm's systemd-user-unit picker, pulled in by withUWSM
home.file = {
".local/share/applications/nvim.desktop".text = ''
[Desktop Entry]
Type=Application
Name=Neovim wrapper
NoDisplay=true
'';
".local/share/applications/uuctl.desktop".text = ''
[Desktop Entry]
Type=Application
Name=uuctl
NoDisplay=true
'';
};
# Notification daemon. Nothing owned org.freedesktop.Notifications before
# this, so notify-send failed outright: hyprshot's "Screenshot saved"
# toasts went nowhere, and kitty logged a DBus ServiceUnknown error on
# every launch. Colours match the cyberdream kitty theme above.
services.mako = {
enable = true;
settings = {
font = "JetBrainsMono Nerd Font 11";
background-color = "#16181a";
text-color = "#ffffff";
border-color = "#5ea1ff";
border-size = 2;
border-radius = 8;
default-timeout = 5000;
anchor = "top-right";
margin = "12";
padding = "12";
width = 380;
};
};
programs.kitty = {
enable = true;
settings = {
@ -105,7 +198,7 @@
hyprcursor.enable = true;
};
# Dark GTK apps (gimp, file dialogs).
# Dark GTK apps (file dialogs, etc).
gtk = {
enable = true;
theme = { name = "Adwaita-dark"; package = pkgs.gnome-themes-extra; };
@ -208,40 +301,83 @@
};
};
home.packages = with pkgs; [ ripgrep fd tridactyl-native lazygit rustc cargo clippy rustfmt rust-analyzer claude-code gcc bluetui impala];
home.packages = with pkgs; [ ripgrep fd tridactyl-native lazygit rustc cargo clippy rustfmt rust-analyzer claude-code gcc bluetui impala postgresql fastfetch (linearTui pkgs) ];
};
# ───────────────────── shared system (both hosts) ─────────────────────
common = { pkgs, ... }: {
nix.settings.experimental-features = [ "nix-command" "flakes" ];
# Trusted so client-side nix config takes effect — the deepvis repo's
# flake nixConfig advertises the team dev cache (attic on the builder,
# over the tailnet), and the daemon ignores it for untrusted users.
# Trusted so client-side nix config takes effect — substituters and
# remote builders are trusted settings, silently ignored for anyone
# else. A cache that appears to do nothing is the usual symptom.
nix.settings.trusted-users = [ "mads" ];
# x86_64 derivations build on the build host instead of under local
# qemu emulation — anything the dev cache doesn't already have ships
# there as a remote build (tailnet; the builder's `builder` user
# exists for exactly this).
# The deepvis binary cache: the embedded flakes in models/ort-nix
# (onnxruntime built from source, a CUDA training env) are hours of
# work to build and seconds to substitute.
nix.settings.extra-substituters = [ "https://deepvis.cachix.org" ];
nix.settings.extra-trusted-public-keys = [
"deepvis.cachix.org-1:UE/H6ERLwqhSqubzh8+pzl9eTm0kpMExGe0ym6fENp8="
];
# Private cache, so reads need a token. nix refuses `netrc-file` on
# the command line under the daemon, and a token in the substituter
# URL would land in the process table — a root-owned 0600 netrc is
# the only form the calling process cannot read.
nix.settings.netrc-file = "/etc/nix/netrc";
# x86_64 derivations build off this machine rather than under local
# qemu emulation, and aarch64 ones go out too when the local
# machine is busy.
#
# The host key is pinned system-wide because the ssh'ing party is the
# root daemon, which has no known_hosts and never gets a tty to
# The host keys are pinned system-wide because the ssh'ing party is
# the root daemon, which has no known_hosts and never gets a tty to
# answer a trust prompt.
programs.ssh.knownHosts."builder" = {
extraHostNames = [ "5.9.71.104" "builder.tailf5eaab.ts.net" ];
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHnKIrxYtlovvYGgkpEZXY8wG/CuSLMGOM/84eIlyeVW";
};
programs.ssh.knownHosts."eu.nixbuild.net" = {
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPIQCZc54poJ8vqawd8TraNryQeJnvH1eLpIDgbiqymM";
};
nix.distributedBuilds = true;
nix.buildMachines = [{
hostName = "builder";
sshUser = "builder";
# The daemon (root) does the ssh'ing, so it needs an explicit
# on-disk key — it has no access to a user's agent.
sshKey = "/home/mads/.ssh/id_ed25519";
protocol = "ssh-ng";
system = "x86_64-linux";
maxJobs = 16;
supportedFeatures = [ "big-parallel" "kvm" "nixos-test" ];
}];
nix.buildMachines = [
{
hostName = "builder";
sshUser = "builder";
# The daemon (root) does the ssh'ing, so it needs an explicit
# on-disk key — it has no access to a user's agent.
sshKey = "/home/mads/.ssh/id_ed25519";
protocol = "ssh-ng";
system = "x86_64-linux";
maxJobs = 16;
supportedFeatures = [ "big-parallel" "kvm" "nixos-test" ];
}
# nixbuild.net, for both architectures. Listed after `builder` so
# the machine we already pay for is preferred.
#
# Every feature it supports is advertised, not just the two in the
# quickstart: nix only sends a derivation to a builder claiming
# *every* feature that derivation requires, so omitting `kvm` or
# `nixos-test` would silently route those back to local builds.
{
hostName = "eu.nixbuild.net";
sshUser = "root";
sshKey = "/home/mads/.ssh/id_ed25519";
protocol = "ssh-ng";
system = "x86_64-linux";
maxJobs = 100;
supportedFeatures = [ "benchmark" "big-parallel" "ca-derivations" "kvm" "nixos-test" ];
}
{
hostName = "eu.nixbuild.net";
sshUser = "root";
sshKey = "/home/mads/.ssh/id_ed25519";
protocol = "ssh-ng";
system = "aarch64-linux";
maxJobs = 100;
supportedFeatures = [ "benchmark" "big-parallel" "ca-derivations" "kvm" "nixos-test" ];
}
];
networking.networkmanager.enable = true;
networking.networkmanager.wifi.backend = "iwd";
@ -273,7 +409,44 @@
programs.hyprland = { enable = true; withUWSM = true; };
programs.hyprlock.enable = true;
# Every GUI app lives in the one wayland-wm@hyprland.service cgroup, so
# with systemd's default OOMPolicy=stop a kernel OOM-kill of ANY process
# in it stops the whole unit -- compositor included. 1Password was always
# the victim (highest oom_score_adj), but losing it killed the entire
# desktop, and uwsm sets Restart=no, so nothing came back. It read as
# "Hyprland crashed" when the compositor was never at fault.
#
# continue: log the kill, lose that one app, keep the session. Merges
# into the drop-in the uwsm module already generates for this unit.
systemd.user.services."wayland-wm@".serviceConfig.OOMPolicy = "continue";
# Autologin means no password reaches PAM at login, so the gnome-keyring
# login keyring is never created or unlocked -- which is why 1Password
# could not persist its 2FA device token and re-prompted the YubiKey
# every unlock. Putting pam_gnome_keyring in hyprlock's stack instead
# makes the lock screen we already have do that job, so there is still
# exactly one password prompt and no second login screen.
security.pam.services.hyprlock.enableGnomeKeyring = true;
services.hypridle.enable = true;
# ── Amphetamine-style "stay awake" ──
# caffeine.service holds ONE logind block-inhibitor covering all three
# ways this machine can go dark:
# idle -> hypridle stops counting (no blank, no hyprlock)
# sleep -> suspend requests are refused
# handle-lid-switch -> closing the lid does nothing
# systemd owns the process, so "is it on?" is just "is the unit
# active?" -- no pidfile, and it can't be orphaned. Deliberately no
# wantedBy: it only ever starts via SUPER+A or the `caffeine` command.
systemd.user.services.caffeine = {
description = "Caffeine - inhibit idle, sleep and lid-switch";
serviceConfig = {
Type = "simple";
ExecStart = "${pkgs.systemd}/bin/systemd-inhibit --what=idle:sleep:handle-lid-switch --who=caffeine --why=stay-awake --mode=block ${pkgs.coreutils}/bin/sleep infinity";
};
};
xdg.portal = {
enable = true;
extraPortals = [ pkgs.xdg-desktop-portal-hyprland ];
@ -289,19 +462,60 @@
};
};
virtualisation.docker.enable = true;
hardware.bluetooth.enable = true;
programs.zsh.enable = true;
# No NixOS manual: it puts a "NixOS Manual" entry in the launcher and we
# read the docs on the web. man pages are unaffected.
documentation.nixos.enable = false;
fonts.packages = [ pkgs.nerd-fonts.jetbrains-mono ];
environment.systemPackages = with pkgs; [
git kitty fuzzel gimp
wl-clipboard grim slurp swaybg brightnessctl playerctl grimblast
git kitty fuzzel
wl-clipboard grim slurp swaybg brightnessctl playerctl hyprshot
hyprpolkitagent xdg-utils
# Front-end for caffeine.service (above). Bound to SUPER+A.
(writeShellApplication {
name = "caffeine";
runtimeInputs = [ systemd ];
text = ''
unit=caffeine.service
is_on() { systemctl --user is-active --quiet "$unit"; }
# Guarded so `caffeine` still works over SSH / with no compositor.
toast() {
command -v hyprctl >/dev/null 2>&1 || return 0
hyprctl notify "$1" 2000 "$2" "$3" >/dev/null 2>&1 || true
}
wake_on() {
systemctl --user start "$unit"
toast 5 "rgb(5eff6c)" "Caffeine ON - idle, sleep and lid disabled"
}
wake_off() {
systemctl --user stop "$unit"
toast 1 "rgb(ff6e5e)" "Caffeine OFF - normal sleep restored"
}
case "''${1:-toggle}" in
toggle) if is_on; then wake_off; else wake_on; fi ;;
on) wake_on ;;
off) wake_off ;;
status) if is_on; then echo on; else echo off; fi ;;
*) echo "usage: caffeine [toggle|on|off|status]" >&2; exit 2 ;;
esac
'';
})
];
users.users.mads = {
isNormalUser = true;
extraGroups = [ "wheel" "networkmanager" "video" ];
extraGroups = [ "wheel" "networkmanager" "video" "docker" ];
shell = pkgs.zsh;
initialPassword = "changeme"; # CHANGE after first boot (`passwd`)
};
@ -347,6 +561,38 @@
boot.extraModprobeConfig = "options hid_apple fnmode=1";
boot.blacklistedKernelModules = [ "pcspkr" ];
# Root is btrfs (p8). A scrub re-reads every block and verifies its
# checksum, which is the only way bit rot on this SSD surfaces before
# something tries to read the damaged file. Metadata is DUP, so a bad
# metadata block gets repaired from its copy automatically.
services.btrfs.autoScrub = {
enable = true;
interval = "monthly";
fileSystems = [ "/" ];
};
# 15G of RAM and no swap at all: a `nixos-rebuild` forks ~60 nix
# evaluators, and stacked on a browser and a few editor sessions
# that was enough to exhaust it. The kernel then OOM-killed a
# process inside wayland-wm@hyprland.service, and systemd's default
# OOMPolicy=stop tore the whole graphical session down with it.
#
# zram rather than a swapfile, because root is compress=zstd and a
# btrfs swapfile needs its own nodatacow, uncompressed subvolume.
zramSwap = {
enable = true;
memoryPercent = 50; # 7.6G of backing store, ~a third of that in real RAM
};
# Swapping to RAM is cheap, so let the kernel compress anon pages
# rather than evict page cache. At the OOM above, cache had already
# been squeezed to 40M while 13G sat in anon and nothing was
# reclaimable — the default swappiness of 60 gets that backwards here.
boot.kernel.sysctl = {
"vm.swappiness" = 180;
"vm.page-cluster" = 0; # zram has no seek penalty, so no readahead
};
# Build x86_64 derivations (packing-station iso/deploys) via qemu.
boot.binfmt.emulatedSystems = [ "x86_64-linux" ];
@ -383,6 +629,18 @@
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true; # normal x86 UEFI
# Desktop: never suspend or hibernate, even if something asks.
systemd.sleep.settings.Sleep = {
AllowSuspend = "no";
AllowHibernation = "no";
AllowHybridSleep = "no";
AllowSuspendThenHibernate = "no";
};
# Ignore the keys that would normally trigger sleep.
services.logind.suspendKey = "ignore";
services.logind.hibernateKey = "ignore";
services.logind.lidSwitch = "ignore";
# NVIDIA (RTX 20-series or newer → open kernel module).
services.xserver.videoDrivers = [ "nvidia" ];
hardware.graphics.enable = true; # OpenGL/Vulkan userspace (was hardware.opengl)

View file

@ -13,9 +13,13 @@
boot.kernelModules = [ ];
boot.extraModulePackages = [ ];
# Root lives on nvme0n1p8 (btrfs, subvol @) — the 256G reclaimed from
# the shrunk macOS container. The old ext4 p5 is kept as a rollback until
# it is added to this pool as a second device.
fileSystems."/" =
{ device = "/dev/disk/by-uuid/2664fdc1-4309-4682-a02e-4e27eb1e14f6";
fsType = "ext4";
{ device = "/dev/disk/by-uuid/c828c1a0-ce7b-4e27-b8e4-bce032bb30bf";
fsType = "btrfs";
options = [ "subvol=@" "compress=zstd" "noatime" ];
};
fileSystems."/boot" =