Files
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

247 lines
10 KiB
Python

"""Is a newer version published? Notify, never install.
Omarchy checks itself for updates (`omarchy-update-available` looks at its own
checkout and its package) but nothing checks plugins, so a user can sit on an
old Nexthop indefinitely without ever being told. This closes that gap the
smallest way it can be closed.
**This module never updates anything.** It answers one question — is the
installed checkout behind its origin — and the answer becomes a quiet glyph in
the panel naming the command the user can run. Updating stays where Omarchy
put it: `omarchy plugin update`, which shows the diff and asks. A plugin that
fetched and ran new code would be self-modifying code inside a system that
deliberately gates updates behind human review, and it would reopen a security
review that took four rounds to clear.
How it stays cheap and read-only:
* `git ls-remote` asks the remote for its HEAD without writing a single byte
into the user's checkout — no fetch, no new objects, no refs touched. It
takes well under a second and needs no credentials.
* Whether we are *behind* rather than merely *different* is then decided from
objects we already have, so a developer checkout that is ahead of origin is
never nagged.
* Egress is to the repository the user installed from and nowhere else. It
carries nothing about them or their network. It is still egress, so it is
disclosed and `updateCheck` turns it off.
The one piece of untrusted input here is the commit id the remote hands back,
and it goes on to be an argument to another `git` call — so it is validated
against the exact 40-hex shape before it reaches a subprocess, the same
doctrine `vet_target()` applies to URLs we did not choose.
"""
import os
import re
import shutil
import subprocess
import threading
from pathlib import Path
# A git object id and nothing else. This is the guard that matters: the value
# arrives from the network and is then passed as an argument to git.
RE_SHA = re.compile(r"^[0-9a-f]{40}$")
# A release is a rare event, and the check exists to catch the user who would
# otherwise never look. Daily is generous.
CHECK_INTERVAL_S = 24 * 3600
# Not at startup: the daemon restarts with the shell, and a check on every
# restart would be noise for no benefit. Nothing is lost by waiting.
FIRST_CHECK_DELAY_S = 300
# The remote may be slow, unreachable, or a captive portal that answers
# everything. None of those may stall the daemon.
GIT_TIMEOUT_S = 20
# `ls-remote` prints one short line per ref; this is far past HEAD alone.
MAX_LS_REMOTE_BYTES = 64 * 1024
def verdict(local: str, remote: str, have_remote: bool,
head_before_remote: bool, remote_before_head: bool) -> str:
"""Where the checkout stands relative to origin. Pure, so it is testable.
* `current` — the same commit.
* `behind` — origin is strictly ahead of us: an update.
* `ahead` — we are strictly ahead of origin: a dev checkout.
* `diverged` — neither contains the other.
* `unknown` — we could not tell, and say so rather than guessing.
Both ancestry directions are needed, and the reason is the likeliest
case of all: `omarchy plugin update` fetches before it shows its diff, so
a user who looked and said "not now" already *holds* origin's commit
while still being behind it. Deciding "behind" from "we have never seen
that object" alone would show that user nothing.
"""
if not local or not remote:
return "unknown"
if local == remote:
return "current"
if not have_remote:
# We do not hold origin's commit at all, so it is newer than anything
# we know about.
return "behind"
if head_before_remote:
return "behind"
if remote_before_head:
return "ahead"
return "diverged"
class UpdateWatch:
"""Asks origin, on a slow cadence, whether this checkout is behind.
The result lives in memory only. A daemon restart forgets it and waits
`FIRST_CHECK_DELAY_S` before asking again, which is why no fifth state
file was added for this.
"""
def __init__(self, repo: Path = None, enabled: bool = True, spawn=None):
# Derived from this file, not from the working directory: the daemon
# can be started from anywhere.
self.repo = Path(repo) if repo else Path(__file__).resolve().parent.parent
self.enabled = enabled
self.state = "unknown"
self.checked_ts = None
self._next = None
# How a check is run. Off the loop by default: `ls-remote` may sit
# at its 20 s timeout on exactly the flaky network where the
# outage watch matters, and the loop must not wait for it. Tests
# pass a synchronous spawn so the verdict lands within the tick.
self._spawn = spawn or self._in_thread
self._lock = threading.Lock()
self._inflight = False
self._pending = None # a verdict awaiting the next tick
@staticmethod
def _in_thread(fn):
threading.Thread(target=fn, name="update-check", daemon=True).start()
def _git(self, *args, capture: bool = True):
"""One git call. Fixed argv, no shell, bounded, always timed out."""
env = dict(os.environ)
# A credential prompt on a private or moved remote would otherwise
# block until the timeout every single time.
env["GIT_TERMINAL_PROMPT"] = "0"
env.pop("GIT_ASKPASS", None)
env.pop("SSH_ASKPASS", None)
# `git -C <dir>` does not require <dir> to BE a repository: git
# walks up until it finds one. A copy-install with no .git of its
# own, inside a home that is itself a checkout (dotfiles), would
# otherwise be compared against — and contact the remote of — the
# wrong repository every day. The ceiling stops the walk at our
# parent, so a missing .git answers "unknown", never someone else.
env["GIT_CEILING_DIRECTORIES"] = str(self.repo.parent)
try:
proc = subprocess.run(
["git", "-C", str(self.repo),
# No credential helper and no askpass program may run for
# this: a private or moved remote gets "unknown", not a
# keyring prompt once a day. GIT_TERMINAL_PROMPT above
# covers only git's own tty prompt.
"-c", "credential.helper=", "-c", "core.askPass=",
*args],
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE if capture else subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=GIT_TIMEOUT_S, env=env,
)
except (OSError, subprocess.TimeoutExpired):
return None, ""
out = ""
if capture and proc.stdout:
out = proc.stdout[:MAX_LS_REMOTE_BYTES].decode(
"utf-8", "replace").strip()
return proc.returncode, out
def _local_head(self):
code, out = self._git("rev-parse", "HEAD")
return out if code == 0 and RE_SHA.match(out) else None
def _remote_head(self):
"""Origin's HEAD, or None. Writes nothing into the checkout."""
code, out = self._git("ls-remote", "origin", "HEAD")
if code != 0 or not out:
return None
sha = out.split()[0] if out.split() else ""
# The guard: this value came off the network and is about to become a
# git argument. Anything but a bare object id is refused outright.
return sha if RE_SHA.match(sha) else None
def check(self) -> str:
"""Run one check now and return the verdict. Read-only throughout."""
if not shutil.which("git"):
return "unknown"
local = self._local_head()
if not local:
return "unknown" # not a git checkout, or no commits
remote = self._remote_head()
if not remote:
return "unknown" # offline, no origin, or a junk answer
if local == remote:
return "current"
# Do we already hold origin's commit? Only objects we have are
# consulted from here on, so no fetch is ever needed.
code, _ = self._git("cat-file", "-e", remote + "^{commit}",
capture=False)
have = code == 0
before, after = False, False
if have:
# Exit 1 means "not an ancestor", which is an answer, not an error.
code, _ = self._git("merge-base", "--is-ancestor", local, remote,
capture=False)
before = code == 0
code, _ = self._git("merge-base", "--is-ancestor", remote, local,
capture=False)
after = code == 0
return verdict(local, remote, have, before, after)
def _run_check(self):
state = self.check()
with self._lock:
self._pending = state
self._inflight = False
def _collect(self, now: float):
with self._lock:
state, self._pending = self._pending, None
if state is not None:
self.state = state
self.checked_ts = round(now)
def tick(self, now: float):
"""Called from the daemon loop; starts a check when one is due and
adopts the verdict of one that has finished. Never blocks."""
if not self.enabled:
# Turning the setting off clears any standing notice, so the
# glyph disappears rather than lingering with a stale answer.
# A check already running is left to finish and its answer
# dropped here, unread.
self.state, self.checked_ts, self._next = "unknown", None, None
with self._lock:
self._pending = None
return
self._collect(now)
if self._next is None:
self._next = now + FIRST_CHECK_DELAY_S
return
if now < self._next:
return
self._next = now + CHECK_INTERVAL_S
with self._lock:
if self._inflight:
return
self._inflight = True
self._spawn(self._run_check)
# A synchronous spawn has already finished; adopt it now rather
# than a tick later.
self._collect(now)
def snapshot(self) -> dict:
"""What the panel reads. None while nothing has been established."""
if not self.enabled or self.state == "unknown":
return None
return {
"state": self.state,
"available": self.state == "behind",
"checked_ts": self.checked_ts,
}