Files
nix-config-ryan4yin/hosts/idols-ai/preservation.nix
T
ryan4yin | 二花 b726c28c14 feat(nixpak): sandbox Steam and Heroic in ~/Gaming (#520)
* feat(nixpak): sandbox Steam and Heroic in ~/Gaming

idols-ai stops preserving ~/.local/share/Steam, ~/.steam, ~/.config/heroic
and their game data, which must be moved into ~/Gaming before deploying.

Assisted-by: DeepSeek Harness (deepseek-flash)

* refactor(nixpak): replace steam-fix.nu with sandboxed protontricks

Assisted-by: DeepSeek Harness (claude-opus-5-5)

* fix(nixpak): hide host documents and GTK config from game sandboxes

Assisted-by: DeepSeek Harness (claude-opus-5-5)

* docs(ai): locate the Steam library under ~/Gaming

Assisted-by: DeepSeek Harness (claude-opus-5-5)

* fix(umu-game): confine writes to ~/Gaming and keep foreign entries

Assisted-by: DeepSeek Harness (claude-opus-5-5)

* fix(nixpak): keep Steam and Heroic in the application launcher

The wrappers only carried bin/, so neither launcher had a desktop entry
after replacing programs.steam.

Assisted-by: DeepSeek Harness (deepseek-flash)

* fix(nixpak): give the game sandboxes a fontconfig that sees CJK fonts

Steam rendered Chinese as tofu: the FHS layers build their own /etc/fonts,
whose config lists only DejaVu and the XDG font directory, so neither the
host fonts nor the FHS /usr/share/fonts were ever scanned. Point
FONTCONFIG_FILE at a small config that includes /usr/share/fonts, and ship
WenQuanYi Zen Hei and Noto Sans CJK in all three FHS layers.

Assisted-by: DeepSeek Harness (deepseek-flash)

* fix(nixpak): grant the game launchers a StatusNotifier tray icon

Steam and Heroic register a tray icon over D-Bus, and the game sandboxes
granted no StatusNotifierWatcher access, so the desktop shell had nothing
to show for them. Enable desktopIntegration.tray for all three launchers
and teach the policy suites to expect it.

Assisted-by: DeepSeek Harness (deepseek-flash)

* fix(nixpak): drop the duplicate host CJK font and tidy the font overrides

Steam reads the fonts inside its own FHS layer, so the system-wide wqy_zenhei
added for it no longer serves any sandbox. Give gaming-policy its own comment
for FONTCONFIG_FILE instead of leaving it under the compat-tool one, make the
desktop-base value a default, and note the SNI tray grant in the README table.

Assisted-by: DeepSeek Harness (deepseek-flash)
2026-11-06 02:18:33 -06:00

501 lines
16 KiB
Nix

{
preservation,
lib,
pkgs,
myvars,
...
}:
let
inherit (myvars) username;
in
{
imports = [
preservation.nixosModules.default
];
preservation.enable = true;
environment.systemPackages = [
# Whole-filesystem disk usage (`-x` stays on one filesystem):
# sudo gdu -n -x / # non-interactive, plain text
# sudo gdu -o /tmp/gdu.json / # JSON export
pkgs.gdu
];
# There are two ways to clear the root filesystem on every boot:
## 1. use tmpfs for /
## 2. (btrfs/zfs only)take a blank snapshot of the root filesystem and revert to it on every boot via:
## boot.initrd.postDeviceCommands = ''
## mkdir -p /run/mymount
## mount -o subvol=/ /dev/disk/by-uuid/UUID /run/mymount
## btrfs subvolume delete /run/mymount
## btrfs subvolume snapshot / /run/mymount
## '';
#
# See also https://grahamc.com/blog/erase-your-darlings/
# NOTE: preservation only mounts the directory/file list below to /persistent
# If the directory/file already exists in the root filesystem you should
# move those files/directories to /persistent first!
# Removing an entry stops its mount on the next activation, but leaves the empty
# root-owned mountpoint behind on the tmpfs root, so a Home Manager link into that
# path fails with "Permission denied" until it is removed once (`sudo rmdir
# <path>`); nothing recreates it afterwards.
preservation.preserveAt."/persistent" = {
directories = [
"/etc/NetworkManager/system-connections"
"/etc/ssh"
"/etc/nix/inputs"
"/etc/secureboot" # lanzaboote - secure boot
# my secrets
"/etc/agenix/"
"/var/log"
# system caches (e.g. restic, plocate; slow to rebuild)
"/var/cache"
# General scratch space. It is re-creatable and can grow large, so keep it
# off the root tmpfs and on the persistent volume. Contents only
# accumulate between reboots: the tmpfiles rule below wipes it at boot.
# World-writable scratch, so harden the bind mount like /tmp; the mode
# must stay 1777 to match that `D!` rule.
{
directory = "/var/tmp";
mode = "1777";
mountOptions = [
"nosuid"
"nodev"
];
}
# btrbk state/home (SSH keys, cache). The owner must match the btrbk
# module's `d /var/lib/btrbk 0750 btrbk btrbk` rule, which preservation
# would otherwise override with the default root:root.
{
directory = "/var/lib/btrbk";
user = "btrbk";
group = "btrbk";
mode = "0750";
}
# CUPS state (the print spool lives in /var/spool/cups, not here)
"/var/lib/cups"
# OpenRGB SDK server settings and profiles
"/var/lib/OpenRGB"
# system-core
"/var/lib/nixos"
"/var/lib/systemd"
# upower device battery history (e.g. Magic Trackpad)
"/var/lib/upower"
{
directory = "/var/lib/private";
mode = "0700";
}
# containers
# "/var/lib/docker"
"/var/lib/cni"
"/var/lib/containers"
# virtualisation
"/var/lib/libvirt"
"/var/lib/qemu"
# "/var/lib/waydroid"
# network
"/var/lib/bluetooth"
"/var/lib/NetworkManager"
"/var/lib/iwd"
"/var/lib/tailscale"
# logrotate state. Persist the directory: a single-file bind mount cannot
# be replaced by logrotate's atomic rename() (EBUSY).
"/var/lib/logrotate"
];
files = [
# auto-generated machine ID
{
file = "/etc/machine-id";
inInitrd = true;
}
];
# the following directories will be passed to /persistent/home/$USER
users.${username} = {
commonMountOptions = [
"x-gvfs-hide"
];
directories = [
# ======================================
# XDG Directories
# ======================================
"Desktop"
"Downloads"
"Music"
"Pictures"
"Documents"
"Videos"
# Keep .cache off tmpfs to avoid high RAM usage; many apps use it and it is storage-heavy.
".cache"
# NOTE: do NOT persist ~/.local/share/Trash here. The trash crate (nushell
# `rm --trash`) picks the home trash only when the file's mount topdir equals
# the trash dir's topdir; a bind-mounted Trash dir would make files straight
# under $HOME fail with EACCES (it would try /.Trash-$uid). The home trash on
# tmpfs is lost on reboot, which is acceptable: scattered per-mount
# .Trash-$uid dirs on persistent volumes are covered by the trash-empty
# retention timer (modules/nixos/base/trash.nix).
# ======================================
# Codes / Work / Playground
# ======================================
"codes" # for personal code
"work" # work code tree
"src" # third-party source checkouts
"nix-config"
"tmp"
"models"
# ======================================
# Nix / Home Manager Profiles
# ======================================
".local/state/home-manager"
".local/state/nix/profiles"
".local/state/noctalia" # shell settings, notification/clipboard history
".local/share/nix"
# ======================================
# IDE / Editors
# ======================================
# zed
".config/zed"
".local/share/zed"
# ai agents
".agents" # skills for all agents
".config/agents"
".codex"
".dsh" # DeepSeek Harness
".pi"
# Qwen Code keeps settings, credentials, history, sessions, and logs
# under ~/.qwen; keep the whole directory private and persistent.
{
directory = ".qwen";
mode = "0700";
}
".config/opencode"
".local/share/opencode"
".local/state/opencode"
# ctx7 docs lookup login
{
directory = ".config/context7";
mode = "0700";
}
# nvim
".local/share/nvim"
".local/state/nvim"
# ======================================
# TUIOS (terminal window manager)
# ======================================
# Without .local/state/tuios the daemon has no saved session state to
# restore after a reboot, and every session is lost.
# session structure (resurrection), plus each pane's saved history
{
directory = ".local/state/tuios";
mode = "0700";
}
# project-tape trust store and tape recordings
{
directory = ".local/share/tuios";
mode = "0700";
}
# layout templates (config.toml is a Home Manager symlink into the repo)
".config/tuios"
# Joplin
".config/joplin" # tui client
".config/Joplin" # joplin-desktop
".local/share/jupyter"
# Deliberately not persisted: IPython keeps its history database in
# memory (HistoryManager.hist_file = ":memory:") and the profile config
# and startup helpers are recreated from the Nix store on every
# activation, so this directory holds no state worth preserving.
# ======================================
# Cloud Native
# ======================================
{
directory = ".aws";
mode = "0700";
}
{
directory = ".aliyun";
mode = "0700";
}
{
directory = ".config/gcloud";
mode = "0700";
}
{
directory = ".config/gh";
mode = "0700";
}
{
directory = ".kube";
mode = "0700";
}
".terraform.d/plugin-cache" # terraform's plugin cache
# ======================================
# language package managers
# ======================================
".npm" # typsescript/javascript
"go"
".cargo" # rust
".m2" # java maven
".gradle" # java gradle
".local/bin"
# python uv
".local/share/uv"
# ======================================
# Security
# ======================================
{
directory = ".gnupg";
mode = "0700";
}
# hand-edited secret config, plus the env file derived from it; Nix deploys neither
{
directory = ".secrets";
mode = "0700";
}
{
directory = ".ssh";
mode = "0700";
}
{
directory = ".pki";
mode = "0700";
}
{
directory = ".local/share/password-store";
mode = "0700";
}
{
# gnmome keyrings
directory = ".local/share/keyrings";
mode = "0700";
}
# ======================================
# Games / Media
# ======================================
# Home of the game sandboxes (hardening/nixpaks/modules/gaming-policy.nix).
# Steam, Heroic, umu and the games they launch all see it as $HOME, so
# their libraries, prefixes, saves and Proton builds live inside it:
# ~/Gaming/.local/share/Steam, ~/Gaming/.config/heroic,
# ~/Gaming/.local/share/umu, ~/Gaming/Games/... .
"Gaming"
# .desktop entries created on the host for sandboxed launchers. Home
# Manager rewrites its mimeapps.list symlink into the bind mount at
# activation, so that survives too.
".local/share/applications"
".config/MangoHud"
".config/blender"
# WiVRn atomically replaces its settings and pairing files, so preserve
# the directory rather than individual files.
{
directory = ".config/wivrn";
mode = "0700";
}
{
directory = ".android"; # ADB host keys for Quest USB authorization
mode = "0700";
}
# ======================================
# Meeting / Remote Desktop / Recording
# ======================================
".zoom"
".config/obs-studio"
".config/Moonlight Game Streaming Project"
".config/sunshine"
".config/freerdp"
".config/remmina"
".local/share/remmina"
# ======================================
# browsers
# ======================================
".mozilla"
".config/google-chrome"
".config/chromium"
".config/BraveSoftware/Brave-Origin"
".config/microsoft-edge"
# ======================================
# CLI data
# ======================================
".local/share/atuin"
".local/share/zoxide"
".local/share/direnv"
".local/share/k9s"
# ======================================
# Containers
# ======================================
".local/share/containers"
# nixpak app's data
{
directory = ".var";
mode = "0700";
}
# ======================================
# Misc
# ======================================
# Mihomo (native core): sources spec, generator and config all live here
# (contains subscription secrets, hence 0700).
{
directory = ".config/mihomo";
mode = "0700";
}
# Orca Slicer - 3D Printer Slicer
".local/share/orca-slicer"
".config/OrcaSlicer"
# Bambu Studio - 3D Printer Slicer
".local/share/bambu-studio"
".config/BambuStudio"
# OpenRGB GUI settings, profiles and plugins
".config/OpenRGB"
# Audio
".config/pulse"
".local/state/wireplumber"
# EasyEffects 8: KConfig settings database plus user presets, autoload
# profiles and imported impulse responses/RNNoise models. The preset
# declared in home/linux/gui/base/media.nix is re-linked here by Home
# Manager activation; everything the GUI writes needs the persistent
# mount or it is gone after a reboot.
".config/easyeffects"
".local/share/easyeffects"
# go-musicfox - TUI NetEase Cloud Music client
# ~/.config/go-musicfox/config.toml is force-managed by Home Manager
# (see home/linux/gui/base/go-musicfox/default.nix) and reset on each
# rebuild; this keeps the runtime themes/ directory and the app's
# between-rebuild config edits.
".config/go-musicfox"
".local/share/go-musicfox" # login cookie + library db
".local/state/go-musicfox" # logs
# Digital Painting
".local/share/krita"
# Japanese IME
".config/mozc" # used by fcitx5-mozc
# Vinput voice input (fcitx5-vinput): the downloaded sherpa-onnx models
# (~/.local/share/vinput/models/). Without them the daemon starts with
# "Local ASR provider model is not configured" after every reboot. Its
# config needs no entry: it is an out-of-store link into the preserved
# nix-config checkout. Setup: home/linux/gui/base/vinput/README.md
".local/share/vinput"
".config/nushell"
];
files = [
{
file = ".config/zoomus.conf";
how = "symlink";
}
{
file = ".config/zoom.conf";
how = "symlink";
}
];
};
};
# Keep logrotate's state inside the directory preserved above instead of the
# default /var/lib/logrotate.status (a mount point `rename()` cannot replace).
services.logrotate.extraArgs = [
"--state"
"/var/lib/logrotate/status"
];
# Create some directories with custom permissions.
#
# In this configuration the path `/home/butz/.local` is not an immediate parent
# of any persisted file so it would be created with the systemd-tmpfiles default
# ownership `root:root` and mode `0755`. This would mean that the user `butz`
# could not create other files or directories inside `/home/butz/.local`.
#
# Therefore systemd-tmpfiles is used to prepare such directories with
# appropriate permissions.
#
# Note that immediate parent directories of persisted files can also be
# configured with ownership and permissions from the `parent` settings if
# `configureParent = true` is set for the file.
systemd.tmpfiles.settings.preservation =
let
permission = {
user = username;
group = lib.mkForce username;
mode = lib.mkForce "0750";
};
in
{
"/home/${username}/.config".d = permission;
"/home/${username}/.local".d = permission;
"/home/${username}/.local/share".d = permission;
"/home/${username}/.local/state".d = permission;
"/home/${username}/.local/state/nix".d = permission;
"/home/${username}/.terraform.d".d = permission;
};
# /var/tmp is re-creatable scratch, but it must not consume RAM, so it is
# bind-mounted onto the persistent volume above. Wipe it at boot (mirrors
# nixpkgs `boot.tmp.cleanOnBoot` for /tmp) so contents only accumulate
# between reboots.
#
# Crash dumps live in the persisted /var/lib/systemd and can contain secrets
# copied out of process memory, so don't keep them across reboots either.
systemd.tmpfiles.rules = [
"D! /var/tmp 1777 root root"
"D! /var/lib/systemd/coredump 0755 root root"
];
# Mask the commit service: /etc/machine-id is persisted by preservation
# (see the `files` entry above), so there is no transient ID to commit.
systemd.suppressedSystemUnits = [ "systemd-machine-id-commit.service" ];
# Inert while the unit is masked above; kept as the alternative approach.
systemd.services.systemd-machine-id-commit = {
unitConfig.ConditionPathIsMountPoint = [
""
"/persistent/etc/machine-id"
];
serviceConfig.ExecStart = [
""
"systemd-machine-id-setup --commit --root /persistent"
];
};
}