Skip to content

Instantly share code, notes, and snippets.

@pixeline
Last active June 6, 2026 15:13
Show Gist options
  • Select an option

  • Save pixeline/11db8076caf89279bb26fa0351cc24c0 to your computer and use it in GitHub Desktop.

Select an option

Save pixeline/11db8076caf89279bb26fa0351cc24c0 to your computer and use it in GitHub Desktop.
xbar plugin: AIM-style Bluesky buddy list in your macOS menu bar — live presence from the public Jetstream firehose, no login, no token. Note: xbar is not maintained so i forked and did some maintenance on it https://github.com/pixeline/xbar/releases/tag/v2.1.9
#!/bin/sh
':' //; for d in $(ls -1 "$HOME/.nvm/versions/node" 2>/dev/null | sort -rV); do c="$HOME/.nvm/versions/node/$d/bin/node"; [ -x "$c" ] && M=$("$c" -p process.versions.node 2>/dev/null | cut -d. -f1) && [ "${M:-0}" -ge 22 ] 2>/dev/null && exec "$c" "$0" "$@"; done; for c in "$HOME/.volta/bin/node" "$HOME/.asdf/shims/node" /opt/homebrew/bin/node /usr/local/bin/node /usr/bin/node; do [ -x "$c" ] && M=$("$c" -p process.versions.node 2>/dev/null | cut -d. -f1) && [ "${M:-0}" -ge 22 ] 2>/dev/null && exec "$c" "$0" "$@"; done; exec "$(command -v node)" "$0" "$@"
//
// Line 1-2 above are a sh/node polyglot: xbar execs the file, /bin/sh picks the
// FIRST node >=22 it can find (nvm highest-version-first, then volta/asdf/
// homebrew/usr), re-execs it, and node reads line 2 as a comment. Node >=22 is
// required for the global WebSocket that powers live presence; older nodes lack
// it and the plugin falls back to "last-seen only". Falls back to any node if
// none >=22 is found. (Previously used `ls -t | head -1`, which picked the
// most-recently-modified nvm version — often an old one without WebSocket.)
//
// <xbar.title>Bluesky Buddy List (AIM)</xbar.title>
// <xbar.version>v1.0</xbar.version>
// <xbar.author>pixeline</xbar.author>
// <xbar.author.github>pixeline</xbar.author.github>
// <xbar.desc>AIM-style buddy list for Bluesky in your menu bar: shows which of your mutuals are online right now, with a door chime when someone signs on. Presence is behavioral, read from the public Jetstream firehose. No login, no token.</xbar.desc>
// <xbar.dependencies>node</xbar.dependencies>
// <xbar.abouturl>https://tangled.org/cee.wtf/aim</xbar.abouturl>
//
// Variables (xbar will prompt for these on install):
// <xbar.var>string(VAR_BSKY_HANDLE=""): Your Bluesky handle, e.g. pixeline.be</xbar.var>
// <xbar.var>boolean(VAR_DOOR_CHIME="true"): Play a sound when a buddy comes online</xbar.var>
// <xbar.var>number(VAR_ONLINE_WINDOW="10"): Minutes of recent activity that count as "online"</xbar.var>
// <xbar.var>boolean(VAR_LAST_SEEN="true"): Backfill "last seen" times for quiet buddies</xbar.var>
// <xbar.var>boolean(VAR_AVATARS="true"): Show buddy avatar thumbnails in the list</xbar.var>
//
// ---------------------------------------------------------------------------
// HOW IT WORKS
// This one file plays two roles:
// 1. Reader (default) - what xbar runs every 30s. Reads a state snapshot the
// daemon writes, prints the menu, exits. Always fast.
// 2. Daemon (--daemon) - a long-lived background process the reader spawns. It
// resolves your mutuals, holds ONE filtered Jetstream
// websocket, classifies presence, plays the door chime,
// and writes state.json whenever anything changes.
// The daemon's lifetime is tied to xbar polling it: the reader touches a
// heartbeat file each refresh, and the daemon exits if the heartbeat goes stale
// (i.e. you removed/disabled the plugin). No orphaned processes.
//
// Concepts ported from cee.wtf/aim (MIT) — the browser original. Notably:
// - hundreds of DIDs must go in a hello message, NOT the URL, or the WS
// handshake is refused. Connect with ?requireHello=true then send an
// options_update with wantedDids on open.
// - presence is behavioral: posted/reposted = online, only liked/followed =
// idle (lurking), quiet = offline.
// - door chimes are an original system sound, not AIM's copyrighted samples.
// ---------------------------------------------------------------------------
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn, execFile } = require('child_process');
// ---- config (from xbar vars, with plain-env / constant fallbacks) ----------
const HANDLE = (process.env.VAR_BSKY_HANDLE || process.env.BSKY_HANDLE || '').trim().replace(/^@/, '');
const DOOR_CHIME = (process.env.VAR_DOOR_CHIME || 'true') !== 'false';
const ONLINE_WINDOW_MIN = Number(process.env.VAR_ONLINE_WINDOW || 10) || 10;
const LAST_SEEN = (process.env.VAR_LAST_SEEN || 'true') !== 'false';
const AVATARS = (process.env.VAR_AVATARS || 'true') !== 'false';
const APPVIEW = 'https://public.api.bsky.app';
const JETSTREAM = 'wss://jetstream2.us-east.bsky.network/subscribe';
const COLLECTIONS = [
'app.bsky.feed.post',
'app.bsky.feed.repost',
'app.bsky.feed.like',
'app.bsky.graph.follow',
];
const DIR = path.join(os.tmpdir(), 'bluesky-aim');
const STATE_FILE = path.join(DIR, 'state.json');
const PID_FILE = path.join(DIR, 'daemon.pid');
const BEAT_FILE = path.join(DIR, 'heartbeat');
const CHIME_SOUND = '/System/Library/Sounds/Glass.aiff';
const ONLINE_WINDOW_MS = ONLINE_WINDOW_MIN * 60 * 1000;
const IDLE_WINDOW_MS = 30 * 60 * 1000; // a "lurking" event keeps you yellow this long
try { fs.mkdirSync(DIR, { recursive: true }); } catch (_) { }
// ===========================================================================
// ROLE SELECTION
// ===========================================================================
// `--restart` (menu action): kill the daemon + clear state, then fall through
// to the reader, which respawns a fresh daemon and shows "connecting…".
if (process.argv.includes('--restart')) {
try {
const pid = Number(fs.readFileSync(PID_FILE, 'utf8'));
if (pid) process.kill(pid, 'SIGTERM');
} catch (_) { }
try { fs.unlinkSync(PID_FILE); } catch (_) { }
try { fs.unlinkSync(STATE_FILE); } catch (_) { }
}
if (process.argv.includes('--daemon')) {
runDaemon();
} else {
runReader();
}
// ===========================================================================
// READER — what xbar executes on its 30s schedule
// ===========================================================================
function runReader() {
// tell the daemon we're still alive
try { fs.writeFileSync(BEAT_FILE, String(Date.now())); } catch (_) { }
if (!HANDLE) {
console.log('Bluesky ⚙️');
console.log('---');
console.log('Set your handle in this plugin\'s settings | color=#ff4136');
console.log('xbar → Bluesky Buddy List → ⋯ → Open plugin… | refresh=true');
return;
}
ensureDaemon();
let state = null;
try {
state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
} catch (_) {
// daemon hasn't produced a snapshot yet
}
if (!state) {
console.log('Bluesky …');
console.log('---');
console.log('Connecting to the firehose… | color=#aaaaaa');
console.log('Refresh | refresh=true');
return;
}
if (state.error) {
console.log('Bluesky ⚠️');
console.log('---');
console.log(`Couldn't load buddies: ${state.error} | color=#ff4136`);
console.log(`Check your handle (@${state.handle || HANDLE}) and connection | size=11 color=#888888`);
console.log('Retry | refresh=true');
return;
}
renderMenu(state);
}
function renderMenu(state) {
const buddies = state.buddies || [];
const online = buddies.filter((b) => b.status === 'online').sort(byRecent);
const idle = buddies.filter((b) => b.status === 'idle').sort(byRecent);
const offline = buddies.filter((b) => b.status === 'offline').sort(bySeen);
const stale = Date.now() - (state.updatedAt || 0) > 90 * 1000;
const live = !state.notice;
// ---- menu bar ----
if (live) console.log(`🟢 ${online.length}${stale ? '' : ''}`);
else console.log(`👥 ${buddies.length}`);
// ---- dropdown ----
console.log('---');
console.log(`Bluesky Buddies — @${state.handle} | href=https://bsky.app/profile/${state.handle} size=13`);
console.log(`${buddies.length} mutuals · ${online.length} online · ${idle.length} idle | size=11 color=#8a8a8a`);
if (state.notice) console.log(`⚠️ ${state.notice} | size=11 color=#ff851b`);
console.log('---');
if (online.length) {
console.log(`ONLINE — ${online.length} | size=10 color=#2ecc40`);
online.forEach((b) => console.log(buddyRow(b)));
} else if (live) {
console.log(`No buddies online right now | size=12 color=#8a8a8a`);
}
if (idle.length) {
console.log('---');
console.log(`IDLE — ${idle.length} | size=10 color=#d4a017`);
idle.forEach((b) => console.log(buddyRow(b)));
}
if (offline.length) {
console.log('---');
// a flat 100+ list is a wall; bucket by recency into submenus instead
const now = Date.now();
const DAY = 86400 * 1000;
const groups = [[], [], [], []];
const labels = ['Seen today', 'Seen this week', 'Seen this month', 'Dormant / unknown'];
for (const b of offline) {
const age = b.lastSeen ? now - b.lastSeen : Infinity;
groups[age < DAY ? 0 : age < 7 * DAY ? 1 : age < 30 * DAY ? 2 : 3].push(b);
}
labels.forEach((label, i) => {
if (!groups[i].length) return;
console.log(`⚪ ${label} — ${groups[i].length}`);
groups[i].forEach((b) => console.log('--' + buddyRow(b, true)));
});
}
console.log('---');
console.log(`Door chime: ${DOOR_CHIME ? 'on' : 'off'} | size=11 color=#8a8a8a`);
console.log(`Reconnect firehose | bash="${process.argv[0]}" param1="${__filename}" param2="--restart" terminal=false refresh=true`);
console.log('Refresh | refresh=true');
console.log(`aim.cee.wtf | href=https://aim.cee.wtf size=11 color=#8a8a8a`);
}
// One menu line per buddy. Name-primary (handles are long and clutter the list);
// the @handle is shown for handle-only accounts and is always the click target.
// No forced text color, so macOS uses the adaptive label color — legible in both
// light and dark menus.
function buddyRow(b, isOffline) {
const display = (b.displayName || '').trim();
let label = display || ('@' + b.handle);
if (isOffline && b.lastSeen) label += ' · ' + relTime(b.lastSeen);
label = label.replace(/\|/g, ''); // the pipe is reserved by xbar
let params = `href=https://bsky.app/profile/${b.handle}`;
if (b.avatar) params += ` image=${b.avatar}`;
return `${label} | ${params}`;
}
function byRecent(a, b) { return (b.lastActivity || 0) - (a.lastActivity || 0); }
function bySeen(a, b) { return (b.lastSeen || 0) - (a.lastSeen || 0); }
// ---- daemon supervision -----------------------------------------------------
function ensureDaemon() {
if (daemonAlive()) return;
// handle a one-off "--restart" action invoked from the menu
const child = spawn(process.argv[0], [__filename, '--daemon'], {
detached: true,
stdio: 'ignore',
env: process.env,
});
try { fs.writeFileSync(PID_FILE, String(child.pid)); } catch (_) { }
child.unref();
}
function daemonAlive() {
let pid;
try { pid = Number(fs.readFileSync(PID_FILE, 'utf8')); } catch (_) { return false; }
if (!pid) return false;
try { process.kill(pid, 0); return true; } catch (_) { return false; }
}
// ===========================================================================
// DAEMON — long-lived: holds the firehose, classifies presence, writes state
// ===========================================================================
async function runDaemon() {
// single-instance guard
process.title = 'bluesky-aim-daemon';
const startMs = Date.now();
const activity = new Map(); // did -> { post: ts, lurk: ts }
const lastSeenMap = new Map(); // did -> ts (newest known public activity)
const avatarB64 = new Map(); // did -> base64 png (16-36px), cached for daemon lifetime
const chimedAt = new Map(); // did -> ts (debounce chimes)
let prevStatus = new Map(); // did -> 'online'|'idle'|'offline' (for chime edge-detect)
let profiles = new Map(); // did -> { handle, displayName }
let mutuals = []; // [did]
let handle = HANDLE;
let did = null;
let lastCursor = (startMs - ONLINE_WINDOW_MS) * 1000; // µs, seed a little replay
// exit if xbar stops polling us (plugin disabled/removed)
setInterval(() => {
let beat = 0;
try { beat = Number(fs.readFileSync(BEAT_FILE, 'utf8')); } catch (_) { }
if (Date.now() - beat > 150 * 1000) cleanExit(0);
}, 30 * 1000).unref();
function cleanExit(code) {
try { fs.unlinkSync(PID_FILE); } catch (_) { }
process.exit(code);
}
process.on('SIGTERM', () => cleanExit(0));
process.on('SIGINT', () => cleanExit(0));
// ---- resolve identity + mutuals --------------------------------------
try {
did = await resolveHandle(handle);
const [follows, followers] = await Promise.all([
listGraph('app.bsky.graph.getFollows', did, 'follows'),
listGraph('app.bsky.graph.getFollowers', did, 'followers'),
]);
const followerSet = new Set(followers);
mutuals = follows.filter((d) => followerSet.has(d));
profiles = await fetchProfiles(mutuals);
handle = (profiles.get(did) || {}).handle || handle;
} catch (err) {
writeState({ error: String(err && err.message || err), handle });
// exit; the reader respawns us on its next tick (≤30s). Avoids stacking
// intervals/handlers on repeated retries.
return cleanExit(1);
}
if (!mutuals.length) {
writeState({ handle, buddies: [] });
}
// optional: seed "last seen" for everyone, throttled
if (LAST_SEEN) backfillLastSeen(mutuals).catch(() => { });
// optional: fetch + downscale avatars (macOS `sips`), throttled, best-effort
if (AVATARS) backfillAvatars(mutuals).catch(() => { });
// ---- presence recompute loop -----------------------------------------
function statusOf(d) {
const a = activity.get(d);
if (!a) return 'offline';
const now = Date.now();
if (a.post && now - a.post < ONLINE_WINDOW_MS) return 'online';
if (a.lurk && now - a.lurk < IDLE_WINDOW_MS) return 'idle';
return 'offline';
}
function snapshot() {
const buddies = mutuals.map((d) => {
const p = profiles.get(d) || {};
const a = activity.get(d) || {};
return {
did: d,
handle: p.handle || d,
displayName: p.displayName || '',
status: statusOf(d),
lastActivity: Math.max(a.post || 0, a.lurk || 0) || 0,
lastSeen: lastSeenMap.get(d) || 0,
avatar: avatarB64.get(d) || '',
};
});
return {
handle, did, updatedAt: Date.now(), buddies,
notice: wsAvailable() ? undefined :
'Live presence needs Node ≥22 or the "ws" package (npm i -g ws). Showing last-seen only.',
};
}
function publish() {
const snap = snapshot();
// chime on offline/idle -> online transitions (live events only)
if (DOOR_CHIME) {
for (const b of snap.buddies) {
const was = prevStatus.get(b.did) || 'offline';
const justOnline = b.status === 'online' && was !== 'online';
const isLive = b.lastActivity > startMs; // not a replayed event
const cooled = Date.now() - (chimedAt.get(b.did) || 0) > 5 * 60 * 1000;
if (justOnline && isLive && cooled) {
chimedAt.set(b.did, Date.now());
chime();
}
}
}
prevStatus = new Map(snap.buddies.map((b) => [b.did, b.status]));
writeState(snap);
}
// recompute every 15s so people decay to idle/offline without new events
setInterval(publish, 15 * 1000).unref();
// ---- firehose --------------------------------------------------------
// ---- firehose (only if this Node has a WebSocket; else last-seen only) -
if (wsAvailable()) {
connectFirehose();
} else {
// No WebSocket on this Node. The recompute loop + last-seen backfill still
// produce a useful buddy list; presence dots just won't go live.
publish();
}
function connectFirehose() {
const url = `${JETSTREAM}?requireHello=true&cursor=${Math.floor(lastCursor)}`;
let ws;
try {
ws = new WS(url);
} catch (err) {
return setTimeout(connectFirehose, 5000);
}
ws.addEventListener('open', () => {
// THE gotcha: wantedDids go here, not in the URL.
ws.send(JSON.stringify({
type: 'options_update',
payload: { wantedCollections: COLLECTIONS, wantedDids: mutuals },
}));
});
ws.addEventListener('message', (ev) => {
let msg;
try { msg = JSON.parse(ev.data); } catch (_) { return; }
if (msg.time_us) lastCursor = msg.time_us;
if (msg.kind !== 'commit' || !msg.commit) return;
const op = msg.commit.operation;
if (op !== 'create' && op !== 'update') return;
const d = msg.did;
const when = msg.time_us ? Math.floor(msg.time_us / 1000) : Date.now();
const a = activity.get(d) || {};
if (msg.commit.collection === 'app.bsky.feed.post' ||
msg.commit.collection === 'app.bsky.feed.repost') {
a.post = Math.max(a.post || 0, when);
} else {
a.lurk = Math.max(a.lurk || 0, when);
}
activity.set(d, a);
if (when > (lastSeenMap.get(d) || 0)) lastSeenMap.set(d, when);
publish();
});
const reconnect = () => setTimeout(connectFirehose, 3000);
ws.addEventListener('close', reconnect);
ws.addEventListener('error', () => { try { ws.close(); } catch (_) { } });
}
// ---- last-seen backfill (throttled, best effort) ---------------------
async function backfillLastSeen(dids) {
const queue = dids.slice();
const workers = Array.from({ length: 4 }, async () => {
while (queue.length) {
const d = queue.shift();
if (activity.get(d)) continue; // already have live data
try {
const r = await xrpc('app.bsky.feed.getAuthorFeed', {
actor: d, limit: 1, filter: 'posts_and_author_threads',
});
const item = r.feed && r.feed[0];
const t = item && item.post && (item.post.indexedAt || item.post.record?.createdAt);
if (t) lastSeenMap.set(d, Date.parse(t));
} catch (_) { }
}
});
await Promise.all(workers);
publish();
}
// ---- avatar backfill: fetch CDN avatar, downscale with macOS `sips` ----
// Best-effort and fully guarded: any failure just means no thumbnail for that
// buddy (the text row still renders). Cached for the daemon's lifetime.
async function backfillAvatars(dids) {
const queue = dids.filter((d) => (profiles.get(d) || {}).avatar);
let done = 0;
const workers = Array.from({ length: 4 }, async () => {
while (queue.length) {
const d = queue.shift();
const b64 = await makeAvatar(d, (profiles.get(d) || {}).avatar);
if (b64) avatarB64.set(d, b64);
if (++done % 16 === 0) publish(); // stream them in as they arrive
}
});
await Promise.all(workers);
publish();
}
async function makeAvatar(did, url) {
const safe = did.replace(/[^a-z0-9]/gi, '_');
const src = path.join(DIR, `av_${safe}.src`);
const out = path.join(DIR, `av_${safe}.png`);
try {
const res = await fetch(url);
if (!res.ok) return null;
fs.writeFileSync(src, Buffer.from(await res.arrayBuffer()));
// -Z 36 = fit within 36px (retina-friendly for a ~18px menu row)
await execFileP('sips', ['-s', 'format', 'png', '-Z', '36', src, '--out', out]);
return fs.readFileSync(out).toString('base64');
} catch (_) {
return null;
} finally {
try { fs.unlinkSync(src); } catch (_) { }
try { fs.unlinkSync(out); } catch (_) { }
}
}
function chime() {
try {
if (fs.existsSync(CHIME_SOUND)) execFile('afplay', [CHIME_SOUND]);
} catch (_) { }
}
function writeState(obj) {
const tmp = STATE_FILE + '.tmp';
try {
fs.writeFileSync(tmp, JSON.stringify(obj));
fs.renameSync(tmp, STATE_FILE);
} catch (_) { }
}
}
// ===========================================================================
// atproto helpers (public, unauthenticated, CORS-friendly endpoints)
// ===========================================================================
async function xrpc(method, params) {
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(params || {})) {
if (Array.isArray(v)) v.forEach((x) => qs.append(k, x));
else qs.append(k, v);
}
const res = await fetch(`${APPVIEW}/xrpc/${method}?${qs.toString()}`, {
headers: { accept: 'application/json' },
});
if (!res.ok) throw new Error(`${method} → HTTP ${res.status}`);
return res.json();
}
async function resolveHandle(handle) {
const r = await xrpc('com.atproto.identity.resolveHandle', { handle });
if (!r.did) throw new Error(`could not resolve @${handle}`);
return r.did;
}
async function listGraph(method, actor, key) {
const out = [];
let cursor;
do {
const r = await xrpc(method, cursor ? { actor, limit: 100, cursor } : { actor, limit: 100 });
for (const item of r[key] || []) out.push(item.did);
cursor = r.cursor;
} while (cursor);
return out;
}
async function fetchProfiles(dids) {
const map = new Map();
for (let i = 0; i < dids.length; i += 25) {
const batch = dids.slice(i, i + 25);
try {
const r = await xrpc('app.bsky.actor.getProfiles', { actors: batch });
for (const p of r.profiles || []) {
map.set(p.did, { handle: p.handle, displayName: p.displayName || '', avatar: p.avatar || '' });
}
} catch (_) { }
}
return map;
}
// ---- WebSocket: prefer Node 22+ global, fall back to the `ws` package -------
function wsAvailable() {
if (typeof WebSocket === 'function') return true;
try { require.resolve('ws'); return true; } catch (_) { return false; }
}
function WS(url) {
if (typeof WebSocket === 'function') return new WebSocket(url);
try {
const WsLib = require('ws');
const sock = new WsLib(url);
// normalize to addEventListener/.data shape used above
sock.addEventListener = (type, fn) =>
sock.on(type, (arg) => fn(type === 'message' ? { data: arg.toString() } : arg));
return sock;
} catch (_) {
throw new Error('No WebSocket. Use Node ≥22, or run: npm i -g ws');
}
}
// ---- tiny time formatter ----------------------------------------------------
function execFileP(cmd, args) {
return new Promise((resolve, reject) => {
execFile(cmd, args, { timeout: 8000 }, (err, stdout) => (err ? reject(err) : resolve(stdout)));
});
}
function relTime(ms) {
if (!ms) return '';
const s = Math.max(0, (Date.now() - ms) / 1000);
if (s < 90) return 'just now';
const m = s / 60;
if (m < 90) return `${Math.round(m)}m ago`;
const h = m / 60;
if (h < 36) return `${Math.round(h)}h ago`;
return `${Math.round(h / 24)}d ago`;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment