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.
This commit is contained in:
ocpway 2026-07-30 04:29:37 +03:00
commit ea834a99a5
6 changed files with 629 additions and 0 deletions

137
README.md Normal file
View File

@ -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`.

View File

@ -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

View File

@ -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"
},

12
config/waybar-style.css Normal file
View File

@ -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;
}

418
scripts/ocd-waybar Executable file
View File

@ -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'<span background="{fc}">{"\u00a0" * fill}</span>'
if empty:
out += f'<span background="{C_TRACK}">{"\u00a0" * empty}</span>'
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()

30
scripts/ocd-waybar-ctl Executable file
View File

@ -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