From 526462f2db7c435aab2464c82f400fe9c1da4648 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 5 Oct 2026 20:02:34 -0700 Subject: [PATCH] Reload Aesel, the Slab card and climbalift renders on save /preview pins a media file to the Slab card and reloads it on every write, renames included; the pin survives a reify. /reify watch reifies the TUI on each save under aesel/src. slab/menubar-swift/dev.sh builds debug, signs and relaunches the menubar, with --watch. pop/notespatial/bin/now.sh renders, masters and writes one stable climbalift mp3, optionally a single section. wannadash marked released. Co-Authored-By: Claude Opus 5.5 --- aesel/README.md | 7 +++ aesel/src/media.mjs | 56 +++++++++++++++++----- aesel/src/source-watch.mjs | 25 ++++++++++ aesel/src/tui.mjs | 80 ++++++++++++++++++++++++++++++-- aesel/test/media.test.mjs | 36 +++++++++++++- aesel/test/source-watch.test.mjs | 30 ++++++++++++ pop/RELEASES.md | 2 +- pop/notespatial/CLIMBALIFT.md | 6 +++ pop/notespatial/bin/now.sh | 29 ++++++++++++ slab/menubar-swift/README.md | 6 +++ slab/menubar-swift/dev.sh | 44 ++++++++++++++++++ 11 files changed, 302 insertions(+), 19 deletions(-) create mode 100644 aesel/src/source-watch.mjs create mode 100644 aesel/test/source-watch.test.mjs create mode 100644 pop/notespatial/bin/now.sh create mode 100644 slab/menubar-swift/dev.sh diff --git a/aesel/README.md b/aesel/README.md index 417125da8e..1b709b3877 100644 --- a/aesel/README.md +++ b/aesel/README.md @@ -60,6 +60,13 @@ artifact, preview and Slab identity survive. Queued messages continue after the reload; the opening prompt is not replayed. Syntax or checkpoint failures leave the current session running. Agents can request it through `aesel_settings({action: "reify"})`; the response is `queued` until the turn ends. +`/reify watch` reifies on every save under `aesel/src/` (again to stop); the +watch survives the reifies it causes, and a save that fails the check leaves the +window running until the next one. `/preview ` pins a picture, sound, +paper or video to the Slab card and reloads the card each time the file is +written again, including by rename; a bare `/preview` lets go. Together with +`slab/menubar-swift/dev.sh --watch` for the card itself, both halves of the +preview reload on save. Bun can host the terminal with `AESEL_JS_RUNTIME=bun ac`. Node remains the default. In a checkout, `bun aesel/bin/build-bun.mjs` builds reusable bytecode; source edits or a different Bun version automatically fall back to current JavaScript until rebuilt. `/reify` keeps the selected runtime and launch options. This setting is separate from the piece’s `/runtime` language. The headless `/perf` command uses a permission-restricted Node helper under Bun; Node 24 or newer must be installed. diff --git a/aesel/src/media.mjs b/aesel/src/media.mjs index 78074361f9..0ffcbb7d9f 100644 --- a/aesel/src/media.mjs +++ b/aesel/src/media.mjs @@ -6,7 +6,7 @@ // finished. This reads paths explicitly named in a tool's input, keeps // the ones that are real files of a kind a card can show, and puts the // freshest first. -import { statSync } from "node:fs"; +import { statSync, watch } from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; @@ -28,22 +28,29 @@ export const MEDIA_TYPES = new Map([ // A colon is a separator too: `out.png:12` is a location, not a file. const BREAKS = /[\s"'`<>|;&(),=:\[\]{}]+/; +// One path, taken whole (spaces and all): the media file it names, or null +// when it is missing, empty, or not a kind a card can show. +export function mediaFile(file, cwd, { home = homedir() } = {}) { + const token = String(file || "").trim(); + const type = MEDIA_TYPES.get(path.extname(token).slice(1).toLowerCase()); + if (!type) return null; + const expanded = token.startsWith("~/") ? path.join(home, token.slice(2)) : token; + const absolute = path.resolve(cwd, expanded); + let info; + try { info = statSync(absolute); } catch { return null; } + if (!info.isFile() || info.size === 0) return null; + return { path: absolute, name: path.basename(absolute), ...type, mtimeMs: info.mtimeMs, size: info.size }; +} + // Every media file named in the text that exists, freshest first. export function mediaPaths(text, cwd, { home = homedir() } = {}) { const found = []; const seen = new Set(); for (const raw of String(text || "").split(BREAKS)) { - const token = raw.replace(/[.!?]+$/, ""); - const type = MEDIA_TYPES.get(path.extname(token).slice(1).toLowerCase()); - if (!type) continue; - const expanded = token.startsWith("~/") ? path.join(home, token.slice(2)) : token; - const absolute = path.resolve(cwd, expanded); - if (seen.has(absolute)) continue; - seen.add(absolute); - let info; - try { info = statSync(absolute); } catch { continue; } - if (!info.isFile() || info.size === 0) continue; - found.push({ path: absolute, name: path.basename(absolute), ...type, mtimeMs: info.mtimeMs, size: info.size }); + const media = mediaFile(raw.replace(/[.!?]+$/, ""), cwd, { home }); + if (!media || seen.has(media.path)) continue; + seen.add(media.path); + found.push(media); } return found.sort((a, b) => b.mtimeMs - a.mtimeMs); } @@ -62,6 +69,31 @@ export function itemText(item) { ].filter(Boolean).join("\n"); } +// Follow one media file as it is written again: a render finishing, a master +// replaced. Watches the directory rather than the file, because a renderer +// that writes beside and renames (the safe way) swaps the file's inode out +// from under a file watch. Calls onChange with the fresh sighting once the +// writes settle. Returns a function that stops watching. +export function watchMedia(media, onChange, { settleMs = 200 } = {}) { + let last = media.mtimeMs, timer = null, watcher; + const check = () => { + timer = null; + const next = mediaFile(media.path, "/"); + if (!next || next.mtimeMs === last) return; + last = next.mtimeMs; + onChange(next); + }; + try { + watcher = watch(path.dirname(media.path), (event, name) => { + if (name && name !== media.name) return; + clearTimeout(timer); + timer = setTimeout(check, settleMs); + }); + } catch { return () => {}; } + watcher.on("error", () => {}); + return () => { clearTimeout(timer); watcher.close(); }; +} + // Whether the next sighting replaces the current one: a different file, or // the same file written again since. export function mediaChanged(current, next) { diff --git a/aesel/src/source-watch.mjs b/aesel/src/source-watch.mjs new file mode 100644 index 0000000000..38b9c943eb --- /dev/null +++ b/aesel/src/source-watch.mjs @@ -0,0 +1,25 @@ +// Aesel watching its own source, for `/reify watch`: save a file under src/ +// and the window reifies onto it — same thread, draft and preview. The +// build outputs are skipped, or writing a bundle would reify again. +import { watch } from "node:fs"; + +const SOURCE = /\.(mjs|json|txt)$/; +const BUILT = /(^|\/)\.tui-(built|bun)/; + +export const isSourceChange = (name) => !!name && SOURCE.test(name) && !BUILT.test(name); + +// Calls onChange with the changed paths once saves settle (an editor writes a +// file in several steps). Returns a function that stops watching. +export function watchSource(dir, onChange, { settleMs = 300 } = {}) { + let timer = null, changed = new Set(), watcher; + try { + watcher = watch(dir, { recursive: true }, (event, name) => { + if (!isSourceChange(String(name || ""))) return; + changed.add(String(name)); + clearTimeout(timer); + timer = setTimeout(() => { const files = [...changed]; changed = new Set(); onChange(files); }, settleMs); + }); + } catch { return null; } + watcher.on("error", () => {}); + return () => { clearTimeout(timer); watcher.close(); }; +} diff --git a/aesel/src/tui.mjs b/aesel/src/tui.mjs index 70f2358521..9ba260f5a4 100755 --- a/aesel/src/tui.mjs +++ b/aesel/src/tui.mjs @@ -67,7 +67,8 @@ import { cleanText, clipText, color, aeselInk, renderBoot, renderFrame, renderGe import { mascotNextFrameIn, mascotRowNextFrameIn } from "./mascot.mjs"; import { DEFAULT_RUNTIME, runtimeMenu } from "./runtimes.mjs"; import { SlabSession } from "./slab-session.mjs"; -import { mediaPaths, itemText, mediaChanged } from "./media.mjs"; +import { mediaPaths, mediaFile, watchMedia, itemText, mediaChanged } from "./media.mjs"; +import { watchSource } from "./source-watch.mjs"; import { Artifacts, MEDIA } from './artifacts.mjs'; import { desktopSnapshot, readDesktopSession, writeDesktopSession, restoreDesktopEngine, writeDesktopControl, readDesktopIntent } from "./desktop-session.mjs"; import { archiveThread, replaceWork } from './new-work.mjs'; @@ -1196,6 +1197,7 @@ function notePublished(item) { state.pieceSlug = slug; // A published piece outranks whatever media the tools touched on the way // there — often the piece's own check screenshot — so the card plays it. + unwatchMedia(); state.media = null; if (!profile.private) slabSession.artifact("piece", null); redraw(); @@ -1241,6 +1243,23 @@ function requestReify() { if (!reifyTimer) reifyTimer = setTimeout(attempt, 150); return "queued"; } +// `/reify watch`: every save under src/ reifies the window onto the new code. +// The flag rides in the environment, which execve hands to the next process, +// so the watch outlives the reify it causes. A save that fails the reify +// check is reported and the window keeps running; the next save tries again. +let stopSourceWatch = null; +const reifyWatching = () => !!stopSourceWatch; +function setReifyWatch(on) { + stopSourceWatch?.(); stopSourceWatch = null; + if (on) stopSourceWatch = watchSource(fileURLToPath(new URL(".", import.meta.url)), (files) => { + if (reifyPending) return; + flash(`${files[0]}${files.length > 1 ? ` +${files.length - 1}` : ""} saved · reifying`); + try { requestReify(); } catch (error) { addEntry("error", errorText(error)); redraw(); } + }); + if (on && !stopSourceWatch) addEntry("error", "Cannot watch aesel/src on this system"); + if (stopSourceWatch) process.env.AESEL_REIFY_WATCH = "1"; else delete process.env.AESEL_REIFY_WATCH; +} + async function restartInPlace() { if (turnRecovery.active || state.busy || liveOperation || manualPublishInFlight || autopublish.running || live.sending) { flash("wait for this turn and uploads to finish, then /restart"); @@ -1570,7 +1589,7 @@ function itemSummary(item) { // command is about to write only exists once it has run. A private session // keeps the file to itself; the status line still names it. function noteMedia(item) { - if (!pro) return; + if (!pro || state.media?.source === "pinned") return; const next = mediaPaths(itemText(item), cwd)[0]; if (!mediaChanged(state.media, next)) return; state.media = { ...next, source: "tool-input", version: (state.media?.version || 0) + 1 }; @@ -1579,6 +1598,36 @@ function noteMedia(item) { slabSession.artifact(next.kind, { path: next.path, mime: next.mime, name: next.name, version: state.media.version, artifactId: slabSession.sessionId }); } +// A file pinned with `/preview out/track.mp3` holds the card until a bare +// `/preview`, whatever the tools touch meanwhile, and every write to it — a +// render finishing — reloads the card. Survives a reify: the pin is in +// state.media and the watch is taken up again at start. +let unwatchMedia = () => {}; +function pinMedia(media) { + unwatchMedia(); + state.media = { ...media, source: "pinned", version: (state.media?.version || 0) + 1 }; + const show = () => { + if (profile.private) return; + const { kind, path, mime, name, version } = state.media; + slabSession.artifact(kind, { path, mime, name, version, artifactId: slabSession.sessionId }); + }; + show(); + unwatchMedia = watchMedia(media, (next) => { + if (state.media?.source !== "pinned" || state.media.path !== next.path) return; + state.media = { ...next, source: "pinned", version: state.media.version + 1 }; + show(); + redraw(); + }); +} +function unpinMedia() { + unwatchMedia(); + unwatchMedia = () => {}; + if (state.media?.source !== "pinned") return false; + state.media = null; + if (!profile.private) slabSession.artifact("piece", null); + return true; +} + function restoreThread(thread) { const restored = []; for (const turn of thread?.turns || []) { @@ -2465,6 +2514,11 @@ async function submitInput(submittedText, submittedMessages = null) { return redraw(); } if (command === "/home") return requestDesktop("home"); + if ((command === "/restart" || command === "/reify") && ["watch", "watch off"].includes(rest.trim())) { + setReifyWatch(rest.trim() === "watch" && !reifyWatching()); + addEntry("notice", reifyWatching() ? "Reifying on every save under aesel/src · /reify watch again to stop" : "Stopped reifying on save"); + return redraw(); + } if (command === "/restart" || command === "/reify") { try { requestReify(); flash("reify queued"); } catch (error) { addEntry("error", errorText(error)); } @@ -2526,7 +2580,7 @@ async function submitInput(submittedText, submittedMessages = null) { "notice", pro ? "/ask [on|off] · /provider · /model [name] · /mouse [on|off] · /layout · /inbox · /mode · /backend [id] · /login · /logout · /whoami · /handle [name] · /reify · /update · /new · /clear · /close · /quit ctrl-c interrupts a running turn" - : "/about · /medium · /artifacts · /select UUID · /artifact · /export FILE · /sharing · /transcript · /profile · /inbox · /mode · /mouse [on|off] · /performance [frames] · /energy · /latest · /login · /logout · /whoami · /publish [file] · /autopublish [on|off] · /ask [on|off] · /piece [name] · /preview [piece] · /versions · /rollback vN · /runtime [id] · /frame [ocr] · /settings · /backend [id] · /model [name] · /effort · /handle [name] · /reify · /update · /open · /qr · /new [thread] · /clear · /quit ctrl-c interrupts a running turn", + : "/about · /medium · /artifacts · /select UUID · /artifact · /export FILE · /sharing · /transcript · /profile · /inbox · /mode · /mouse [on|off] · /performance [frames] · /energy · /latest · /login · /logout · /whoami · /publish [file] · /autopublish [on|off] · /ask [on|off] · /piece [name] · /preview [piece|file] · /versions · /rollback vN · /runtime [id] · /frame [ocr] · /settings · /backend [id] · /model [name] · /effort · /handle [name] · /reify · /update · /open · /qr · /new [thread] · /clear · /quit ctrl-c interrupts a running turn", ); return redraw(); } @@ -2602,7 +2656,20 @@ async function submitInput(submittedText, submittedMessages = null) { // Look at any piece on the rock's card without making it this session's: // `/preview notepat`, `/preview @handle/slug`, `/preview $cow`. Bare // `/preview` hands the card back to the piece being written here. + // A media file — `/preview out/track.mp3` — is pinned and followed: the + // card reloads each time the file is written again. if (command === "/preview") { + const media = rest && mediaFile(rest, cwd); + if (media) { + slabSession.preview(""); + pinMedia(media); + addEntry("notice", `Previewing ${media.name} · reloads on every write · /preview to come back`); + return redraw(); + } + if (!rest && unpinMedia()) { + addEntry("notice", "Previewing this session's own piece"); + return redraw(); + } const shown = slabSession.preview(rest); addEntry("notice", shown ? `Previewing ${shown} · /preview to come back` : "Previewing this session's own piece"); return redraw(); @@ -3174,7 +3241,12 @@ markStartup('account-refreshed'); slabSession.restore(desktopRestored?.slab); // Older auto-selections could come from unrelated paths in command output. // Drop those once on reload; explicit piece previews keep their own source. -if (pro && state.media && state.media.source !== "tool-input") { +if (process.env.AESEL_REIFY_WATCH === "1") setReifyWatch(true); +if (state.media?.source === "pinned") { + const media = mediaFile(state.media.path, cwd); + if (media) pinMedia(media); + else state.media = null; +} else if (pro && state.media && state.media.source !== "tool-input") { state.media = null; slabSession.artifact("piece", null); } diff --git a/aesel/test/media.test.mjs b/aesel/test/media.test.mjs index d6a8b83a06..c2665dfec1 100644 --- a/aesel/test/media.test.mjs +++ b/aesel/test/media.test.mjs @@ -1,9 +1,9 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, writeFileSync, utimesSync, mkdirSync } from "node:fs"; +import { mkdtempSync, writeFileSync, utimesSync, mkdirSync, renameSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { mediaPaths, itemText, mediaChanged, MEDIA_TYPES } from "../src/media.mjs"; +import { mediaPaths, mediaFile, watchMedia, itemText, mediaChanged, MEDIA_TYPES } from "../src/media.mjs"; const root = mkdtempSync(path.join(tmpdir(), "easel-media-")); const png = path.join(root, "frame.png"); @@ -63,6 +63,38 @@ test("a sighting replaces the current one only when the file or its write time d assert.equal(mediaChanged(a, undefined), false); }); +test("takes one path whole, spaces included", () => { + const spaced = path.join(root, "my track.mp3"); + writeFileSync(spaced, "ID3"); + assert.equal(mediaFile("my track.mp3", root)?.mime, "audio/mpeg"); + assert.equal(mediaFile("~/frame.png", "/", { home: root })?.path, png); + assert.equal(mediaFile("empty.png", root), null); + assert.equal(mediaFile("notes.txt", root), null); + assert.equal(mediaFile("", root), null); +}); + +test("follows a file that is rewritten in place or replaced by a rename", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "easel-watch-")); + const mp3 = path.join(dir, "now.mp3"); + writeFileSync(mp3, "one"); + utimesSync(mp3, new Date(1000), new Date(1000)); + const seen = []; + const stop = watchMedia(mediaFile(mp3, dir), (next) => seen.push(next.size), { settleMs: 30 }); + const settle = () => new Promise((resolve) => setTimeout(resolve, 250)); + writeFileSync(path.join(dir, "other.mp3"), "ignored"); + await settle(); + assert.deepEqual(seen, []); + writeFileSync(mp3, "two!"); + await settle(); + writeFileSync(path.join(dir, ".tmp.mp3"), "three"); + renameSync(path.join(dir, ".tmp.mp3"), mp3); + await settle(); + stop(); + writeFileSync(mp3, "four!!"); + await settle(); + assert.deepEqual(seen, [4, 5]); +}); + test("every kind the card can show has a glyph and a mime", () => { for (const [ext, type] of MEDIA_TYPES) { assert.ok(["picture", "sound", "paper", "video"].includes(type.kind), ext); diff --git a/aesel/test/source-watch.test.mjs b/aesel/test/source-watch.test.mjs new file mode 100644 index 0000000000..4193e40384 --- /dev/null +++ b/aesel/test/source-watch.test.mjs @@ -0,0 +1,30 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { isSourceChange, watchSource } from "../src/source-watch.mjs"; + +test("source files count, build outputs and other files do not", () => { + for (const name of ["tui.mjs", "media/sound.mjs", "terminal-help.txt", "model-catalog.json"]) assert.ok(isSourceChange(name), name); + for (const name of [".tui-built.mjs", ".tui-built.json", ".tui-bun.cjs", "notes.md", "", "out.wav"]) assert.ok(!isSourceChange(name), name); +}); + +test("a burst of saves settles into one change, nested files included", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "aesel-src-")); + mkdirSync(path.join(dir, "media")); + const calls = []; + const stop = watchSource(dir, (files) => calls.push(files.sort()), { settleMs: 60 }); + assert.equal(typeof stop, "function"); + await new Promise((resolve) => setTimeout(resolve, 100)); + writeFileSync(path.join(dir, ".tui-built.mjs"), "ignored"); + writeFileSync(path.join(dir, "tui.mjs"), "1"); + writeFileSync(path.join(dir, "tui.mjs"), "2"); + writeFileSync(path.join(dir, "media", "sound.mjs"), "3"); + await new Promise((resolve) => setTimeout(resolve, 400)); + stop(); + writeFileSync(path.join(dir, "tui.mjs"), "after"); + await new Promise((resolve) => setTimeout(resolve, 200)); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0], [path.join("media", "sound.mjs"), "tui.mjs"]); +}); diff --git a/pop/RELEASES.md b/pop/RELEASES.md index 3adcbc1ea0..70c5ddabe3 100644 --- a/pop/RELEASES.md +++ b/pop/RELEASES.md @@ -225,7 +225,7 @@ Status legend: **RELEASED** · **SUBMITTED** · **MASTERING** · **RENDER** · * --- -## wannadash — SUBMITTED-READY (DistroKid form filled 2026-09-01; @jeffrey ticks the attestations + Continue) +## wannadash — RELEASED (confirmed by @jeffrey 2026-10-05; Spotify link not yet recorded here) - **Artist:** Whistlegraph Dot Org · **title:** `wannadash` (all lowercase, preserve-caps ticked; no featured artist in the title) · label Aesthetic Dot Computer · Electronic / diff --git a/pop/notespatial/CLIMBALIFT.md b/pop/notespatial/CLIMBALIFT.md index 180506a116..9e28861069 100644 --- a/pop/notespatial/CLIMBALIFT.md +++ b/pop/notespatial/CLIMBALIFT.md @@ -10,6 +10,12 @@ ARC=0 PAD=3 PAD_FADE=1.5 bash pop/notespatial/bin/master-v2.sh out/climbalift-pr node pop/notespatial/bin/score-video.mjs --score out/climbalift-score.json --audio out/climbalift-vN.flac --out out/climbalift-vN-score.mp4 ``` +The working loop, in Aesel: `/preview pop/notespatial/out/climbalift-now.mp3` once, +then `bash pop/notespatial/bin/now.sh [section [to-section]]` after each change +(~35 s warm; the first run fills the bell cache and takes ~2.5 min). The card reloads +on every write. A section name renders the whole record but plays only that span, a +bar either side; `now.sh` prints LUFS, true peak and LRA for the full master. + ## The through line A chord broken into a line, running the ring, getting faster and higher until it diff --git a/pop/notespatial/bin/now.sh b/pop/notespatial/bin/now.sh new file mode 100644 index 0000000000..b5d6b968ce --- /dev/null +++ b/pop/notespatial/bin/now.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# pop/notespatial/bin/now.sh — one turn of the climbalift loop: print, master, +# and an mp3 at one path that never moves, so a Slab card (or any player) +# pinned to it hears every change. ~35 s with a warm cache. +# bash pop/notespatial/bin/now.sh # the whole record +# bash pop/notespatial/bin/now.sh lift2 # just lift2, a bar either side +# bash pop/notespatial/bin/now.sh climb3 lift3 # from climb3 through lift3 +# Writes out/climbalift-now.{flac,mp3} and out/climbalift-score.json. +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)"; O="$(cd "$HERE/../out" && pwd)" +FROM="${1:-}"; TO="${2:-$FROM}" +node "$HERE/climbalift.mjs" --events "$O/climbalift-score.json" | grep '^intro' +ARC=0 PAD=3 PAD_FADE=1.5 bash "$HERE/master-v2.sh" "$O/climbalift-print.wav" "$O/climbalift-now.flac" -11 -2.0 | grep '^stage' +TRIM=() +if [ -n "$FROM" ]; then + read -r T0 T1 < <(node -e ' + const { sec } = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); + const [a, b] = [sec[process.argv[2]], sec[process.argv[3]]]; + if (!a || !b) { console.error("sections: " + Object.keys(sec).join(" ")); process.exit(1); } + console.log(Math.max(0, a.t - a.barLen).toFixed(3), (b.end + b.barLen).toFixed(3)); + ' "$O/climbalift-score.json" "$FROM" "$TO") + TRIM=(-ss "$T0" -to "$T1" -af "afade=t=in:d=0.05,areverse,afade=t=in:d=0.3,areverse") + echo "excerpt $FROM..$TO · ${T0}s → ${T1}s" +fi +# write beside, then rename: a watcher never reads a half-written mp3 +ffmpeg -y -v error -i "$O/climbalift-now.flac" "${TRIM[@]}" -c:a libmp3lame -b:a 256k "$O/.climbalift-now.tmp.mp3" +mv "$O/.climbalift-now.tmp.mp3" "$O/climbalift-now.mp3" +ffmpeg -hide_banner -nostats -i "$O/climbalift-now.flac" -af ebur128=peak=true -f null - 2>&1 | awk '/^ +I:/{i=$2} /^ +LRA:/{l=$2} /^ +Peak:/{p=$2} END{print i" LUFS · "p" dBTP · LRA "l}' +echo "$O/climbalift-now.mp3" diff --git a/slab/menubar-swift/README.md b/slab/menubar-swift/README.md index d2f1245ee3..9c8712049d 100644 --- a/slab/menubar-swift/README.md +++ b/slab/menubar-swift/README.md @@ -286,6 +286,12 @@ On Macs using the sandboxed Tailscale app, installation also provisions a user-local CLI wrapper so the fleet ledger can discover and bind its tailnet IP. Loopboy's iMessage watcher also needs one macOS privacy grant for the stable +To see a change running, `./dev.sh` builds debug and hands the binary to +`install.sh` (`SLAB_PREBUILT`), which signs it with the stable identity and +relaunches the agent in place; `./dev.sh --watch` repeats that on every save +under `Sources/` (fswatch). A one-file change is live in seconds. The live +menubar is then the debug build until `./install.sh` restores release. + signed app at `~/Applications/SlabMenubar.app`: System Settings → Privacy & Security → Full Disk Access. The menu and logs report an authorization error instead of emitting a false quiet heartbeat when that grant is absent. The diff --git a/slab/menubar-swift/dev.sh b/slab/menubar-swift/dev.sh new file mode 100644 index 0000000000..52a1ddb288 --- /dev/null +++ b/slab/menubar-swift/dev.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# dev.sh — the Slab iteration loop: incremental debug build, then install.sh +# signs it with the stable identity (TCC grants hold) and relaunches the agent +# in place. A one-file change is back on screen in seconds. +# ./dev.sh build and relaunch once +# ./dev.sh --watch again on every save under Sources/ (needs fswatch) +# This runs the DEBUG binary as the live menubar. ./install.sh puts the +# release build back. +set -uo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LOG="${TMPDIR:-/tmp}/slab-dev-build.log" + +once() { + local t0=$SECONDS bin + # build-dev.sh and install.sh each take the host build lock, so they run + # one after the other, never nested. + if ! "${DIR}/build-dev.sh" >"${LOG}" 2>&1; then + grep -E 'error:' "${LOG}" | sort -u | head -20 + printf '✗ build failed (%ss) · full log %s\n' "$((SECONDS - t0))" "${LOG}" + return 1 + fi + bin="$(cd "${DIR}" && swift build -c debug --show-bin-path)/slab-menubar-swift" + if ! SLAB_PREBUILT="${bin}" "${DIR}/install.sh" >"${LOG}.install" 2>&1; then + tail -8 "${LOG}.install" + printf '✗ install failed (%ss)\n' "$((SECONDS - t0))" + return 1 + fi + grep -E 'identical|signed with|running \(pid|recovered|! ' "${LOG}.install" | tail -3 + if pgrep -f "SlabMenubar.app/Contents/MacOS/slab-menubar" >/dev/null; then + printf '✓ slab relaunched on the debug build (%ss)\n' "$((SECONDS - t0))" + else + printf '✗ slab is not running after install (%ss) · /tmp/slab-menubar.err\n' "$((SECONDS - t0))" + return 1 + fi +} + +if [[ "${1:-}" != "--watch" ]]; then once; exit $?; fi +command -v fswatch >/dev/null || { echo "--watch needs fswatch: brew install fswatch"; exit 1; } +once +echo "• watching ${DIR}/Sources (ctrl-c to stop)" +# -o: one line per batch of events; -l: let an editor's burst of writes settle +fswatch -o -l 0.4 -e '.*' -i '\.swift$' "${DIR}/Sources" | while read -r _; do + echo "• change"; once +done -- 2.51.2