fix: disk picker fd0 (#112), menu Back guard (#106), offline theme (#113)
Some checks failed
Check / eval (push) Has been cancelled

- #112: list_installable_disks drops fd/loop/sr/zram/<8GiB, largest-first
  so OVMF no longer offers /dev/fd0 as the default wipe target
- #106: tools/check-menu-back.py + checks.menu-back (proved to fail)
- #113: offline theme-switch contract documented (default/pinned only);
  theme-sync run_switch adds offline-oriented hint when no network

Verified: V0 flake check; installer-safety; menu-back; py_compile.
This commit is contained in:
2026-07-15 10:02:19 +01:00
parent 748d4af414
commit e6be5a5770
10 changed files with 202 additions and 49 deletions

View File

@@ -72,28 +72,6 @@ buttons in the installer (VM gum screens under Boreal look legible —
artifacts in `/tmp/nomarchy-v2-swap-93/`). Needs Bernardo's screenshot or artifacts in `/tmp/nomarchy-v2-swap-93/`). Needs Bernardo's screenshot or
an Acer repro to pin the actual widget before fixing. an Acer repro to pin the actual widget before fixing.
### 113. Live session: offline theme switch rebuilds the world from source
Surfaced by #99's evidence run (2026-07-13, `/tmp/nomarchy-v2-theme-99/`):
in the **live ISO** session, offline `nomarchy-theme-sync apply gruvbox`
fails — its `home-manager switch` tries to build **1176 derivations** from
the `stage0`/`hex0` bootstrap up, then dies on offline source fetches
(`vala`, `yasm`, `config.jsonc`, `ghostty-config`, `easyeffects.svg`,
`typogrify`…), ending `error: Build failed due to failed dependency`. This
contradicts docs/TESTING.md item 6 ("apply gruvbox → switch runs offline").
The failing generation is `bvl30ggn…-home-manager-generation.drv`; it is
**not** the drv the ISO pins (the pin is the *default*-theme
`homeConfigurations.${username}.activationPackage`, i.e. Boreal — confirmed
drv-identical to the failing one only for gruvbox state, so a non-default
theme's generation is unpinned). NOT caused by #99's git-seed: the drv is
byte-identical path-seeded vs git-seeded. **Decide the contract:** either
pin every baked theme's live activationPackage into the ISO (size cost) or
scope the offline-switch promise to the shipped theme + correct
TESTING.md/MEMORY. Pass = a fresh live ISO either switches to any baked
theme offline, or fails with a clear "needs network for this theme" message
and accurate docs. **Not caused by anything in today's batch** — separate
pre-existing ISO-pinning gap.
## NEXT ## NEXT
### 122. A crisper Nomarchy logo in fastfetch ### 122. A crisper Nomarchy logo in fastfetch
@@ -283,14 +261,6 @@ scoped implementation/verification tasks before work starts. Pass = every
current route has one deliberate home, navigation remains shallow, and no current route has one deliberate home, navigation remains shallow, and no
root-level entry is added or lost. root-level entry is added or lost.
### 106. Internal menu Back/Left navigation contract
Reproduce the reported internal leaf with neither Back nor Left behavior, then
fix any breach of the already-intended navigation contract. Add a permanent
guard covering every internal leaf; external GUIs and free-text prompts retain
their explicit Esc exceptions. Pass = no internal leaf can strand the user and
the guard fails on a regression.
### 108. Keybindings menu presentation and completeness audit ### 108. Keybindings menu presentation and completeness audit
Group the menu as Window, Workspace, Menu, and Media, then prove every live Group the menu as Window, Workspace, Menu, and Media, then prove every live
@@ -328,16 +298,6 @@ generation, System boot generation, and Files/root BTRFS scopes. Pass = labels
state what is restored and from where before action, with destructive or reboot state what is restored and from where before action, with destructive or reboot
effects made clear and each existing recovery path represented once. effects made clear and each existing recovery path represented once.
### 112. Installer disk-picker safety
Exclude floppy, pseudo, tiny, and otherwise non-installable devices such as
`/dev/fd0`; never select them by default. **Reproduced in QEMU 2026-07-13**
(#93's V2 run): with OVMF the guest exposes `/dev/fd0` and the picker listed
it FIRST — a blind Enter selected it (artifacts:
`/tmp/nomarchy-v2-swap-93/20-review.png`, `Disk: /dev/fd0 (WILL BE ERASED)`). Pass = the real install disk is clear,
invalid devices cannot reach destructive setup, a permanent guard covers the
filter/default logic, and a pre-destructive KVM run proves the picker behavior.
## LATER ## LATER
- **Wallpapers artifact split** (ROADMAP § Faster switches — decided, - **Wallpapers artifact split** (ROADMAP § Faster switches — decided,

View File

@@ -19,6 +19,19 @@ Template:
--- ---
## 2026-07-15 — Fix batch #112 disk picker, #106 menu Back, #113 offline theme
- **Task:** Second fix-only batch from NEXT/NOW residuals.
- **Did:** (112) `list_installable_disks` excludes fd/loop/sr/zram/tiny
(<8GiB), sorts largest-first; installer-safety markers. (106)
`tools/check-menu-back.py` + `checks.menu-back` (proved to fail when Back
stripped). (113) offline theme contract: TESTING item 6 scoped to default/
current theme; `run_switch` prints clear offline-oriented message on
network/build failures.
- **Verified:** V0 flake check; installer-safety; menu-back prove-fail;
py_compile theme-sync.
- **Pending:** V2 unattended install with OVMF fd0 present (optional).
- **Next suggestion:** #108 keybinds audit, or #94 test-install chromium.
## 2026-07-15 — Fix batch #123#126 (install bake, flake ref, ISO name, display) ## 2026-07-15 — Fix batch #123#126 (install bake, flake ref, ISO name, display)
- **Task:** Fix-only NOW batch: #123 dconf pre-activate, #124 main flake - **Task:** Fix-only NOW batch: #123 dconf pre-activate, #124 main flake
seed, #125 ISO filename, #126 display mode list. seed, #125 ISO filename, #126 display mode list.

View File

@@ -137,6 +137,11 @@ iteration would otherwise rediscover.
without a hierarchy pass. without a hierarchy pass.
## Gotchas (cost a debugging session once) ## Gotchas (cost a debugging session once)
- **OVMF exposes /dev/fd0 as TYPE=disk (#112):** disk picker must exclude
`/dev/fd*` and tiny sizes; sort largest-first or blind Enter picks floppy.
- **Live ISO offline theme switch (#113):** only the *default/pinned* HM
generation is offline-safe; other presets may try to build the world —
document the contract and fail with a network-oriented message.
- **Installer HM pre-activate needs XDG_RUNTIME_DIR (+ session bus) (#123):** - **Installer HM pre-activate needs XDG_RUNTIME_DIR (+ session bus) (#123):**
`runuser … activate` inside `nixos-enter` has no user session. Without `runuser … activate` inside `nixos-enter` has no user session. Without
`mkdir -p /run/user/$UID` owned by the install user and `mkdir -p /run/user/$UID` owned by the install user and

View File

@@ -404,6 +404,11 @@ Design/decision records and a running log of shipped work (items marked
decision rather than a drive-by. Proved to fail: dropping snapshot makes it decision rather than a drive-by. Proved to fail: dropping snapshot makes it
name the missing entry. **V3 pending** — that the apps *launch* needs real name the missing entry. **V3 pending** — that the apps *launch* needs real
hardware (HARDWARE-QUEUE, Acer M5-481T). hardware (HARDWARE-QUEUE, Acer M5-481T).
- ✓ **Fix batch #112 / #106 / #113:** installer disk picker drops fd0/loop/
optical/<8GiB and lists largest-first (OVMF no longer defaults to floppy);
`checks.menu-back` guards every internal rofi list emits ↩ Back; offline
theme-switch promise scoped to the live-pinned (default) theme with a clear
error when a non-pinned preset needs network.
- ✓ **Install fix batch (#123#126):** (123) HM pre-activate creates - ✓ **Install fix batch (#123#126):** (123) HM pre-activate creates
`/run/user/$UID` + `dbus-run-session` so dconf no longer aborts the bake; `/run/user/$UID` + `dbus-run-session` so dconf no longer aborts the bake;
(124) main-built ISOs seed `?ref=main` and installer actually rewrites (124) main-built ISOs seed `?ref=main` and installer actually rewrites

View File

@@ -56,8 +56,11 @@ session may not start.
Hyprland via `initial_session`; logging out lands on tuigreet. Hyprland via `initial_session`; logging out lands on tuigreet.
- The flake is seeded writable at `~/.nomarchy` (from the read-only - The flake is seeded writable at `~/.nomarchy` (from the read-only
`/etc/nomarchy` copy) — `$NOMARCHY_PATH` already points there. `/etc/nomarchy` copy) — `$NOMARCHY_PATH` already points there.
- Locked flake inputs are pinned into the ISO store, so - Locked flake inputs are pinned into the ISO store, so a
`home-manager switch` works **without a network**. `home-manager switch` for the **already-active / default theme** works
**without a network**. Switching to an arbitrary other preset can still
need downloads (that preset's HM generation is not in the pin) — the
tool should fail with an offline-oriented message (#113).
## 3. Verification checklist ## 3. Verification checklist
@@ -70,7 +73,7 @@ Work through these in order; each one exercises a different layer.
| 3 | `SUPER+Return` opens Kitty with the default theme colors (Boreal) | terminal default, ANSI palette | | 3 | `SUPER+Return` opens Kitty with the default theme colors (Boreal) | terminal default, ANSI palette |
| 4 | `btop` in the terminal is themed | per-theme asset baking | | 4 | `btop` in the terminal is themed | per-theme asset baking |
| 5 | `nomarchy-theme-sync list` prints 24 presets | package, baked themes dir | | 5 | `nomarchy-theme-sync list` prints 24 presets | package, baked themes dir |
| 6 | `nomarchy-theme-sync apply gruvbox` → state written, `home-manager switch` runs, desktop re-themes, wallpaper changes | the whole engine: state write, pure eval, HM rebuild, wallpaper hook | | 6 | `nomarchy-theme-sync apply boreal` (or re-apply the **current** theme) offline → switch succeeds. Applying a *different* preset may need a network — the ISO pins the default theme's generation, not every preset (#113) | state write + offline rebuild contract |
| 7 | `SUPER+SHIFT+T` cycles wallpapers instantly (try `tokyo-night` for 4, or `boreal` for its set) | the runtime wallpaper path | | 7 | `SUPER+SHIFT+T` cycles wallpapers instantly (try `tokyo-night` for 4, or `boreal` for its set) | the runtime wallpaper path |
| 8 | `nomarchy-theme-sync apply summer-night` → after the switch the bar has its own identity (light bar, different styling) | whole-swap waybar.css assets | | 8 | `nomarchy-theme-sync apply summer-night` → after the switch the bar has its own identity (light bar, different styling) | whole-swap waybar.css assets |
| 9 | Open a GTK app — dark theme matching the palette | Stylix layer | | 9 | Open a GTK app — dark theme matching the palette | Stylix layer |

View File

@@ -456,7 +456,8 @@
# Installer safety guards (item 54): offline compose-lock fail-closed # Installer safety guards (item 54): offline compose-lock fail-closed
# (no network flake lock), disk-signature warn regex, pre-activate # (no network flake lock), disk-signature warn regex, pre-activate
# recovery hint path, password floors. Pure source+regex contract. # recovery hint path, password floors, disk-picker filter (#112).
# Pure source+regex contract.
installer-safety = pkgs.runCommand "nomarchy-installer-safety" installer-safety = pkgs.runCommand "nomarchy-installer-safety"
{ nativeBuildInputs = [ pkgs.python3 ]; } { nativeBuildInputs = [ pkgs.python3 ]; }
'' ''
@@ -467,6 +468,15 @@
touch $out touch $out
''; '';
# Menu Back/Left contract (#106): every internal rofi_menu list
# emits ↩ Back (external modi / free-text keep Esc exceptions).
menu-back = pkgs.runCommand "nomarchy-menu-back"
{ nativeBuildInputs = [ pkgs.python3 ]; }
''
python3 ${./tools/check-menu-back.py} ${./modules/home/rofi.nix}
touch $out
'';
# Installer keyboard no-variant regression (item 91): gum's `(none)` # Installer keyboard no-variant regression (item 91): gum's `(none)`
# row is display-only. Exercise it through the real template # row is display-only. Exercise it through the real template
# patcher, then build the exact us + empty-variant console keymap # patcher, then build the exact us + empty-variant console keymap

View File

@@ -224,13 +224,43 @@ live_disk=""
live_src=$(findmnt -no SOURCE /iso 2>/dev/null || true) live_src=$(findmnt -no SOURCE /iso 2>/dev/null || true)
[[ -n "$live_src" ]] && live_disk=$(lsblk -no PKNAME "$live_src" 2>/dev/null || true) [[ -n "$live_src" ]] && live_disk=$(lsblk -no PKNAME "$live_src" 2>/dev/null || true)
mapfile -t disks < <(lsblk -dpno NAME,SIZE,MODEL,TYPE \ # Installable whole disks only (#112): drop floppy/pseudo/tiny devices that
| awk -v skip="/dev/${live_disk:-NONE}" \ # OVMF and some firmwares expose as TYPE=disk (e.g. /dev/fd0 first in the
'$NF == "disk" && $1 != skip && $1 !~ /loop|zram|sr[0-9]/ {NF--; print}') # list — a blind Enter would erase nothing useful and break the install).
[[ ${#disks[@]} -gt 0 ]] || fail "No installable disks found." # Bytes via lsblk -b; 8 GiB floor catches fd0/USB crumbs; REQUIREMENTS still
# recommend ≥40 GiB for a real install.
# NOMARCHY_MIN_DISK_BYTES overrides the floor (tests / expert installs).
MIN_DISK_BYTES="${NOMARCHY_MIN_DISK_BYTES:-$((8 * 1024 * 1024 * 1024))}"
list_installable_disks() {
local skip="/dev/${live_disk:-NONE}"
local name size type model hsize
# NAME SIZE TYPE in bytes (-b); MODEL looked up separately (may contain spaces).
{
while read -r name size type; do
[[ "$type" == "disk" ]] || continue
[[ "$name" == "$skip" ]] && continue
case "$name" in
/dev/loop*|/dev/zram*|/dev/sr*|/dev/fd*|/dev/ram*|/dev/nbd*|/dev/md*)
continue ;;
esac
[[ "$size" =~ ^[0-9]+$ ]] || continue
(( size >= MIN_DISK_BYTES )) || continue
hsize=$(lsblk -dno SIZE "$name" 2>/dev/null || echo "?")
model=$(lsblk -dno MODEL "$name" 2>/dev/null | tr -s '[:space:]' ' ' | sed 's/^ //;s/ $//')
printf '%s %s %s\n' "$name" "$hsize" "${model:-}"
done < <(lsblk -dpbno NAME,SIZE,TYPE 2>/dev/null)
} | sort -k2 -h -r # largest first — never default to a tiny leftover
}
mapfile -t disks < <(list_installable_disks)
[[ ${#disks[@]} -gt 0 ]] || fail "No installable disks found (need a whole disk ≥ $(( MIN_DISK_BYTES / 1024 / 1024 / 1024 )) GiB; floppy/loop/optical excluded)."
if [[ "$UNATTENDED" == "1" ]]; then if [[ "$UNATTENDED" == "1" ]]; then
TARGET_DISK="${NOMARCHY_DISK:?NOMARCHY_DISK required in unattended mode}" TARGET_DISK="${NOMARCHY_DISK:?NOMARCHY_DISK required in unattended mode}"
# Unattended still rejects non-installable targets (CI footgun).
if ! printf '%s\n' "${disks[@]}" | grep -q "^${TARGET_DISK} "; then
fail "$TARGET_DISK is not an installable disk (missing, live medium, or below size floor)."
fi
else else
choice=$(printf '%s\n' "${disks[@]}" \ choice=$(printf '%s\n' "${disks[@]}" \
| gum choose --header "Install Nomarchy on which disk? (EVERYTHING on it will be erased)") | gum choose --header "Install Nomarchy on which disk? (EVERYTHING on it will be erased)")

View File

@@ -370,9 +370,23 @@ def run_switch() -> None:
"Rebuild FAILED — scroll up for last lines; run nomarchy-doctor", "Rebuild FAILED — scroll up for last lines; run nomarchy-doctor",
urgency="critical", urgency="critical",
) )
# Live ISO pins only the *default* theme's HM generation. Applying
# another preset offline tries to build the world from source (#113).
offline_hint = ""
try:
import socket
socket.create_connection(("1.1.1.1", 53), timeout=1.5).close()
except OSError:
offline_hint = (
" Offline? Only the live ISO's already-pinned theme is "
"guaranteed without a network — other themes need builds "
"or a binary cache. Connect and retry, or apply the default "
"theme (boreal)."
)
die( die(
"rebuild failed (state already written; fix and re-run). " "rebuild failed (state already written; fix and re-run). "
"Diagnose: nomarchy-doctor. Recovery: docs/RECOVERY.md" "Diagnose: nomarchy-doctor. Recovery: docs/RECOVERY.md."
+ offline_hint
) )
notify("Changes applied ✓") notify("Changes applied ✓")
# Waybar runs from Hyprland's exec-once (not a systemd unit HM would # Waybar runs from Hyprland's exec-once (not a systemd unit HM would

View File

@@ -72,6 +72,25 @@ check(
"missing online-only compose-lock fallback warn", "missing online-only compose-lock fallback warn",
) )
# ── 1b. Disk picker: exclude floppy/pseudo/tiny (#112) ─────────────────
check(
"list_installable_disks" in script,
"missing list_installable_disks helper (#112)",
)
check(
"/dev/fd*" in script or r"/dev/fd*" in script,
"disk filter must exclude /dev/fd* (floppy — OVMF lists fd0 first)",
)
check(
"MIN_DISK_BYTES" in script,
"disk filter must enforce a minimum size floor",
)
check(
"8 * 1024 * 1024 * 1024" in script or "8 * 1024**3" in script
or "$((8 * 1024 * 1024 * 1024))" in script,
"default min disk size should be 8 GiB (catches fd0 and crumbs)",
)
# ── 2. Disk signature warn ────────────────────────────────────────────── # ── 2. Disk signature warn ──────────────────────────────────────────────
sig_re = re.search( sig_re = re.search(
r"grep -qiE '([^']+)'\s*<<<\s*\"\$existing_sig\"", r"grep -qiE '([^']+)'\s*<<<\s*\"\$existing_sig\"",

94
tools/check-menu-back.py Normal file
View File

@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Guard the nomarchy-menu Back/Left contract (BACKLOG #106).
Every *internal* list picker built with `rofi_menu` must emit the shared
`↩ Back` row (via `back`, `printf … "$BACK"`, or a literal). External
modi/tools and free-text prompts are listed as exceptions — Esc is their
back path.
Usage: check-menu-back.py <modules/home/rofi.nix>
Pure — no rofi runtime. Fails on a list menu that can strand the user.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
if len(sys.argv) != 2:
sys.exit(f"usage: {sys.argv[0]} <rofi.nix>")
text = Path(sys.argv[1]).read_text(encoding="utf-8")
# Strip Nix multi-line comments lightly? Keep simple — work on the whole file.
# Find `| rofi_menu` call sites (possibly with line breaks before `|`).
# Look at a window of lines before each call for BACK emission.
lines = text.splitlines()
# Exception markers: if the window mentions these, allow no BACK row.
EXCEPTION_MARKERS = (
"External tool:",
"external modi",
"Esc closes",
"Esc is their back",
"free-text",
"Free-text",
"no list to",
"rofi_menu -p search", # web: empty stdin free-text
"rofi_menu -p ask",
# calc/emoji/network are exec-only without rofi_menu list construction
)
def window_has_back(chunk: str) -> bool:
# Shared helpers emit ↩ Back: bare `back`, printf of $BACK, or a literal.
if re.search(r"(?m)^\s*back\s*$", chunk):
return True
if re.search(r"\bback\b", chunk) and "back()" not in chunk.split("rofi_menu")[0][-20:]:
# `back` as a command (not only the word in comments). Prefer line-start.
if re.search(r"(?m)^\s*back\b", chunk):
return True
if "$BACK" in chunk or r"\$BACK" in chunk or "↩ Back" in chunk:
return True
if re.search(r'printf[^\n]*BACK', chunk):
return True
return False
def window_is_exception(chunk: str) -> bool:
return any(m in chunk for m in EXCEPTION_MARKERS)
# Match lines that invoke rofi_menu as a picker (not the function definition).
invoke_re = re.compile(r"\brofi_menu\b")
bad: list[str] = []
for i, line in enumerate(lines):
if "rofi_menu()" in line or "rofi_menu ()" in line:
continue
if not invoke_re.search(line):
continue
# Skip comments and the definition of the helper's docs.
stripped = line.lstrip()
if stripped.startswith("#"):
continue
# Window: up to 40 lines before this call (menu construction).
start = max(0, i - 40)
chunk = "\n".join(lines[start : i + 1])
if window_is_exception(chunk):
continue
# Free-text: empty stdin piped in
if re.search(r"rofi_menu[^\n]*<\s*/dev/null", chunk) or re.search(
r"rofi_menu[^\n]*\n[^\n]*<\s*/dev/null", chunk
):
continue
if not window_has_back(chunk):
bad.append(f"line {i + 1}: rofi_menu without ↩ Back in preceding construction:\n {line.strip()}")
if bad:
print("check-menu-back: FAIL — internal list menus must end with ↩ Back:\n", file=sys.stderr)
for b in bad:
print(b, file=sys.stderr)
sys.exit(1)
print(f"check-menu-back: ok — {sum(1 for l in lines if invoke_re.search(l) and 'rofi_menu()' not in l)} rofi_menu sites scanned")