- 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
192 lines
7.1 KiB
QML
192 lines
7.1 KiB
QML
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
|
|
}
|
|
}
|