- 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
150 lines
5.4 KiB
Python
150 lines
5.4 KiB
Python
"""Reading and writing the JSON state files, safely.
|
||
|
||
live.json is rewritten twice a second and is all the bar widget ever looks
|
||
at; recent.json is a pre-downsampled 30-minute window so the panel's default
|
||
graphs paint without a query; apps.json is per-application traffic. All are
|
||
written to a temp file and renamed, so a reader never sees a half-written
|
||
file. The QML side does not open any of them itself (0.1.9): `nexthop
|
||
stream` reads them through `read_text_bounded` — no symlink following, a
|
||
regular file or nothing, a size cap on the read itself — and hands the shell
|
||
one re-serialised line per record.
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import stat
|
||
import tempfile
|
||
import time
|
||
from pathlib import Path
|
||
|
||
from .paths import APPS, LIVE, RECENT
|
||
|
||
|
||
def write_atomic(path: Path, payload: dict, durable: bool = False):
|
||
"""Write a JSON file so a reader sees the old one or the new one.
|
||
|
||
The temp file plus rename is what gives readers that guarantee, and it
|
||
costs nothing. The fsync is a different promise — that the bytes
|
||
survive a power cut — and none of the snapshots written here needs
|
||
it: each is replaced within seconds of the daemon starting. On btrfs
|
||
that fsync was 25× the payload at the block layer (62.5 KiB per 2.5 KB
|
||
write, measured), twice a second. `durable` keeps it for a caller
|
||
that genuinely wants it.
|
||
"""
|
||
path = Path(path)
|
||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.")
|
||
try:
|
||
with os.fdopen(fd, "w") as f:
|
||
json.dump(payload, f, separators=(",", ":"))
|
||
f.flush()
|
||
if durable:
|
||
os.fsync(f.fileno())
|
||
os.replace(tmp, path)
|
||
except BaseException:
|
||
try:
|
||
os.unlink(tmp)
|
||
except OSError:
|
||
pass
|
||
raise
|
||
|
||
|
||
def read_text_bounded(path: Path, max_bytes: int):
|
||
"""Read a state file, enforcing every property on the fd actually read.
|
||
|
||
`O_NOFOLLOW` refuses a symlinked path outright, `O_NONBLOCK` means a
|
||
FIFO left at the path returns instead of stalling the caller, `fstat`
|
||
on the descriptor proves it is a regular file, and the cap bounds the
|
||
read itself rather than trusting a size sampled beforehand. Returns
|
||
(text, stamp) or None; `stamp` is (mtime_ns, size), enough for a
|
||
caller to skip re-reading an unchanged file.
|
||
|
||
This is the only way state reaches a reader — the QML side consumes it
|
||
through `nexthop stream` rather than opening these paths itself, so an
|
||
oversized or non-regular file can never allocate or block inside the
|
||
long-lived shell process.
|
||
"""
|
||
try:
|
||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK)
|
||
except OSError:
|
||
return None
|
||
try:
|
||
st = os.fstat(fd)
|
||
if not stat.S_ISREG(st.st_mode):
|
||
return None
|
||
chunks, total = [], 0
|
||
while total <= max_bytes:
|
||
try:
|
||
chunk = os.read(fd, min(65536, max_bytes + 1 - total))
|
||
except BlockingIOError:
|
||
break
|
||
except OSError:
|
||
return None
|
||
if not chunk:
|
||
break
|
||
chunks.append(chunk)
|
||
total += len(chunk)
|
||
if total > max_bytes:
|
||
return None
|
||
stamp = (st.st_mtime_ns, st.st_size)
|
||
finally:
|
||
os.close(fd)
|
||
try:
|
||
return b"".join(chunks).decode("utf-8"), stamp
|
||
except UnicodeDecodeError:
|
||
return None
|
||
|
||
|
||
def read_json(path: Path, default=None, max_bytes: int = 4 * 1024 * 1024):
|
||
"""Bounded read of a state file, parsed. The bound lives on the read,
|
||
not on a prior stat, for the same reason as the daemon's config read."""
|
||
got = read_text_bounded(path, max_bytes)
|
||
if got is None:
|
||
return default
|
||
try:
|
||
return json.loads(got[0])
|
||
except ValueError:
|
||
return default
|
||
|
||
|
||
def retire_legacy_snapshots(old_dir: Path, new_dir: Path, now: float = None):
|
||
"""The snapshots moved to the runtime dir in 0.2.22; tidy the old place.
|
||
|
||
`nexthop stream` is a long-lived process that resolved its paths when it
|
||
started, so a reader from before the move keeps watching the state dir
|
||
for as long as it lives — through the daemon handover, until the shell
|
||
reloads the QML. Deleting live.json there would leave that reader
|
||
holding the last number it saw, as if it were current. It is rewritten
|
||
once instead, as a tombstone: state "no-daemon", no index, and no pid,
|
||
so the old bar shows "no data" and the old version watch finds nothing
|
||
to retire. recent.json and apps.json are simply removed.
|
||
"""
|
||
old_dir, new_dir = Path(old_dir), Path(new_dir)
|
||
if old_dir == new_dir:
|
||
return
|
||
now = time.time() if now is None else now
|
||
for name in (RECENT, APPS):
|
||
try:
|
||
(old_dir / name).unlink()
|
||
except OSError:
|
||
pass
|
||
live = old_dir / LIVE
|
||
got = read_text_bounded(live, 256 * 1024)
|
||
if got is None:
|
||
return
|
||
try:
|
||
payload = json.loads(got[0])
|
||
except ValueError:
|
||
payload = None
|
||
if not isinstance(payload, dict):
|
||
try:
|
||
live.unlink()
|
||
except OSError:
|
||
pass
|
||
return
|
||
for key in ("pid", "pid_start", "daemon_version"):
|
||
payload.pop(key, None)
|
||
payload.update({"t": round(now, 3), "state": "no-daemon", "index": None,
|
||
"band": None, "down_since": None})
|
||
write_atomic(live, payload, durable=True)
|