# Moonarch Reproducible Arch Linux setup based on archinstall + post-install automation. ## Project Structure - `config/` — archinstall configuration (incl. custom-commands that clone the repo to /opt/moonarch, root-owned) - `scripts/` — post-install and helper scripts - `packages/` — package lists (official + AUR), maintained separately - `defaults/` — XDG configs, shell config, helper binaries, systemd services, udev rules, greetd/moongreet config, wallpaper ## Battery Conservation Mode Laptops with `charge_control_end_threshold` support (ThinkPad, Framework, etc.) get a Waybar toggle: - Clicking the battery module toggles the charge limit between 80% and 100% - When conservation is active, a ♥ icon appears next to the battery indicator - State is persisted in `/var/lib/moonarch/batsaver-threshold` and restored on boot via a systemd service (`moonarch-batsaver-restore`) - Toggle flow: `moonarch-batsaver-toggle` (user script) reads sysfs, decides 80↔100, calls `pkexec /usr/bin/moonarch-batsaver-apply $NEW` for the privileged sysfs+state write step. Standard pkexec prompt (password once per session cache) - On desktops without battery support the feature is hidden entirely ## Nightlight (Blue Light Filter) Waybar toggle for wlsunset (Wayland-native blue light filter), persistent state via systemd: - `wlsunset.service` (systemd user service) with `After=kanshi.service` — starts only once all outputs are configured - **Default OFF** — fresh installs start without the filter. The PKGBUILD deliberately creates NO symlink for `wlsunset` in `/etc/systemd/user/graphical-session.target.wants/`, and post-install.sh does not enable the service. - Clicking the nightlight module in `group/brightness` toggles wlsunset on/off (`enable --now` / `disable --now`) - State survives reboots (user-scope symlink in `~/.config/systemd/user/...wants/`) - Active state shows 󰌵 in Catppuccin Yellow, inactive 󰌶 in the default text color - Signal SIGRTMIN+11 for immediate Waybar refresh — the `ExecStartPost` pkill has a leading `-` (`ExecStartPost=-/usr/bin/pkill …`) so a missing Waybar (e.g. a quickshell-only session) doesn't fail the unit. Without it, pkill's exit 1 trips `Restart=on-failure` and wlsunset loops, flickering the screen. - Scripts: `moonarch-nightlight` (toggle), `moonarch-waybar-nightlight` (status JSON) - **Important**: Never create a global-scope symlink at `/etc/systemd/user/...wants/wlsunset.service` — it overrides any user `disable` and makes the filter effectively impossible to turn off. ## Waybar Config Merger (moonarch-waybar) Waybar is started via `moonarch-waybar` (not directly). The wrapper merges an optional user config (`~/.config/waybar/userconfig`) with the system config (`/etc/xdg/waybar/config`): - `prepend`/`append` keys in the userconfig extend the `modules-left`/`modules-center`/`modules-right` arrays - All other top-level keys are inserted as module definitions via object merge - The merge runs only on changes (timestamp comparison) - On error: `notify-send` + `logger`, Waybar starts with the system config - Generates `~/.config/waybar/style.css` with an `@import` of the system styles if not present - Requires `jq` (declared as a dependency in the PKGBUILD) - The system config must be valid JSON (no JSONC) ## Quickshell Bar quickshell is the **default bar and notification daemon**, spawned by niri as `quickshell -c moonarch` (`defaults/xdg/niri/config.kdl`). It is shipped as a **named config** at `/etc/xdg/quickshell/moonarch/` — the officially recommended way to distribute a quickshell config as a distro package (`$XDG_CONFIG_DIRS/quickshell/`). Both Waybar and swaync stay fully installed as **reserves** (no hard deletes) but are no longer autostarted: Waybar toggles via **Alt+W** (`pkill quickshell` ⇄ `moonarch-waybar`, restart is `quickshell -c moonarch`); swaync's `spawn-at-startup` is commented out so it releases `org.freedesktop.Notifications` for quickshell's own `NotificationServer`. To switch back: re-enable swaync's spawn and drop quickshell's `NotifToastHost` from `shell.qml`. **Customization = composition, not a toggle layer.** quickshell has no native settings/toggle/override/merge concept ([verified](https://quickshell.org/docs/configuration/intro/)) — a config is composable QML. moonarch ships the widgets as an importable component library; the composition lives in `shell.qml`. - **Customize** — copy `/etc/xdg/quickshell/moonarch/shell.qml` to `~/.config/quickshell/moonarch/shell.qml` and edit it: add/remove/reorder widgets. The user copy **wins by XDG precedence** (`$XDG_CONFIG_HOME` is searched before `$XDG_CONFIG_DIRS`; verified in quickshell `src/launch/command.cpp` — `configBaseDirs()` prepends config-home, first `shell.qml` found wins). System component updates still flow via the import; structural updates to your own `shell.qml` do not. - **`Bar.qml`** is a generic container exposing three composition slots — `leftContent` / `centerContent` / `rightContent` (assign a list of widgets; they reparent into the cluster) — plus a `popouts` alias so composed widgets bind `popouts: bar.popouts`. `shell.qml` fills the slots; that is the whole composition: ```qml import QtQuick import QtQuick.Layouts import Quickshell // ShellRoot, Variants, Quickshell.screens import "file:///etc/xdg/quickshell/moonarch" // Theme + Bar/ModuleBox/… via the qmldir ShellRoot { Variants { model: Quickshell.screens Bar { id: bar rightContent: [ ModuleBox { Layout.alignment: Qt.AlignVCenter; Clock {} } ] } } } ``` `ModuleBox` wraps its content in a `Row` (and self-hides when its content collapses to zero width). Do **not** anchor a direct child with `anchors.centerIn: parent` — a `Row` rejects horizontal child anchors with a runtime warning (`QML Row: Cannot specify … centerIn anchors for items inside Row`) and ignores them. There is no binding loop (the `Row` sizes from children's implicit sizes, not `childrenRect`), but the centering silently does nothing. Center vertically with `anchors.verticalCenter: parent.verticalCenter` instead. - **qmldir** exposes the singletons/components for the cross-dir `import`. It is **generated** from the `.qml` files; regenerate when adding components: ```sh cd defaults/xdg/quickshell/moonarch && for f in *.qml; do [ "$f" = shell.qml ] && continue; n=${f%.qml}; grep -q 'pragma Singleton' "$f" && echo "singleton $n $f" || echo "$n $f"; done | sort > qmldir ``` - **Own popouts:** a composed widget carries its own popout as a `Component` and renders it in the shared morphing host. Pattern (see any indicator, e.g. `AudioIndicator.qml`): an inline `Component { AudioPopout { popouts: root.popouts } }`; the widget declares a `popoutName` and registers on completion (`popouts.register(popoutName, popoutComponent, root, payloadFn)`), then its click handler calls `popouts.toggleByName(popoutName)`. The widget file needs `pragma ComponentBehavior: Bound` so the popout resolves `root` when the shared host instantiates it. The low-level `popouts.open(component, rect, payload)` still exists for non-registered popouts (Tray). - **Popouts by keybind:** registered popouts are addressable by name through the `PopoutRouter` singleton, which exposes an `IpcHandler { target: "popout" }` with `toggle`/`open`/`close`. A niri keybind fires `qs -c moonarch ipc call popout toggle ` (e.g. `Mod+A` → `audio`, in `defaults/xdg/niri/config.kdl`). The router routes to the focused monitor's `PopoutState` via `Niri.activeOutput` (workspace `is_focused` → `output`, matched against `screen.name`); an open popout grabs the keyboard (Escape closes). Names: `audio backlight network bluetooth nightlight cpugov battery notifications media`. Tray is not keybind-addressable. - **Keyboard navigation:** an open popout grabs the keyboard, so it is fully operable without the mouse. Controls (`Toggle`, `ListRow`, `VolumeSlider`, `IconButton`) set `activeFocusOnTab` and show a focus ring; `PopoutPanel` focuses the first control on open and maps ↑/↓ onto Qt's native Tab chain (`nextItemInFocusChain` + an `activeFocusOnTab` filter). Keys: Tab/↑/↓ move focus, ←/→ nudge a slider ±5%, Space/Enter activate, Esc closes. Icon actions use the shared `IconButton` (destructive ones set `activeColor: Theme.red`); Rectangle-shaped buttons (clear-all, notification actions) are made focusable inline. Rationale + spike results: `DECISIONS.md` (2026-07-05 pilot + user-extension, 2026-07-06 composition + composable popouts, 2026-07-08 keybind registry + IPC router, keyboard-navigable controls). ## mpv + ModernZ OSC The video player is `mpv` with [ModernZ](https://github.com/Samillion/ModernZ) as the OSC, thumbnails via thumbfast: - `mpv-modernz-git` provides `modernz.lua` + font + default config to `/etc/mpv/` - `mpv-thumbfast-git` provides `thumbfast.lua` to `/etc/mpv/scripts/` (auto-detected by ModernZ) - `defaults/etc/mpv/mpv.conf` is installed directly to `/etc/mpv/mpv.conf` by moonarch-git (owned) - Stock OSC + title bar disabled, `autofit-larger=80%x80%` caps oversized windows - ModernZ overrides via `script-opts-append` in mpv.conf: orange accent → Catppuccin Lavender (`#b4befe`), OSC scale 0.75, `window_title_font_size=18`, `ontop_button=no` - **Important**: mpv treats `#` as a mid-line comment; hex colors must be quoted: `script-opts-append="modernz-seekbarfg_color=#b4befe"` (not `\#`, which only escapes and swallows the rest) - Niri opens mpv floating (`window-rule` in `defaults/xdg/niri/config.kdl`) ## System Health Check (moonarch-doctor / moondoc) Diagnostic script that checks the system state against moonarch defaults: - Packages (official.txt + aur.txt installed? Orphans?) - System services (NetworkManager, bluetooth, greetd, ufw, auto-cpufreq, etc.) - User services (kanshi, wlsunset, stasis, walker, nautilus, cliphist-text, cliphist-image) - Config files (SHA256 comparison deployed vs. moonarch default) - Helper scripts + symlinks (moonup, moondoc) - System config (UFW, pacman/paru repos, default shell) - Directories + permissions ## Fontconfig Defaults System-wide generic-family defaults via `defaults/etc/fonts/conf.d/65-moonarch-fonts.conf` (owned by moonarch-git): - `sans-serif` → UbuntuSans Nerd Font, `monospace` → UbuntuSansMono Nerd Font - Number **65**: loads after `60-latin.conf`, so the moonarch prefs beat the stock default (Noto/DejaVu). `local.conf` (at 51 via `51-local.conf`) loads too early and is overridden by 60-latin — therefore **do not** use it (it is also reserved for local user overrides). - Aliases need `binding="strong"` — a weak `` (fontconfig default) ranks behind the effective generic fallback and does not take effect. - Only applies to apps that use generic families (e.g. Firefox web fallback). moonarch apps (Waybar, foot, GTK, walker, swaync) set the font explicitly. ## Browser Idle-Inhibit (xdg-desktop-portal) So that windowed browser video (Firefox/Waterfox) keeps the screen awake, via `defaults/etc/xdg-desktop-portal/niri-portals.conf` (owned by moonarch-git, higher priority than niri's `/usr/share/xdg-desktop-portal/niri-portals.conf`): - `xdg-desktop-portal-gtk` reports the `Inhibit` interface as success even though nobody implements it under Niri → Firefox believes the idle-inhibit went through the portal and does not use the native Wayland `idle-inhibit`. Result: no inhibitor, screen sleeps. - Fix: `org.freedesktop.impl.portal.Inhibit=none` → Firefox falls back to `zwp_idle_inhibit`, which Niri honors. The remaining `[preferred]` lines are taken 1:1 from niri's default (portals.conf is not merged — the highest-priority file applies in full). - stasis is uninvolved here: `monitor_media` (pactl) does not capture browser audio by design (non-browser players only); browsers go through the inhibit path. - **Activation:** restart xdg-desktop-portal + the browser — Firefox queries portal support at startup. ### Windowed / Muted Video: wayland-pipewire-idle-inhibit The portal fix only applies to **fullscreen** video — Firefox/Waterfox send the idle-inhibit only in fullscreen. Windowed video remains unprotected: stasis ignores browser audio (pactl, browser-excluded, no MPRIS backend), Niri gets no inhibitor, the screen sleeps mid-video. Solution: `wayland-pipewire-idle-inhibit` (AUR, systemd user service; in `packages/aur.txt` + `post-install.sh` USER_SERVICES + moonarch-doctor). Holds a Wayland `zwp_idle_inhibit` inhibitor while PipeWire outputs audio (default threshold 5s). Niri then suppresses idle → stasis stays awake, daemon-independent. Releases automatically when audio stops. - Catches windowed video **with sound**. Misses muted video (no audio signal) — edge case, accepted. - **Never** put `waterfox`/a browser in stasis `inhibit_apps`: that inhibits unconditionally as long as the browser process runs → the system never idles. This exact wrong fix caused the "no idle" bug. ## Conventions - Package lists are plain text files, one package per line, comments with `#` - Shell scripts must be POSIX-compatible or explicitly bash/zsh - All paths in the archinstall config are relative to the install target