Files
omarchy/plugins/io.github.x3me.nexthop/Service.qml
T
asepharyana 1cdb82a76f 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
2026-09-23 15:19:12 +07:00

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
}
}