Sync config from arch
- hypr/apps.lua - hypr/autostart.lua - hypr/envs.lua - hypr/hyprland.lua - hypr/hyprsunset.conf - hypr/input.lua - hypr/looknfeel.lua - hypr/omasettings.lua - hypr/xdph.conf - omarchy/branding/about.txt - omarchy/branding/screensaver.txt - omarchy/extensions/omarchy-menu.jsonc - omarchy/hooks/battery-low.d/play-warning-sound.sample - omarchy/hooks/font-set.d/show-font-notification.sample - omarchy/hooks/post-boot.d/weather.sample - omarchy/hooks/post-update.d/install-voxtype.hook - omarchy/hooks/post-update.d/setup-agent.hook - omarchy/hooks/post-update.d/setup-fingerprint.hook - omarchy/hooks/post-update.d/show-update-notification.sample - omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample - omarchy/hooks/theme-set.d/show-theme-notification.sample - omarchy/shell.json - omarchy/shell.toml - omarchy/theme.name - omarchy/themes/azure-glow/README.md - omarchy/themes/azure-glow/alacritty.toml - omarchy/themes/azure-glow/btop.theme - omarchy/themes/azure-glow/hyprland.conf - omarchy/themes/azure-glow/hyprlock.conf - omarchy/themes/azure-glow/icons.theme - … 269 more
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
|
||||
// Nexthop's headless half: keeps nexthopd running.
|
||||
//
|
||||
// The daemon is a separate process on purpose — history has to survive shell
|
||||
// restarts (every theme change is one), and nothing that probes twice a
|
||||
// second belongs inside the process that draws the desktop. This service
|
||||
// spawns it at shell startup and respawns it with backoff if it dies. The
|
||||
// daemon holds a flock, so if a systemd --user unit already runs one, the
|
||||
// spawn here exits immediately and cleanly.
|
||||
Item {
|
||||
id: root
|
||||
|
||||
readonly property string pluginDir: {
|
||||
// A URL, not a path: percent-encoded, so decode before it is used as
|
||||
// a filesystem path or a space in the way becomes %20 and cd fails.
|
||||
var url = decodeURIComponent(Qt.resolvedUrl(".").toString())
|
||||
return url.replace(/^file:\/\//, "").replace(/\/$/, "")
|
||||
}
|
||||
|
||||
property int failures: 0
|
||||
|
||||
// ---- update handover -----------------------------------------------------
|
||||
//
|
||||
// `omarchy plugin update` fast-forwards the checkout and the shell
|
||||
// hot-reloads the QML, but a running daemon holds the flock and keeps
|
||||
// executing the old code until something restarts it. So the daemon
|
||||
// publishes its version in live.json, and this service compares it with
|
||||
// the manifest on disk: mismatch means the code under our feet changed —
|
||||
// retire the old daemon and let supervision (ours or systemd's) respawn
|
||||
// it fresh. A daemon too old to publish a version is treated as stale,
|
||||
// which is exactly right for the first update that ships this check.
|
||||
property string manifestVersion: ""
|
||||
property int lastRetiredPid: 0
|
||||
|
||||
// ---- liveness ------------------------------------------------------------
|
||||
//
|
||||
// A daemon that hangs keeps its flock and leaves its last live.json in
|
||||
// place, and until 0.2.24 nothing noticed: the stream emits only on
|
||||
// change, the bar kept the last number, and this service only ever
|
||||
// compared versions. The snapshot carries its own timestamp, so its age
|
||||
// is knowable. Two stale readings 15 s apart before acting, because a
|
||||
// suspend, a resume or a clock step produces one stale reading and the
|
||||
// next tick clears it, while a hung daemon produces them forever. The
|
||||
// action is the same identity-checked SIGTERM the version handover
|
||||
// uses, once per pid: if the daemon does not exit on it, the bar's own
|
||||
// stale marker keeps telling the truth and nothing loops.
|
||||
property real lastLiveT: 0
|
||||
property int livePid: 0
|
||||
property int liveStart: 0
|
||||
property int staleStrikes: 0
|
||||
readonly property int staleAfterS: 30
|
||||
|
||||
Timer {
|
||||
id: liveness
|
||||
interval: 15000
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: {
|
||||
if (root.lastLiveT <= 0 || root.livePid <= 0) return
|
||||
var age = Date.now() / 1000 - root.lastLiveT
|
||||
if (age < root.staleAfterS) {
|
||||
root.staleStrikes = 0
|
||||
return
|
||||
}
|
||||
root.staleStrikes += 1
|
||||
if (root.staleStrikes < 2) return
|
||||
root.staleStrikes = 0
|
||||
root.retire(root.livePid, root.liveStart)
|
||||
}
|
||||
}
|
||||
|
||||
// Neither the manifest nor live.json is opened from QML: `nexthop
|
||||
// stream` reads both with a bounded, non-blocking, no-follow,
|
||||
// regular-file-only read and emits them as lines. The version handover
|
||||
// below acts on that data, so a tampered state file cannot stall or
|
||||
// bloat the shell on its way to a SIGTERM decision. Two seconds is
|
||||
// plenty — this only has to notice a fast-forwarded checkout.
|
||||
Process {
|
||||
id: versionStream
|
||||
running: true
|
||||
command: ["sh", "-c",
|
||||
'cd "$1" && exec python3 -m nexthopd.cli stream manifest live --interval 2',
|
||||
"sh", root.pluginDir]
|
||||
stdout: SplitParser {
|
||||
splitMarker: "\n"
|
||||
onRead: function (line) { root.applyStream(line) }
|
||||
}
|
||||
onExited: streamRestart.start()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: streamRestart
|
||||
interval: 2000
|
||||
onTriggered: versionStream.running = true
|
||||
}
|
||||
|
||||
function applyStream(line) {
|
||||
if (!line || line.length > 262144) return
|
||||
if (line.indexOf("manifest ") === 0) {
|
||||
try {
|
||||
root.manifestVersion = String(JSON.parse(line.slice(9)).version || "")
|
||||
} catch (e) {}
|
||||
} else if (line.indexOf("live ") === 0) {
|
||||
root.checkDaemonVersion(line.slice(5))
|
||||
}
|
||||
}
|
||||
|
||||
function checkDaemonVersion(raw) {
|
||||
var live
|
||||
try { live = JSON.parse(raw) } catch (e) { return }
|
||||
var pid = Math.floor(Number(live.pid))
|
||||
if (!isFinite(pid) || pid <= 0) return
|
||||
var startTicks = Math.floor(Number(live.pid_start))
|
||||
if (!isFinite(startTicks) || startTicks <= 0) startTicks = 0
|
||||
// Every snapshot from a real daemon feeds the liveness watch above.
|
||||
var t = Number(live.t)
|
||||
if (isFinite(t) && t > 0) {
|
||||
root.lastLiveT = t
|
||||
root.livePid = pid
|
||||
root.liveStart = startTicks
|
||||
}
|
||||
if (manifestVersion === "") return
|
||||
var daemonVersion = String(live.daemon_version || "")
|
||||
if (daemonVersion === manifestVersion) return
|
||||
root.retire(pid, startTicks)
|
||||
}
|
||||
|
||||
function retire(pid, startTicks) {
|
||||
// Retire each pid once — if the respawn comes back stale too,
|
||||
// something else is wrong and looping SIGTERMs will not fix it.
|
||||
if (pid === lastRetiredPid) return
|
||||
lastRetiredPid = pid
|
||||
// Authorizing the signal is `nexthop retire`'s job, not a shell
|
||||
// one-liner's: it must belong to this user, its argv must be exactly a
|
||||
// python interpreter running `-m nexthopd`, and its start time must
|
||||
// match the one the daemon published — a recycled pid can share a
|
||||
// number, never a start time. Doing it in Python makes those three
|
||||
// checks testable, which the one-liner never was.
|
||||
retireProc.command = ["sh", "-c",
|
||||
'cd "$1" && exec python3 -m nexthopd.cli retire --pid "$2" --start "$3"',
|
||||
"sh", root.pluginDir, String(pid), String(startTicks)]
|
||||
retireProc.running = true
|
||||
respawnTimer.restart()
|
||||
}
|
||||
|
||||
Process { id: retireProc }
|
||||
|
||||
// If the retired daemon was our child, onExited respawns it with backoff.
|
||||
// If it belonged to a systemd unit or an earlier shell, nothing of ours
|
||||
// exits — so also respawn on a timer; whoever loses the flock race exits
|
||||
// cleanly, and either way exactly one fresh daemon survives.
|
||||
Timer {
|
||||
id: respawnTimer
|
||||
interval: 2500
|
||||
repeat: false
|
||||
onTriggered: daemon.running = true
|
||||
}
|
||||
|
||||
Process {
|
||||
id: daemon
|
||||
command: ["sh", "-c", 'cd "$1" && exec python3 -m nexthopd',
|
||||
"sh", root.pluginDir]
|
||||
running: true
|
||||
|
||||
onExited: function(code, status) {
|
||||
// Exit 3 is the daemon's "lock already held" code — another instance
|
||||
// owns the measurement, so there is nothing to supervise. Every
|
||||
// other exit gets a respawn with backoff: a SIGTERM'd daemon exits 0
|
||||
// and treating that as success once left the plugin unmonitored
|
||||
// until the next shell restart.
|
||||
if (code === 3) return
|
||||
root.failures += 1
|
||||
restartTimer.interval = Math.min(60000, 2000 * Math.pow(2, root.failures - 1))
|
||||
restartTimer.restart()
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: restartTimer
|
||||
repeat: false
|
||||
onTriggered: daemon.running = true
|
||||
}
|
||||
|
||||
Component.onDestruction: {
|
||||
// The shell is going down (restart, theme change). Leave a systemd-run
|
||||
// daemon alone; only reap the one we spawned.
|
||||
daemon.running = false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user