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,149 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user