#!/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 rsync this surface to the phone and deploy there set -euo pipefail SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" QS="${HOME}/.config/quickshell" II="${QS}/ii" SV="${QS}/souveraine" TARGET_ARCH="${SOUVERAINE_TARGET_ARCH:-$(uname -m)}" # 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/Ai.qml souveraine/services/Ai.qml services/Cellular.qml souveraine/services/Cellular.qml 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/SessionEvents.qml souveraine/services/SessionEvents.qml services/SessiondBridge.qml souveraine/services/SessiondBridge.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/volumeMixer/VolumeDialogContent.qml souveraine/modules/ii/sidebarRight/volumeMixer/VolumeDialogContent.qml modules/ii/bar/UtilButtons.qml souveraine/modules/ii/bar/UtilButtons.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/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/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/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/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/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" rsync -a --delete -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 the mobile settings launcher 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" 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)" 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 echo "Run with: qs -c souveraine (restart: pkill -f 'qs -c souveraine')"