#!/usr/bin/env bash # Compose the Souveraine quickshell config (`qs -c souveraine`). # # Model: ~/.config/quickshell/souveraine is BUILT by this script — # - our files (this repo) are symlinked in, repo stays source of truth # - untouched upstream directories are borrowed as whole-dir symlinks # into the ii tree (so upstream updates flow through) # - directories where we override any file are composed file-by-file # The ii tree itself is DEPLOYED from this repo (ii-base/, the pinned base — # vendored 2026-07-21 after laptop/phone drifted ~900 files): every run # rsyncs ii-base -> ~/.config/quickshell/ii, so "borrowed from ii" means # borrowed from the same pin on every device. On the phone (aarch64) the # ii-phone/ overlay is applied on top — the declared home for phone-only # files (Cellular, mobile bar/OSK/wallpaper behavior). Never hand-edit # ~/.config/quickshell/ii; change ii-base/ (or ii-phone/) and redeploy. # # deploy.sh compose ~/.config/quickshell/souveraine # deploy.sh -u remove the souveraine config dir (ii untouched) # deploy.sh --legacy-clean remove the OLD overlay symlinks from ii and # restore its .upstream backups (one-time migration) # deploy.sh --phone copy this surface to the phone and deploy there. # NEVER deletes on the device: it snapshots the phone's # tree first, copies by checksum, and only *reports* # files that exist solely on the phone. set -euo pipefail SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SRC/../.." && pwd)" FACE_SRC="$REPO_ROOT/assets/face" QS="${HOME}/.config/quickshell" II="${QS}/ii" SV="${QS}/souveraine" TARGET_ARCH="${SOUVERAINE_TARGET_ARCH:-$(uname -m)}" # ── Which edits are live, and which are inert ──────────────────────────── # Our manifest entries are composed as SYMLINKS into this repo, so the repo IS # the running shell for every file listed below. quickshell reloads on write. # The consequence, and it is the thing to know before touching anything here: # # - Adding a NEW file + its MANIFEST line is INERT. The composed tree gains # nothing until the next deploy.sh run. Build whole modules this way. # - Editing a file ALREADY in the manifest is LIVE, immediately, in the # running shell — no deploy needed, no confirmation, no undo. # # So registration is the dangerous act, not creation. A QML file that fails to # compile takes its whole module with it (a QtObject root with no default # property failed all of qs.services once, 29ec9fe), and quickshell will not # restart a config that crashes within 10s of launch — the bar simply stays # gone. Restarting the shell also re-locks the session via PAM, so it costs # the human a password. # # Practice: write patches into surfaces/quickshell/patches/ and let the human # apply and reload on their own beat; edit in a detached git worktree, never in # this tree. TASK-72's AgentPaneHost makes this structural — a candidate pane # loads beside the active one and only swaps on its ready signal — after which # a bad edit leaves the working pane drawn instead of taking the surface down. # # Manifest: " " MANIFEST=" shell.qml souveraine/shell.qml GlobalStates.qml souveraine/GlobalStates.qml SettingsWindow.qml souveraine/SettingsWindow.qml panelFamilies/SouveraineFamily.qml souveraine/panelFamilies/SouveraineFamily.qml services/Souveraine.qml souveraine/services/Souveraine.qml services/AccessoryPresentation.qml souveraine/services/AccessoryPresentation.qml services/Ai.qml souveraine/services/Ai.qml services/Cellular.qml souveraine/services/Cellular.qml services/ChargeRate.qml souveraine/services/ChargeRate.qml services/Haptics.qml souveraine/services/Haptics.qml services/HidController.qml souveraine/services/HidController.qml services/Selection.qml souveraine/services/Selection.qml services/DeviceEvidence.qml souveraine/services/DeviceEvidence.qml services/UsbState.qml souveraine/services/UsbState.qml services/Gestures.qml souveraine/services/Gestures.qml modules/souveraine/dial/RadialDial.qml souveraine/modules/souveraine/dial/RadialDial.qml modules/souveraine/dial/DialHost.qml souveraine/modules/souveraine/dial/DialHost.qml modules/souveraine/dial/qmldir souveraine/modules/souveraine/dial/qmldir modules/souveraine/selection/SelectionHost.qml souveraine/modules/souveraine/selection/SelectionHost.qml modules/souveraine/selection/SelectionChip.qml souveraine/modules/souveraine/selection/SelectionChip.qml modules/souveraine/selection/SelectionActions.qml souveraine/modules/souveraine/selection/SelectionActions.qml modules/souveraine/selection/SelectionAction.qml souveraine/modules/souveraine/selection/SelectionAction.qml modules/souveraine/selection/SelectionSeparator.qml souveraine/modules/souveraine/selection/SelectionSeparator.qml modules/souveraine/selection/qmldir souveraine/modules/souveraine/selection/qmldir modules/souveraine/windowSheet/WindowSheet.qml souveraine/modules/souveraine/windowSheet/WindowSheet.qml modules/souveraine/windowSheet/SheetButton.qml souveraine/modules/souveraine/windowSheet/SheetButton.qml modules/souveraine/windowSheet/PowerMenu.qml souveraine/modules/souveraine/windowSheet/PowerMenu.qml modules/souveraine/windowSheet/PowerOptionRow.qml souveraine/modules/souveraine/windowSheet/PowerOptionRow.qml modules/souveraine/windowSheet/qmldir souveraine/modules/souveraine/windowSheet/qmldir modules/souveraine/airpods/AirPodsSurface.qml souveraine/modules/souveraine/airpods/AirPodsSurface.qml modules/souveraine/airpods/qmldir souveraine/modules/souveraine/airpods/qmldir services/Face.qml souveraine/services/Face.qml services/Lens.qml souveraine/services/Lens.qml services/FingerprintPreview.qml souveraine/services/FingerprintPreview.qml services/ViewtopControl.qml souveraine/services/ViewtopControl.qml services/ZoneTransition.qml souveraine/services/ZoneTransition.qml services/Hyprsunset.qml souveraine/services/Hyprsunset.qml modules/souveraine/subconscious/SubconsciousTicker.qml souveraine/modules/souveraine/subconscious/SubconsciousTicker.qml modules/souveraine/subconscious/SubconsciousEventPanel.qml souveraine/modules/souveraine/subconscious/SubconsciousEventPanel.qml modules/souveraine/subconscious/qmldir souveraine/modules/souveraine/subconscious/qmldir services/Network.qml souveraine/services/Network.qml services/TaskbarApps.qml souveraine/services/TaskbarApps.qml services/GlobalFocusGrab.qml souveraine/services/GlobalFocusGrab.qml services/Idle.qml souveraine/services/Idle.qml services/IdleCoordinator.qml souveraine/services/IdleCoordinator.qml services/LockContentPolicy.qml souveraine/services/LockContentPolicy.qml services/WallpaperAssets.qml souveraine/services/WallpaperAssets.qml services/WallpaperDownload.qml souveraine/services/WallpaperDownload.qml scripts/wallpaper/download_wallhaven.sh souveraine/scripts/wallpaper/download_wallhaven.sh services/ConflictKiller.qml souveraine/services/ConflictKiller.qml services/AgentSessions.qml souveraine/services/AgentSessions.qml scripts/agent/agent-sessions.sh souveraine/scripts/agent/agent-sessions.sh modules/souveraine/island/Island.qml souveraine/modules/souveraine/island/Island.qml modules/souveraine/island/IslandExpansion.qml souveraine/modules/souveraine/island/IslandExpansion.qml modules/souveraine/island/AgentSessionPanel.qml souveraine/modules/souveraine/island/AgentSessionPanel.qml modules/souveraine/island/qmldir souveraine/modules/souveraine/island/qmldir modules/souveraine/agent/ToolVocabulary.qml souveraine/modules/souveraine/agent/ToolVocabulary.qml modules/souveraine/agent/ToolCard.qml souveraine/modules/souveraine/agent/ToolCard.qml modules/souveraine/agent/ThinkingCard.qml souveraine/modules/souveraine/agent/ThinkingCard.qml modules/souveraine/agent/AgentMessage.qml souveraine/modules/souveraine/agent/AgentMessage.qml modules/souveraine/agent/qmldir souveraine/modules/souveraine/agent/qmldir services/SessionEvents.qml souveraine/services/SessionEvents.qml services/SessiondBridge.qml souveraine/services/SessiondBridge.qml services/SessiondPolicy.qml souveraine/services/SessiondPolicy.qml services/StepUpAuth.qml souveraine/services/StepUpAuth.qml services/SessionAudit.qml souveraine/services/SessionAudit.qml services/Speech.qml souveraine/services/Speech.qml services/NotifyEvents.qml souveraine/services/NotifyEvents.qml services/CrashReporter.qml souveraine/services/CrashReporter.qml modules/ii/sidebarLeft/SidebarLeft.qml souveraine/modules/ii/sidebarLeft/SidebarLeft.qml modules/ii/sidebarLeft/AiChat.qml souveraine/modules/ii/sidebarLeft/AiChat.qml modules/ii/sidebarRight/SidebarRight.qml souveraine/modules/ii/sidebarRight/SidebarRight.qml modules/ii/sidebarRight/SidebarRightContent.qml souveraine/modules/ii/sidebarRight/SidebarRightContent.qml modules/ii/sidebarRight/QuickSliders.qml souveraine/modules/ii/sidebarRight/QuickSliders.qml modules/ii/sidebarRight/CenterWidgetGroup.qml souveraine/modules/ii/sidebarRight/CenterWidgetGroup.qml modules/ii/sidebarRight/lens/LensWidget.qml souveraine/modules/ii/sidebarRight/lens/LensWidget.qml modules/ii/sidebarRight/volumeMixer/VolumeDialogContent.qml souveraine/modules/ii/sidebarRight/volumeMixer/VolumeDialogContent.qml modules/ii/bar/UtilButtons.qml souveraine/modules/ii/bar/UtilButtons.qml modules/ii/bar/BarContent.qml souveraine/modules/ii/bar/BarContent.qml modules/ii/bar/ClockWidget.qml souveraine/modules/ii/bar/ClockWidget.qml modules/ii/bar/Resources.qml souveraine/modules/ii/bar/Resources.qml modules/common/Config.qml souveraine/modules/common/Config.qml modules/common/ShellModel.qml souveraine/modules/common/ShellModel.qml modules/common/functions/Session.qml souveraine/modules/common/functions/Session.qml modules/common/widgets/ContentPage.qml souveraine/modules/common/widgets/ContentPage.qml modules/common/widgets/FullscreenPolkitWindow.qml souveraine/modules/common/widgets/FullscreenPolkitWindow.qml modules/common/widgets/FloatingActionButton.qml souveraine/modules/common/widgets/FloatingActionButton.qml modules/common/widgets/StyledToolTip.qml souveraine/modules/common/widgets/StyledToolTip.qml modules/settings/DeviceConfig.qml souveraine/modules/settings/DeviceConfig.qml modules/settings/NetworkConfig.qml souveraine/modules/settings/NetworkConfig.qml modules/settings/DisplayConfig.qml souveraine/modules/settings/DisplayConfig.qml modules/settings/SoundConfig.qml souveraine/modules/settings/SoundConfig.qml modules/settings/LockConfig.qml souveraine/modules/settings/LockConfig.qml modules/settings/WallpaperConfig.qml souveraine/modules/settings/WallpaperConfig.qml modules/settings/OverviewConfig.qml souveraine/modules/settings/OverviewConfig.qml modules/settings/DockConfig.qml souveraine/modules/settings/DockConfig.qml modules/settings/NavigationConfig.qml souveraine/modules/settings/NavigationConfig.qml modules/settings/KeyboardConfig.qml souveraine/modules/settings/KeyboardConfig.qml modules/settings/IdleConfig.qml souveraine/modules/settings/IdleConfig.qml modules/settings/SpeechConfig.qml souveraine/modules/settings/SpeechConfig.qml modules/ii/polkit/Polkit.qml souveraine/modules/ii/polkit/Polkit.qml modules/ii/polkit/PolkitContent.qml souveraine/modules/ii/polkit/PolkitContent.qml modules/ii/dock/Dock.qml souveraine/modules/ii/dock/Dock.qml modules/ii/dock/DockManifest.qml souveraine/modules/ii/dock/DockManifest.qml modules/ii/dock/DockApps.qml souveraine/modules/ii/dock/DockApps.qml modules/ii/dock/DockAppButton.qml souveraine/modules/ii/dock/DockAppButton.qml modules/ii/dock/DockButton.qml souveraine/modules/ii/dock/DockButton.qml modules/ii/dock/DockSeparator.qml souveraine/modules/ii/dock/DockSeparator.qml modules/ii/dock/DockStack.qml souveraine/modules/ii/dock/DockStack.qml modules/ii/appInventory/AppInventory.qml souveraine/modules/ii/appInventory/AppInventory.qml modules/ii/appInventory/AppInventoryScope.qml souveraine/modules/ii/appInventory/AppInventoryScope.qml modules/ii/overview/Overview.qml souveraine/modules/ii/overview/Overview.qml modules/ii/overview/AppGrid.qml souveraine/modules/ii/overview/AppGrid.qml modules/ii/background/widgets/clock/CookieClock.qml souveraine/modules/ii/background/widgets/clock/CookieClock.qml modules/ii/background/widgets/clock/CookieQuote.qml souveraine/modules/ii/background/widgets/clock/CookieQuote.qml modules/ii/screenCorners/ScreenCorners.qml souveraine/modules/ii/screenCorners/ScreenCorners.qml modules/ii/onScreenKeyboard/OnScreenKeyboard.qml souveraine/modules/ii/onScreenKeyboard/OnScreenKeyboard.qml modules/common/Persistent.qml souveraine/modules/common/Persistent.qml modules/common/panels/lock/LockScreen.qml souveraine/modules/common/panels/lock/LockScreen.qml modules/ii/lock/Lock.qml souveraine/modules/ii/lock/Lock.qml modules/ii/lock/TouchLockSurface.qml souveraine/modules/ii/lock/TouchLockSurface.qml modules/souveraine/lock/LockMediaCard.qml souveraine/modules/souveraine/lock/LockMediaCard.qml modules/souveraine/lock/LockAgentCard.qml souveraine/modules/souveraine/lock/LockAgentCard.qml modules/souveraine/lock/LockNotifyCard.qml souveraine/modules/souveraine/lock/LockNotifyCard.qml modules/souveraine/lock/LockSurfaceHost.qml souveraine/modules/souveraine/lock/LockSurfaceHost.qml modules/souveraine/lock/qmldir souveraine/modules/souveraine/lock/qmldir modules/souveraine/navigation/SystemGestureRail.qml souveraine/modules/souveraine/navigation/SystemGestureRail.qml modules/souveraine/navigation/WindowOverview.qml souveraine/modules/souveraine/navigation/WindowOverview.qml modules/souveraine/navigation/ZoneOverview.qml souveraine/modules/souveraine/navigation/ZoneOverview.qml modules/souveraine/navigation/qmldir souveraine/modules/souveraine/navigation/qmldir modules/souveraine/boot/BootBloom.qml souveraine/modules/souveraine/boot/BootBloom.qml modules/souveraine/boot/BootBloom.frag.qsb souveraine/modules/souveraine/boot/BootBloom.frag.qsb modules/souveraine/boot/souvie.png souveraine/modules/souveraine/boot/souvie.png modules/souveraine/boot/qmldir souveraine/modules/souveraine/boot/qmldir modules/ii/sessionScreen/SessionScreen.qml souveraine/modules/ii/sessionScreen/SessionScreen.qml qmldir souveraine/qmldir panelFamilies/qmldir souveraine/panelFamilies/qmldir services/qmldir souveraine/services/qmldir modules/common/qmldir souveraine/modules/common/qmldir modules/common/functions/qmldir souveraine/modules/common/functions/qmldir modules/common/panels/lock/qmldir souveraine/modules/common/panels/lock/qmldir modules/common/widgets/qmldir souveraine/modules/common/widgets/qmldir modules/ii/appInventory/qmldir souveraine/modules/ii/appInventory/qmldir modules/ii/bar/qmldir souveraine/modules/ii/bar/qmldir modules/ii/dock/qmldir souveraine/modules/ii/dock/qmldir modules/ii/lock/qmldir souveraine/modules/ii/lock/qmldir modules/ii/onScreenKeyboard/qmldir souveraine/modules/ii/onScreenKeyboard/qmldir modules/ii/overview/qmldir souveraine/modules/ii/overview/qmldir modules/ii/polkit/qmldir souveraine/modules/ii/polkit/qmldir modules/ii/screenCorners/qmldir souveraine/modules/ii/screenCorners/qmldir modules/ii/sessionScreen/qmldir souveraine/modules/ii/sessionScreen/qmldir modules/ii/sidebarLeft/qmldir souveraine/modules/ii/sidebarLeft/qmldir modules/ii/sidebarRight/qmldir souveraine/modules/ii/sidebarRight/qmldir modules/ii/sidebarRight/volumeMixer/qmldir souveraine/modules/ii/sidebarRight/volumeMixer/qmldir modules/settings/qmldir souveraine/modules/settings/qmldir " # wifi fallback: PHONE_HOST=casey@10.10.20.234 ./deploy.sh --phone PHONE_USB="${PHONE_HOST:-casey@172.16.42.1}" PHONE_DEST="souveraine-surfaces/quickshell" manifest_lines() { printf '%s\n' "$MANIFEST" | sed '/^[[:space:]]*$/d'; } # Compile the boot bloom shader to .qsb (Qt RHI) if the source is newer. The # .qsb is architecture-independent (SPIR-V + reflection), so we build it once # here and the manifest symlink / phone rsync carries it — no qsb on the phone. compile_shaders() { local frag="$SRC/modules/souveraine/boot/BootBloom.frag" local qsb_out="$SRC/modules/souveraine/boot/BootBloom.frag.qsb" [[ -f "$frag" ]] || return 0 if [[ ! -f "$qsb_out" || "$frag" -nt "$qsb_out" ]]; then local qsb qsb="$(command -v qsb || echo /usr/lib/qt6/bin/qsb)" if [[ ! -x "$qsb" ]]; then echo "ERROR: qsb not found (need qt6-shadertools) to build BootBloom.frag.qsb" >&2 exit 1 fi # --glsl 100es,120,150 + --hlsl/--msl targets so it runs on the Adreno # GLES backend the phone uses; -O optimizes. "$qsb" --glsl "100es,120,150" --hlsl 50 --msl 12 -O -o "$qsb_out" "$frag" echo "compiled BootBloom.frag -> BootBloom.frag.qsb" fi } compile_shaders if [[ "${1:-}" == "--manifest" ]]; then manifest_lines exit 0 fi if [[ "${1:-}" == "--phone" ]]; then ssh_i=(ssh -F /dev/null -i "$HOME/.ssh/ani" -o ConnectTimeout=5) maintenance_active=0 hypridle_was_active=0 phone_lock_state() { local state last_secure state="$("${ssh_i[@]}" "$PHONE_USB" \ "qs -c souveraine ipc --any-display call session state 2>/dev/null" || true)" if grep -Eq '"locked"[[:space:]]*:[[:space:]]*false' <<< "$state" \ && grep -Eq '"lockRequested"[[:space:]]*:[[:space:]]*false' <<< "$state"; then printf '%s\n' unlocked return fi if grep -Eq '"locked"[[:space:]]*:[[:space:]]*true|"lockRequested"[[:space:]]*:[[:space:]]*true' <<< "$state"; then printf '%s\n' locked return fi # Bootstrap for a phone that predates the session IPC handler. Every # compositor-acknowledged lock edge is journaled by LockScreen.qml. last_secure="$("${ssh_i[@]}" "$PHONE_USB" \ "journalctl --user -u souveraine-shell.service -b --no-pager | sed -n 's/.*\\[lock\\] session lock secure=\\(true\\|false\\).*/\\1/p' | tail -n 1" || true)" [[ "$last_secure" == "false" ]] && printf '%s\n' unlocked || printf '%s\n' locked } finish_phone_maintenance() { (( maintenance_active )) || return 0 if (( hypridle_was_active )); then "${ssh_i[@]}" "$PHONE_USB" \ "systemctl --user restart hypridle.service; systemctl --user stop souveraine-deploy-lease.timer 2>/dev/null || true; systemctl --user reset-failed souveraine-deploy-lease.service 2>/dev/null || true" \ >/dev/null || true fi maintenance_active=0 } if [[ "$(phone_lock_state)" != "unlocked" ]]; then echo "ERROR: phone session is locked; refusing a live shell deploy" >&2 exit 1 fi if "${ssh_i[@]}" "$PHONE_USB" "systemctl --user is-active --quiet hypridle.service"; then hypridle_was_active=1 # Schedule recovery before stopping idle handling. If this deploy or # its SSH connection dies, systemd restores hypridle within five # minutes. Manual lock remains available throughout the lease. "${ssh_i[@]}" "$PHONE_USB" \ "systemctl --user stop souveraine-deploy-lease.timer 2>/dev/null || true; systemd-run --user --unit=souveraine-deploy-lease --on-active=5min --timer-property=AccuracySec=1s --collect /usr/bin/systemctl --user restart hypridle.service >/dev/null; systemctl --user stop hypridle.service" fi maintenance_active=1 trap finish_phone_maintenance EXIT # Close the small check→inhibit race. A manual lock always wins. if [[ "$(phone_lock_state)" != "unlocked" ]]; then echo "ERROR: phone locked while acquiring the maintenance lease" >&2 exit 1 fi deployed_revision="unknown" if repo_root="$(git -C "$SRC" rev-parse --show-toplevel 2>/dev/null)"; then deployed_revision="$(git -C "$repo_root" rev-parse HEAD)" if ! git -C "$repo_root" diff --quiet -- "$SRC" \ || [[ -n "$(git -C "$repo_root" ls-files --others --exclude-standard -- "$SRC")" ]]; then deployed_revision="${deployed_revision}-dirty" fi fi "${ssh_i[@]}" "$PHONE_USB" "mkdir -p ~/$PHONE_DEST" # NEVER --delete into the phone's tree. # # `~/souveraine-surfaces` on the device is not a git repo — nothing there has # history — and files get edited directly on the phone. This path used to be # `rsync -a --delete` with no snapshot, so an on-device edit was destroyed # with no record that it had existed. It cost real work more than once, and # it is the root of both the ~900-file ii drift and the stevia pkgrels that # only ever existed on the phone. # # Note the asymmetry that made it survive: the LOCAL compose path below # snapshots to .ii-previous/.souveraine-previous before it switches # anything. Only the phone — the one machine that is a daily driver and has # no history — was handled without one. # # So: snapshot first, copy without deleting, and REPORT what a --delete # would have taken. Divergence becomes visible instead of being silently # resolved in the laptop's favour. PHONE_SNAPSHOT="souveraine-surfaces/.quickshell-previous" if ! "${ssh_i[@]}" "$PHONE_USB" \ "mkdir -p ~/$PHONE_SNAPSHOT && rsync -a --delete ~/$PHONE_DEST/ ~/$PHONE_SNAPSHOT/"; then echo "ERROR: could not snapshot the phone's surface tree — refusing to write to it" >&2 exit 1 fi echo "phone tree snapshotted to ~/$PHONE_SNAPSHOT" # What a --delete would have destroyed. Listed, never removed. phone_only="$(rsync -ain --delete -e "ssh -F /dev/null -i $HOME/.ssh/ani" \ "$SRC/" "$PHONE_USB:$PHONE_DEST/" 2>/dev/null | sed -n 's/^deleting //p')" if [[ -n "$phone_only" ]]; then echo "phone-only files LEFT IN PLACE (a --delete would have destroyed these):" >&2 printf '%s\n' "$phone_only" | sed 's/^/ /' >&2 echo " → if any of that is work you want, copy it back into the repo now." >&2 fi # The face host reads its page and rig from ~/.souveraine/face, outside # the composed QML tree. Stage that tracked limb before the QML switch so # a newly-added control cannot arrive without the page that implements it. # Preserve the prior phone copy first; this is the daily driver and the # destination has no git history of its own. [[ -f "$FACE_SRC/index.html" && -d "$FACE_SRC/live2d" ]] || { echo "ERROR: tracked face assets are incomplete at $FACE_SRC" >&2 exit 1 } "${ssh_i[@]}" "$PHONE_USB" \ "mkdir -p ~/.souveraine/.face-previous ~/.souveraine/face; rsync -a --delete ~/.souveraine/face/ ~/.souveraine/.face-previous/" rsync -ac -e "ssh -F /dev/null -i $HOME/.ssh/ani" \ "$FACE_SRC/" "$PHONE_USB:.souveraine/face/" # Checksum, not timestamp. Plain -a reports files that differ only in mtime # from earlier hand copies, which lies about what is actually changing. rsync -ac -e "ssh -F /dev/null -i $HOME/.ssh/ani" "$SRC/" "$PHONE_USB:$PHONE_DEST/" printf '%s\n' "$deployed_revision" \ | "${ssh_i[@]}" "$PHONE_USB" "cat > ~/$PHONE_DEST/DEPLOYED-REVISION" "${ssh_i[@]}" "$PHONE_USB" "bash ~/$PHONE_DEST/deploy.sh" # Phone-only: install mobile launchers into the app grid. "${ssh_i[@]}" "$PHONE_USB" "mkdir -p ~/.local/share/applications && ln -sf ~/$PHONE_DEST/souveraine-settings.desktop ~/.local/share/applications/souveraine-settings.desktop && ln -sf ~/$PHONE_DEST/ii-base/modules/common/workspace-selector.desktop ~/.local/share/applications/workspace-selector.desktop" finish_phone_maintenance trap - EXIT echo "Deployed to phone. Hyprland starts qs -c souveraine; no secondary shell is required." exit 0 fi if [[ "${1:-}" == "-u" || "${1:-}" == "--uninstall" ]]; then rm -rf "$SV" echo "removed $SV (ii tree untouched)" exit 0 fi if [[ "${1:-}" == "--legacy-clean" ]]; then # One-time migration: strip the old overlay out of ii. # 1. remove any symlink in ii that points into a souveraine checkout find "$II" -type l | while read -r l; do case "$(readlink "$l")" in *souveraine*) rm -f "$l"; echo "unlinked ${l#$QS/}" ;; esac done # 2. restore upstream backups find "$II" -name '*.upstream' | while read -r u; do mv -f "$u" "${u%.upstream}" echo "restored ${u%.upstream}" done exit 0 fi # --- Sync the pinned ii base -------------------------------------------- # Build both trees away from their live paths, then converge the stable live # directories with delayed renames. Quickshell's watcher follows the config # directory inode and does not tolerate replacing that root, even atomically. # The three-phase order keeps every symlink valid throughout the update: # add/update ii files, switch the composed surface, then delete stale ii files. mkdir -p "$QS" clear_generated_tree() { local path="$1" case "$path" in "$QS/.ii-previous"|"$QS/.souveraine-previous"|"$QS"/.ii-next.*|"$QS"/.souveraine-next.*) [[ ! -e "$path" && ! -L "$path" ]] || find "$path" -depth -delete ;; *) echo "refusing to clear unexpected rollback path: $path" >&2 return 1 ;; esac } snapshot_tree() { # live rollback local live="$1" rollback="$2" clear_generated_tree "$rollback" if [[ -e "$live" || -L "$live" ]]; then mkdir -p "$rollback" rsync -a --delete --exclude='.git' "$live/" "$rollback/" fi } II_NEXT="$QS/.ii-next.$$" SV_NEXT="$QS/.souveraine-next.$$" [[ ! -e "$II_NEXT" && ! -e "$SV_NEXT" ]] || { echo "staging path already exists; refusing: $II_NEXT or $SV_NEXT" >&2 exit 1 } cleanup_staging() { clear_generated_tree "$II_NEXT" clear_generated_tree "$SV_NEXT" } trap cleanup_staging EXIT mkdir -p "$II_NEXT" rsync -a --delete --exclude='.git' "$SRC/ii-base/" "$II_NEXT/" if [[ "$TARGET_ARCH" == "aarch64" ]]; then rsync -a "$SRC/ii-phone/" "$II_NEXT/" ii_description="pin + phone overlay" else ii_description="pin" fi install -m 0644 "$SRC/ii-base.pin" "$II_NEXT/.souveraine-upstream-pin" snapshot_tree "$II" "$QS/.ii-previous" mkdir -p "$II" # Phase 1: make every path the new composition can reference available, but # retain old extras until their Souveraine override/symlink has switched. rsync -a --delay-updates "$II_NEXT/" "$II/" # --- Compose ------------------------------------------------------------- # Targets under souveraine/, relative to $SV SV_TARGETS="$(manifest_lines | awk '$2 ~ /^souveraine\// {sub(/^souveraine\//, "", $2); print $2}')" is_replaced() { # exact file override grep -qxF "$1" <<< "$SV_TARGETS" } is_touched() { # dir contains an override somewhere below grep -q "^$1/" <<< "$SV_TARGETS" } compose_dir() { # $1 = path relative to ii root ("" for root) local rel="$1" entry name erel mkdir -p "$SV_NEXT${rel:+/$rel}" for entry in "$II_NEXT${rel:+/$rel}"/*; do [[ -e "$entry" ]] || continue name="$(basename "$entry")" [[ "$name" == *.upstream ]] && continue erel="${rel:+$rel/}$name" if [[ -d "$entry" ]]; then if is_touched "$erel"; then compose_dir "$erel" else ln -sfn "$II/$erel" "$SV_NEXT/$erel" fi else # root files and files inside touched dirs: link individually, # skipping ones our manifest replaces is_replaced "$erel" && continue ln -sf "$II/$erel" "$SV_NEXT/$erel" fi done } # Build from scratch at the staging path: cheap, with no stale links. compose_dir "" # Our files on top (this also creates dirs that exist only in our tree, # e.g. brand-new settings pages) mkdir -p "$QS/pill" while read -r rel target; do [[ -z "$rel" ]] && continue src="$SRC/$rel"; t="$SV_NEXT/${target#souveraine/}" [[ -f "$src" ]] || { echo "missing $src" >&2; exit 1; } mkdir -p "$(dirname "$t")" ln -sf "$src" "$t" done < <(manifest_lines) snapshot_tree "$SV" "$QS/.souveraine-previous" mkdir -p "$SV" # Phase 2: switch the composed tree using delayed per-entry renames while its # root directory remains stable for Quickshell's recursive watcher. rsync -a --delete-delay --delay-updates "$SV_NEXT/" "$SV/" # Phase 3: the composition no longer points at obsolete ii-only paths, so it # is now safe to remove them and finish exact convergence with the pin. rsync -a --delete-delay --delay-updates "$II_NEXT/" "$II/" clear_generated_tree "$II_NEXT" clear_generated_tree "$SV_NEXT" trap - EXIT echo "ii synced from $ii_description (rollback: $QS/.ii-previous)" n_ours=$(manifest_lines | grep -c .) n_borrowed_dirs=$(find "$SV" -maxdepth 3 -type l -xtype d | wc -l) echo "souveraine config composed at $SV ($n_ours files ours, $n_borrowed_dirs dirs borrowed from ii)" # Every qmldir entry must resolve to a file that exists in the composed tree. # # This is the failure that has bitten three times (ReloadPopup 2026-07-22, # SessiondPolicy 07-25, ChargeRate 07-26). A qmldir naming a file that is not # there does not fail that one type: Quickshell fails the whole MODULE, and # `qs.services` failing takes GlobalStates with it, which takes shell.qml. # The shell then refuses every reload and keeps serving the last scene that # loaded — silently, for days. On 2026-07-27 the laptop was found running a # 49-hour-old scene for exactly this reason. # # Loud, not fatal: the tree is already switched by this point, and the old # tree is no better. Say it clearly and exit non-zero so a caller notices. qmldir_broken=0 while IFS= read -r qd; do dir=$(dirname "$qd") # Field 2 of `singleton Name 1.0 File.qml` / `Name 1.0 File.qml` is the # version for singletons and the file for plain types; take the last # field, which is the filename in both shapes. while read -r file; do [[ -n "$file" ]] || continue [[ -e "$dir/$file" ]] && continue echo "BROKEN qmldir: $qd names '$file' which is not in the composed tree" >&2 qmldir_broken=1 done < <(awk '/\.qml[[:space:]]*$/ { print $NF }' "$qd") done < <(find "$SV" -name qmldir -type f -o -name qmldir -type l) if (( qmldir_broken )); then echo "REFUSING TO CALL THIS DEPLOYED: the shell will fail every reload and" >&2 echo "keep serving its last good scene. Add the missing file to the deploy" >&2 echo "manifest above, or remove its qmldir line." >&2 exit 1 fi # The phone's quick-toggle chooser is a WHOLE-FILE override of the base one, so # a choice added to the base never reaches the phone unless it is added there # too. That drift is silent by construction: the panel derives what it can build # from the chooser, so a missing choice is not an error, it is a toggle that # quietly stops existing on one device. Base must be a subset of phone. # # This is the same class as the qmldir check above and it has already cost a # session: five configured toggles rendered nothing for a day because a second # enumeration had drifted from this one. chooser_rel="modules/ii/sidebarRight/quickToggles/androidStyle/AndroidToggleDelegateChooser.qml" base_chooser="$SRC/ii-base/$chooser_rel" phone_chooser="$SRC/ii-phone/$chooser_rel" if [[ -f "$base_chooser" && -f "$phone_chooser" ]]; then role_values() { grep -o 'roleValue: "[A-Za-z]*"' "$1" | sed 's/.*"\(.*\)"/\1/' | sort -u; } missing_on_phone="$(comm -23 <(role_values "$base_chooser") <(role_values "$phone_chooser") | tr '\n' ' ')" if [[ -n "${missing_on_phone// /}" ]]; then echo "BROKEN toggle chooser overlay: ii-phone is missing base choices: $missing_on_phone" >&2 echo "The phone chooser overrides the base file wholesale — add the same" >&2 echo "DelegateChoice blocks to ii-phone/$chooser_rel or those toggles will" >&2 echo "silently not exist on the phone." >&2 exit 1 fi fi if grep -q 'qsConfig", "ii"' "$HOME/.config/hypr/hyprland/variables.lua" 2>/dev/null; then echo "NOTE: hyprland qsConfig is still 'ii' — flip variables.lua to 'souveraine' to switch." fi if [[ -x "$REPO_ROOT/tools/stage-face.sh" ]]; then "$REPO_ROOT/tools/stage-face.sh" >/dev/null elif [[ -f "$HOME/.souveraine/face/index.html" \ && -d "$HOME/.souveraine/face/live2d" ]]; then # `--phone` stages these assets before it invokes this copied compositor # script. That copy lives below ~/souveraine-surfaces rather than a full # checkout, so there is intentionally no repository-level helper beside # it. The old unconditional call failed after the QML tree had already # switched, making a successful remote composition report as a failure. : else echo "ERROR: face assets are not staged and stage-face.sh is unavailable" >&2 exit 1 fi echo "face assets staged at $HOME/.souveraine/face" echo "Run with: qs -c souveraine (restart: pkill -f 'qs -c souveraine')"