diff --git a/README.md b/README.md index 8d70867..9c7fd00 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,44 @@ silently orphaned process. Options: `--port N`, `--host ADDR`, `VITALS_PORT` env var. +### The bookmark + +`install.sh` also puts Vitals in your browser's bookmark bar, as a bookmark and +as a popup bookmarklet that opens it in its own window. A tool you have to +remember the URL of does not get used. + +Chromium keeps the bookmark model in memory and rewrites the file on its next +save, so the browser has to be closed while that write happens. If it is running, +install prints the one command to run afterwards instead of failing: + +```bash +python3 bookmark.py --install # add bookmark + popup +python3 bookmark.py --install --wait 600 # wait for the browser to exit +python3 bookmark.py --status # is it there, is the file sane +python3 bookmark.py --remove # take them back out +``` + +The tool recomputes Chromium's bookmark checksum (MD5 over a canonical +serialisation of every node) and verifies it after writing, because a file that +fails that check leaves the browser showing an empty bar while the file on disk +still looks perfect. It keeps a timestamped backup of the file it edited. + +## Live updates + +The sampler runs every 2 seconds, because one sample costs about 400 ms of CPU +and a monitor that eats the machine is the joke writing itself. The page polls +faster than that and eases between frames, so the numbers move continuously +instead of snapping twice a second: + +- header values are tabular-width and tweened toward the newest sample +- machine-bar segments are keyed and reordered with flex `order`, so their + widths actually animate instead of being recreated mid-transition +- rows are keyed DOM nodes updated in place, sorted on a 0.1-core grid with the + previous order as tiebreak, so a row does not jump position over a hundredth + of a core +- a row appears at 0.05 cores but only leaves after it stays under 0.02, so + nothing flickers in and out at the threshold + ## Getting browser tabs Tabs come from the DevTools Protocol, which a browser only exposes if it was diff --git a/bookmark.py b/bookmark.py new file mode 100644 index 0000000..cdaa2b0 --- /dev/null +++ b/bookmark.py @@ -0,0 +1,363 @@ +#!/usr/bin/env python3 +"""Put Vitals in the browser's bookmark bar. + +Chromium-family browsers (Brave, Chrome, Chromium) store bookmarks as a JSON +file with an MD5 checksum over a canonical serialisation of every node. If the +checksum does not match, the browser silently falls back to Bookmarks.bak and +the edit disappears, so this tool recomputes it and refuses to write a file it +cannot verify. + +Checksum algorithm (components/bookmarks/browser/bookmark_codec.cc): + for each root folder, in order bookmark_bar, other, synced: + feed the root itself as a folder node + then every descendant, depth first, in order + folder node : id + name (UTF-16LE bytes) + b"folder" + url node : id + name (UTF-16LE bytes) + b"url" + url (UTF-8) + +Verified by reproducing the stored checksum of a real 13.8 MB profile file. + +Safe by construction: refuses while the browser owns the profile, writes through +a temp file with an atomic rename, keeps a timestamped backup, and re-reads the +file it just wrote to confirm the checksum lands. + +Usage: + python3 bookmark.py --install # add Vitals + Vitals popup + python3 bookmark.py --install --wait 600 # wait up to 10 min for browser exit + python3 bookmark.py --status # what is in the bar right now + python3 bookmark.py --remove # take them back out + python3 bookmark.py --install --profile-dir /path/to/Profile + python3 bookmark.py --install --dry-run +""" +from __future__ import annotations + +import argparse +import glob +import hashlib +import json +import os +import shutil +import sys +import time +import uuid + +VITALS_URL = "http://127.0.0.1:8791/" +BOOKMARKLET = ( + "javascript:(function(){window.open('http://127.0.0.1:8791/'," + "'vitals','width=1500,height=950,menubar=no,toolbar=no,location=no," + "status=no');})();void(0);" +) +ROOTS = ("bookmark_bar", "other", "synced") +WINDOWS_EPOCH_OFFSET = 11644473600 # seconds between 1601-01-01 and 1970-01-01 + + +# ---------------------------------------------------------------- checksum +def _u16(s: str) -> bytes: + return s.encode("utf-16-le") + + +def _feed_url(h, node) -> None: + h.update(str(node["id"]).encode()) + h.update(_u16(node.get("name") or "")) + h.update(b"url") + h.update((node.get("url") or "").encode("utf-8")) + + +def _feed_folder(h, node) -> None: + h.update(str(node["id"]).encode()) + h.update(_u16(node.get("name") or "")) + h.update(b"folder") + + +def _walk(h, node) -> None: + if node.get("type") == "url": + _feed_url(h, node) + else: + _feed_folder(h, node) + for child in node.get("children", []): + _walk(h, child) + + +def checksum(doc) -> str: + h = hashlib.md5() + for key in ROOTS: + root = doc["roots"].get(key) + if not root: + continue + _feed_folder(h, root) + for child in root.get("children", []): + _walk(h, child) + return h.hexdigest() + + +# ---------------------------------------------------------------- discovery +def candidate_profiles(): + home = os.path.expanduser("~") + pats = [ + # Flatpak builds keep their profile inside the sandbox. + f"{home}/.var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser/Default", + f"{home}/.var/app/com.google.Chrome/config/google-chrome/Default", + f"{home}/.var/app/org.chromium.Chromium/config/chromium/Default", + # Native builds. + f"{home}/.config/BraveSoftware/Brave-Browser/Default", + f"{home}/.config/google-chrome/Default", + f"{home}/.config/chromium/Default", + # Chromium-family shells. + f"{home}/.config/microsoft-edge/Default", + f"{home}/.config/vivaldi/Default", + ] + out = [] + for p in pats: + if os.path.isfile(os.path.join(p, "Bookmarks")): + out.append(p) + return out + + +def profile_in_use(profile_dir: str) -> bool: + """True when a browser currently owns this profile. + + Chromium takes a SingletonLock in the user data dir (the parent of the + profile dir). Reading the bookmarks file under a live browser is pointless: + the in-memory model overwrites it on the next save. + """ + user_data_dir = os.path.dirname(profile_dir.rstrip("/")) + for name in ("SingletonLock", "SingletonSocket", "SingletonCookie"): + if os.path.exists(os.path.join(user_data_dir, name)): + return True + # Also catch a browser pointed at this profile with an explicit flag. + needle = os.path.abspath(profile_dir) + try: + pids = [p for p in os.listdir("/proc") if p.isdigit()] + except OSError: + return False + for pid in pids: + try: + with open(f"/proc/{pid}/cmdline", "rb") as f: + cmd = f.read().replace(b"\x00", b" ").decode("utf8", "replace") + except OSError: + continue + if "--type=" in cmd: + continue + if needle in cmd and any(b in cmd for b in ("brave", "chrome", "chromium", "edge", "vivaldi")): + return True + return False + + +def max_id(doc) -> int: + top = 0 + + def walk(node): + nonlocal top + try: + top = max(top, int(node.get("id", 0))) + except (TypeError, ValueError): + pass + for child in node.get("children", []): + walk(child) + + for key in ROOTS: + if doc["roots"].get(key): + walk(doc["roots"][key]) + return top + + +def now_micros() -> str: + return str(int((time.time() + WINDOWS_EPOCH_OFFSET) * 1_000_000)) + + +def find_ours(doc): + """Return (vitals_node, bookmarklet_node) currently in the bar, if any.""" + plain = bm = None + + def walk(node): + nonlocal plain, bm + if node.get("type") == "url": + url = node.get("url") or "" + if url == VITALS_URL: + plain = node + elif url.startswith("javascript:") and "8791" in url: + bm = node + for child in node.get("children", []): + walk(child) + + for key in ROOTS: + if doc["roots"].get(key): + walk(doc["roots"][key]) + return plain, bm + + +def make_node(node_id: int, name: str, url: str) -> dict: + stamp = now_micros() + return { + "date_added": stamp, + "date_last_used": "0", + "guid": str(uuid.uuid4()), + "id": str(node_id), + "name": name, + "type": "url", + "url": url, + } + + +# ---------------------------------------------------------------- operations +def load(profile_dir): + path = os.path.join(profile_dir, "Bookmarks") + with open(path, encoding="utf8") as f: + raw = f.read() + return path, raw, json.loads(raw) + + +def save(path, doc, raw_before) -> None: + doc["checksum"] = checksum(doc) + doc.pop("checksum_sha256", None) # browser rewrites it; absent is valid + text = json.dumps(doc, indent=3, sort_keys=True, ensure_ascii=True) + stamp = time.strftime("%Y%m%d-%H%M%S") + backup = f"{path}.pre-vitals-{stamp}.bak" + shutil.copy2(path, backup) + tmp = f"{path}.vitals.tmp" + with open(tmp, "w", encoding="utf8") as f: + f.write(text) + shutil.copystat(path, tmp) + os.replace(tmp, path) + print(f" backup: {backup}") + + # Verify what actually landed on disk. + with open(path, encoding="utf8") as f: + check = json.load(f) + if checksum(check) != check.get("checksum"): + raise SystemExit("REFUSING: checksum did not verify after write") + if len(raw_before) and not check["roots"].get("bookmark_bar"): + raise SystemExit("REFUSING: bookmark bar missing after write") + + +def install(profile_dir, dry_run=False, name="Vitals", popup_name="Vitals popup") -> int: + path, raw, doc = load(profile_dir) + plain, bm = find_ours(doc) + next_id = max_id(doc) + 1 + added = [] + + if plain is None: + doc["roots"]["bookmark_bar"].setdefault("children", []).append( + make_node(next_id, name, VITALS_URL) + ) + next_id += 1 + added.append(name) + elif plain.get("name") != name: + plain["name"] = name + added.append(f"{name} (renamed)") + + if bm is None: + doc["roots"]["bookmark_bar"].setdefault("children", []).append( + make_node(next_id, popup_name, BOOKMARKLET) + ) + added.append(popup_name) + + if not added: + print(" already installed, nothing to do") + return 0 + + if dry_run: + probe = json.loads(raw) + probe["checksum"] = checksum(doc) + print(f" dry run: would add {added} | new checksum {probe['checksum']}") + return 0 + + print(f" profile: {profile_dir}") + print(f" adding: {', '.join(added)}") + save(path, doc, raw) + print(f" checksum: {doc['checksum']} verified on disk") + return 0 + + +def remove(profile_dir, dry_run=False) -> int: + path, raw, doc = load(profile_dir) + plain, bm = find_ours(doc) + if plain is None and bm is None: + print(" nothing to remove") + return 0 + removed = [] + for key in ROOTS: + root = doc["roots"].get(key) + if not root: + continue + keep = [] + for child in root.get("children", []): + if child is plain or child is bm: + removed.append(child.get("name")) + else: + keep.append(child) + root["children"] = keep + if dry_run: + print(f" dry run: would remove {removed}") + return 0 + save(path, doc, raw) + print(f" removed: {removed}") + return 0 + + +def status(profile_dir) -> int: + _, _, doc = load(profile_dir) + plain, bm = find_ours(doc) + bar = doc["roots"]["bookmark_bar"] + print(f" profile : {profile_dir}") + print(f" bar : {len(bar.get('children', []))} items") + print(f" Vitals : {plain.get('name') + ' -> ' + plain['url'] if plain else 'not present'}") + print(f" popup : {bm.get('name') if bm else 'not present'}") + stored = doc.get("checksum", "") + good = stored == checksum(doc) + print(f" checksum: {'valid' if good else 'MISMATCH'}") + return 0 + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description="Install Vitals into the bookmark bar") + ap.add_argument("--install", action="store_true") + ap.add_argument("--remove", action="store_true") + ap.add_argument("--status", action="store_true") + ap.add_argument("--profile-dir", default=None) + ap.add_argument("--name", default="Vitals") + ap.add_argument("--dry-run", action="store_true") + ap.add_argument("--wait", type=int, default=0, + help="seconds to wait for the browser to exit before giving up") + args = ap.parse_args(argv) + + if not (args.install or args.remove or args.status): + ap.print_help() + return 2 + + profiles = [args.profile_dir] if args.profile_dir else candidate_profiles() + if not profiles: + print("no Chromium-family profile found; pass --profile-dir") + return 1 + + targets = [] + for p in profiles: + if not os.path.isfile(os.path.join(p, "Bookmarks")): + print(f" skip {p}: no Bookmarks file") + continue + waited = 0 + while profile_in_use(p) and waited < args.wait: + if waited == 0: + print(f" browser is running on {p}; waiting up to {args.wait}s") + time.sleep(2) + waited += 2 + if profile_in_use(p): + print(f" skip {p}: browser holds the profile lock. Close the browser " + f"and re-run, or pass --wait N.") + continue + targets.append(p) + + if not targets: + return 1 + + for p in targets: + if args.status: + status(p) + elif args.remove: + remove(p, args.dry_run) + else: + install(p, args.dry_run, name=args.name) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/index.html b/index.html index 486c6ac..a5a2a88 100644 --- a/index.html +++ b/index.html @@ -115,6 +115,19 @@ input[type=search]:focus{border-color:#37424d} button{background:var(--panel);border:1px solid var(--line);color:var(--dim);border-radius:3px; padding:4px 9px;font-size:11px;cursor:pointer;font-family:inherit} button:hover{border-color:#37424d;color:var(--fg)} + +/* --- live-update stability: nothing moves unless its own value moved --- */ +.stat b{font-variant-numeric:tabular-nums} +.stat .v{display:inline-block;text-align:right} +.stat.busy .v{min-width:2.2ch} +.stat.cores .v{min-width:4.4ch} +.machine div{transition:width .9s linear} +.legend span{white-space:nowrap;font-variant-numeric:tabular-nums} +.legend span em{font-style:normal;display:inline-block;min-width:3.1em;text-align:right} +.rows{display:flex;flex-direction:column} +.row .bar i{transition:width .9s linear} +.num{font-variant-numeric:tabular-nums} +.stat, .legend span, .tabs .tr{will-change:auto}
@@ -126,9 +139,9 @@ button:hover{border-color:#37424d;color:var(--fg)}