Watch
1
0
Fork
You've already forked souveraine
0
souveraine/surfaces/quickshell/deploy.sh
Fimeg b8e76c4e9e shell: own the right sidebar, move garden into the drawer
Garden was welded onto the notification list in CenterWidgetGroup —
always on, unrelated, stealing space from notifications that said
"Nothing". Moved it into the BottomWidgetGroup drawer as a fifth tab
alongside Calendar, To Do, Pomodoro, and Stopwatch.

CenterWidgetGroup is now just the notification list. Lens events
(note_created, synced) emit through notify-send so they land in the
notification system properly.

Forked calendar, todo, pomodoro, notifications out of ii-base into
our tree — the right sidebar no longer borrows any widget files from
ii. Toggle dialogs (bluetooth, wifi, nightLight) still symlinked as
infrastructure.
2026-08-21 15:42:37 -04:00

620 lines
35 KiB
Shell
Executable file

#!/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: "<repo-relative> <target-relative-to-~/.config/quickshell>"
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/WindowResize.qml souveraine/modules/souveraine/windowSheet/WindowResize.qml
modules/souveraine/windowSheet/PoseRamp.qml souveraine/modules/souveraine/windowSheet/PoseRamp.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/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/AgentPaneMenu.qml souveraine/modules/souveraine/agent/AgentPaneMenu.qml
modules/souveraine/agent/ConversationMenu.qml souveraine/modules/souveraine/agent/ConversationMenu.qml
modules/souveraine/agent/ItineraryRibbon.qml souveraine/modules/souveraine/agent/ItineraryRibbon.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/BottomWidgetGroup.qml souveraine/modules/ii/sidebarRight/BottomWidgetGroup.qml
modules/ii/sidebarRight/lens/LensWidget.qml souveraine/modules/ii/sidebarRight/lens/LensWidget.qml
modules/ii/sidebarRight/calendar/CalendarWidget.qml souveraine/modules/ii/sidebarRight/calendar/CalendarWidget.qml
modules/ii/sidebarRight/calendar/CalendarDayButton.qml souveraine/modules/ii/sidebarRight/calendar/CalendarDayButton.qml
modules/ii/sidebarRight/calendar/CalendarHeaderButton.qml souveraine/modules/ii/sidebarRight/calendar/CalendarHeaderButton.qml
modules/ii/sidebarRight/calendar/calendar_layout.js souveraine/modules/ii/sidebarRight/calendar/calendar_layout.js
modules/ii/sidebarRight/todo/TodoWidget.qml souveraine/modules/ii/sidebarRight/todo/TodoWidget.qml
modules/ii/sidebarRight/todo/TaskList.qml souveraine/modules/ii/sidebarRight/todo/TaskList.qml
modules/ii/sidebarRight/todo/TodoItemActionButton.qml souveraine/modules/ii/sidebarRight/todo/TodoItemActionButton.qml
modules/ii/sidebarRight/pomodoro/PomodoroWidget.qml souveraine/modules/ii/sidebarRight/pomodoro/PomodoroWidget.qml
modules/ii/sidebarRight/pomodoro/StopwatchWidget.qml souveraine/modules/ii/sidebarRight/pomodoro/StopwatchWidget.qml
modules/ii/sidebarRight/pomodoro/PomodoroTimer.qml souveraine/modules/ii/sidebarRight/pomodoro/PomodoroTimer.qml
modules/ii/sidebarRight/pomodoro/PomodoroSettings.qml souveraine/modules/ii/sidebarRight/pomodoro/PomodoroSettings.qml
modules/ii/sidebarRight/pomodoro/Stopwatch.qml souveraine/modules/ii/sidebarRight/pomodoro/Stopwatch.qml
modules/ii/sidebarRight/notifications/NotificationList.qml souveraine/modules/ii/sidebarRight/notifications/NotificationList.qml
modules/ii/sidebarRight/notifications/NotificationStatusButton.qml souveraine/modules/ii/sidebarRight/notifications/NotificationStatusButton.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/ii/sidebarRight/calendar/qmldir souveraine/modules/ii/sidebarRight/calendar/qmldir
modules/ii/sidebarRight/todo/qmldir souveraine/modules/ii/sidebarRight/todo/qmldir
modules/ii/sidebarRight/pomodoro/qmldir souveraine/modules/ii/sidebarRight/pomodoro/qmldir
modules/ii/sidebarRight/notifications/qmldir souveraine/modules/ii/sidebarRight/notifications/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')"