diff --git a/slab/bin/slab-wallpaper b/slab/bin/slab-wallpaper deleted file mode 100755 index 5e934b94f1..0000000000 --- a/slab/bin/slab-wallpaper +++ /dev/null @@ -1,38 +0,0 @@ -#!/bin/bash -# slab-wallpaper — node-resolving wrapper around slab-wallpaper.mjs. -# -# The menubar runs under launchd with a minimal PATH that does NOT include -# fnm's per-shell node shim, so the Swift side calls THIS absolute path and -# we resolve a usable `node` ourselves (mirrors Tools.resolve in Swift). -set -u -# Resolve through symlinks (this is symlinked into ~/.local/bin like the -# other slab bins) so SCRIPT points at the real .mjs next to the wrapper. -SRC="${BASH_SOURCE[0]}" -while [ -L "$SRC" ]; do - DIR="$(cd -P "$(dirname "$SRC")" && pwd)" - SRC="$(readlink "$SRC")" - [[ "$SRC" != /* ]] && SRC="$DIR/$SRC" -done -HERE="$(cd -P "$(dirname "$SRC")" && pwd)" -SCRIPT="$HERE/slab-wallpaper.mjs" - -find_node() { - if command -v node >/dev/null 2>&1; then command -v node; return; fi - local c - for c in \ - "$HOME/.local/share/fnm/aliases/default/bin/node" \ - "$HOME/.fnm/aliases/default/bin/node" \ - /opt/homebrew/bin/node \ - /usr/local/bin/node \ - "$HOME/.volta/bin/node"; do - [ -x "$c" ] && { echo "$c"; return; } - done - # Newest fnm-managed version, if any. - c=$(ls -td "$HOME"/.local/share/fnm/node-versions/*/installation/bin/node \ - 2>/dev/null | head -1) - [ -n "$c" ] && [ -x "$c" ] && { echo "$c"; return; } - return 1 -} - -NODE="$(find_node)" || { echo ""; exit 0; } # never break the caller -exec "$NODE" "$SCRIPT" "$@" diff --git a/slab/bin/slab-wallpaper.mjs b/slab/bin/slab-wallpaper.mjs deleted file mode 100755 index 41ba6513cb..0000000000 --- a/slab/bin/slab-wallpaper.mjs +++ /dev/null @@ -1,383 +0,0 @@ -#!/usr/bin/env node -// slab-wallpaper — subject/status-driven terminal wallpaper generator. -// -// Generates budgeted Cloudflare FLUX images through Aesthetic Computer and -// caches them permanently by subject/status. Cached defaults remain available -// when the image service or its daily allowance is unavailable. -// -// Subcommands: -// slab-wallpaper defaults ensure all 5 status wallpapers -// slab-wallpaper status print path (gen if missing) -// slab-wallpaper subject "" print path (gen; falls back to -// the status default on failure) -// slab-wallpaper path subject "" cached path only, no gen, "" -// if absent (non-blocking probe -// for the menubar refresh tick) -// -// Never throws; always exits 0 with a best-effort path on stdout. Logs to -// ~/.local/share/slab/logs/wallpaper.log. - -import { execFile } from "node:child_process"; -import { createHash } from "node:crypto"; -import { promises as fs, existsSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { wallpaperBackoff } from "./wallpaper-backoff.mjs"; - -const WALLPAPER_HOME = os.homedir(); -const SLAB_HOME = - process.env.SLAB_HOME || path.join(WALLPAPER_HOME, ".local/share/slab"); -const WALL_DIR = path.join(SLAB_HOME, "wallpaper"); -const STATUS_DIR = path.join(WALL_DIR, "status"); -const SUBJECT_DIR = path.join(WALL_DIR, "subject"); -const LOCK_DIR = path.join(WALL_DIR, ".lock"); // atomic mkdir lock -const LOG = path.join(SLAB_HOME, "logs/wallpaper.log"); - -const backoff = wallpaperBackoff(WALL_DIR); - -const FLUX_PROXY = "https://aesthetic.computer/api/flux"; - -// The hosted Cloudflare model returns fixed 1024×1024 images. -const W = 1024; -const H = 1024; -// "raw" = pass our prompt through verbatim (no AC pixel-art preset — the -// forced bitmap/CRT look reads as code/UI, which we explicitly don't want). -const PRESET = "raw"; - -// Shared style + anti-text steering appended to every prompt. FLUX schnell -// has no true negative prompt (cfg 0), but "no X" in the positive prompt -// biases it away from X (same trick flux.mjs's presets use for "no text"). -// Keep texture descriptions short and natural. -const STYLE = - "soft abstract material texture, painterly, low contrast, dim, even"; -const NEG = "no text, no code, no screen, no people"; - -// Per-status color phrase + a hand-tuned default texture. Material words -// only (fiber, ink, mist, grain) — nothing techy that FLUX renders as -// diagrams or code. Palette-locked so status stays glanceable. -const STATUS = { - working: { - color: "deep forest green and dark mint", - prompt: - "deep forest-green woven fiber and soft moss, calm steady rhythm, " + - "on near-black", - }, - awaiting: { - color: "warm amber and ember orange", - prompt: - "warm amber and ember-orange glowing mist over dark ground, " + - "gently insistent slow swirl", - }, - complete: { - color: "cool slate blue and pale lavender", - prompt: - "calm slate-blue marbled ink with pale lavender veining on deep " + - "slate, settled and quiet", - }, - blank: { - color: "neutral graphite and faint grey", - prompt: "calm minimal dark grey paper grain, soft and even", - }, - stale: { - color: "deep red and dim crimson", - prompt: - "dim crimson and dark-red cooling ember haze, low muted smolder on " + - "near-black", - }, -}; - -const log = (...m) => { - const line = `[${new Date().toISOString()}] ${m.join(" ")}\n`; - try { - fs.mkdir(path.dirname(LOG), { recursive: true }).then(() => - fs.appendFile(LOG, line), - ); - } catch {} -}; - -// Keep subjects abstract by dropping clusters of proper -// nouns + dense modifiers (see flux.mjs). Drop code/paths/urls/@handles/ -// CamelCase+ALLCAPS identifiers, collapse, cap length, lowercase. -function sanitize(summary) { - let s = (summary || "").toString(); - s = s.replace(/```[\s\S]*?```/g, " "); - s = s.replace(/https?:\/\/\S+/g, " "); - s = s.replace(/[~/.]?\/[\w./-]+/g, " "); // file paths - s = s.replace(/@[\w-]+/g, " "); // handles - s = s.replace(/[`"'<>{}[\]|]+/g, " "); - s = s - .split(/\s+/) - .filter( - (w) => - w && - !/^[A-Z][a-z]+[A-Z]/.test(w) && // CamelCase identifiers - !/^[A-Z]{3,}$/.test(w) && // ALLCAPS - !/\d{3,}/.test(w), - ) - .join(" "); - s = s.toLowerCase().replace(/\s+/g, " ").trim(); - return s.slice(0, 120); -} - -// Compose the final prompt: a base description + shared style + the -// anti-text/code steering, capped under FLUX's 1000-char limit. -function finalize(base) { - return `${base}, ${STYLE}, ${NEG}`.slice(0, 980); -} - -function subjectPrompt(summary, status) { - const st = STATUS[status] || STATUS.working; - const theme = sanitize(summary) || "a quiet idea"; - // The topic steers MOOD only — never literal content. Result must read - // as abstract material, not an illustration of (or about) the work. - return finalize( - `dim ${st.color} abstract texture loosely evoking the mood of ` + - `${theme}, soft organic material, not an illustration, no scene`, - ); -} - -function cacheKey(prompt, height = H) { - return createHash("sha1") - .update(`${PRESET}|${W}x${height}|${prompt}`) - .digest("hex") - .slice(0, 16); -} - -// Previously generated landscape textures remain useful. Prefer the current -// square cache, then reuse the old entry before spending another reservation. -export function cachedImagePath(dir, prompt) { - for (const height of [H, 768]) { - const candidate = path.join(dir, `${cacheKey(prompt, height)}.jpg`); - if (existsSync(candidate)) return candidate; - } - return ""; -} - -async function withLock(fn) { - // Serialize network gens (one at a time, with a persistent provider cooldown). Atomic - // mkdir lock; reclaim if older than 3 min (a crashed gen). - for (let i = 0; i < 90; i++) { - try { - await fs.mkdir(LOCK_DIR); - try { - return await fn(); - } finally { - await fs.rmdir(LOCK_DIR).catch(() => {}); - } - } catch { - try { - const st = await fs.stat(LOCK_DIR); - if (Date.now() - st.mtimeMs > 180000) { - await fs.rmdir(LOCK_DIR).catch(() => {}); - continue; - } - } catch {} - await new Promise((r) => setTimeout(r, 2000)); - } - } - return null; // couldn't acquire — caller falls back to cache/default -} - -async function fetchJSON(url, opts, ms) { - const ctl = new AbortController(); - const t = setTimeout(() => ctl.abort(), ms); - try { - const response = await fetch(url, { ...opts, signal: ctl.signal }); - // The provider can send headers and then stall the image payload. Keep - // the same deadline active until JSON is fully read, not just headers. - const data = response.ok ? await response.json() : null; - if (!response.ok) await response.body?.cancel(); - return { response, data }; - } finally { - clearTimeout(t); - } -} - -// Returns a JPEG Buffer or null. Generation is metered by the shared AC budget. -export async function generate(prompt) { - if (await backoff.active("proxy")) return null; - try { - const { response: r, data: d } = await fetchJSON( - FLUX_PROXY, - { - method: "POST", - headers: { - "Content-Type": "application/json", - "User-Agent": "slab-wallpaper/1", - }, - body: JSON.stringify({ - prompt, - // Older servers must not use their premium fallback during rollout. - // The Cloudflare-only server ignores this legacy option. - allow_fallback: false, - preset: PRESET, - }), - }, - 35000, - ); - if (r.ok) { - if (d?.ok && typeof d.png === "string" && d.png.startsWith("data:")) { - log("gen ok via prod proxy"); - return Buffer.from(d.png.split(",", 2)[1], "base64"); - } - log("proxy not ok", d?.reason || "?"); - } else { - log("proxy http", r.status); - if (r.status === 429 || r.status >= 500) { - await backoff.defer("proxy", r.headers.get("Retry-After")); - } - } - } catch (e) { - log("proxy err", e.name || String(e)); - await backoff.defer("proxy"); - } - return null; -} - -// Resolve a tool to an absolute path — execFile uses the process PATH, -// which is minimal under launchd/non-interactive, so bare names miss -// Homebrew. Mirrors the node-resolution in the slab-wallpaper wrapper. -function bin(name) { - for (const d of [ - "/opt/homebrew/bin", - "/usr/local/bin", - "/opt/local/bin", - "/usr/bin", - "/bin", - ]) { - const p = path.join(d, name); - if (existsSync(p)) return p; - } - return null; -} - -function run(cmd, args, ms = 20000) { - const abs = bin(cmd); - if (!abs) return Promise.resolve(false); - return new Promise((res) => - execFile(abs, args, { timeout: ms }, (e) => res(!e)), - ); -} - -// Mirror-tile `src` → `dst`: append a horizontally-flipped copy (seams -// vanish left↔right) then a vertically-flipped copy of that (seams vanish -// top↔bottom). Any image becomes a perfectly seamless tile for iTerm2's -// Tile mode. ImageMagick first, ffmpeg fallback, else signal passthrough. -async function tileify(src, dst) { - const im = ["(", "+clone", "-flop", ")", "+append", - "(", "+clone", "-flip", ")", "-append"]; - if (await run("magick", [src, ...im, dst])) return true; - if (await run("convert", [src, ...im, dst])) return true; - if ( - await run("ffmpeg", [ - "-y", "-i", src, "-filter_complex", - "[0]split=2[a][b];[b]hflip[bf];[a][bf]hstack=2[t];" + - "[t]split=2[c][d];[d]vflip[df];[c][df]vstack=2[o]", - "-map", "[o]", "-frames:v", "1", dst, - ]) - ) - return true; - return false; -} - -// Ensure /.jpg exists for `prompt`. Returns its path, or null if -// generation failed and nothing is cached yet. The generated image is -// mirror-tiled before caching so it's seamless under iTerm2 Tile mode. -async function ensure(dir, prompt) { - await fs.mkdir(dir, { recursive: true }); - const out = path.join(dir, `${cacheKey(prompt)}.jpg`); - const cached = cachedImagePath(dir, prompt); - if (cached) return cached; - const buf = await withLock(async () => { - const cached = cachedImagePath(dir, prompt); - if (cached) return cached; // won the race meanwhile - const b = await generate(prompt); - if (!b) return null; - // Keep .jpg extensions: ImageMagick/ffmpeg infer format from the name, - // and a ".raw" suffix makes magick try to decode literal RAW pixels. - const raw = `${out}.${process.pid}.in.jpg`; - const tmp = `${out}.${process.pid}.out.jpg`; - await fs.writeFile(raw, b); - if (await tileify(raw, tmp)) { - await fs.rename(tmp, out); - log("tiled + cached"); - } else { - // Post-process unavailable — cache the raw image so it still works - // (just not seamless under Tile). - await fs.rename(raw, out); - log("cached raw (tileify unavailable)"); - } - await fs.rm(raw, { force: true }).catch(() => {}); - await fs.rm(tmp, { force: true }).catch(() => {}); - await fs - .writeFile( - out.replace(/\.jpg$/, ".json"), - JSON.stringify({ prompt, preset: PRESET, w: W, h: H, at: Date.now() }), - ) - .catch(() => {}); - return out; - }); - if (buf) return buf; - return cachedImagePath(dir, prompt) || null; -} - -async function statusPath(name, { gen = true } = {}) { - const st = STATUS[name] || STATUS.working; - await fs.mkdir(STATUS_DIR, { recursive: true }); - const out = path.join(STATUS_DIR, `${name}.jpg`); - if (existsSync(out)) return out; - if (!gen) return ""; - const made = await ensure(SUBJECT_DIR, finalize(st.prompt)); - if (made) { - await fs.copyFile(made, out).catch(() => {}); - if (existsSync(out)) return out; - } - return ""; -} - -async function main() { - const [cmd, a1, a2, a3] = process.argv.slice(2); - try { - if (cmd === "defaults") { - const done = []; - for (const name of Object.keys(STATUS)) { - const p = await statusPath(name); - done.push(`${name}:${p ? "ok" : "pending"}`); - } - process.stdout.write(done.join(" ") + "\n"); - return; - } - if (cmd === "status") { - process.stdout.write((await statusPath(a1 || "working")) + "\n"); - return; - } - if (cmd === "subject") { - const status = a2 || "working"; - const prompt = subjectPrompt(a1 || "", status); - const p = await ensure(SUBJECT_DIR, prompt); - // Failure floor: hand back the status default so the caller always - // has *something* to set as the background. - process.stdout.write((p || (await statusPath(status))) + "\n"); - return; - } - if (cmd === "path" && a1 === "subject") { - const prompt = subjectPrompt(a2 || "", a3 || "working"); - process.stdout.write(cachedImagePath(SUBJECT_DIR, prompt) + "\n"); - return; - } - process.stderr.write( - "usage: slab-wallpaper defaults|status |subject |" + - "path subject \n", - ); - } catch (e) { - log("fatal", e?.stack || String(e)); - process.stdout.write("\n"); // never break the caller - } -} - -if ( - process.argv[1] && - path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) -) - main(); diff --git a/slab/bin/wallpaper-backoff.mjs b/slab/bin/wallpaper-backoff.mjs deleted file mode 100644 index 32a145018e..0000000000 --- a/slab/bin/wallpaper-backoff.mjs +++ /dev/null @@ -1,30 +0,0 @@ -import { promises as fs } from "node:fs"; -import path from "node:path"; - -// Persist across the short-lived wallpaper CLI processes and subject changes. -export function wallpaperBackoff(directory, now = Date.now) { - const file = (provider) => path.join(directory, `.retry-${provider}.json`); - return { - async active(provider) { - try { - const { until } = JSON.parse(await fs.readFile(file(provider), "utf8")); - return Number.isFinite(until) && until > now(); - } catch { - return false; - } - }, - async defer(provider, retryAfter) { - const seconds = Number(retryAfter); - const delay = - retryAfter && Number.isFinite(seconds) - ? seconds * 1000 - : Date.parse(retryAfter) - now(); - const until = - now() + (Number.isFinite(delay) && delay > 0 ? delay : 60000); - await fs.mkdir(directory, { recursive: true }); - const temp = `${file(provider)}.${process.pid}.tmp`; - await fs.writeFile(temp, JSON.stringify({ until })); - await fs.rename(temp, file(provider)); - }, - }; -} diff --git a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift index 860aac9b8e..934044d18a 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift @@ -23,32 +23,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { /// overlapping 2 s tick doesn't pile up worker threads (a stalled /// `tailscale status` can run right up to its 2 s timeout). private var gathering = false - /// Subject keys we've already fired a `slab-wallpaper` gen for, so a slow - /// generation isn't re-kicked every 2 s tick. Guarded by `wallpaperLock` - /// (touched from the off-main resolve). - private var kickedWallpapers = Set() - /// At most one `slab-wallpaper` gen may run at a time. Every status flip and - /// every retitled session mints a fresh key, and a gen takes minutes on a - /// loaded box — detached spawns outran their own renders and piled up - /// (8 nodes × ~16 MB on neo, self-amplifying into swap). A request landing - /// mid-render coalesces into `pendingWallpaper`, replacing whatever was - /// waiting: only the newest state is worth drawing. - private var wallpaperBusy = false - private var pendingWallpaper: (key: String, subject: String, status: String)? - private let wallpaperLock = NSLock() - /// `slab-wallpaper path` is a pure hash of (subject, status) plus an - /// existence check, so once it names a real file that answer is permanent — - /// but we were forking a node to re-ask it for every session on every 2 s - /// tick. Seven sessions is ~3.5 node spawns a second, forever, and it scales - /// with the one number that keeps growing. Remember the answers instead. - private var wallpaperPaths: [String: String] = [:] - /// Keys whose render hasn't landed yet still answer "" — those we re-ask, but - /// on a slow clock, not every tick. A finished gen clears its stamp so the - /// fresh picture is picked up on the next pass rather than after the wait. - private var wallpaperProbedAt: [String: Date] = [:] - private let wallpaperProbeRetry: TimeInterval = 20 - /// One-shot guard so the status-defaults gen is kicked once per launch. - private var defaultsKicked = false private var refreshTimer: Timer? /// Dedicated contact-awareness clock. iMessage must not depend on the /// heavier fleet snapshot completing; a stalled system probe should never @@ -567,10 +541,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { DispatchQueue.global(qos: .utility).async { [weak self] in guard let self = self else { return } var snapshot = StateSnapshot.gather() - // Resolve each session's wallpaper here (off-main): only instant - // cache probes + fire-and-forget gen kicks, so the main thread - // never waits on node/network (see slab-menubar-perf). - snapshot.claudeSessions = self.resolveWallpapers(snapshot.claudeSessions) // Sticky per-session title emoji — assigned here (off-main, // serialized by the `gathering` guard) so decor + menu read one // consistent mark. @@ -2202,8 +2172,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } /// Toggle the "spawn in iTerm2" preference: when on, restore-threads / - /// restart-all open sessions in iTerm2 (the only terminal that shows the - /// tiled topic wallpapers) instead of Terminal.app. A pure spawn-target + /// restart-all open sessions in iTerm2 instead of Terminal.app. A spawn-target /// preference — existing windows are untouched until you restart them. @objc func togglePreferIterm() { let path = Paths.preferItermFlag @@ -2237,116 +2206,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } } - /// Off-main: pick each session's wallpaper from cache only (instant - /// `slab-wallpaper path` probe + status-default file check), and queue a - /// gen for anything not yet cached (`kickWallpaper` — one at a time). Never - /// blocks on node/network beyond the ~50 ms probe (see slab-menubar-perf). - private func resolveWallpapers(_ sessions: [ClaudeSession]) -> [ClaudeSession] { - let bin = Paths.slabWallpaper - let fm = FileManager.default - guard fm.isExecutableFile(atPath: bin) else { return sessions } - - // Status-default set: kick once per launch, detached. - wallpaperLock.lock() - let kickDefaults = !defaultsKicked - if kickDefaults { defaultsKicked = true } - wallpaperLock.unlock() - if kickDefaults { ShellRunner.runAsync(bin, args: ["defaults"]) } - - var out: [ClaudeSession] = [] - out.reserveCapacity(sessions.count) - for var s in sessions { - let st = stateName(s.state) - let summary = s.titleString - var pick = "" - if !summary.isEmpty { - pick = wallpaperPath(subject: summary, status: st, fm: fm) - } - if pick.isEmpty { - let def = "\(Paths.wallpaperStatusDir)/\(st).jpg" - if fm.fileExists(atPath: def) { pick = def } - } - s.wallpaper = pick - - if !summary.isEmpty { kickWallpaper(subject: summary, status: st) } - out.append(s) - } - return out - } - - /// The cached answer if we have one, else a throttled `slab-wallpaper path`. - /// Off-main (called from `resolveWallpapers`). - private func wallpaperPath(subject: String, status: String, fm: FileManager) -> String { - let key = "\(status)\u{1}\(subject)" - - wallpaperLock.lock() - if let known = wallpaperPaths[key] { - wallpaperLock.unlock() - // The file can be swept out from under us; a stale name is worse - // than a fresh probe, so drop it and let the retry path re-ask. - if fm.fileExists(atPath: known) { return known } - wallpaperLock.lock() - wallpaperPaths[key] = nil - } - if let last = wallpaperProbedAt[key], Date().timeIntervalSince(last) < wallpaperProbeRetry { - wallpaperLock.unlock() - return "" - } - wallpaperProbedAt[key] = Date() - wallpaperLock.unlock() - - let probe = ShellRunner.output( - Paths.slabWallpaper, args: ["path", "subject", subject, status], timeout: 4 - )?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !probe.isEmpty, fm.fileExists(atPath: probe) else { return "" } - - wallpaperLock.lock() - wallpaperPaths[key] = probe - wallpaperProbedAt[key] = nil - wallpaperLock.unlock() - return probe - } - - /// Queue a subject gen: new if unseen, otherwise nothing. Coalescing — a - /// request that arrives while a gen runs displaces the one waiting, and the - /// displaced key leaves the once-only set so it can be asked for again if - /// that state comes back. - private func kickWallpaper(subject: String, status: String) { - let key = "\(status)\u{1}\(subject)" - wallpaperLock.lock() - guard kickedWallpapers.insert(key).inserted else { wallpaperLock.unlock(); return } - if wallpaperBusy { - if let stale = pendingWallpaper { kickedWallpapers.remove(stale.key) } - pendingWallpaper = (key, subject, status) - wallpaperLock.unlock() - return - } - wallpaperBusy = true - wallpaperLock.unlock() - runWallpaper(subject, status) - } - - /// One gen, then drain whatever coalesced behind it. The 180 s cap matches - /// slab-wallpaper's own stale-lock reclaim — past that the render is wedged, - /// and a wedged render must die rather than hold the slot forever. - private func runWallpaper(_ subject: String, _ status: String) { - DispatchQueue.global(qos: .utility).async { [weak self] in - _ = ShellRunner.run( - Paths.slabWallpaper, args: ["subject", subject, status], timeout: 180 - ) - guard let self = self else { return } - self.wallpaperLock.lock() - // The picture this gen just drew is the one the probe was waiting - // for: forget the throttle so the next tick asks once and caches it. - self.wallpaperProbedAt["\(status)\u{1}\(subject)"] = nil - let next = self.pendingWallpaper - self.pendingWallpaper = nil - if next == nil { self.wallpaperBusy = false } - self.wallpaperLock.unlock() - if let next = next { self.runWallpaper(next.subject, next.status) } - } - } - /// RGB triple in AppleScript colorspace (0–65535 per channel). typealias RGB = (Int, Int, Int) /// One coordinated per-status palette: bg + foreground + bold + cursor, @@ -2579,7 +2438,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { /// session, in iTerm2 *and* Terminal.app. Called from `refresh()`, but /// only emits an osascript when something actually changed since the /// last pass (state flipped, subject moved, appearance flipped, or a new - /// session appeared). iTerm2 gets ad-hoc per-session colors + wallpaper; + /// session appeared). iTerm2 gets ad-hoc per-session colors; /// Terminal.app uses a readable base profile plus per-tab RGB overrides. /// Both blocks are `is running`-guarded so a non-running terminal is a /// cheap no-op and is never launched. @@ -2609,7 +2468,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { guard state.themeByStatus else { return } struct Assignment { let tty: String; let palette: Palette; let title: String - let wallpaper: String; let profile: String + let profile: String } var changes: [Assignment] = [] var seen = Set() @@ -2748,7 +2607,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { loopboyBeat ? "heartbeat" : "-", title, profile, - s.wallpaper.isEmpty ? "-" : s.wallpaper, // Pulse phase joins the key only for attention states so // working/blank tiles don't churn — see `updateBlinkTimer`. blink ? "pulse" : "-", @@ -2757,7 +2615,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { lastTerminalDecor[s.sessionId] = key changes.append(Assignment( tty: s.tty, palette: palette, title: title, - wallpaper: s.wallpaper, profile: profile)) + profile: profile)) } // Reap entries for sessions that disappeared since last tick — they // either died or got reaped by the janitor; either way our memo is @@ -2819,7 +2677,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { it.append(" set _slabContrast to (ASCII character 27) & \"]1337;SetProfileProperty=Minimum Contrast=MC41NQ==\" & (ASCII character 7)") it.append(" do shell script \"/usr/bin/printf %s \" & quoted form of _slabContrast & \" > \" & quoted form of ttyName") it.append(" set name of s to \"\(esc(a.title))\"") - it.append(" set background image of s to \"\(esc(a.wallpaper))\"") it.append(" end if") } it.append(contentsOf: [ @@ -3078,8 +2935,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private static func preferredTerminalApp() -> String { // "Make iTerm2 the home": when the user has pinned iTerm2 (and it's // installed), every slab-spawned session goes there — even while - // they're typing in a Terminal window — so the iTerm2-only tiled - // topic wallpapers are the default surface again. + // they're typing in a Terminal window. let itermInstalled = NSWorkspace.shared.urlForApplication( withBundleIdentifier: "com.googlecode.iterm2") != nil if itermInstalled, @@ -3451,7 +3307,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { /// is deliberately agent-agnostic: Claude and Codex hosts share the same /// geometry and Far/Near/Tiny font calculation. /// Terminal gets bounds only — AppleScript can't set per-session decor - /// or wallpaper on Terminal.app, so those windows tile but stay + /// on Terminal.app, so those windows tile but stay /// un-themed (the iTerm2-only port intentionally dropped Terminal decor). /// Menu / hotkey entry point: an explicit tile, which DOES reset each /// Terminal window's font zoom so the grid-derived size actually lands. @@ -4017,7 +3873,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { /// by **bundle id**: on installs where the app registers as `iTerm.app` /// (the common case), the by-name `application "iTerm2"` term fails to /// resolve with -1728, which silently zeroed every window count, spawn, - /// and tile pass — the bug that made the iTerm2 topic wallpapers vanish. + /// and tile pass. /// Terminal.app resolves fine by name. Single source of truth so no /// AppleScript path can regress to the broken by-name form again. static func appSpecifier(_ app: String) -> String { diff --git a/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift b/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift index 51db7f96fd..e5fd80158d 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift @@ -60,10 +60,6 @@ struct ClaudeSession { var transcriptPath: String = "" var state: State var awaitingMessage: String? - /// Absolute path to this session's iTerm2 background-image wallpaper, - /// resolved off-main during refresh (instant cache probe; empty until - /// the async generator has produced one). Empty → leave bg image unset. - var wallpaper: String = "" /// Sticky per-session emoji (see TitleEmoji) prefixed to the window /// title and menu row so the eye can re-find a session after the tiler /// shuffles the grid. Stamped during refresh; "" until the first prompt. diff --git a/slab/menubar-swift/Sources/SlabMenubar/Paths.swift b/slab/menubar-swift/Sources/SlabMenubar/Paths.swift index a44cd5c9b6..3d5688d0ea 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/Paths.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/Paths.swift @@ -17,8 +17,6 @@ enum Paths { ProcessInfo.processInfo.environment["AC_REPO"] ?? "\(home)/aesthetic-computer" } - static var slabWallpaper: String { "\(slabBin)/slab-wallpaper" } - static var wallpaperStatusDir: String { "\(slabHome)/wallpaper/status" } /// Cached near-black + status-glow PNGs set as the macOS desktop /// picture (aggregate Claude status, matching the menubar icon). static var desktopWallpaperDir: String { "\(slabHome)/wallpaper/desktop" } @@ -28,7 +26,7 @@ enum Paths { static var desktopOriginalFile: String { "\(desktopWallpaperDir)/.original" } /// Generic iMessage bridge (contact lives in the untracked config below, - /// never in tracked code). Mirrors the slab-wallpaper wrapper convention. + /// never in tracked code). static var imsgHelper: String { "\(slabBin)/imsg" } static var imsgConfig: String { "\(home)/.config/slab/imsg.json" } static var signalHelper: String { "\(slabBin)/signal" } @@ -194,8 +192,7 @@ enum Paths { /// When this file exists (and iTerm2 is installed), every slab-spawned /// Claude session (restore-threads, restart-all) opens in iTerm2 rather /// than Terminal.app — even while the user is typing in a Terminal - /// window. This is what makes the iTerm2-only tiled topic wallpapers the - /// default surface again. Toggled from the menubar's "Spawn in iTerm2". + /// window. Toggled from the menubar's "Spawn in iTerm2". static var preferItermFlag: String { "\(slabHome)/state/prefer-iterm" } /// When this file exists, status themes use the *light* (bright, diff --git a/slab/menubar-swift/install.sh b/slab/menubar-swift/install.sh index e245ba3e27..3b69579f1b 100755 --- a/slab/menubar-swift/install.sh +++ b/slab/menubar-swift/install.sh @@ -190,30 +190,6 @@ EOP return 1 } -# Provision iTerm2 profiles for the per-session tiled topic wallpapers: every -# profile needs tiled-image mode + a Blend so the dim topic image shows behind -# the status color while text stays legible. iTerm2 caches its prefs in memory -# and rewrites the plist on quit, so this is only safe while it's NOT running. -provision_iterm2_profiles() { - local pl="${HOME}/Library/Preferences/com.googlecode.iterm2.plist" - [[ -e "${pl}" ]] || { warn "iTerm2 prefs not found — skipping profile provisioning"; return 0; } - if pgrep -f "iTerm.app/Contents/MacOS" >/dev/null 2>&1; then - warn "iTerm2 is running — skipping profile provisioning (quit iTerm2 and re-run to set tiled wallpaper Blend)" - return 0 - fi - local pb=/usr/libexec/PlistBuddy i=0 n=0 - while ${pb} -c "Print :'New Bookmarks':${i}:'Guid'" "${pl}" >/dev/null 2>&1; do - ${pb} -c "Set :'New Bookmarks':${i}:'Blend' 0.5" "${pl}" 2>/dev/null \ - || ${pb} -c "Add :'New Bookmarks':${i}:'Blend' real 0.5" "${pl}" 2>/dev/null || true - ${pb} -c "Set :'New Bookmarks':${i}:'Background Image Mode' 1" "${pl}" 2>/dev/null \ - || ${pb} -c "Add :'New Bookmarks':${i}:'Background Image Mode' integer 1" "${pl}" 2>/dev/null || true - ${pb} -c "Set :'New Bookmarks':${i}:'Background Image Is Tiled' true" "${pl}" 2>/dev/null \ - || ${pb} -c "Add :'New Bookmarks':${i}:'Background Image Is Tiled' bool true" "${pl}" 2>/dev/null || true - i=$((i+1)); n=$((n+1)) - done - ok "provisioned ${n} iTerm2 profile(s): tiled bg image + Blend 0.5" -} - # Keep the launcher and its color-probe handoff with this installed release. provision_terminal_launcher() { mkdir -p "${HOME}/.local/bin" @@ -369,7 +345,6 @@ if [[ -x "${APP_BIN}" && -f "${PLIST_PATH}" ]] \ fi provision_tailscale_cli -provision_iterm2_profiles provision_terminal_launcher provision_terminal_close_warning diff --git a/slab/test/slab-wallpaper.test.mjs b/slab/test/slab-wallpaper.test.mjs deleted file mode 100644 index 3d0b8fd88d..0000000000 --- a/slab/test/slab-wallpaper.test.mjs +++ /dev/null @@ -1,133 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { promises as fs } from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { wallpaperBackoff } from "../bin/wallpaper-backoff.mjs"; - -test("wallpaper cooldown persists across invocations and expires", async () => { - const directory = await fs.mkdtemp( - path.join(os.tmpdir(), "wallpaper-backoff-"), - ); - try { - let now = 1000000; - const first = wallpaperBackoff(directory, () => now); - await first.defer("proxy", "120"); - const next = wallpaperBackoff(directory, () => now); - assert.equal(await next.active("proxy"), true); - assert.equal(await next.active("direct"), false); - now += 119999; - assert.equal(await next.active("proxy"), true); - now++; - assert.equal(await next.active("proxy"), false); - await next.defer("proxy", new Date(now + 60000).toUTCString()); - assert.equal(await next.active("proxy"), true); - now += 60000; - assert.equal(await next.active("proxy"), false); - await next.defer("proxy", "invalid"); - now += 60000; - assert.equal(await next.active("proxy"), false); - } finally { - await fs.rm(directory, { recursive: true, force: true }); - } -}); - -test("wallpaper uses only the budgeted proxy and honors Retry-After", async (t) => { - const directory = await fs.mkdtemp( - path.join(os.tmpdir(), "wallpaper-caller-"), - ); - const originalHome = process.env.SLAB_HOME; - const originalKey = process.env.NVIDIA_API_KEY; - process.env.SLAB_HOME = directory; - process.env.NVIDIA_API_KEY = "test-nvidia"; - let calls = []; - t.mock.method(globalThis, "fetch", async (url, options) => { - calls.push({ url, options }); - return new Response("allowance exhausted", { - status: 429, - headers: { "Retry-After": "120" }, - }); - }); - try { - const { generate } = await import("../bin/slab-wallpaper.mjs"); - assert.equal(await generate("synthetic test"), null); - assert.equal(calls.length, 1); - assert.equal(calls[0].url, "https://aesthetic.computer/api/flux"); - assert.deepEqual(JSON.parse(calls[0].options.body), { - prompt: "synthetic test", - allow_fallback: false, - preset: "raw", - }); - assert.equal(calls[0].options.headers["User-Agent"], "slab-wallpaper/1"); - assert.equal(await generate("another synthetic test"), null); - assert.equal( - calls.length, - 1, - "different subjects still share the provider cooldown", - ); - } finally { - if (originalHome === undefined) delete process.env.SLAB_HOME; - else process.env.SLAB_HOME = originalHome; - if (originalKey === undefined) delete process.env.NVIDIA_API_KEY; - else process.env.NVIDIA_API_KEY = originalKey; - await fs.rm(directory, { recursive: true, force: true }); - } -}); - -test("wallpaper timeout includes the image response body and defers later subjects", async (t) => { - const directory = await fs.mkdtemp(path.join(os.tmpdir(), "wallpaper-body-")); - const originalHome = process.env.SLAB_HOME; - const originalKey = process.env.NVIDIA_API_KEY; - process.env.SLAB_HOME = directory; - process.env.NVIDIA_API_KEY = "test-nvidia"; - const originalSetTimeout = globalThis.setTimeout; - t.mock.method(globalThis, "setTimeout", (callback, ms) => - originalSetTimeout(callback, ms === 35000 ? 5 : ms), - ); - let calls = 0; - t.mock.method(globalThis, "fetch", async (url, { signal }) => { - calls++; - return { - ok: true, - json: () => - new Promise((resolve, reject) => { - signal.addEventListener( - "abort", - () => reject(new DOMException("timeout", "AbortError")), - { once: true }, - ); - }), - }; - }); - try { - const { generate } = await import( - "../bin/slab-wallpaper.mjs?body-timeout-test" - ); - assert.equal(await generate("synthetic test"), null); - assert.equal(calls, 1); - assert.equal(await generate("another synthetic test"), null); - assert.equal(calls, 1, "body timeouts persist the provider cooldown"); - } finally { - if (originalHome === undefined) delete process.env.SLAB_HOME; - else process.env.SLAB_HOME = originalHome; - if (originalKey === undefined) delete process.env.NVIDIA_API_KEY; - else process.env.NVIDIA_API_KEY = originalKey; - await fs.rm(directory, { recursive: true, force: true }); - } -}); - -test("existing landscape wallpaper cache remains usable after square-model migration", async () => { - const directory = await fs.mkdtemp( - path.join(os.tmpdir(), "wallpaper-legacy-"), - ); - try { - const { cachedImagePath } = await import("../bin/slab-wallpaper.mjs"); - // Fixture filename generated by the previous raw|1024x768 cache format. - const legacy = path.join(directory, "a7bff732d250b87a.jpg"); - await fs.writeFile(legacy, "synthetic cached image"); - assert.equal(cachedImagePath(directory, "synthetic texture"), legacy); - assert.equal(cachedImagePath(directory, "uncached texture"), ""); - } finally { - await fs.rm(directory, { recursive: true, force: true }); - } -});