On swap-back Hyprland brings DP-1 up normal, binds a workspace to it, then apply mirrors DP-1 — trapping that workspace on a layout-excluded mirror (unreachable; symptom: 'can't make workspace 1 show'). apply now moves any workspace off a mirrored output onto the headless primary. Verified the heal operation (move-off-mirror) manually. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
189 lines
7.7 KiB
Bash
Executable File
189 lines
7.7 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# omarchy-moonlight — HEADLESS-PRIMARY manager.
|
|
#
|
|
# Model: the desktop permanently lives on a virtual HEADLESS output that always
|
|
# exists, independent of the physical monitor. Sunshine captures it. The
|
|
# physical DP-1 MIRRORS the headless primary when it's present, so the at-desk
|
|
# view == the stream. When DP-1 is off (remote, monitor asleep/unplugged),
|
|
# nothing is orphaned — the headless output keeps the whole desktop and the
|
|
# stream keeps working.
|
|
#
|
|
# Why this replaces the old "HEADLESS mirrors DP-1" design: when the physical
|
|
# monitor fully powers off, DP-1 disappears from Hyprland, the mirror collapses,
|
|
# and every workspace bound to DP-1 is stranded off-screen. Inverting the mirror
|
|
# (headless is the source of truth) removes that failure mode entirely.
|
|
#
|
|
# SAFETY: DP-1 is a NORMAL monitor in monitors.conf. This script only ever ADDS
|
|
# a mirror on top; if it never runs, DP-1 still displays normally. The physical
|
|
# screen is never left blank by this machinery.
|
|
#
|
|
# Subcommands:
|
|
# apply (default) establish/repair state — idempotent (topology + rescues)
|
|
# rescue just pull off-screen floating windows back on-screen
|
|
# watch long-running: re-apply on monitor hotplug AND rescue windows that
|
|
# open off-screen (this is what makes stray windows self-heal)
|
|
|
|
set -uo pipefail
|
|
|
|
log() { printf '[headless-primary] %s\n' "$*" >&2; }
|
|
|
|
WIDTH=5120
|
|
HEIGHT=1440
|
|
RATE=60
|
|
POS="0x0"
|
|
CONF="$HOME/.config/sunshine/sunshine.conf"
|
|
|
|
ensure_hypr_sig() {
|
|
[[ -n "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]] && return 0
|
|
for sig in "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"/hypr/*/; do
|
|
[[ -d "$sig" ]] || continue
|
|
export HYPRLAND_INSTANCE_SIGNATURE="$(basename "$sig")"
|
|
return 0
|
|
done
|
|
return 1
|
|
}
|
|
|
|
have_tools() { command -v hyprctl >/dev/null && command -v jq >/dev/null; }
|
|
|
|
# NOTE: always `hyprctl monitors all` — a MIRRORED output is excluded from plain
|
|
# `hyprctl monitors`, so the plain form is blind to exactly the outputs we manage.
|
|
headless_names() {
|
|
hyprctl monitors all -j 2>/dev/null \
|
|
| jq -r '.[] | select(.name | startswith("HEADLESS")) | .name' | sort -V
|
|
}
|
|
|
|
# Recenter any FLOATING, mapped window whose rectangle does not intersect the
|
|
# monitor its workspace lives on (i.e. it's stranded off-screen after a monitor
|
|
# topology change, or it opened off-screen). Pass a single window address to
|
|
# check just that window (used on the openwindow event); no arg = sweep all.
|
|
rescue_offscreen_windows() {
|
|
local only="${1:-}" mons clients addrs a
|
|
have_tools || return 0
|
|
mons="$(hyprctl monitors all -j 2>/dev/null)" || return 0
|
|
clients="$(hyprctl clients -j 2>/dev/null)" || return 0
|
|
addrs="$(printf '%s' "$clients" | jq -r --argjson mons "$mons" --arg only "$only" '
|
|
($mons | map({key:(.id|tostring), value:{x:.x,y:.y,w:.width,h:.height}}) | from_entries) as $M
|
|
| .[]
|
|
| select(.mapped == true and .floating == true)
|
|
| select($only == "" or .address == $only)
|
|
| . as $c | ($M[$c.monitor|tostring]) as $m
|
|
| select($m != null)
|
|
| select( ($c.at[0]+$c.size[0]) <= $m.x
|
|
or $c.at[0] >= ($m.x+$m.w)
|
|
or ($c.at[1]+$c.size[1]) <= $m.y
|
|
or $c.at[1] >= ($m.y+$m.h) )
|
|
| .address' 2>/dev/null)"
|
|
for a in $addrs; do
|
|
hyprctl dispatch focuswindow "address:$a" >/dev/null 2>&1
|
|
hyprctl dispatch centerwindow >/dev/null 2>&1
|
|
log "rescued off-screen window $a"
|
|
done
|
|
}
|
|
|
|
apply() {
|
|
ensure_hypr_sig || { log "Hyprland not running; skip."; return 0; }
|
|
have_tools || { log "hyprctl/jq missing."; return 0; }
|
|
|
|
# 1. Exactly one headless output. Create if none; disable extras (remove is
|
|
# unreliable for mirrored/persistent headless — it returns "output not
|
|
# found" and exits 0). Keep the lowest-numbered.
|
|
mapfile -t hs < <(headless_names)
|
|
local head="${hs[0]:-}"
|
|
if [[ -z "$head" ]]; then
|
|
log "no headless output; creating one"
|
|
hyprctl output create headless >/dev/null
|
|
for _ in 1 2 3 4 5; do
|
|
head="$(headless_names | head -1)"
|
|
[[ -n "$head" ]] && break
|
|
sleep 0.2
|
|
done
|
|
else
|
|
for extra in "${hs[@]:1}"; do
|
|
hyprctl keyword monitor "$extra,disable" >/dev/null 2>&1 || true
|
|
done
|
|
fi
|
|
[[ -z "$head" ]] && { log "failed to obtain a headless output"; return 0; }
|
|
|
|
# 2. Size/position the headless primary (top-left origin, full res, scale 1).
|
|
hyprctl keyword monitor "$head,${WIDTH}x${HEIGHT}@${RATE},${POS},1" >/dev/null
|
|
|
|
# 3. If DP-1 is present, mirror the headless primary onto it (at-desk == stream).
|
|
if hyprctl monitors all -j | jq -e '.[] | select(.name=="DP-1")' >/dev/null 2>&1; then
|
|
hyprctl keyword monitor "DP-1,${WIDTH}x${HEIGHT}@${RATE},${POS},1,mirror,$head" >/dev/null
|
|
log "DP-1 mirroring $head"
|
|
fi
|
|
|
|
# 4. Relocate any workspace that lives on a MIRRORED output onto the headless
|
|
# primary. A mirror is excluded from the layout, so a workspace bound to it
|
|
# is trapped and unreachable — exactly what happens on swap-back when
|
|
# Hyprland brings DP-1 up normal, binds a workspace to it, then we mirror
|
|
# it. (tostring guards mirrorOf being null | "None" | a numeric id.)
|
|
local mon ws
|
|
for mon in $(hyprctl monitors all -j \
|
|
| jq -r '.[] | select(((.mirrorOf // "None") | tostring | ascii_downcase) != "none") | .name'); do
|
|
[[ "$mon" == "$head" ]] && continue
|
|
for ws in $(hyprctl workspaces -j | jq -r --arg m "$mon" '.[] | select(.monitor==$m) | .id'); do
|
|
log "relocating workspace $ws off mirror $mon -> $head"
|
|
hyprctl dispatch moveworkspacetomonitor "$ws $head" >/dev/null 2>&1 || true
|
|
done
|
|
done
|
|
|
|
# 5. Rescue orphaned workspaces (monitorID == -1) onto the headless primary.
|
|
# Narrow by design — a healthy second monitor (DP-2) is left alone.
|
|
for ws in $(hyprctl workspaces -j | jq -r '.[] | select(.monitorID == -1) | .id'); do
|
|
log "rescuing orphaned workspace $ws -> $head"
|
|
hyprctl dispatch moveworkspacetomonitor "$ws $head" >/dev/null 2>&1 || true
|
|
done
|
|
|
|
# 6. Rescue any floating windows stranded off-screen by the topology change.
|
|
rescue_offscreen_windows
|
|
|
|
# 7. Keep Sunshine's output_name pointed at the live headless name.
|
|
if [[ -f "$CONF" ]] && grep -qF '# managed-by: omarchy-moonlight' "$CONF"; then
|
|
local cur; cur="$(awk '/^output_name = / {print $3; exit}' "$CONF" 2>/dev/null || true)"
|
|
if [[ "$cur" != "$head" ]]; then
|
|
log "sunshine.conf output_name: ${cur:-(unset)} -> $head"
|
|
sed -i "s|^output_name = .*|output_name = $head|" "$CONF" 2>/dev/null || true
|
|
fi
|
|
fi
|
|
|
|
log "headless-primary established on $head"
|
|
}
|
|
|
|
watch() {
|
|
ensure_hypr_sig || { log "Hyprland not running; watcher exiting."; return 0; }
|
|
have_tools || { log "hyprctl/jq missing; watcher exiting."; return 0; }
|
|
command -v socat >/dev/null || { log "socat missing; no watcher."; return 0; }
|
|
local sock="${XDG_RUNTIME_DIR}/hypr/${HYPRLAND_INSTANCE_SIGNATURE}/.socket2.sock"
|
|
log "watching Hyprland events on $sock"
|
|
|
|
# monitoradded/removed -> full re-apply (re-impose mirror + rescue everything)
|
|
# openwindow -> targeted rescue if the NEW window opened off-screen
|
|
socat -U - "UNIX-CONNECT:$sock" 2>/dev/null | {
|
|
local last=0 now addr
|
|
while read -r event; do
|
|
case "$event" in
|
|
monitoradded*|monitorremoved*)
|
|
now=$(date +%s)
|
|
(( now - last < 2 )) && continue # debounce event bursts (v1+v2)
|
|
last=$now
|
|
log "monitor event (${event%%>*}) -> apply"
|
|
apply
|
|
;;
|
|
openwindow*)
|
|
addr="0x${event#openwindow>>}"; addr="${addr%%,*}"
|
|
sleep 0.3 # let the new window's geometry settle
|
|
rescue_offscreen_windows "$addr"
|
|
;;
|
|
esac
|
|
done
|
|
}
|
|
}
|
|
|
|
case "${1:-apply}" in
|
|
apply) apply ;;
|
|
rescue) rescue_offscreen_windows "${2:-}" ;;
|
|
watch) watch ;;
|
|
*) echo "Usage: $(basename "$0") {apply|rescue|watch}" >&2; exit 1 ;;
|
|
esac
|