commit ea834a99a522deca6a5aab57eb5675b046cbe9d3 Author: ocpway Date: Thu Jul 30 04:29:37 2026 +0300 Initial commit: ocd waybar plugin Daemon (scripts/ocd-waybar) polls { "schema_version": 1, "generated_at": "2026-07-30T01:29:37.918330160+00:00", "accounts": [ { "account": "legal", "workspace": "wrk_01KBJFEAZV7G2FJBE1KAPRQSJN", "fetched_at": "2026-07-30T01:27:49.014903087+00:00", "status": "cached", "limits": { "usage_5h": { "used": 0.0, "total": 100.0, "unit": "%", "resets_at": "2026-07-30T06:27:49.014903087+00:00", "status": "ok" }, "usage_week": { "used": 88.0, "total": 100.0, "unit": "%", "resets_at": "2026-08-03T00:00:01.014903087+00:00", "status": "ok" }, "usage_month": { "used": 94.0, "total": 100.0, "unit": "%", "resets_at": "2026-08-21T22:07:23.014903087+00:00", "status": "ok" }, "next_update": null }, "balance": { "remaining": 0.0, "unit": "$" } }, { "account": "legal2", "workspace": "wrk_01KQHV4ZP2SHZHTCTF0HW1BKHA", "fetched_at": "2026-07-30T01:27:49.920803926+00:00", "status": "cached", "limits": { "usage_5h": { "used": 0.0, "total": 100.0, "unit": "%", "resets_at": "2026-07-30T06:27:49.920803926+00:00", "status": "ok" }, "usage_week": { "used": 100.0, "total": 100.0, "unit": "%", "resets_at": "2026-08-03T00:00:00.920803926+00:00", "status": "rate-limited" }, "usage_month": { "used": 90.0, "total": 100.0, "unit": "%", "resets_at": "2026-08-23T18:01:33.920803926+00:00", "status": "ok" }, "next_update": null }, "balance": { "remaining": 0.0, "unit": "$" } }, { "account": "kllr560", "workspace": "wrk_01KYJ5B4PM2EGR16ZDC0JTH2CC", "fetched_at": "2026-07-30T01:27:50.791975864+00:00", "status": "cached", "limits": { "usage_5h": { "used": 0.0, "total": 100.0, "unit": "%", "resets_at": "2026-07-30T06:27:50.791975864+00:00", "status": "ok" }, "usage_week": { "used": 79.0, "total": 100.0, "unit": "%", "resets_at": "2026-08-03T00:00:00.791975864+00:00", "status": "ok" }, "usage_month": { "used": 39.0, "total": 100.0, "unit": "%", "resets_at": "2026-08-27T16:10:15.791975864+00:00", "status": "ok" }, "next_update": null }, "balance": { "remaining": 0.0, "unit": "$" } }, { "account": "pj-vendee", "workspace": "wrk_01KYNGQCX4XEM0KD3PWVVE636D", "fetched_at": "2026-07-30T01:27:51.638144968+00:00", "status": "cached", "limits": { "usage_5h": { "used": 0.0, "total": 100.0, "unit": "%", "resets_at": "2026-07-30T06:27:51.638144968+00:00", "status": "ok" }, "usage_week": { "used": 40.0, "total": 100.0, "unit": "%", "resets_at": "2026-08-03T00:00:00.638144968+00:00", "status": "ok" }, "usage_month": { "used": 20.0, "total": 100.0, "unit": "%", "resets_at": "2026-08-29T01:09:27.638144968+00:00", "status": "ok" }, "next_update": null }, "balance": { "remaining": 0.0, "unit": "$" } }, { "account": "iyaunalunarovell", "workspace": "wrk_01KYNTSGXF7GFXMAMT36N3GH21", "fetched_at": "2026-07-30T01:27:52.584601387+00:00", "status": "cached", "limits": { "usage_5h": { "used": 94.0, "total": 100.0, "unit": "%", "resets_at": "2026-07-30T02:27:25.584601387+00:00", "status": "ok" }, "usage_week": { "used": 99.0, "total": 100.0, "unit": "%", "resets_at": "2026-08-03T00:00:00.584601387+00:00", "status": "ok" }, "usage_month": { "used": 49.0, "total": 100.0, "unit": "%", "resets_at": "2026-08-29T02:22:06.584601387+00:00", "status": "ok" }, "next_update": null }, "balance": { "remaining": 0.0, "unit": "$" } }, { "account": "balensiefenpiwihu2906", "workspace": "wrk_01KYQNFP718WP7X9W7RXC2Q133", "fetched_at": "2026-07-30T01:27:53.457572405+00:00", "status": "cached", "limits": { "usage_5h": { "used": 53.0, "total": 100.0, "unit": "%", "resets_at": "2026-07-30T03:43:22.457572405+00:00", "status": "ok" }, "usage_week": { "used": 21.0, "total": 100.0, "unit": "%", "resets_at": "2026-08-03T00:00:00.457572405+00:00", "status": "ok" }, "usage_month": { "used": 10.0, "total": 100.0, "unit": "%", "resets_at": "2026-08-29T19:27:31.457572405+00:00", "status": "ok" }, "next_update": null }, "balance": { "remaining": 0.0, "unit": "$" } } ] } every 30s (no force flags, so ocd cache/TTL stays in charge), runs a 5s account rotation in config order, and supports right-click 1h lock. The waybar custom/ocd module reads a state.json written by the daemon; hover shows all accounts via the native tooltip. Bars are Pango background spans (~50px) in the label and plain block chars in the tooltip, since waybar 0.15.0 renders custom tooltips as plain text. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5e9dc53 --- /dev/null +++ b/README.md @@ -0,0 +1,137 @@ +# ocpway — waybar plugin for `ocd` + +A waybar module that shows opencode usage limits and balance from the +`ocd` dashboard binary. It rotates through the accounts in your ocd +config, one every 5 seconds. Left-click advances at once. Right-click +locks the current account for one hour. Hover the bar to see all +accounts at once. + +## What it shows + +One line per account: + +``` +kllr560 [0%/5h] ▓░░░░░░ 4:59 [79%/1w] █████▌░ 3d 22h [39%/1m] ██▋░░░░ 28d $0.00 / 1m ago +``` + +- Account name, and a lock emoji `🔒` while locked. +- Three bars: the 5-hour, weekly, and monthly usage window. + - The label holds the used value with its unit, e.g. `[79%/1w]`. + - The bar is the percentage, drawn as filled cells up to the value. + - The text after the bar is the time until the window resets. +- The balance and the age of the last data fetch: + `$0.00 / 1m ago`. + +When locked, the bar cells lock to blue and the line starts with `🔒`. + +The hover tooltip lists every account the same way. The active row is +marked with `▸`, and the lock emoji shows only on the locked row. + +## Design decisions + +The bar is rectangular. It uses Pango `background` spans over non-breaking +space cells. Seven cells are about 50 px at a normal bar font. The bar in +the tooltip uses plain block characters because waybar 0.15.0 renders the +custom-module tooltip as plain text. If your waybar gains Pango markup in +custom tooltips (see Waybar PR #5034), set `TOOLTIP_MARKUP = True` in the +daemon to get colored background-span bars in the tooltip too. + +The daemon polls `ocd raw json` every 30 seconds. It sends no +`--force-refresh` or `--no-cache` flags, so `ocd` keeps full control of +politeness: its own cache, TTL, concurrency, and pacing stay in charge. +Most polls read the cache and never touch the network. The daemon also +polls at once waybar starts + +waybar 0.15.0 has no `on-hover` event for custom modules, so the +all-accounts view is the native tooltip, not a separate floating window. + +## Layout + +``` +ocpway/ + scripts/ + ocd-waybar the daemon (python) + ocd-waybar-ctl the click helper (sh) for advance / lock / poll / quit + config/ + waybar-config.jsonc the "custom/ocd" module block to add + waybar-style.css the CSS to append + hypr-autostart.conf the exec-once line to start the daemon + README.md +``` + +## Install + +Copy the scripts and wiring them into your own config. + +1. Copy the scripts: + + ```sh + mkdir -p ~/.config/waybar/scripts + cp scripts/ocd-waybar scripts/ocd-waybar-ctl ~/.config/waybar/scripts/ + chmod +x ~/.config/waybar/scripts/ocd-waybar{,-ctl} + ``` + +2. Add the `custom/ocd` module to `~/.config/waybar/config.jsonc`. + See `config/waybar-config.jsonc` for the block and where the module + name goes in `modules-right`. + +3. Append the CSS in `config/waybar-style.css` to `~/.config/waybar/style.css`. + +4. Add the `exec-once` line in `config/hypr-autostart.conf` to + `~/.config/hypr/autostart.conf`. + +5. Restart waybar, and start the daemon: + + ```sh + pkill -x waybar; waybar & + ~/.config/waybar/scripts/ocd-waybar & + ``` + + On later logins, the `exec-once` line starts the daemon for you. + +## Usage + +- Hover the module: see all accounts. +- Left-click: advance to the next account at once (resets the 5-second timer). +- Right-click: lock the current account for one hour. + +## `ocd-waybar-ctl` commands + +| Command | Effect | +|----------|--------| +| advance | Reset the timer and move to the next account. | +| lock | Freeze the current account for one hour. | +| poll | Run an ocd poll now (still respects ocd cache and TTL). | +| quit | Stop the daemon. | + +The helper tries `socat`, then `nc -U -u`, then a small python fallback. + +## Tunables + +At the top of `scripts/ocd-waybar`: + +| Name | Default | Meaning | +|---|---|---| +| `ROTATE_SECS` | 5 | Auto-advance cadence. | +| `LOCK_SECS` | 3600 | One hour lock duration. | +| `POLL_SECS` | 30 | ocd re-poll cadence. | +| `BAR_WIDTH` | 7 | Bar cells, about 50 px. | +| `TOOLTIP_MARKUP` | False | Set True if your waybar renders Pango in custom tooltips. | +| `C_OK` / `C_WARN` / `C_CRIT` / `C_LOCK` / `C_TRACK` | see source | Zone colors. | +| `OCD_BIN` | `ocd` | Path to the ocd binary (or set the `OCD_BIN` env var). | + +Zone thresholds: under 70 percent is green, 70 to 90 is amber, over 90 or a +`rate-limited` frame status is red. + +## Dependencies + +- `ocd`, built and on `PATH` (or pointed at with `OCD_BIN`). +- `python3` (the daemon). Standard library only. +- `waybar`. Tested on 0.15.0. +- For clicks: `socat` is preferred, `nc` is a fallback, else the python fallback runs. + +## State + +The daemon writes `~/.local/state/ocd-waybar/state.json`. The waybar module +reads that file once per second with `cat` and emits it as a JSON object. +The socket lives at `$XDG_RUNTIME_DIR/ocd-waybar.sock`. \ No newline at end of file diff --git a/config/hypr-autostart.conf b/config/hypr-autostart.conf new file mode 100644 index 0000000..d73534d --- /dev/null +++ b/config/hypr-autostart.conf @@ -0,0 +1,5 @@ +# Add to ~/.config/hypr/autostart.conf to start the daemon on login. +# The omarchy default uses uwsm app -- for wayland-session-managed processes. + +# ocd waybar daemon (polls ocd, drives the custom/ocd module) +exec-once = uwsm app -- ~/.config/waybar/scripts/ocd-waybar \ No newline at end of file diff --git a/config/waybar-config.jsonc b/config/waybar-config.jsonc new file mode 100644 index 0000000..3b5cf17 --- /dev/null +++ b/config/waybar-config.jsonc @@ -0,0 +1,27 @@ +// Snippet to add to ~/.config/waybar/config.jsonc +// +// 1. Add "custom/ocd" to the "modules-right" (or modules-left/center) array: +// +// "modules-right": [ +// "custom/lang", +// "custom/ocd", // <- add this +// "group/tray-expander", +// "network", +// "pulseaudio", +// "cpu", +// "memory", +// "clock", +// ], +// +// 2. Add the module block below anywhere in the top-level object: + + "custom/ocd": { + "exec": "cat $HOME/.local/state/ocd-waybar/state.json", + "exec-if": "test -f $HOME/.local/state/ocd-waybar/state.json", + "return-type": "json", + "interval": 1, + "format": "{}", + "tooltip-format": "{}", + "on-click": "~/.config/waybar/scripts/ocd-waybar-ctl advance", + "on-click-right": "~/.config/waybar/scripts/ocd-waybar-ctl lock" + }, \ No newline at end of file diff --git a/config/waybar-style.css b/config/waybar-style.css new file mode 100644 index 0000000..1b7a28c --- /dev/null +++ b/config/waybar-style.css @@ -0,0 +1,12 @@ +/* Append to ~/.config/waybar/style.css. */ +/* The monospace font makes the nbsp bar cells line up as rectangles. */ +/* The lock class tints the label blue while a 1-hour lock is active. */ + +#custom-ocd { + font-family: monospace; + padding: 0 6px; + min-width: 0; +} +#custom-ocd.ocd-locked { + color: #5aa6e0; +} \ No newline at end of file diff --git a/scripts/ocd-waybar b/scripts/ocd-waybar new file mode 100755 index 0000000..26e33f3 --- /dev/null +++ b/scripts/ocd-waybar @@ -0,0 +1,418 @@ +#!/usr/bin/env python3 +# ocd-waybar: waybar daemon for the ocd opencode-limits dashboard. +# +# It polls `ocd raw json` every 30 s. It does not pass --force-refresh or +# --no-cache, so ocd's own cache and TTL stay in charge of politeness. +# It holds the 5 s rotation timer and the 1 h lock, renders one Pango line +# for the displayed account and a multi-line Pango tooltip for all accounts, +# and writes the result as JSON for the waybar custom/ocd module. +# +# Clicks arrive over a unix socket from ocd-waybar-ctl: +# advance -> reset the 5 s timer and move to the next account +# lock -> freeze the current account for one hour +# poll -> force an ocd poll now (still respects ocd's cache/TTL) +# quit -> stop the daemon + +import json +import os +import re +import signal +import socket +import subprocess +import sys +import time +from datetime import datetime, timezone +from html import escape +from pathlib import Path + +# --- tunables --------------------------------------------------------------- +ROTATE_SECS = 5 # normal auto-advance cadence +LOCK_SECS = 3600 # one hour lock +POLL_SECS = 30 # ocd re-poll cadence (cache serves most calls) +TICK_SECS = 0.5 # main loop resolution +BAR_WIDTH = 7 # nbsp cells per bar (~50 px at normal bar font) +STATE_DIR = Path.home() / ".local" / "state" / "ocd-waybar" +STATE_FILE = STATE_DIR / "state.json" +SOCK_FILE = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) / "ocd-waybar.sock" +OCD_BIN = os.environ.get("OCD_BIN", "ocd") +# Default False: waybar 0.15.0 renders the custom-module tooltip as plain +# text, so tags would show literally. Set True if your waybar supports +# Pango markup in custom tooltips (see Waybar PR #5034). +TOOLTIP_MARKUP = False + +# zone colors +C_OK = "#43c267" +C_WARN = "#e0a030" +C_CRIT = "#e0533b" +C_LOCK = "#5aa6e0" +C_TRACK = "#39424d" + +ZONE = {("ok",): 0, ("warn",): 1, ("crit",): 2} +# Each label is a template: {} is filled with the used value plus its +# unit, e.g. 88 and "%" -> "[88%/5h]". With no data the unit alone shows. +FRAME_LABELS = (("usage_5h", "[{}/5h]"), ("usage_week", "[{}/1w]"), ("usage_month", "[{}/1m]")) + + +def zone_color(pct, status=None, locked=False): + if locked: + return C_LOCK + if status == "rate-limited" or pct >= 90: + return C_CRIT + if pct >= 70: + return C_WARN + return C_OK + + +def zone_name(pct, status=None): + if status == "rate-limited" or pct >= 90: + return "ocd-crit" + if pct >= 70: + return "ocd-warn" + return "ocd-ok" + + +def parse_dt(s): + """Parse an ISO 8601 string with possible nanosecond precision.""" + if not s: + return None + s = s.strip() + if s.endswith("Z"): + s = s[:-1] + "+00:00" + # Python only handles up to 6 fractional digits. Trim the rest. + m = re.search(r"(\.\d{6})\d+", s) + if m: + s = s[:m.start()] + m.group(1) + s[m.end():] + try: + dt = datetime.fromisoformat(s) + except ValueError: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + +def rel_reset(resets_at, now): + """Human string for time until reset, e.g. '4:39', '3d 1h', '20d'.""" + if not resets_at: + return "\u2014" + dt = parse_dt(resets_at) + if dt is None: + return "\u2014" + sec = (dt - now).total_seconds() + if sec <= 0: + return "now" + days = int(sec // 86400) + rem = sec - days * 86400 + hours = int(rem // 3600) + mins = int((rem % 3600) // 60) + if days >= 7: + return f"{days}d" + if days >= 1: + h = hours if hours else 0 + return f"{days}d {h}h" + return f"{hours}:{mins:02d}" + + +def rel_ago(fetched_at, now): + if not fetched_at: + return "\u2014" + dt = parse_dt(fetched_at) + if dt is None: + return "\u2014" + sec = (now - dt).total_seconds() + if sec < 0: + sec = 0 + if sec < 60: + return f"{int(sec)}s ago" + m = int(sec // 60) + if m < 60: + return f"{max(1, m)}m ago" + h = int(sec // 3600) + if h < 24: + return f"{h}h ago" + return f"{h // 24}d ago" + + +def bar_html(pct, status=None, locked=False): + pct = max(0.0, min(100.0, float(pct))) + fill = int(round(pct / 100.0 * BAR_WIDTH)) + fill = max(0, min(BAR_WIDTH, fill)) + empty = BAR_WIDTH - fill + fc = zone_color(pct, status, locked) + out = f'{"\u00a0" * fill}' + if empty: + out += f'{"\u00a0" * empty}' + return out + + +def bar_plain(pct, status=None, locked=False): + """Plain block-character bar for tooltip text that may not parse markup.""" + pct = max(0.0, min(100.0, float(pct))) + units = pct / 100.0 * BAR_WIDTH # cells, fractional + full = int(units) + frac = units - full + blocks = "▏▎▍▌▋▊▉" # one-eighth steps + out = "█" * full + if full < BAR_WIDTH and frac > 0: + idx = int(round(frac * 7)) - 1 # 7 fractional steps + idx = max(0, min(6, idx)) + out += blocks[idx] + full += 1 + out += "░" * (BAR_WIDTH - full) # light shade for the empty track + if locked and full < BAR_WIDTH: + out = out[:full] + "▓" + out[full + 1:] # dim mark on locked rows + return out[:BAR_WIDTH] + + +def frame_get(limits, key): + if not limits: + return None + return limits.get(key) + + +def pct_of(frame): + if frame is None: + return 0.0 + total = float(frame.get("total") or 0) or 100.0 + used = float(frame.get("used") or 0) + return used / total * 100.0 if total else 0.0 + + +def worst_zone(account, locked=False): + if not account or not account.get("limits"): + return "ocd-ok" + worst = 0 + names = ("usage_5h", "usage_week", "usage_month") + for k in names: + f = account["limits"].get(k) + if not f: + continue + zn = zone_name(pct_of(f), f.get("status")) + w = {"ocd-ok": 0, "ocd-warn": 1, "ocd-crit": 2}[zn] + worst = max(worst, w) + return ("ocd-ok", "ocd-warn", "ocd-crit")[worst] + + +def balance_str(account): + bal = account.get("balance") if account else None + if not bal: + return "\u2014" + remaining = float(bal.get("remaining") or 0.0) + unit = bal.get("unit") or "" + # Currency symbol prefixes; others append a space. + if unit and not unit.isalpha(): + return f"{unit}{remaining:.2f}" + return f"{remaining:.2f} {unit}".strip() + + +def render_account_line(account, now, locked, markup=True, with_name=True): + """Render one account: name + lock emoji (optional), then the three + bars with their percentage labels, then balance and update age.""" + limits = account.get("limits") if account else None + parts = [] + if with_name: + nm = account.get("account") if account else "" + if nm: + nm = escape(nm) if markup else nm + parts.append(("\U0001f512 " if locked else "") + nm) + segs = [] + for key, label in FRAME_LABELS: + f = frame_get(limits, key) + if f is None: + filled = label.format("\u2014") + bar = "" if markup else "" + rel = "\u2014" + else: + used = f.get("used") or 0.0 + unit = f.get("unit") or "" + val = f"{int(round(used))}{unit}" + filled = label.format(val) + bar = bar_html(pct_of(f), f.get("status"), locked) if markup else bar_plain(pct_of(f), f.get("status"), locked) + rel = rel_reset(f.get("resets_at"), now) + segs.append(f"{filled} {bar} {rel}") + parts.append(" ".join(segs)) + bal = balance_str(account or {}) + fa = rel_ago(account.get("fetched_at") if account else None, now) + parts.append(f"{bal} / {fa}") + return " ".join(parts) + + +def render_text(report, index, locked, now): + accounts = (report or {}).get("accounts") or [] + if not accounts: + return ("ocd: no data", worst_zone(None)) + idx = index % len(accounts) + acct = accounts[idx] + line = render_account_line(acct, now, locked, markup=True, with_name=True) + return (line, worst_zone(acct, locked)) + + +def render_tooltip(report, index, locked, now): + accounts = (report or {}).get("accounts") or [] + if not accounts: + return "ocd: waiting for first poll" + gen = parse_dt((report or {}).get("generated_at")) + head = "opencode limits" + if gen: + head += f" \u00b7 updated {rel_ago(report.get('generated_at'), now)}" + lines = [head, ""] + for i, acct in enumerate(accounts): + marker = "\u25b8 " if i == (index % len(accounts)) else " " + # The lock emoji belongs only to the currently displayed row. + row_locked = locked and i == (index % len(accounts)) + body = render_account_line(acct, now, row_locked, markup=TOOLTIP_MARKUP, with_name=True) + lines.append(f"{marker}{body}") + return "\n".join(lines) + + +def write_state(text, tooltip, klass): + STATE_DIR.mkdir(parents=True, exist_ok=True) + payload = { + "text": text, + "tooltip": tooltip, + "class": klass, + "alt": "ocd", + } + tmp = STATE_FILE.with_suffix(".json.tmp") + tmp.write_text(json.dumps(payload, ensure_ascii=False)) + tmp.replace(STATE_FILE) + + +def fetch_ocd(): + """Run `ocd raw json`. Returns parsed dict or None on failure.""" + try: + proc = subprocess.run( + [OCD_BIN, "raw", "json"], + capture_output=True, text=True, timeout=60, + ) + if proc.returncode != 0: + return None + return json.loads(proc.stdout) + except Exception: + return None + + +class Daemon: + def __init__(self): + self.report = None + self.index = 0 + self.last_advance = time.monotonic() + self.locked_until = 0.0 + self.next_poll = 0.0 + self.last_text = None + self.last_key = None + self.sock = None + self.running = True + + def event_deadline(self): + now = time.monotonic() + deadlines = [now + TICK_SECS, self.next_poll] + if not self.is_locked(now): + deadlines.append(self.last_advance + ROTATE_SECS) + else: + deadlines.append(self.locked_until) + return max(0.0, min(deadlines) - now) + + def is_locked(self, now=None): + if now is None: + now = time.monotonic() + return self.locked_until and now < self.locked_until + + def advance(self): + n = len((self.report or {}).get("accounts") or []) + if n: + self.index = (self.index + 1) % n + self.last_advance = time.monotonic() + + def lock(self): + self.locked_until = time.monotonic() + LOCK_SECS + + def maybe_tick(self): + now = time.monotonic() + if now >= self.next_poll: + self.next_poll = now + POLL_SECS + rep = fetch_ocd() + if rep: + self.report = rep + # keep index in range + n = len(rep.get("accounts") or []) + if n and self.index >= n: + self.index %= n + if not self.is_locked(now) and (now - self.last_advance) >= ROTATE_SECS: + self.advance() + if self.is_locked(now) and now >= self.locked_until: + self.locked_until = 0.0 + + def render_and_write(self): + now = datetime.now(timezone.utc) + text, zn = render_text(self.report, self.index, self.is_locked(), now) + tooltip = render_tooltip(self.report, self.index, self.is_locked(), now) + klass = ("ocd " + zn + (" ocd-locked" if self.is_locked() else "")).strip() + # Write only when something changed. Displayed relative times + # are minute-resolution, so the file updates a few times per minute. + key = (text, tooltip, klass) + if key != self.last_key: + write_state(text, tooltip, klass) + self.last_key = key + + def handle_cmd(self, cmd): + cmd = (cmd or "").strip() + if cmd == "advance": + self.advance() + self.locked_until = 0.0 + elif cmd == "lock": + self.lock() + elif cmd == "poll": + self.next_poll = 0.0 + elif cmd == "quit": + self.running = False + + def setup_socket(self): + try: + os.unlink(SOCK_FILE) + except FileNotFoundError: + pass + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) + self.sock.bind(str(SOCK_FILE)) + self.sock.setblocking(False) + os.chmod(SOCK_FILE, 0o600) + + def drain_socket(self): + if self.sock is None: + return + while True: + try: + data, _ = self.sock.recvfrom(4096) + except BlockingIOError: + return + self.handle_cmd(data.decode("utf-8", "replace")) + + def stop(self, *_): + self.running = False + try: + if self.sock: + self.sock.close() + os.unlink(SOCK_FILE) + except FileNotFoundError: + pass + sys.exit(0) + + +def main(): + STATE_DIR.mkdir(parents=True, exist_ok=True) + d = Daemon() + d.setup_socket() + signal.signal(signal.SIGTERM, d.stop) + signal.signal(signal.SIGINT, d.stop) + # force first poll + d.next_poll = 0.0 + while d.running: + d.maybe_tick() + d.drain_socket() + d.render_and_write() + time.sleep(d.event_deadline()) + d.stop() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/ocd-waybar-ctl b/scripts/ocd-waybar-ctl new file mode 100755 index 0000000..f347f46 --- /dev/null +++ b/scripts/ocd-waybar-ctl @@ -0,0 +1,30 @@ +#!/bin/sh +# ocd-waybar-ctl: send a command to the ocd-waybar daemon. +# Usage: ocd-waybar-ctl {advance|lock|poll|quit} +# wired into the waybar custom/ocd module as on-click / on-click-right. + +set -eu +cmd="${1:-advance}" +sock="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/ocd-waybar.sock" + +if [ "$#" -gt 0 ]; then + cmd="$1" +fi + +case "$cmd" in + advance|lock|poll|quit) ;; + *) echo "ocd-waybar-ctl: unknown command '$cmd'" >&2; exit 2 ;; +esac + +if [ ! -S "$sock" ]; then + echo "ocd-waybar-ctl: daemon socket not found at $sock" >&2 + exit 1 +fi + +printf '%s\n' "$cmd" | socat - UNIX-SENDTO:"$sock" 2>/dev/null \ + || printf '%s\n' "$cmd" | nc -U -u -w1 "$sock" 2>/dev/null \ + || python3 - "$sock" "$cmd" <<'PY' +import socket, sys +s = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) +s.sendto((sys.argv[2] + "\n").encode(), sys.argv[1]) +PY \ No newline at end of file