diff --git a/macpal/README.md b/macpal/README.md
index 611187207..8171efd14 100644
--- a/macpal/README.md
+++ b/macpal/README.md
@@ -62,23 +62,63 @@ signing uses the *Developer ID Application: Jeffrey Scudder (FB5948YR3S)* cert.
squircle) from `star-glyph.svg` via `rsvg-convert` + `iconutil`. Re-run it after
changing the star art. `package.sh` regenerates it automatically if missing.
+## Profiles & plugins
+
+One binary, two lives, chosen by launch profile:
+
+- **(no args)** → the **star**, for Fía, with the **affirmations** plugin (a
+ caption @jeffrey can change remotely — see below). This is the App-Store build.
+- **`--profile fuser --name panda --corner TL [--repo …]`** → the **fuser badge**
+ for the fleet (neo / panda / chicken / blueberry): git status, Asana tasks,
+ ⚡OVERTIME, and a live terminal pane, via the **fuser** plugin.
+
+The avatar (float, poses, name wiggle, hover, drag-to-corner, collapse, Menu
+Band sing) lives in `PalCore.swift` and is shared. Everything that stacks
+*beneath the name* is a `PalPlugin`.
+
+## Affirmations (remote status under the star)
+
+The star polls `GET /api/macpal-status?to=fia` every ~45s and shows the message
+under herself; a new one slides in with a hop and a soft chime, and the last is
+cached so it persists offline. @jeffrey pushes one (admin-only) with:
+
+```bash
+node macpal/affirm.mjs "proud of you 💛" # → to=fia, production
+npm run affirm "great work today ✨" -- --to fia
+node macpal/affirm.mjs "testing" --local # → https://localhost:8888
+node macpal/affirm.mjs --clear # blank the caption
+```
+
+Auth uses `~/.ac-token` (run `node tezos/ac-login.mjs` once). The endpoint is
+`system/netlify/functions/macpal-status.mjs` (Redis-backed, served by lith).
+
## Layout
```
macpal/
-├── build.sh swiftc → .app bundle, sign, optional install
+├── build.sh swiftc Sources/*.swift → .app, sign, optional install
├── package.sh Developer ID sign + notarize → AirDrop-ready
├── make-icon.sh render Resources/AppIcon.icns from the star
+├── affirm.mjs CLI: push an affirmation to a star (admin-only)
├── Sources/
-│ └── MacPal.swift the whole app, one file
-└── Resources/
- ├── Info.plist bundle metadata (LSUIElement, min macOS 13)
- ├── star-glyph*.svg gold poses: home · blink · lean · sing
- └── star-silver*.svg silver poses: home · blink · lean · sing
+│ ├── main.swift entry: parse profile → PalConfig → attach plugins
+│ ├── PalCore.swift shared avatar + PalPlugin protocol + About
+│ ├── AffirmationsPlugin.swift Fía: remote affirmation caption
+│ └── FuserPlugin.swift fleet: git · Asana · OVERTIME · terminal pane
+├── Resources/
+│ ├── Info.plist bundle metadata (LSUIElement, min macOS 13)
+│ ├── star-glyph*.svg gold poses: home · blink · lean · sing (bundled)
+│ ├── star-silver*.svg silver poses (bundled)
+│ └── {neo,panda,chicken}-glyph*.svg fleet glyphs (installer source, not bundled)
+└── fuser/ symlinks into the vault (client-sensitive deploy bits)
+ ├── install.sh, deploy.sh build the badge from Sources, flip the fleet
+ └── badge-asana-sync.sh, … (kept in aesthetic-computer-vault)
```
-Per-user state (chosen corner, color, collapsed flag) lives in
-`~/Library/Application Support/MacPal/`.
+Per-user star state (corner, color, collapsed flag, last affirmation) lives in
+`~/Library/Application Support/MacPal/`; the fuser badge uses
+`~/.local/share/desktop-badge/`. Both share the Menu Band note signal at
+`~/.local/share/desktop-badge/note`.
## Swap the avatar
@@ -89,8 +129,9 @@ loader cycles through the `home`/`-2`/`-3` poses for the active color and uses
## Lineage
-Carved down from the `desktop-badge` corner character in the slab/fuser tooling,
-stripped of git status, Asana tasks, OVERTIME mode, the terminal pane, and the
-display glow — keeping the charm (floating avatar, drag-to-corner snap,
-click-to-collapse, idle pose cycling, name wiggle, hover spring, and the Menu
-Band sing) plus a focused right-click menu (About / color / Quit).
+MacPal began as the charm carved out of the fleet `desktop-badge`. The two have
+now merged the other way: the badge's features (git status, Asana, OVERTIME, the
+terminal pane) returned as the `FuserPlugin`, the shared avatar became
+`PalCore`, and the star gained the `AffirmationsPlugin`. The old
+`aesthetic-computer-vault/fuser/bin/desktop-badge/desktop-badge.swift` is
+superseded by `macpal --profile fuser`.
diff --git a/macpal/Resources/chicken-glyph-2.svg b/macpal/Resources/chicken-glyph-2.svg
new file mode 100644
index 000000000..99b5d6f1c
--- /dev/null
+++ b/macpal/Resources/chicken-glyph-2.svg
@@ -0,0 +1,21 @@
+
diff --git a/macpal/Resources/chicken-glyph-3.svg b/macpal/Resources/chicken-glyph-3.svg
new file mode 100644
index 000000000..6df683fd2
--- /dev/null
+++ b/macpal/Resources/chicken-glyph-3.svg
@@ -0,0 +1,22 @@
+
diff --git a/macpal/Resources/chicken-glyph-sing.svg b/macpal/Resources/chicken-glyph-sing.svg
new file mode 100644
index 000000000..a952b2ca5
--- /dev/null
+++ b/macpal/Resources/chicken-glyph-sing.svg
@@ -0,0 +1,25 @@
+
diff --git a/macpal/Resources/chicken-glyph.svg b/macpal/Resources/chicken-glyph.svg
new file mode 100644
index 000000000..7bc9222ab
--- /dev/null
+++ b/macpal/Resources/chicken-glyph.svg
@@ -0,0 +1,35 @@
+
diff --git a/macpal/Resources/neo-glyph-2.svg b/macpal/Resources/neo-glyph-2.svg
new file mode 100644
index 000000000..a3367804b
--- /dev/null
+++ b/macpal/Resources/neo-glyph-2.svg
@@ -0,0 +1,16 @@
+
diff --git a/macpal/Resources/neo-glyph-3.svg b/macpal/Resources/neo-glyph-3.svg
new file mode 100644
index 000000000..a2ce924c2
--- /dev/null
+++ b/macpal/Resources/neo-glyph-3.svg
@@ -0,0 +1,17 @@
+
diff --git a/macpal/Resources/neo-glyph-sing.svg b/macpal/Resources/neo-glyph-sing.svg
new file mode 100644
index 000000000..8a3a188dd
--- /dev/null
+++ b/macpal/Resources/neo-glyph-sing.svg
@@ -0,0 +1,18 @@
+
diff --git a/macpal/Resources/neo-glyph.svg b/macpal/Resources/neo-glyph.svg
new file mode 100644
index 000000000..e91563e85
--- /dev/null
+++ b/macpal/Resources/neo-glyph.svg
@@ -0,0 +1,28 @@
+
diff --git a/macpal/Resources/panda-glyph-2.svg b/macpal/Resources/panda-glyph-2.svg
new file mode 100644
index 000000000..18a412297
--- /dev/null
+++ b/macpal/Resources/panda-glyph-2.svg
@@ -0,0 +1,19 @@
+
diff --git a/macpal/Resources/panda-glyph-3.svg b/macpal/Resources/panda-glyph-3.svg
new file mode 100644
index 000000000..572e3cfc9
--- /dev/null
+++ b/macpal/Resources/panda-glyph-3.svg
@@ -0,0 +1,16 @@
+
diff --git a/macpal/Resources/panda-glyph-sing.svg b/macpal/Resources/panda-glyph-sing.svg
new file mode 100644
index 000000000..becba0727
--- /dev/null
+++ b/macpal/Resources/panda-glyph-sing.svg
@@ -0,0 +1,17 @@
+
diff --git a/macpal/Resources/panda-glyph.svg b/macpal/Resources/panda-glyph.svg
new file mode 100644
index 000000000..fe8618692
--- /dev/null
+++ b/macpal/Resources/panda-glyph.svg
@@ -0,0 +1,27 @@
+
diff --git a/macpal/Sources/AffirmationsPlugin.swift b/macpal/Sources/AffirmationsPlugin.swift
new file mode 100644
index 000000000..d2e177dce
--- /dev/null
+++ b/macpal/Sources/AffirmationsPlugin.swift
@@ -0,0 +1,120 @@
+// AffirmationsPlugin — a little caption beneath Fía's star that @jeffrey can
+// change remotely. The star polls an aesthetic.computer endpoint
+// GET /api/macpal-status?to= → { to, text, seq }
+// every ~45s; when the `seq` bumps, the new affirmation slides in, the name
+// hops, and a soft chime plays. The last affirmation is cached to disk so she
+// still sees it offline / on the next launch.
+//
+// @jeffrey pushes one with: node macpal/affirm.mjs "proud of you 💛" --to fia
+//
+// Uses the shared MarqueeField (from FuserPlugin.swift) so a long affirmation
+// scrolls gracefully instead of truncating.
+
+import AppKit
+
+final class AffirmationsPlugin: NSObject, PalPlugin, WidthHinting {
+ private let recipient: String
+ private let host: String // e.g. "https://aesthetic.computer"
+ private let stateFile: String // cache: "\t"
+ private weak var c: PalController?
+
+ private let caption = MarqueeField()
+ private var text = ""
+ private var seq = -1
+ private var polling = false
+ private var tickCount = 0
+ private let pollEvery = 45 // core ticks at ~1Hz → ~45s
+
+ private let captionH: CGFloat = 20
+ private let gap: CGFloat = 4
+ private let wideW: CGFloat = 224 // room for a sentence; star's default is narrower
+
+ var preferredWidth: CGFloat { text.isEmpty ? 160 : wideW }
+
+ init(recipient: String, host: String, supportDir: String) {
+ self.recipient = recipient
+ self.host = host.hasSuffix("/") ? String(host.dropLast()) : host
+ self.stateFile = supportDir + "/affirmation"
+ super.init()
+ // Seed from the on-disk cache so something shows before the first poll.
+ if let raw = try? String(contentsOfFile: stateFile, encoding: .utf8) {
+ let parts = raw.split(separator: "\t", maxSplits: 1)
+ if let first = parts.first, let s = Int(first) {
+ seq = s
+ text = parts.count > 1 ? String(parts[1]) : ""
+ }
+ }
+ }
+
+ func attach(to controller: PalController) {
+ c = controller
+ controller.content.addSubview(caption)
+ if !text.isEmpty { renderCaption() }
+ }
+
+ func stackHeight(in controller: PalController) -> CGFloat {
+ text.isEmpty ? 0 : captionH + gap
+ }
+
+ func layoutRows(in controller: PalController, originY: CGFloat) {
+ caption.isHidden = text.isEmpty
+ let w = controller.fullWidth
+ caption.frame = NSRect(x: 0, y: originY, width: w, height: captionH)
+ }
+
+ func setCollapsed(_ collapsed: Bool) {
+ caption.isHidden = collapsed || text.isEmpty
+ }
+
+ func tick() {
+ if tickCount % pollEvery == 0 { poll() }
+ tickCount += 1
+ }
+
+ // ── network ────────────────────────────────────────────────────────────
+ private func poll() {
+ guard !polling, let url = URL(string: "\(host)/api/macpal-status?to=\(recipient)") else { return }
+ polling = true
+ var req = URLRequest(url: url)
+ req.timeoutInterval = 12
+ req.cachePolicy = .reloadIgnoringLocalCacheData
+ URLSession.shared.dataTask(with: req) { [weak self] data, _, _ in
+ guard let self = self else { return }
+ defer { self.polling = false }
+ guard let data = data,
+ let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let newSeq = (obj["seq"] as? Int) ?? (obj["seq"] as? NSNumber)?.intValue
+ else { return }
+ let newText = (obj["text"] as? String) ?? ""
+ guard newSeq != self.seq else { return }
+ DispatchQueue.main.async { self.apply(seq: newSeq, text: newText) }
+ }.resume()
+ }
+
+ private func apply(seq newSeq: Int, text newText: String) {
+ let firstFill = seq < 0
+ seq = newSeq
+ text = newText
+ try? "\(seq)\t\(text)".write(toFile: stateFile, atomically: true, encoding: .utf8)
+ renderCaption()
+ c?.layout()
+ // Celebrate a genuinely new message (not the silent seed on launch).
+ if !firstFill, !text.isEmpty {
+ c?.nameLabel.bounce()
+ let snd = NSSound(named: "Glass"); snd?.volume = 0.35; snd?.play()
+ }
+ }
+
+ private func renderCaption() {
+ let para = NSMutableParagraphStyle()
+ para.alignment = .center; para.lineBreakMode = .byClipping
+ let attr = NSAttributedString(string: text, attributes: [
+ .font: playfulFont(15, bold: true),
+ .foregroundColor: NSColor.white,
+ .strokeColor: NSColor(white: 0.08, alpha: 1),
+ .strokeWidth: -3.0,
+ .paragraphStyle: para,
+ ])
+ caption.setText(attr)
+ }
+}
diff --git a/macpal/Sources/FuserPlugin.swift b/macpal/Sources/FuserPlugin.swift
new file mode 100644
index 000000000..8bcbeadc5
--- /dev/null
+++ b/macpal/Sources/FuserPlugin.swift
@@ -0,0 +1,660 @@
+// FuserPlugin — the machine-badge features that stack beneath the pal on the
+// fuser fleet (neo / panda / chicken / blueberry): a color-coded git status
+// line (marquee when it overflows), the machine's current Asana tasks, an
+// ⚡OVERTIME alarm + work queue, and a live ANSI "terminal" pane tailing a log.
+//
+// Every input is a plain file polled at runtime, so content updates never need
+// a recompile:
+// /gitstatus " " (badge-git-sync.sh) or "local"
+// /tasks Asana task lines (badge-asana-sync.sh on neo)
+// /overtime flag file; presence = OVERTIME on
+// /overtime-status work-queue lines while in OVERTIME
+// /pane.log the log the terminal pane tails (optional)
+// where is the fuser state dir (~/.local/share/desktop-badge), which is
+// also PalConfig.supportDir for the fuser profile.
+//
+// Carved out of the original desktop-badge.swift; the avatar/sing/collapse/drag
+// machinery now lives in PalCore.swift and is shared with the star.
+
+import AppKit
+
+func monoFont(_ pt: CGFloat) -> NSFont {
+ for n in ["MonaspaceArgon-Bold", "SFMono-Semibold", "Menlo-Bold", "MonaspaceArgon-Regular", "Menlo"] {
+ if let f = NSFont(name: n, size: pt) { return f }
+ }
+ return NSFont.monospacedSystemFont(ofSize: pt, weight: .semibold)
+}
+
+// Visual-only overlay — never intercepts the pane's clicks/scroll/selection.
+final class GhostView: NSView {
+ override func hitTest(_ p: NSPoint) -> NSView? { nil }
+}
+
+// ── marquee status field ──────────────────────────────────────────────────
+// No truncation: the styled status line is baked (accent shadow and all) into
+// an image; when it fits it centers like a label, and when it overflows two
+// copies scroll in an endless leftward loop.
+final class MarqueeField: NSView {
+ private let lead = CALayer()
+ private let trail = CALayer()
+ private var textW: CGFloat = 0
+ private var textH: CGFloat = 0
+ private let gap: CGFloat = 28
+ private let speed: CGFloat = 26 // px/s
+
+ init() {
+ super.init(frame: .zero)
+ layer = CALayer()
+ wantsLayer = true
+ layer?.masksToBounds = true
+ for l in [lead, trail] { l.anchorPoint = .zero; layer?.addSublayer(l) }
+ }
+ required init?(coder: NSCoder) { fatalError("no nib") }
+
+ func setText(_ attr: NSAttributedString) {
+ let m = NSMutableAttributedString(attributedString: attr)
+ let sh = NSShadow()
+ sh.shadowColor = accent; sh.shadowBlurRadius = 0
+ sh.shadowOffset = NSSize(width: 2, height: -2)
+ m.addAttribute(.shadow, value: sh, range: NSRange(location: 0, length: m.length))
+ let size = m.size()
+ textW = ceil(size.width) + 2; textH = ceil(size.height) + 2
+ let img = NSImage(size: NSSize(width: textW, height: textH), flipped: false) { _ in
+ m.draw(at: NSPoint(x: 0, y: 2))
+ return true
+ }
+ let scale = NSScreen.main?.backingScaleFactor ?? 2
+ for l in [lead, trail] {
+ l.contentsScale = scale
+ l.contents = img
+ l.bounds = CGRect(x: 0, y: 0, width: textW, height: textH)
+ }
+ relayout()
+ }
+
+ override func setFrameSize(_ s: NSSize) { super.setFrameSize(s); relayout() }
+
+ private func relayout() {
+ CATransaction.begin(); CATransaction.setDisableActions(true)
+ lead.removeAnimation(forKey: "marquee"); trail.removeAnimation(forKey: "marquee")
+ let y = (bounds.height - textH) / 2
+ if textW <= bounds.width {
+ trail.isHidden = true
+ lead.position = CGPoint(x: (bounds.width - textW) / 2, y: y)
+ } else {
+ trail.isHidden = false
+ let span = textW + gap
+ lead.position = CGPoint(x: 0, y: y)
+ trail.position = CGPoint(x: span, y: y)
+ for l in [lead, trail] {
+ let a = CABasicAnimation(keyPath: "transform.translation.x")
+ a.fromValue = 0; a.toValue = -span
+ a.duration = CFTimeInterval(span / speed)
+ a.repeatCount = .infinity
+ l.add(a, forKey: "marquee")
+ }
+ }
+ CATransaction.commit()
+ }
+}
+
+// ── ANSI + heuristic pane colorizer ──────────────────────────────────────
+struct PaneTheme {
+ let dark: Bool
+ var defaultText: NSColor {
+ dark ? NSColor(calibratedRed: 0.80, green: 0.95, blue: 0.84, alpha: 1)
+ : NSColor(calibratedRed: 0.08, green: 0.16, blue: 0.10, alpha: 1)
+ }
+ private func hex(_ v: UInt32) -> NSColor {
+ NSColor(calibratedRed: CGFloat((v >> 16) & 0xff) / 255,
+ green: CGFloat((v >> 8) & 0xff) / 255,
+ blue: CGFloat(v & 0xff) / 255, alpha: 1)
+ }
+ func ansi(_ i: Int) -> NSColor {
+ let darkP: [UInt32] = [0x8b949e, 0xff6b6b, 0x7ee787, 0xffd66b,
+ 0x79b8ff, 0xd2a8ff, 0x76e3ea, 0xe6edf3,
+ 0xa5b1bd, 0xff8f8f, 0xa5f3b4, 0xffe28f,
+ 0xa3cfff, 0xe2c5ff, 0xa6edf2, 0xffffff]
+ let lightP: [UInt32] = [0x57606a, 0xc0342b, 0x1a7f37, 0x9a6700,
+ 0x0969da, 0x8250df, 0x1b7c83, 0x1f2328,
+ 0x6e7781, 0xa40e26, 0x116329, 0x7d4e00,
+ 0x0550ae, 0x6639ba, 0x155e63, 0x24292f]
+ return hex((dark ? darkP : lightP)[max(0, min(15, i))])
+ }
+ func rgb(_ r: Int, _ g: Int, _ b: Int) -> NSColor {
+ let (cr, cg, cb) = (CGFloat(r) / 255, CGFloat(g) / 255, CGFloat(b) / 255)
+ let luma = 0.299 * cr + 0.587 * cg + 0.114 * cb
+ if dark && luma < 0.22 { return ansi(0) }
+ if !dark && luma > 0.78 { return ansi(0) }
+ return NSColor(calibratedRed: cr, green: cg, blue: cb, alpha: 1)
+ }
+ func ansi256(_ n: Int) -> NSColor {
+ if n < 16 { return ansi(n) }
+ if n < 232 {
+ let v = n - 16
+ let steps = [0, 95, 135, 175, 215, 255]
+ return rgb(steps[(v / 36) % 6], steps[(v / 6) % 6], steps[v % 6])
+ }
+ let g = 8 + (n - 232) * 10
+ return rgb(g, g, g)
+ }
+}
+
+final class PaneRenderer {
+ static let urlRx = try! NSRegularExpression(pattern: "https?://[^\\s'\"]+|localhost:\\d+")
+ static let strRx = try! NSRegularExpression(pattern: "'[^']*'|\"[^\"]*\"")
+ static let numRx = try! NSRegularExpression(pattern: "(?<=[\\s:,\\[(=])-?\\d+(?:\\.\\d+)?(?:ms|s|%)?(?=[\\s,}\\])]|$)")
+ static let boolRx = try! NSRegularExpression(pattern: "\\b(true|false|null|undefined)\\b")
+
+ let theme: PaneTheme
+ let font = monoFont(8.5)
+ private var fg: NSColor?
+ private var isDim = false
+
+ init(dark: Bool) { theme = PaneTheme(dark: dark) }
+
+ private func attrs(_ color: NSColor?) -> [NSAttributedString.Key: Any] {
+ var c = color ?? theme.defaultText
+ if isDim { c = c.withAlphaComponent(0.62) }
+ return [.font: font, .foregroundColor: c]
+ }
+
+ private func applySGR(_ params: [Int]) {
+ if params.isEmpty { fg = nil; isDim = false; return }
+ var i = 0
+ while i < params.count {
+ switch params[i] {
+ case 0: fg = nil; isDim = false
+ case 2: isDim = true
+ case 22: isDim = false
+ case 39: fg = nil
+ case 30...37: fg = theme.ansi(params[i] - 30)
+ case 90...97: fg = theme.ansi(params[i] - 90 + 8)
+ case 38 where i + 2 < params.count && params[i + 1] == 5:
+ fg = theme.ansi256(params[i + 2]); i += 2
+ case 38 where i + 4 < params.count && params[i + 1] == 2:
+ fg = theme.rgb(params[i + 2], params[i + 3], params[i + 4]); i += 4
+ case 48 where i + 2 < params.count && params[i + 1] == 5: i += 2
+ case 48 where i + 4 < params.count && params[i + 1] == 2: i += 4
+ default: break
+ }
+ i += 1
+ }
+ }
+
+ func render(_ raw: String) -> NSAttributedString {
+ let out = NSMutableAttributedString()
+ let lines = raw.split(separator: "\n", omittingEmptySubsequences: false)
+ for (li, sub) in lines.enumerated() {
+ var line = String(sub)
+ if let r = line.lastIndex(of: "\r") { line = String(line[line.index(after: r)...]) }
+ out.append(renderLine(line))
+ if li < lines.count - 1 { out.append(NSAttributedString(string: "\n", attributes: attrs(nil))) }
+ }
+ return out
+ }
+
+ private func renderLine(_ line: String) -> NSAttributedString {
+ let out = NSMutableAttributedString()
+ var sawColor = fg != nil
+ var seg = ""
+ var i = line.startIndex
+ func flush() {
+ if !seg.isEmpty { out.append(NSAttributedString(string: seg, attributes: attrs(fg))); seg = "" }
+ }
+ while i < line.endIndex {
+ guard line[i] == "\u{1B}" else { seg.append(line[i]); i = line.index(after: i); continue }
+ var j = line.index(after: i)
+ if j < line.endIndex, line[j] == "[" {
+ j = line.index(after: j)
+ var num = "", params: [Int] = [], fin: Character? = nil
+ while j < line.endIndex {
+ let c = line[j]
+ if c.isNumber { num.append(c) }
+ else if c == ";" { params.append(Int(num) ?? 0); num = "" }
+ else if c == "?" || c == " " || c == "!" {}
+ else { fin = c; break }
+ j = line.index(after: j)
+ }
+ if !num.isEmpty { params.append(Int(num) ?? 0) }
+ if fin == "m" { flush(); applySGR(params); if fg != nil { sawColor = true } }
+ i = j < line.endIndex ? line.index(after: j) : line.endIndex
+ } else if j < line.endIndex, line[j] == "]" {
+ var k = line.index(after: j)
+ while k < line.endIndex, line[k] != "\u{07}" { k = line.index(after: k) }
+ i = k < line.endIndex ? line.index(after: k) : line.endIndex
+ } else {
+ i = j < line.endIndex ? line.index(after: j) : line.endIndex
+ }
+ }
+ flush()
+ return sawColor ? out : highlight(out.string)
+ }
+
+ private func serviceColor(_ name: String) -> NSColor {
+ var h: UInt32 = 2166136261
+ for b in name.utf8 { h = (h ^ UInt32(b)) &* 16777619 }
+ let picks = [2, 3, 4, 5, 6, 10, 11, 12, 13, 14]
+ return theme.ansi(picks[Int(h % UInt32(picks.count))])
+ }
+
+ private func highlight(_ line: String) -> NSAttributedString {
+ let out = NSMutableAttributedString(string: line, attributes: attrs(nil))
+ let ns = line as NSString
+ var rest = NSRange(location: 0, length: ns.length)
+
+ if let colon = line.range(of: ": "),
+ line.distance(from: line.startIndex, to: colon.lowerBound) <= 40 {
+ let prefix = String(line[.. String {
+ let p = Process()
+ p.executableURL = URL(fileURLWithPath: "/usr/bin/git")
+ // fsmonitor=false: the repo's daemon belongs to the login session; git
+ // spawned from a LaunchAgent hangs for minutes handshaking with it.
+ p.arguments = ["-C", repo, "-c", "core.fsmonitor=false"] + a
+ let out = Pipe(); p.standardOutput = out; p.standardError = Pipe()
+ do { try p.run() } catch { return "" }
+ p.waitUntilExit()
+ let d = out.fileHandleForReading.readDataToEndOfFile()
+ return (String(data: d, encoding: .utf8) ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ private func tailLog(_ path: String, maxBytes: UInt64 = 48000) -> String {
+ guard let fh = FileHandle(forReadingAtPath: path) else { return "" }
+ defer { try? fh.close() }
+ let end = (try? fh.seekToEnd()) ?? 0
+ let start = end > maxBytes ? end - maxBytes : 0
+ try? fh.seek(toOffset: start)
+ let data = (try? fh.readToEnd()) ?? Data()
+ var s = String(data: data, encoding: .utf8) ?? String(decoding: data, as: UTF8.self)
+ if start > 0, let nl = s.firstIndex(of: "\n") { s = String(s[s.index(after: nl)...]) }
+ return s.trimmingCharacters(in: CharacterSet.newlines)
+ }
+
+ // ── PalPlugin ──────────────────────────────────────────────────────────
+ func attach(to controller: PalController) {
+ c = controller
+ controller.styleField(tasksField); controller.styleField(overtimeField)
+ tasksField.maximumNumberOfLines = 3
+ overtimeField.maximumNumberOfLines = 4
+ overtimeChip.isBordered = false
+ overtimeChip.drawsBackground = false
+ overtimeChip.alignment = .center
+ overtimeChip.wantsLayer = true
+ overtimeChip.layer?.masksToBounds = false
+ let chipShadow = NSShadow()
+ chipShadow.shadowColor = hexColor(0x0a3cff) // alarm blue
+ chipShadow.shadowBlurRadius = 2
+ chipShadow.shadowOffset = NSSize(width: 2, height: -2)
+ overtimeChip.shadow = chipShadow
+
+ controller.content.addSubview(statusField)
+ controller.content.addSubview(tasksField)
+ controller.content.addSubview(overtimeChip)
+ controller.content.addSubview(overtimeField)
+
+ if hasPane {
+ let container = NSView(frame: NSRect(x: pad, y: 8, width: paneW, height: 10))
+ container.wantsLayer = true
+ container.layer?.shadowColor = accent.cgColor
+ container.layer?.shadowOffset = CGSize(width: 2, height: -2)
+ container.layer?.shadowRadius = 0
+ container.layer?.shadowOpacity = 1
+ container.layer?.masksToBounds = false
+ let scroll = NSScrollView(frame: container.bounds)
+ scroll.autoresizingMask = [.width, .height]
+ scroll.drawsBackground = true
+ scroll.hasVerticalScroller = true
+ scroll.scrollerStyle = .overlay
+ scroll.autohidesScrollers = true
+ scroll.wantsLayer = true
+ scroll.layer?.cornerRadius = 7
+ scroll.layer?.borderColor = accent.withAlphaComponent(0.9).cgColor
+ scroll.layer?.borderWidth = 1.5
+ scroll.layer?.masksToBounds = true
+ let tv = NSTextView(frame: scroll.bounds)
+ tv.isEditable = false; tv.isSelectable = true
+ tv.drawsBackground = false
+ tv.font = monoFont(8.5)
+ tv.textContainerInset = NSSize(width: 5, height: 5)
+ tv.isVerticallyResizable = true; tv.isHorizontallyResizable = false
+ tv.textContainer?.widthTracksTextView = true
+ tv.autoresizingMask = [.width]
+ scroll.documentView = tv
+ container.addSubview(scroll)
+ let flash = GhostView(frame: container.bounds)
+ flash.autoresizingMask = [.width, .height]
+ flash.wantsLayer = true
+ flash.layer?.backgroundColor = accent.cgColor
+ flash.layer?.cornerRadius = 7
+ flash.layer?.opacity = 0
+ container.addSubview(flash)
+ controller.content.addSubview(container)
+ paneContainer = container; paneScroll = scroll; paneView = tv
+ paneFlash = flash
+ applyPaneTheme()
+ }
+ }
+
+ // Reserved height from the y=12 baseline up to where the name sits — the
+ // exact bottom-up stack the original badge computed.
+ func stackHeight(in controller: PalController) -> CGFloat {
+ let off = (hasPane && curPaneH > 0) ? curPaneH + 8 : 0
+ let tasksH: CGFloat = taskLines.isEmpty ? 0 : CGFloat(taskLines.count) * 13 + 3
+ let chipH: CGFloat = overtimeOn ? 36 : 0
+ let queueH: CGFloat = (overtimeOn && !overtimeLines.isEmpty)
+ ? CGFloat(overtimeLines.count) * 16 + 3 : 0
+ let overtimeH: CGFloat = overtimeOn ? chipH + (queueH > 0 ? queueH + 4 : 0) : 0
+ return off + (tasksH > 0 ? tasksH + 5 : 0) + 18 + (overtimeH > 0 ? 4 : 0) + overtimeH + 14
+ }
+
+ func layoutRows(in controller: PalController, originY: CGFloat) {
+ let base = originY // 12 in practice (single plugin)
+ let W = controller.fullWidth
+ let off = (hasPane && curPaneH > 0) ? curPaneH + 8 : 0
+ let tasksH: CGFloat = taskLines.isEmpty ? 0 : CGFloat(taskLines.count) * 13 + 3
+ let chipH: CGFloat = overtimeOn ? 36 : 0
+ let queueH: CGFloat = (overtimeOn && !overtimeLines.isEmpty)
+ ? CGFloat(overtimeLines.count) * 16 + 3 : 0
+ let overtimeH: CGFloat = overtimeOn ? chipH + (queueH > 0 ? queueH + 4 : 0) : 0
+ let statusY = base + off + (tasksH > 0 ? tasksH + 5 : 0)
+ let overtimeY = statusY + 18 + (overtimeH > 0 ? 4 : 0)
+
+ statusField.isHidden = false
+ statusField.frame = NSRect(x: 0, y: statusY, width: W, height: 18)
+ overtimeField.isHidden = queueH <= 0
+ overtimeField.frame = NSRect(x: 6, y: overtimeY, width: W - 12, height: queueH)
+ overtimeChip.isHidden = chipH <= 0
+ let chipW = min(overtimeChip.attributedStringValue.size().width + 16, W - 4)
+ overtimeChip.frame = NSRect(x: (W - chipW) / 2,
+ y: overtimeY + queueH + (queueH > 0 ? 4 : 0),
+ width: chipW, height: chipH)
+ tasksField.isHidden = tasksH <= 0
+ tasksField.frame = NSRect(x: 6, y: base + off, width: W - 12, height: tasksH)
+
+ if let c = paneContainer {
+ c.isHidden = curPaneH <= 0
+ c.frame = NSRect(x: pad, y: base - 4, width: paneW, height: curPaneH)
+ if !c.isHidden { controller.content.liveRects.append(c.frame) }
+ }
+ }
+
+ func setCollapsed(_ collapsed: Bool) {
+ let hide = collapsed
+ statusField.isHidden = hide
+ tasksField.isHidden = hide || taskLines.isEmpty
+ overtimeField.isHidden = hide || !overtimeOn || overtimeLines.isEmpty
+ overtimeChip.isHidden = hide || !overtimeOn
+ paneContainer?.isHidden = hide || curPaneH <= 0
+ }
+
+ func tick() {
+ refresh()
+ if hasPane { refreshPane() }
+ }
+
+ func menuItems(for controller: PalController) -> [NSMenuItem] {
+ let item = NSMenuItem(title: "Overtime", action: #selector(toggleOvertime), keyEquivalent: "")
+ item.target = self
+ item.state = FileManager.default.fileExists(atPath: overtimeFlag) ? .on : .off
+ return [item]
+ }
+
+ // ── overtime ───────────────────────────────────────────────────────────
+ @objc func toggleOvertime() {
+ let fm = FileManager.default
+ if fm.fileExists(atPath: overtimeFlag) {
+ try? fm.removeItem(atPath: overtimeFlag)
+ try? fm.removeItem(atPath: overtimeStatusFile)
+ } else {
+ fm.createFile(atPath: overtimeFlag, contents: nil)
+ try? "idle — overtime on".write(toFile: overtimeStatusFile,
+ atomically: true, encoding: .utf8)
+ let p = Process()
+ p.executableURL = URL(fileURLWithPath: "/bin/bash")
+ p.arguments = ["-c",
+ "launchctl kickstart gui/$(id -u)/computer.aesthetic.overtimeworker 2>/dev/null"]
+ try? p.run()
+ }
+ c?.nameLabel.bounce()
+ refresh()
+ }
+
+ // ── pane ─────────────────────────────────────────────────────────────
+ func paneIsDark() -> Bool {
+ NSApp.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
+ }
+
+ func applyPaneTheme() {
+ guard let scroll = paneScroll else { return }
+ scroll.backgroundColor = paneIsDark()
+ ? NSColor.black.withAlphaComponent(0.58)
+ : NSColor.white.withAlphaComponent(0.90)
+ }
+
+ func measurePaneH() -> CGFloat {
+ guard let tv = paneView, let tc = tv.textContainer, let lm = tv.layoutManager else { return 0 }
+ if (tv.string).isEmpty { return 0 }
+ lm.ensureLayout(for: tc)
+ let used = lm.usedRect(for: tc).height
+ return min(max(used + tv.textContainerInset.height * 2 + 3, 22), maxPaneH)
+ }
+
+ func flashPane() {
+ guard let l = paneFlash?.layer else { return }
+ let a = CAKeyframeAnimation(keyPath: "opacity")
+ a.values = [0, 0.5, 0]
+ a.keyTimes = [0, 0.15, 1]
+ a.duration = 0.45
+ l.add(a, forKey: "flash")
+ }
+
+ func refreshPane() {
+ guard let tv = paneView, let c = c else { return }
+ applyPaneTheme()
+ let dark = paneIsDark()
+ let text = tailLog(paneLog)
+ guard text != lastPaneRaw || dark != lastPaneDark else { return }
+ let newOutput = lastPaneDark != nil && text != lastPaneRaw
+ lastPaneRaw = text; lastPaneDark = dark
+ tv.textStorage?.setAttributedString(PaneRenderer(dark: dark).render(text))
+ let newH = measurePaneH()
+ if abs(newH - curPaneH) > 0.5 { curPaneH = newH; if !c.collapsed { c.layout() } }
+ tv.scrollRangeToVisible(NSRange(location: (tv.string as NSString).length, length: 0))
+ if newOutput && !c.collapsed { c.nameLabel.bounce(); flashPane() }
+ }
+
+ // git runs off-main: a slow/hung git must never stall the window.
+ func refresh() {
+ // git only every ~4th tick; status files are read every tick (cheap).
+ let doGit = tick4 % 4 == 0
+ tick4 += 1
+ guard !refreshing else { return }
+ refreshing = true
+ DispatchQueue.global(qos: .utility).async { [weak self] in
+ guard let self = self else { return }
+ let branch = doGit ? self.git(["branch", "--show-current"]) : self.lastBranch
+ let dirty = doGit ? !self.git(["status", "--porcelain"]).isEmpty : self.lastDirty
+ let syncRaw = (try? String(contentsOfFile: self.statusFile, encoding: .utf8)) ?? ""
+ let rawTasks = (try? String(contentsOfFile: self.tasksFile, encoding: .utf8)) ?? ""
+ let otOn = FileManager.default.fileExists(atPath: self.overtimeFlag)
+ let otRaw = otOn
+ ? ((try? String(contentsOfFile: self.overtimeStatusFile, encoding: .utf8)) ?? "")
+ : ""
+ DispatchQueue.main.async {
+ self.refreshing = false
+ self.lastBranch = branch; self.lastDirty = dirty
+ self.applyStatus(branch: branch, dirty: dirty,
+ syncRaw: syncRaw, rawTasks: rawTasks,
+ otOn: otOn, otRaw: otRaw)
+ }
+ }
+ }
+ private var lastBranch = ""
+ private var lastDirty = false
+
+ func applyStatus(branch: String, dirty: Bool, syncRaw: String, rawTasks: String,
+ otOn: Bool, otRaw: String) {
+ guard let c = c else { return }
+ var sync: String
+ let t = syncRaw.trimmingCharacters(in: .whitespacesAndNewlines)
+ let parts = t.split(separator: " ")
+ if t.isEmpty { sync = "checking…" }
+ else if t == "local" { sync = "local only" }
+ else if parts.count >= 2, let a = Int(parts[0]), let b = Int(parts[1]) {
+ if a == 0 && b == 0 { sync = "synced" }
+ else if a > 0 && b > 0 { sync = "\(a) ahead, \(b) behind" }
+ else if a > 0 { sync = "\(a) ahead" }
+ else { sync = "\(b) behind" }
+ } else { sync = "checking…" }
+ let syncColor: NSColor
+ if sync == "synced" { syncColor = hexColor(0x7ee787) }
+ else if sync.contains("ahead") && sync.contains("behind") { syncColor = hexColor(0xffb14d) }
+ else if sync.contains("ahead") { syncColor = hexColor(0xffd66b) }
+ else if sync.contains("behind") { syncColor = hexColor(0xff6b6b) }
+ else { syncColor = hexColor(0xa5b1bd) }
+ let f10 = monoFont(10)
+ func seg(_ s: String, _ col: NSColor) -> NSAttributedString {
+ NSAttributedString(string: s, attributes: [.font: f10, .foregroundColor: col])
+ }
+ let dim = NSColor.white.withAlphaComponent(0.55)
+ let line = NSMutableAttributedString()
+ line.append(seg(branch.isEmpty ? "—" : branch, .white))
+ line.append(seg(" · ", dim))
+ line.append(seg(sync, syncColor))
+ if dirty {
+ line.append(seg(" · ", dim))
+ line.append(seg("uncommitted", hexColor(0xffaa33)))
+ }
+ statusField.setText(line)
+
+ let lines = Array(rawTasks.split(separator: "\n")
+ .map { $0.trimmingCharacters(in: .whitespaces) }
+ .filter { !$0.isEmpty }
+ .prefix(3))
+ if lines != taskLines {
+ taskLines = lines
+ let para = NSMutableParagraphStyle()
+ para.alignment = .center; para.lineBreakMode = .byTruncatingTail
+ tasksField.attributedStringValue = NSAttributedString(
+ string: lines.joined(separator: "\n"),
+ attributes: [.font: monoFont(9), .foregroundColor: NSColor.white,
+ .paragraphStyle: para])
+ if !c.collapsed { c.layout() }
+ }
+
+ let otLines = Array(otRaw.split(separator: "\n")
+ .map { $0.trimmingCharacters(in: .whitespaces) }
+ .filter { !$0.isEmpty }
+ .prefix(4))
+ if otOn != overtimeOn || otLines != overtimeLines {
+ overtimeOn = otOn; overtimeLines = otLines
+ let para = NSMutableParagraphStyle()
+ para.alignment = .center; para.lineBreakMode = .byTruncatingTail
+ overtimeChip.attributedStringValue = otOn
+ ? NSAttributedString(string: "⚡ OVERTIME", attributes: [
+ .font: playfulFont(24, bold: true),
+ .foregroundColor: hexColor(0xff0000),
+ .strokeColor: hexColor(0xffe000),
+ .strokeWidth: -6.0,
+ .paragraphStyle: para])
+ : NSAttributedString()
+ overtimeField.attributedStringValue = otOn && !otLines.isEmpty
+ ? NSAttributedString(string: otLines.joined(separator: "\n"), attributes: [
+ .font: monoFont(11), .foregroundColor: NSColor.white,
+ .paragraphStyle: para])
+ : NSAttributedString()
+ if !c.collapsed { c.layout() }
+ }
+ }
+}
diff --git a/macpal/Sources/Pal3D.swift b/macpal/Sources/Pal3D.swift
new file mode 100644
index 000000000..db75ee3da
--- /dev/null
+++ b/macpal/Sources/Pal3D.swift
@@ -0,0 +1,483 @@
+// Pal3D.swift — a 3D avatar for MacPal.
+//
+// Drops into PalController as `glyphView` (an NSView) in place of the flat SVG
+// NSImageView, so the surrounding charm — drag-to-corner, collapse, hover
+// spring, press pop — all still wrap it unchanged (those poke `glyphView.layer`,
+// which a layer-backed SCNView honors). What's specific to 3D lives here: the
+// model load, a transparent-background SceneKit scene, a gentle idle spin + bob,
+// and a `sing()` flourish for the Menu Band note easter egg.
+//
+// It loads an OBJ (Meshy → fal, see macpal/bin/gen-blueberry-3d.mjs) via
+// ModelIO and applies the matching `*-base_color.png` to the material. Until
+// that asset exists, it falls back to a procedurally-built blueberry so the 3D
+// path is always live and testable — `--model3d` with no file shows the
+// placeholder; `--model3d ` shows the real berry.
+import AppKit
+import SceneKit
+import ModelIO
+import SceneKit.ModelIO
+
+// A built scene plus the handles callers need (camera for the point of view,
+// spin node for idle/animation). Shared by the live view and the offscreen
+// snapshot/thumbnail path.
+struct Pal3DScene {
+ let scene: SCNScene
+ let camera: SCNNode
+ let spin: SCNNode
+ let model: SCNNode
+ let ambient: SCNNode // recolored from the desktop pixels behind the pal
+ let key: SCNNode
+ let clips: [String: SCNNode] // hidden one-shot models (wave/cheer)
+ let clipPlayers: [String: SCNAnimationPlayer]
+}
+
+final class Pal3DView: NSView {
+ private let scnView = SCNView()
+ private let spinNode: SCNNode // gentle idle float rides here
+ private let modelHolder: SCNNode // centered, scaled-to-fit mesh
+ private let ambientNode: SCNNode // recolored from the desktop behind the pal
+ private let keyNode: SCNNode
+ private var ambientTimer: Timer?
+ private let clips: [String: SCNNode] // wave / cheer (hidden)
+ private let clipPlayers: [String: SCNAnimationPlayer]
+ private var clipReturn: DispatchWorkItem?
+ private var clipBusy = false
+
+ init(frame: NSRect, objPath: String?, tint: NSColor) {
+ let built = Pal3DView.buildScene(objPath: objPath, tint: tint)
+ spinNode = built.spin
+ modelHolder = built.model
+ ambientNode = built.ambient
+ keyNode = built.key
+ clips = built.clips
+ clipPlayers = built.clipPlayers
+ super.init(frame: frame)
+ wantsLayer = true
+
+ scnView.frame = bounds
+ scnView.autoresizingMask = [.width, .height]
+ scnView.backgroundColor = .clear // float over the desktop
+ scnView.allowsCameraControl = false
+ scnView.antialiasingMode = .multisampling4X
+ scnView.isPlaying = true // keep the action loop alive
+ scnView.scene = built.scene
+ scnView.pointOfView = built.camera
+ addSubview(scnView)
+
+ startIdle()
+ // Light the pal from his surroundings: sample the desktop pixels behind
+ // him every ~1.5s and tint the ambient/key lights to match.
+ ambientTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: true) { [weak self] _ in
+ self?.sampleAmbient()
+ }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in self?.sampleAmbient() }
+ }
+
+ deinit { ambientTimer?.invalidate() }
+
+ required init?(coder: NSCoder) { nil }
+
+ // A light tint pulled toward the system accent — blends white→accent by k so
+ // it nudges the berry's hue to match the machine's indigo without flattening
+ // the texture detail (applied as a material multiply).
+ static func accentTint(_ accent: NSColor, _ k: CGFloat) -> NSColor {
+ let a = accent.usingColorSpace(.deviceRGB) ?? accent
+ return NSColor(deviceRed: 1 - (1 - a.redComponent) * k,
+ green: 1 - (1 - a.greenComponent) * k,
+ blue: 1 - (1 - a.blueComponent) * k, alpha: 1)
+ }
+
+ // ── scene construction (camera · lights · centered mesh) ────────────────
+ static func buildScene(objPath: String?, tint: NSColor) -> Pal3DScene {
+ let scene = SCNScene()
+
+ let camDist: CGFloat = 3.4
+ let vFOV: CGFloat = 55
+ let cam = SCNNode()
+ cam.camera = SCNCamera()
+ cam.camera?.fieldOfView = vFOV
+ cam.camera?.projectionDirection = .vertical // fix the VERTICAL fov so the
+ // pal's size is independent of the view's aspect (square snapshot vs the
+ // landscape glyph frame) — no more "looks fine offscreen, tiny live".
+ cam.position = SCNVector3(x: 0, y: 0, z: camDist)
+ scene.rootNode.addChildNode(cam)
+
+ let key = SCNNode()
+ key.light = SCNLight(); key.light!.type = .directional; key.light!.intensity = 850
+ key.eulerAngles = SCNVector3(x: -0.6, y: 0.5, z: 0)
+ scene.rootNode.addChildNode(key)
+ let ambient = SCNNode()
+ ambient.light = SCNLight(); ambient.light!.type = .ambient; ambient.light!.intensity = 480
+ scene.rootNode.addChildNode(ambient)
+
+ let spin = SCNNode()
+ let model = SCNNode()
+ spin.addChildNode(model)
+ scene.rootNode.addChildNode(spin)
+
+ if let p = objPath, FileManager.default.fileExists(atPath: p),
+ let loaded = loadModel(p, tint: tint) {
+ model.addChildNode(loaded)
+ } else {
+ model.addChildNode(placeholderBlueberry())
+ }
+ // ── viewport-containment constraint ─────────────────────────────────
+ // The character must NEVER render outside its viewport, in any pose. So
+ // we fit the REST pose small enough that its most extreme animated reach
+ // (raised arms, plus the idle float) still sits inside the visible frame:
+ //
+ // restHeight · ANIM_REACH ≤ visibleHeight · VIEWPORT_FILL
+ //
+ // visibleHeight is the world-space height the camera sees at the model
+ // plane: 2·dist·tan(vFOV/2). Solving for the fit target:
+ let visibleHeight = 2 * camDist * tan(vFOV * 0.5 * .pi / 180)
+ let ANIM_REACH: CGFloat = 1.7 // tallest clip (arms up) vs rest, + float headroom
+ let VIEWPORT_FILL: CGFloat = 0.95
+ normalizeToFit(model, target: visibleHeight * VIEWPORT_FILL / ANIM_REACH)
+
+ // Extra one-shot clips (wave/cheer) — sibling -.usdc models,
+ // loaded hidden + paused; playClip() swaps them in for a beat. (Swapping
+ // whole rigged models avoids cross-file skeletal retargeting.)
+ var clips: [String: SCNNode] = [:]
+ var clipPlayers: [String: SCNAnimationPlayer] = [:]
+ if let p = objPath {
+ let dir = (p as NSString).deletingLastPathComponent
+ let stem = ((p as NSString).lastPathComponent as NSString).deletingPathExtension
+ for clip in ["wave", "cheer"] {
+ let cp = "\(dir)/\(stem)-\(clip).usdc"
+ guard FileManager.default.fileExists(atPath: cp),
+ let n = loadUSD(cp, tint: tint, loop: false) else { continue }
+ // Same rig as the idle → reuse the idle's exact fit transform so
+ // swapping clips never zooms/shifts the camera framing. (Fitting
+ // each independently differs because they load at different poses.)
+ n.scale = model.scale
+ n.pivot = model.pivot
+ n.position = model.position
+ n.isHidden = true
+ spin.addChildNode(n)
+ clips[clip] = n
+ n.enumerateChildNodes { cn, _ in
+ for k in cn.animationKeys where clipPlayers[clip] == nil {
+ if let pl = cn.animationPlayer(forKey: k) { pl.stop(); clipPlayers[clip] = pl }
+ }
+ }
+ }
+ }
+ return Pal3DScene(scene: scene, camera: cam, spin: spin, model: model,
+ ambient: ambient, key: key, clips: clips, clipPlayers: clipPlayers)
+ }
+
+ // Pick a loader by extension: animated USD/USDZ (the rigged blueberry,
+ // converted from Meshy's GLB via usdcat) vs a static OBJ.
+ private static func loadModel(_ path: String, tint: NSColor) -> SCNNode? {
+ let ext = (path as NSString).pathExtension.lowercased()
+ if ["usdz", "usdc", "usda", "usd", "scn"].contains(ext) { return loadUSD(path, tint: tint) }
+ return loadOBJ(path, tint: tint)
+ }
+
+ // ── load a rigged USD/USDZ and loop its skeletal animation ──────────────
+ // SceneKit reads UsdSkel animation natively; .playRepeatedly auto-starts and
+ // loops every embedded clip. glb→usd conversion can drop textures, so the
+ // sibling PBR maps are re-applied (same as the OBJ path).
+ private static func loadUSD(_ path: String, tint: NSColor, loop: Bool = true) -> SCNNode? {
+ let url = URL(fileURLWithPath: path)
+ let policy: SCNSceneSource.AnimationImportPolicy = loop ? .playRepeatedly : .doNotPlay
+ guard let scene = try? SCNScene(url: url, options: [.animationImportPolicy: policy]) else { return nil }
+ let holder = SCNNode()
+ for child in scene.rootNode.childNodes { holder.addChildNode(child) }
+ if holder.childNodes.isEmpty { return nil }
+
+ let dir = url.deletingLastPathComponent()
+ let stem = url.deletingPathExtension().lastPathComponent // e.g. "blueberry" / "neo"
+ let tintColor = accentTint(tint, 0.5) // pull the model toward the accent indigo
+ func map(_ suffix: String) -> NSImage? {
+ for n in ["\(stem)-\(suffix).png", "blueberry-\(suffix).png"] {
+ let p = dir.appendingPathComponent(n)
+ if FileManager.default.fileExists(atPath: p.path) { return NSImage(contentsOf: p) }
+ }
+ return nil
+ }
+ let base = map("base_color"), normal = map("normal"), rough = map("roughness")
+ holder.enumerateChildNodes { node, _ in
+ for m in node.geometry?.materials ?? [] {
+ m.lightingModel = .physicallyBased
+ if let base = base { m.diffuse.contents = base }
+ if let normal = normal { m.normal.contents = normal }
+ if let rough = rough { m.roughness.contents = rough }
+ m.multiply.contents = tintColor
+ }
+ // The looping idle auto-plays + repeats forever; one-shot clips are
+ // left paused (caller grabs the player and triggers it on demand).
+ if loop {
+ for key in node.animationKeys {
+ if let player = node.animationPlayer(forKey: key) {
+ player.animation.repeatCount = .greatestFiniteMagnitude
+ player.play()
+ }
+ }
+ }
+ }
+ return holder
+ }
+
+ // ── load a Meshy OBJ + its base-color texture ──────────────────────────
+ private static func loadOBJ(_ path: String, tint: NSColor) -> SCNNode? {
+ let url = URL(fileURLWithPath: path)
+ let asset = MDLAsset(url: url)
+ asset.loadTextures()
+ let scene = SCNScene(mdlAsset: asset)
+ let holder = SCNNode()
+ for child in scene.rootNode.childNodes { holder.addChildNode(child) }
+ if holder.childNodes.isEmpty { return nil }
+
+ // Meshy ships PBR maps as siblings (blueberry-base_color.png, …). The OBJ
+ // itself may carry no material binding, so wire base_color in by hand.
+ // Meshy ships PBR maps as siblings (blueberry-base_color.png, …). The OBJ
+ // carries no usable material binding, so wire the maps in by hand — and
+ // FORCE them on: Meshy's default material isn't nil, so a "fill only if
+ // empty" check would skip base_color and leave the berry flat gray.
+ let stem = url.deletingPathExtension().lastPathComponent
+ let dir = url.deletingLastPathComponent()
+ func map(_ suffix: String) -> NSImage? {
+ for n in ["\(stem)-\(suffix).png", "blueberry-\(suffix).png"] {
+ let p = dir.appendingPathComponent(n)
+ if FileManager.default.fileExists(atPath: p.path) { return NSImage(contentsOf: p) }
+ }
+ return nil
+ }
+ let base = map("base_color"), normal = map("normal"), rough = map("roughness")
+ let tintColor = accentTint(tint, 0.5)
+ holder.enumerateChildNodes { node, _ in
+ for m in node.geometry?.materials ?? [] {
+ m.lightingModel = .physicallyBased
+ if let base = base { m.diffuse.contents = base }
+ if let normal = normal { m.normal.contents = normal }
+ if let rough = rough { m.roughness.contents = rough }
+ m.multiply.contents = tintColor
+ }
+ }
+ return holder
+ }
+
+ // ── procedural stand-in: a plump berry with a calyx star + leaf ─────────
+ private static func placeholderBlueberry() -> SCNNode {
+ let node = SCNNode()
+
+ let berry = SCNSphere(radius: 0.92)
+ berry.segmentCount = 64
+ let skin = SCNMaterial()
+ skin.lightingModel = .physicallyBased
+ skin.diffuse.contents = NSColor(calibratedRed: 0.21, green: 0.25, blue: 0.55, alpha: 1)
+ skin.roughness.contents = 0.78
+ skin.metalness.contents = 0.0
+ berry.materials = [skin]
+ let berryNode = SCNNode(geometry: berry)
+ berryNode.scale = SCNVector3(x: 1, y: 0.96, z: 1) // slightly squat
+ node.addChildNode(berryNode)
+
+ // calyx — a small dark dimpled disk at the top
+ let calyx = SCNCylinder(radius: 0.2, height: 0.05)
+ let cm = SCNMaterial(); cm.diffuse.contents = NSColor(white: 0.14, alpha: 1)
+ calyx.materials = [cm]
+ let calyxNode = SCNNode(geometry: calyx)
+ calyxNode.position = SCNVector3(x: 0, y: 0.84, z: 0)
+ node.addChildNode(calyxNode)
+
+ // one little leaf
+ let leaf = SCNPyramid(width: 0.5, height: 0.55, length: 0.06)
+ let lm = SCNMaterial(); lm.diffuse.contents = NSColor.systemGreen
+ leaf.materials = [lm]
+ let leafNode = SCNNode(geometry: leaf)
+ leafNode.position = SCNVector3(x: 0.28, y: 0.96, z: 0)
+ leafNode.eulerAngles = SCNVector3(x: 0, y: 0, z: -0.6)
+ node.addChildNode(leafNode)
+
+ return node
+ }
+
+ // Center the mesh on the spin axis and scale its largest dimension to `target`
+ // world units (so any model — tiny or huge — frames the same).
+ private static func normalizeToFit(_ node: SCNNode, target: CGFloat) {
+ // Accumulate real geometry bounds across the hierarchy. flattenedClone()'s
+ // boundingBox comes back empty for SKINNED meshes (the rigged USD), which
+ // left the berry unscaled + uncentered (a tiny blob in the corner). Walking
+ // each geometry node's box into the parent's space works for skinned too.
+ var lo = SCNVector3(CGFloat.greatestFiniteMagnitude, .greatestFiniteMagnitude, .greatestFiniteMagnitude)
+ var hi = SCNVector3(-CGFloat.greatestFiniteMagnitude, -.greatestFiniteMagnitude, -.greatestFiniteMagnitude)
+ var found = false
+ func expand(_ p: SCNVector3) {
+ lo = SCNVector3(min(lo.x, p.x), min(lo.y, p.y), min(lo.z, p.z))
+ hi = SCNVector3(max(hi.x, p.x), max(hi.y, p.y), max(hi.z, p.z))
+ found = true
+ }
+ node.enumerateHierarchy { child, _ in
+ // Every node origin — for a SKINNED mesh the geometry box is tiny
+ // (authored pre-skin; the skinner's bind transform scales it ~100×,
+ // which a node-transform walk can't see), but the skeleton joints'
+ // origins span the real character, so they set the true extent.
+ expand(node.convertPosition(SCNVector3Zero, from: child))
+ guard child.geometry != nil else { return }
+ let (bMin, bMax) = child.boundingBox
+ for x in [bMin.x, bMax.x] { for y in [bMin.y, bMax.y] { for z in [bMin.z, bMax.z] {
+ expand(node.convertPosition(SCNVector3(x, y, z), from: child))
+ } } }
+ }
+ guard found else { return }
+ node.pivot = SCNMatrix4MakeTranslation((lo.x + hi.x) / 2, (lo.y + hi.y) / 2, (lo.z + hi.z) / 2)
+ let maxDim = max(hi.x - lo.x, max(hi.y - lo.y, hi.z - lo.z))
+ if maxDim > 0 {
+ let s = target / maxDim
+ node.scale = SCNVector3(s, s, s)
+ }
+ }
+
+ // ── idle life ───────────────────────────────────────────────────────────
+ // No turntable spin — he faces front; any turning comes from the rig's own
+ // clips (e.g. a walk). Just a gentle vertical float so he feels alive.
+ private func startIdle() {
+ if ProcessInfo.processInfo.environment["MACPAL_NO_IDLE"] != nil { return }
+ let up = SCNAction.moveBy(x: 0, y: 0.05, z: 0, duration: 1.7)
+ up.timingMode = .easeInEaseOut
+ let bob = SCNAction.sequence([up, up.reversed()])
+ spinNode.runAction(.repeatForever(bob), forKey: "bob")
+ }
+
+ // ── ambient lighting from the desktop ───────────────────────────────────
+ // Tint the ambient + key lights toward the average color of the wallpaper on
+ // the screen the pal sits on — so he picks up the room's mood and shifts when
+ // dragged to a screen with a different desktop. (Wallpaper needs no Screen-
+ // Recording permission; sampling live window pixels would require
+ // ScreenCaptureKit + that permission — a possible upgrade later.)
+ private func sampleAmbient() {
+ guard let screen = window?.screen ?? NSScreen.main,
+ let url = NSWorkspace.shared.desktopImageURL(for: screen),
+ let img = NSImage(contentsOf: url),
+ let avg = Pal3DView.averageColor(img) else { return }
+ let amb = Pal3DView.mix(avg, .white, 0.45) // toned toward white so he stays readable
+ let keyC = Pal3DView.mix(avg, .white, 0.20)
+ SCNTransaction.begin(); SCNTransaction.animationDuration = 0.9
+ ambientNode.light?.color = amb
+ keyNode.light?.color = keyC
+ SCNTransaction.commit()
+ }
+
+ private static func averageColor(_ image: NSImage) -> NSColor? {
+ guard let tiff = image.tiffRepresentation,
+ let bm = NSBitmapImageRep(data: tiff), let cg = bm.cgImage else { return nil }
+ var px = [UInt8](repeating: 0, count: 4)
+ guard let cs = CGColorSpace(name: CGColorSpace.sRGB),
+ let ctx = CGContext(data: &px, width: 1, height: 1, bitsPerComponent: 8,
+ bytesPerRow: 4, space: cs,
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil }
+ ctx.interpolationQuality = .medium
+ ctx.draw(cg, in: CGRect(x: 0, y: 0, width: 1, height: 1))
+ return NSColor(srgbRed: CGFloat(px[0]) / 255, green: CGFloat(px[1]) / 255,
+ blue: CGFloat(px[2]) / 255, alpha: 1)
+ }
+
+ private static func mix(_ a: NSColor, _ b: NSColor, _ t: CGFloat) -> NSColor {
+ let x = a.usingColorSpace(.sRGB) ?? a, y = b.usingColorSpace(.sRGB) ?? b
+ return NSColor(srgbRed: x.redComponent + (y.redComponent - x.redComponent) * t,
+ green: x.greenComponent + (y.greenComponent - x.greenComponent) * t,
+ blue: x.blueComponent + (y.blueComponent - x.blueComponent) * t, alpha: 1)
+ }
+
+ // ── offscreen snapshot (QA / debugging the model itself) ────────────────
+ // Renders the exact same scene (load + fit + textures) to a PNG at a mid-
+ // animation time, so framing/orientation/texture can be checked without the
+ // tiny corner widget in the way. Used by `MacPal --snapshot `.
+ static func snapshot(objPath: String?, size: CGSize, time: TimeInterval = 1.5) -> NSImage? {
+ let built = buildScene(objPath: objPath, tint: accent)
+ guard let device = MTLCreateSystemDefaultDevice() else { return nil }
+ let r = SCNRenderer(device: device, options: nil)
+ r.scene = built.scene
+ r.pointOfView = built.camera
+ r.scene?.background.contents = NSColor(white: 0.12, alpha: 1)
+ return r.snapshot(atTime: time, with: size, antialiasingMode: .multisampling4X)
+ }
+
+ /// A quick excited squash, called on each Menu Band note. Runs on spinNode
+ /// so it reads whether the idle or a one-shot clip is currently showing.
+ func sing() {
+ let pop = SCNAction.sequence([
+ SCNAction.scale(by: 1.12, duration: 0.12),
+ SCNAction.scale(by: 1 / 1.12, duration: 0.14),
+ ])
+ pop.timingMode = .easeInEaseOut
+ spinNode.runAction(pop, forKey: "singPop")
+ }
+
+ /// A musical note that rises out of the pal and fades — rendered INSIDE the
+ /// scene (billboarded toward the camera) so it composites above the metal
+ /// view, unlike a CALayer overlay which the SCNView paints over.
+ func floatNote(_ glyph: String, color: NSColor, up: Bool = true) {
+ guard let root = scnView.scene?.rootNode else { return }
+ // A billboarded plane textured with the note glyph — renders reliably
+ // (SCNText at sub-point sizes comes out degenerate).
+ let plane = SCNPlane(width: 0.9, height: 0.9)
+ let m = SCNMaterial()
+ m.diffuse.contents = Pal3DView.noteImage(glyph, color)
+ m.lightingModel = .constant
+ m.isDoubleSided = true
+ m.blendMode = .alpha
+ plane.materials = [m]
+ let n = SCNNode(geometry: plane)
+ let dir: CGFloat = up ? 1 : -1 // top-corner pal drifts notes DOWN (up exits the screen)
+ n.position = SCNVector3(CGFloat.random(in: -0.3...0.3), 0.3 * dir, 0.9)
+ n.constraints = [SCNBillboardConstraint()]
+ root.addChildNode(n)
+ let move = SCNAction.moveBy(x: CGFloat.random(in: -0.25...0.25), y: 1.3 * dir, z: 0, duration: 1.2)
+ move.timingMode = .easeOut
+ let fade = SCNAction.sequence([.wait(duration: 0.5), .fadeOut(duration: 0.7)])
+ n.runAction(.sequence([.group([move, fade]), .removeFromParentNode()]))
+ }
+
+ private static func noteImage(_ glyph: String, _ color: NSColor) -> NSImage {
+ let s: CGFloat = 128
+ let img = NSImage(size: NSSize(width: s, height: s))
+ img.lockFocus()
+ let para = NSMutableParagraphStyle(); para.alignment = .center
+ // Sharp dark drop shadow (no blur, offset down-right) — crisp comic pop,
+ // matching the house style's hard offset shadows.
+ let drop = NSShadow()
+ drop.shadowColor = NSColor.black.withAlphaComponent(0.9)
+ drop.shadowBlurRadius = 0
+ drop.shadowOffset = NSSize(width: 2.5, height: -2.5)
+ let attrs: [NSAttributedString.Key: Any] = [
+ .font: NSFont.boldSystemFont(ofSize: 86),
+ .foregroundColor: color,
+ .paragraphStyle: para,
+ .strokeColor: NSColor.black.withAlphaComponent(0.5),
+ .strokeWidth: -3.0,
+ .shadow: drop,
+ ]
+ let str = glyph as NSString
+ let sz = str.size(withAttributes: attrs)
+ str.draw(at: NSPoint(x: (s - sz.width) / 2, y: (s - sz.height) / 2), withAttributes: attrs)
+ img.unlockFocus()
+ return img
+ }
+
+ /// Play a one-shot clip (e.g. "wave" on hover, "cheer" on a note) by swapping
+ /// the visible model for that clip's rigged node for the clip's duration,
+ /// then returning to the looping idle. No-op if a clip is already running.
+ func playClip(_ name: String) {
+ guard !clipBusy, let node = clips[name], let player = clipPlayers[name] else { return }
+ clipBusy = true
+ clipReturn?.cancel()
+ modelHolder.isHidden = true
+ node.isHidden = false
+ player.stop()
+ player.animation.repeatCount = 1
+ player.play()
+ let dur = player.animation.duration > 0.1 ? player.animation.duration : 2.5
+ let work = DispatchWorkItem { [weak self] in
+ guard let self = self else { return }
+ node.isHidden = true
+ self.modelHolder.isHidden = false
+ self.clipBusy = false
+ }
+ clipReturn = work
+ DispatchQueue.main.asyncAfter(deadline: .now() + dur, execute: work)
+ }
+}
diff --git a/macpal/Sources/MacPal.swift b/macpal/Sources/PalCore.swift
similarity index 65%
rename from macpal/Sources/MacPal.swift
rename to macpal/Sources/PalCore.swift
index 810a527db..aaa7a925b 100644
--- a/macpal/Sources/MacPal.swift
+++ b/macpal/Sources/PalCore.swift
@@ -1,97 +1,113 @@
-// MacPal — a friendly little character who lives in the corner of your screen.
+// PalCore — the shared heart of MacPal: a friendly little character who lives
+// in the corner of your screen.
//
-// This is the FIRST, deliberately-tiny MacPal: just the pal. No work
-// features, no git, no menus — a star named "star" who floats in a corner,
-// blinks and leans on his own, springs when you hover, squashes when you
-// press, and rides along when you drag him (snapping to the nearest corner
-// with a little clink on release). Click him and he ESCAPES — collapses into
-// a micro chip tucked into his corner, name beside him; click again and he
-// tinks back to full size. Corner + collapsed state persist across launches.
+// This is the avatar and nothing domain-specific: a glyph that floats, blinks
+// and leans on its own, springs when you hover, squashes when you press, and
+// rides along when you drag (snapping to the nearest corner with a clink).
+// Click it and it ESCAPES — collapses into a micro chip tucked into its corner,
+// name beside it; click again and it tinks back. Corner, color, and collapsed
+// state persist across launches. It also speaks the Menu Band "sing" protocol
+// (see noteSignalDir below).
//
-// It is the seed of a distributable MacPal (eventual Mac App Store build):
-// a single .app bundle, signed, that registers itself as a login item on
-// first run so it's always there to say hi.
+// Everything that differs between the distributable star (Fía) and the fuser
+// machines' badge (neo/panda/chicken/blueberry) is a PalPlugin stacked beneath
+// the name. The profile chosen in MacPal.swift decides the PalConfig + plugins.
//
-// Built from one Swift file with swiftc (see build.sh). The badge it grew
-// out of lives in the aesthetic-computer slab/fuser tooling; this one is
-// stripped down to the charm and nothing else.
+// Built from one swiftc invocation over Sources/*.swift (see build.sh).
import AppKit
-import ServiceManagement
-
-setbuf(stdout, nil)
-
-// ── identity ────────────────────────────────────────────────────────────
-let palName = "star"
-let palEmoji = "⭐" // fallback if the glyph art can't load
-
-// Per-user state lives in Application Support (an App-Store-friendly home,
-// unlike the badge's ~/.local/share). Just two tiny marker files.
-let support = NSString(string: "~/Library/Application Support/MacPal").expandingTildeInPath
-try? FileManager.default.createDirectory(atPath: support, withIntermediateDirectories: true)
-let cornerFile = support + "/corner"
-let collapsedFlag = support + "/collapsed"
-let colorFile = support + "/color"
-
-// ── Menu Band compatibility ───────────────────────────────────────────────
-// Menu Band (the AC menu-bar piano) pings a "now playing" note by writing
-// " " to ~/.local/share/desktop-badge/note — but ONLY if that
-// directory exists. We create it and poll the file, so when Menu Band plays,
-// the star opens his mouth and floats the note out. This is the existing,
-// unchanged Menu Band protocol (shared with the slab desktop-badge), so the
-// two ship independently and just work together. Future signal kinds can land
-// in the same dir without touching Menu Band.
-let badgeDir = NSString(string: "~/.local/share/desktop-badge").expandingTildeInPath
-let noteSignalFile = badgeDir + "/note"
-try? FileManager.default.createDirectory(atPath: badgeDir, withIntermediateDirectories: true)
-
-// gold (factory) or silver — toggled from the right-click menu, persisted.
-var starColor: String = {
- let s = ((try? String(contentsOfFile: colorFile, encoding: .utf8)) ?? "")
- .trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
- return s == "silver" ? "silver" : "gold"
-}()
-func glyphBase() -> String { starColor == "silver" ? "star-silver" : "star-glyph" }
-func glyphPoseNames() -> [String] { [glyphBase(), glyphBase() + "-2", glyphBase() + "-3"] }
-func glyphSingName() -> String { glyphBase() + "-sing" }
-
-// Where he sits on a fresh install, before any drag. A dragged-to corner
-// outlives this: the file wins once it holds a valid value.
-let factoryCorner = "BR"
-var corner: String = {
- let saved = ((try? String(contentsOfFile: cornerFile, encoding: .utf8)) ?? "")
- .trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
- return ["TL", "TR", "BL", "BR"].contains(saved) ? saved : factoryCorner
-}()
-
-// ── app bootstrap ───────────────────────────────────────────────────────
-let app = NSApplication.shared
-app.setActivationPolicy(.accessory) // no Dock icon, no menu bar item
-
-// First-run: add MacPal to the user's Login Items so the pal is always there.
-// SMAppService reflects real system state, so we only ask once — if she later
-// removes it from System Settings, we don't fight her.
-func registerLoginItemOnce() {
- let key = "MacPal.didRegisterLoginItem"
- guard !UserDefaults.standard.bool(forKey: key) else { return }
- if #available(macOS 13.0, *) {
- do { try SMAppService.mainApp.register() } catch {
- print("MacPal: login-item register skipped — \(error)")
- }
- }
- UserDefaults.standard.set(true, forKey: key)
+
+// ── shared helpers (visible to every Sources/*.swift file) ────────────────────
+let accent = NSColor.controlAccentColor
+
+func hexColor(_ v: UInt32) -> NSColor {
+ NSColor(calibratedRed: CGFloat((v >> 16) & 0xff) / 255,
+ green: CGFloat((v >> 8) & 0xff) / 255,
+ blue: CGFloat(v & 0xff) / 255, alpha: 1)
}
-// ── windows & views ───────────────────────────────────────────────────────
+func playfulFont(_ pt: CGFloat, bold: Bool) -> NSFont {
+ let names = bold
+ ? ["Comic Sans MS Bold", "ComicSansMS-Bold", "Chalkboard SE Bold", "ChalkboardSE-Bold"]
+ : ["Comic Sans MS", "Chalkboard SE"]
+ for n in names { if let f = NSFont(name: n, size: pt) { return f } }
+ let base = NSFont(name: "Comic Sans MS", size: pt) ?? NSFont.systemFont(ofSize: pt, weight: .heavy)
+ return bold ? NSFontManager.shared.convert(base, toHaveTrait: .boldFontMask) : base
+}
-// A Spotlight-style non-activating panel: clicking the pal never steals focus
-// from whatever you were working in.
+func resource(_ name: String, _ ext: String) -> String? {
+ Bundle.main.path(forResource: name, ofType: ext)
+}
+
+// ── profile configuration ─────────────────────────────────────────────────
+// Seeded once in MacPal.swift from the launch profile. Replaces what used to be
+// file-scope globals so the same controller drives the star and the badge.
+struct PalConfig {
+ var profile: String // "star" | "fuser"
+ var palName: String // "star", or a machine name like "panda"
+ var palEmoji: String // fallback if the glyph art can't load
+ var supportDir: String // per-profile state home
+ var noteSignalDir: String // Menu Band "now playing" dir (shared protocol)
+ var factoryCorner: String // where it sits before any drag
+ var fixedCorner: String? // fuser pins a corner; star is draggable (nil)
+ var marginX: CGFloat // resting gap from the screen's side
+ var marginY: CGFloat // resting gap from the screen's top/bottom
+ var draggable: Bool // star drags to corners; badge stays put
+ var colorTogglable: Bool // star: gold/silver right-click toggle
+ var registersLoginItem: Bool // star adds itself to Login Items once
+ var showsAbout: Bool // star shows the About panel
+ var aboutDedication: String? // the dedication line in About
+ // 3D avatar (e.g. the blueberry). When set, the pal renders a SceneKit
+ // model instead of the flat SVG/emoji glyph. `model3DPath` nil (or missing)
+ // → a procedural placeholder berry, so the 3D path is always live.
+ var model3D: Bool = false
+ var model3DPath: String? = nil
+ // When true, the right-click menu offers a 3D ⇄ 2D avatar toggle (the
+ // blueberry can show its SceneKit self or a flat thumbnail). Persisted in
+ // avatar2DFlag.
+ var avatarTogglable: Bool = false
+ // Glyph resolution. Given the active color ("gold"/"silver"/""), return the
+ // absolute pose paths (home · blink · lean) and the open-mouth "sing" pose.
+ // Star reads bundle Resources; the badge reads files staged in its home dir.
+ var posePaths: (_ color: String) -> [String]
+ var singPath: (_ color: String) -> String?
+
+ var cornerFile: String { supportDir + "/corner" }
+ var collapsedFlag: String { supportDir + "/collapsed" }
+ var colorFile: String { supportDir + "/color" }
+ var avatar2DFlag: String { supportDir + "/avatar2d" } // present ⇒ 2D mode
+ var noteSignalFile: String { noteSignalDir + "/note" }
+}
+
+// ── plugin protocol ─────────────────────────────────────────────────────────
+// A plugin owns whatever stacks BENEATH the name. The controller owns the glyph,
+// the name, collapse, drag, and the sing easter egg; it asks each plugin for its
+// reserved height, lets it place its rows, and ticks it on the shared timer.
+protocol PalPlugin: AnyObject {
+ func attach(to c: PalController) // build subviews once
+ func stackHeight(in c: PalController) -> CGFloat // height to reserve under the name (full mode)
+ func layoutRows(in c: PalController, originY: CGFloat) // place rows upward from originY; may append liveRects
+ func setCollapsed(_ collapsed: Bool) // hide/show rows
+ func tick() // periodic refresh (polling)
+ func menuItems(for c: PalController) -> [NSMenuItem] // contributed right-click items
+}
+extension PalPlugin {
+ func stackHeight(in c: PalController) -> CGFloat { 0 }
+ func layoutRows(in c: PalController, originY: CGFloat) {}
+ func setCollapsed(_ collapsed: Bool) {}
+ func tick() {}
+ func menuItems(for c: PalController) -> [NSMenuItem] { [] }
+}
+
+// ── windows & views ───────────────────────────────────────────────────────
+// A Spotlight-style non-activating panel: clicking the pal never steals focus.
final class KeyWindow: NSPanel {
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { false }
}
-// Click-through everywhere except the registered live rect (the pal himself).
+// Click-through everywhere except the registered live rects (the pal + any
+// plugin rows that need the mouse, like the fuser pane).
final class PassThroughView: NSView {
var liveRects: [NSRect] = []
override func hitTest(_ p: NSPoint) -> NSView? {
@@ -100,17 +116,17 @@ final class PassThroughView: NSView {
}
}
-// The pal is also his own handle: press-and-move drags the whole window, a
-// still press stays a click. The 4px slop keeps shaky clicks from registering
-// as micro-drags. Past the slop the drag is handed to the window server via
-// performDrag — app-side per-event moves stutter on a layered transparent
-// window; the native drag is the smooth move a titlebar gets. The server
-// swallows the mouseUp, so the drop is detected by polling pressedMouseButtons.
+// The pal is also his own handle: press-and-move drags the whole window, a still
+// press stays a click. Past a 4px slop the drag is handed to the window server
+// via performDrag (app-side per-event moves stutter on a transparent window).
+// The server swallows the mouseUp, so the drop is detected by polling
+// pressedMouseButtons.
final class ClickView: NSView {
var onClick: () -> Void = {}
var onPress: () -> Void = {}
var onDragStart: () -> Void = {}
var onRightClick: (NSEvent) -> Void = { _ in }
+ var draggable = true
override func rightMouseDown(with e: NSEvent) { onRightClick(e) }
private var dragging = false
private var downAt = NSPoint.zero
@@ -121,7 +137,7 @@ final class ClickView: NSView {
onPress()
}
override func mouseDragged(with e: NSEvent) {
- guard !dragging else { return }
+ guard draggable, !dragging else { return }
let p = NSEvent.mouseLocation
guard abs(p.x - downAt.x) > 4 || abs(p.y - downAt.y) > 4 else { return }
dragging = true
@@ -134,33 +150,11 @@ final class ClickView: NSView {
}
}
-// ── helpers ────────────────────────────────────────────────────────────────
-let accent = NSColor.controlAccentColor
-
-func hexColor(_ v: UInt32) -> NSColor {
- NSColor(calibratedRed: CGFloat((v >> 16) & 0xff) / 255,
- green: CGFloat((v >> 8) & 0xff) / 255,
- blue: CGFloat(v & 0xff) / 255, alpha: 1)
-}
-
-func playfulFont(_ pt: CGFloat, bold: Bool) -> NSFont {
- let names = bold
- ? ["Comic Sans MS Bold", "ComicSansMS-Bold", "Chalkboard SE Bold", "ChalkboardSE-Bold"]
- : ["Comic Sans MS", "Chalkboard SE"]
- for n in names { if let f = NSFont(name: n, size: pt) { return f } }
- let base = NSFont(name: "Comic Sans MS", size: pt) ?? NSFont.systemFont(ofSize: pt, weight: .heavy)
- return bold ? NSFontManager.shared.convert(base, toHaveTrait: .boldFontMask) : base
-}
-
-func resource(_ name: String, _ ext: String) -> String? {
- Bundle.main.path(forResource: name, ofType: ext)
-}
-
// ── per-character name label ──────────────────────────────────────────────
// Each character is its own CALayer, hand-placed with a deterministic static
// jitter (baseline nudge + tilt). At idle they drift on a slow, subtle wave;
-// bounce() fires a full-energy hop (used on expand). Kept whole from the badge
-// because it's self-contained and is most of the pal's personality.
+// bounce() fires a full-energy hop (used on expand / new affirmation / new
+// fuser output).
final class CharLayer: CALayer {
var attr: NSAttributedString?
var inset: CGFloat = 0
@@ -295,6 +289,9 @@ final class WiggleLabel: NSView {
// ── the pal ────────────────────────────────────────────────────────────────
final class PalController: NSObject {
+ let config: PalConfig
+ var plugins: [PalPlugin] = []
+
var window: NSWindow!
var content: PassThroughView!
var glyphView: NSView!
@@ -314,11 +311,39 @@ final class PalController: NSObject {
var singUntil: CFTimeInterval = 0 // hold the open mouth until this time
var showingSing = false
- var collapsed = FileManager.default.fileExists(atPath: collapsedFlag)
- let W: CGFloat = 160 // full-size window width
+ var starColor: String
+ var collapsed: Bool
+ var corner: String
+ var use3D: Bool // render the SceneKit avatar vs the flat glyph
+ let W: CGFloat = 160 // full-size window width (the star); plugins may widen via plugin metrics
let micro: CGFloat = 34 // collapsed glyph size
- let marginX: CGFloat = 4
- let marginY: CGFloat = 14
+ var marginX: CGFloat { config.marginX }
+ var marginY: CGFloat { config.marginY }
+
+ init(config: PalConfig) {
+ self.config = config
+ try? FileManager.default.createDirectory(atPath: config.supportDir, withIntermediateDirectories: true)
+ try? FileManager.default.createDirectory(atPath: config.noteSignalDir, withIntermediateDirectories: true)
+ if config.colorTogglable {
+ let s = ((try? String(contentsOfFile: config.colorFile, encoding: .utf8)) ?? "")
+ .trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ starColor = s == "silver" ? "silver" : "gold"
+ } else {
+ starColor = ""
+ }
+ collapsed = FileManager.default.fileExists(atPath: config.collapsedFlag)
+ // 3D by default when a model is configured; the menu can flip to 2D.
+ use3D = config.model3D && !FileManager.default.fileExists(atPath: config.avatar2DFlag)
+ if let fixed = config.fixedCorner,
+ ["TL", "TR", "BL", "BR"].contains(fixed.uppercased()) {
+ corner = fixed.uppercased()
+ } else {
+ let saved = ((try? String(contentsOfFile: config.cornerFile, encoding: .utf8)) ?? "")
+ .trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
+ corner = ["TL", "TR", "BL", "BR"].contains(saved) ? saved : config.factoryCorner
+ }
+ super.init()
+ }
func styleField(_ f: NSTextField) {
f.isBordered = false; f.drawsBackground = false; f.alignment = .center
@@ -328,6 +353,50 @@ final class PalController: NSObject {
f.shadow = sh
}
+ // Build the glyph view for the current avatar mode — reusable so the 3D⇄2D
+ // menu toggle can swap it live. The 3D pal gets a bigger frame so it reads.
+ func makeGlyphView() -> NSView {
+ if use3D, let path = config.model3DPath {
+ glyphW = 188; glyphH = 188
+ let v = Pal3DView(frame: NSRect(x: (fullWidth - glyphW) / 2, y: 0, width: glyphW, height: glyphH),
+ objPath: path, tint: accent)
+ v.wantsLayer = true
+ return v
+ }
+ glyphW = 128; glyphH = 116
+ if let img = glyphImages.first {
+ let iv = NSImageView(frame: NSRect(x: (fullWidth - glyphW) / 2, y: 0, width: glyphW, height: glyphH))
+ iv.image = img
+ iv.imageScaling = .scaleProportionallyUpOrDown
+ iv.wantsLayer = true
+ let sh = NSShadow()
+ sh.shadowColor = accent; sh.shadowBlurRadius = 0
+ sh.shadowOffset = NSSize(width: 2, height: -2)
+ iv.shadow = sh
+ DispatchQueue.main.async { [weak self] in self?.scheduleGlyphPose() }
+ return iv
+ }
+ glyphH = 76
+ let e = NSTextField(labelWithString: config.palEmoji)
+ e.font = NSFont.systemFont(ofSize: 60); e.alignment = .center
+ e.isBordered = false; e.drawsBackground = false
+ return e
+ }
+
+ // Swap the avatar in place (3D ⇄ 2D) without relaunching.
+ func rebuildGlyph() {
+ let old = glyphView
+ let nv = makeGlyphView()
+ glyphView = nv
+ if let cc = clickCatcher {
+ content.addSubview(nv, positioned: .below, relativeTo: cc)
+ } else {
+ content.addSubview(nv)
+ }
+ old?.removeFromSuperview()
+ layout()
+ }
+
func build() {
let win = KeyWindow(contentRect: NSRect(x: 0, y: 0, width: W, height: 158),
styleMask: [.borderless, .nonactivatingPanel],
@@ -342,32 +411,14 @@ final class PalController: NSObject {
content = PassThroughView(frame: NSRect(x: 0, y: 0, width: W, height: 158))
content.wantsLayer = true
- // Load the star's poses for the current color (gold/silver).
loadGlyphs()
- if let img = glyphImages.first {
- let iv = NSImageView(frame: NSRect(x: (W - glyphW) / 2, y: 0, width: glyphW, height: glyphH))
- iv.image = img
- iv.imageScaling = .scaleProportionallyUpOrDown
- iv.wantsLayer = true
- let sh = NSShadow()
- sh.shadowColor = accent; sh.shadowBlurRadius = 0
- sh.shadowOffset = NSSize(width: 2, height: -2)
- iv.shadow = sh
- glyphView = iv
- scheduleGlyphPose()
- } else {
- glyphH = 76
- let e = NSTextField(labelWithString: palEmoji)
- e.font = NSFont.systemFont(ofSize: 60); e.alignment = .center
- e.isBordered = false; e.drawsBackground = false
- glyphView = e
- }
+ glyphView = makeGlyphView()
content.addSubview(glyphView)
- nameLabel.setText(palName, font: playfulFont(29, bold: true),
+ nameLabel.setText(config.palName, font: playfulFont(29, bold: true),
fill: .white, stroke: NSColor(white: 0.08, alpha: 1), strokeW: 4)
styleField(microLabel)
- microLabel.attributedStringValue = NSAttributedString(string: palName, attributes: [
+ microLabel.attributedStringValue = NSAttributedString(string: config.palName, attributes: [
.font: playfulFont(14, bold: true),
.foregroundColor: NSColor.white,
.strokeColor: NSColor(white: 0.08, alpha: 1),
@@ -377,7 +428,11 @@ final class PalController: NSObject {
content.addSubview(nameLabel)
content.addSubview(microLabel)
+ // Plugins build their rows before the click-catcher so they sit beneath it.
+ for p in plugins { p.attach(to: self) }
+
clickCatcher = ClickView(frame: .zero)
+ clickCatcher.draggable = config.draggable
clickCatcher.onClick = { [weak self] in self?.toggleCollapsed() }
clickCatcher.onPress = { [weak self] in self?.pressPop() }
clickCatcher.onRightClick = { [weak self] e in self?.showContextMenu(e) }
@@ -400,46 +455,44 @@ final class PalController: NSObject {
layout()
win.orderFrontRegardless()
- // 20Hz tick keeps the click-through flag fresh so a fast click can
- // never land stale (event monitors usually beat it; this is the cap).
+ // 20Hz tick keeps the click-through flag fresh and drives the note poll.
Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
self?.updateClickability()
self?.tickNotes()
}
+ // Plugins poll on their own cadence off a 1s tick (cheap; each gates itself).
+ Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
+ self?.plugins.forEach { $0.tick() }
+ }
+ plugins.forEach { $0.tick() } // first fill immediately
NotificationCenter.default.addObserver(self, selector: #selector(layout),
name: NSApplication.didChangeScreenParametersNotification, object: nil)
- // Re-theme the moment the user picks a new system accent in Settings.
- // The glyph/micro shadows hold the dynamic accent color and just need a
- // redraw; the name's shadow is baked into CALayers, so re-render it.
NotificationCenter.default.addObserver(self, selector: #selector(accentChanged),
name: NSColor.systemColorsDidChangeNotification, object: nil)
}
@objc func accentChanged() {
- nameLabel.setText(palName, font: playfulFont(29, bold: true),
+ nameLabel.setText(config.palName, font: playfulFont(29, bold: true),
fill: .white, stroke: NSColor(white: 0.08, alpha: 1), strokeW: 4)
glyphView?.needsDisplay = true
microLabel.needsDisplay = true
}
- // ── glyph art (gold / silver) ─────────────────────────────────────────
- // NSImage renders the SVG natively — no WKWebView (its SafeBrowsing init
- // can deadlock the runloop, and its clipped box crops the drop shadow).
- // Reloadable so the right-click menu can swap the whole pose set live.
+ // ── glyph art ─────────────────────────────────────────────────────────
+ // NSImage renders the SVG natively — no WKWebView (its SafeBrowsing init can
+ // deadlock the runloop, and its clipped box crops the drop shadow).
func loadGlyphs() {
- glyphImages = glyphPoseNames()
- .compactMap { resource($0, "svg") }
- .compactMap { NSImage(contentsOfFile: $0) }
+ glyphImages = config.posePaths(starColor).compactMap { NSImage(contentsOfFile: $0) }
glyphBitmaps = glyphImages.compactMap {
$0.tiffRepresentation.flatMap { NSBitmapImageRep(data: $0) }
}
- singImage = resource(glyphSingName(), "svg").flatMap { NSImage(contentsOfFile: $0) }
+ singImage = config.singPath(starColor).flatMap { NSImage(contentsOfFile: $0) }
}
func setStarColor(_ c: String) {
- guard c != starColor, c == "gold" || c == "silver" else { return }
+ guard config.colorTogglable, c != starColor, c == "gold" || c == "silver" else { return }
starColor = c
- try? c.write(toFile: colorFile, atomically: true, encoding: .utf8)
+ try? c.write(toFile: config.colorFile, atomically: true, encoding: .utf8)
loadGlyphs()
glyphState = min(glyphState, max(0, glyphImages.count - 1))
if let iv = glyphView as? NSImageView, !glyphImages.isEmpty {
@@ -456,33 +509,57 @@ final class PalController: NSObject {
}
// ── right-click menu ──────────────────────────────────────────────────
+ // Plugins contribute items (fuser: Overtime); the star adds About + color +
+ // Quit. A fuser badge with no About just shows Overtime · Quit.
func showContextMenu(_ e: NSEvent) {
let menu = NSMenu()
- let about = menu.addItem(withTitle: "About MacPal",
- action: #selector(showAbout), keyEquivalent: "")
- about.target = self
- menu.addItem(.separator())
- let gold = menu.addItem(withTitle: "Gold Star",
- action: #selector(pickGold), keyEquivalent: "")
- gold.target = self; gold.state = starColor == "gold" ? .on : .off
- let silver = menu.addItem(withTitle: "Silver Star",
- action: #selector(pickSilver), keyEquivalent: "")
- silver.target = self; silver.state = starColor == "silver" ? .on : .off
- menu.addItem(.separator())
- let quit = menu.addItem(withTitle: "Quit MacPal",
- action: #selector(quit), keyEquivalent: "q")
+ var items = plugins.flatMap { $0.menuItems(for: self) }
+ if config.showsAbout {
+ let about = NSMenuItem(title: "About MacPal", action: #selector(showAbout), keyEquivalent: "")
+ about.target = self
+ items.append(about)
+ }
+ if config.colorTogglable {
+ let gold = NSMenuItem(title: "Gold Star", action: #selector(pickGold), keyEquivalent: "")
+ gold.target = self; gold.state = starColor == "gold" ? .on : .off
+ let silver = NSMenuItem(title: "Silver Star", action: #selector(pickSilver), keyEquivalent: "")
+ silver.target = self; silver.state = starColor == "silver" ? .on : .off
+ items.append(gold); items.append(silver)
+ }
+ if config.avatarTogglable {
+ let m3 = NSMenuItem(title: "3D Blueberry", action: #selector(use3DAvatar), keyEquivalent: "")
+ m3.target = self; m3.state = use3D ? .on : .off
+ let m2 = NSMenuItem(title: "2D Blueberry", action: #selector(use2DAvatar), keyEquivalent: "")
+ m2.target = self; m2.state = use3D ? .off : .on
+ items.append(m3); items.append(m2)
+ }
+ items.forEach { menu.addItem($0) }
+ if !items.isEmpty { menu.addItem(.separator()) }
+ let quit = NSMenuItem(title: "Quit MacPal", action: #selector(quit), keyEquivalent: "q")
quit.target = self
+ menu.addItem(quit)
NSMenu.popUpContextMenu(menu, with: e, for: clickCatcher)
}
- @objc func showAbout() { AboutWindow.show() }
+ @objc func showAbout() { AboutWindow.show(config: config) }
@objc func pickGold() { setStarColor("gold") }
@objc func pickSilver() { setStarColor("silver") }
+ @objc func use3DAvatar() { setAvatar3D(true) }
+ @objc func use2DAvatar() { setAvatar3D(false) }
@objc func quit() { NSApp.terminate(nil) }
+ // Swap between the SceneKit pal and the flat thumbnail; the choice persists.
+ func setAvatar3D(_ on: Bool) {
+ guard config.avatarTogglable, on != use3D else { return }
+ use3D = on
+ if on { try? FileManager.default.removeItem(atPath: config.avatar2DFlag) }
+ else { FileManager.default.createFile(atPath: config.avatar2DFlag, contents: nil) }
+ rebuildGlyph()
+ nameLabel.bounce()
+ let s = NSSound(named: "Tink"); s?.volume = 0.4; s?.play()
+ }
+
// ── Menu Band "sing" easter egg ───────────────────────────────────────
- // Poll the note signal file: each fresh seq opens the mouth and floats a
- // note out. lastNoteSeq < 0 on the first read so we don't erupt on launch.
func tickNotes() {
let now = CACurrentMediaTime()
if showingSing, now >= singUntil {
@@ -491,12 +568,11 @@ final class PalController: NSObject {
iv.image = glyphImages[min(glyphState, glyphImages.count - 1)]
}
}
- guard let raw = try? String(contentsOfFile: noteSignalFile, encoding: .utf8) else { return }
+ guard let raw = try? String(contentsOfFile: config.noteSignalFile, encoding: .utf8) else { return }
let parts = raw.trimmingCharacters(in: .whitespacesAndNewlines)
.split(separator: " ", maxSplits: 1)
guard let first = parts.first, let seq = Int(first) else { return }
if lastNoteSeq < 0 { lastNoteSeq = seq; return }
- // Fire on any CHANGE — Menu Band restarts reset its counter to 0.
guard seq != lastNoteSeq else { return }
let burst = seq > lastNoteSeq ? min(seq - lastNoteSeq, 3) : 1
lastNoteSeq = seq
@@ -510,6 +586,7 @@ final class PalController: NSObject {
func sing(_ name: String) {
singUntil = CACurrentMediaTime() + 0.26
+ if let pal = glyphView as? Pal3DView { pal.sing(); pal.playClip("cheer") } // 3D pal cheers to the music
if let iv = glyphView as? NSImageView, let s = singImage {
iv.image = s
showingSing = true
@@ -520,21 +597,28 @@ final class PalController: NSObject {
l.add(pop, forKey: "singPop")
}
}
- floatNote(name)
+ if !collapsed { floatNote(name) }
}
// A musical glyph rises out of the mouth, drifting + fading + spinning a
// touch, tinted by the note's pitch class.
func floatNote(_ name: String) {
- guard let host = content.layer else { return }
let glyphs = ["♪", "♫", "♩", "♬"]
let pick = glyphs[((name.hashValue % glyphs.count) + glyphs.count) % glyphs.count]
+ // In 3D the note is spawned inside the SceneKit scene (a CALayer overlay
+ // would render behind the metal view).
+ if let pal = glyphView as? Pal3DView {
+ pal.floatNote(pick, color: noteTint(name), up: !corner.hasPrefix("T"))
+ return
+ }
+ guard let host = content.layer else { return }
let t = CATextLayer()
t.string = pick
t.font = NSFont.boldSystemFont(ofSize: 19)
t.fontSize = 19
t.foregroundColor = noteTint(name).cgColor
t.alignmentMode = .center
+ t.zPosition = 1000 // float ABOVE the 3D SCNView's metal layer, not behind it
t.contentsScale = NSScreen.main?.backingScaleFactor ?? 2
t.shadowColor = NSColor.black.cgColor
t.shadowOffset = CGSize(width: 1, height: -1)
@@ -588,6 +672,7 @@ final class PalController: NSObject {
: Double.random(in: 0.45...1.0)
DispatchQueue.main.asyncAfter(deadline: .now() + hold) { [weak self] in
guard let self = self, let iv = self.glyphView as? NSImageView else { return }
+ if CACurrentMediaTime() < self.singUntil { self.scheduleGlyphPose(); return }
if self.glyphState == 0 {
self.glyphState = Int.random(in: 1.. 2, Int.random(in: 0..<4) == 0 {
@@ -609,20 +694,17 @@ final class PalController: NSObject {
func toggleCollapsed() {
collapsed.toggle()
- if collapsed { FileManager.default.createFile(atPath: collapsedFlag, contents: nil) }
- else { try? FileManager.default.removeItem(atPath: collapsedFlag) }
+ if collapsed { FileManager.default.createFile(atPath: config.collapsedFlag, contents: nil) }
+ else { try? FileManager.default.removeItem(atPath: config.collapsedFlag) }
let s = NSSound(named: collapsed ? "Pop" : "Tink")
s?.volume = 0.5
s?.play()
+ plugins.forEach { $0.setCollapsed(collapsed) }
layout()
if !collapsed { nameLabel.bounce() }
}
// ── per-pixel click-through ───────────────────────────────────────────
- // The star is clickable only where he's actually painted; over a
- // transparent pixel the whole window flips ignoresMouseEvents so the click
- // sails through to whatever is underneath. Window-level ignore is the only
- // thing that can do this — a swallowed event never reaches the app below.
func glyphHit(_ p: NSPoint) -> Bool {
guard let iv = glyphView as? NSImageView, let img = iv.image else { return true }
let rep = glyphState < glyphBitmaps.count ? glyphBitmaps[glyphState] : glyphBitmaps.first
@@ -645,8 +727,6 @@ final class PalController: NSObject {
NSEvent.addGlobalMonitorForEvents(matching: mask) { [weak self] e in
guard let self = self else { return }
self.updateClickability()
- // Backstop: a left-click that slipped past a stale ignore flag but
- // landed on his painted pixels STILL toggles him — clicks resolve.
if e.type == .leftMouseDown, let window = self.window {
let p = window.convertPoint(fromScreen: NSEvent.mouseLocation)
if self.glyphView.frame.contains(p),
@@ -672,6 +752,10 @@ final class PalController: NSObject {
live = microChipRect.contains(p)
} else if glyphView.frame.contains(p) {
live = glyphHit(glyphView.convert(p, from: nil))
+ } else {
+ // A plugin row under the mouse (e.g. the fuser pane) keeps the
+ // window live so it can be scrolled / selected.
+ for r in content.liveRects where r != glyphView.frame && r.contains(p) { live = true }
}
window.ignoresMouseEvents = !live
}
@@ -679,11 +763,10 @@ final class PalController: NSObject {
|| glyphView.frame.contains(window.convertPoint(fromScreen: m))))
}
- // Cursor over him springs him a touch bigger, with a quiet tick on enter.
- // Model-value transform so the additive pose/press pops stack on top.
func setHover(_ h: Bool) {
guard hovering != h else { return }
hovering = h
+ if h { (glyphView as? Pal3DView)?.playClip("wave") } // 3D pal waves hello
guard let l = glyphView.layer else { return }
let s: CGFloat = h ? 1.12 : 1.0
let target = CATransform3DMakeScale(s, s, 1)
@@ -711,9 +794,6 @@ final class PalController: NSObject {
l.add(a, forKey: "press")
}
- // Drop him and he picks his new home: whichever corner of the drop screen
- // his center is closest to. The pick is written so it sticks, then layout()
- // computes the snapped frame and the window eases over with a clink.
func snapToNearestCorner() {
guard draggingBadge else { return }
draggingBadge = false
@@ -721,7 +801,7 @@ final class PalController: NSObject {
let f = screen.frame
let mid = NSPoint(x: window.frame.midX, y: window.frame.midY)
corner = (mid.y > f.midY ? "T" : "B") + (mid.x > f.midX ? "R" : "L")
- try? corner.write(toFile: cornerFile, atomically: true, encoding: .utf8)
+ try? corner.write(toFile: config.cornerFile, atomically: true, encoding: .utf8)
let dropped = window.frame
layout()
let target = window.frame
@@ -738,6 +818,12 @@ final class PalController: NSObject {
var microChipRect = NSRect.zero
+ // The full window width: the star is W; a plugin (the fuser pane) may want
+ // more, so plugins can raise it via `preferredWidth`.
+ var fullWidth: CGFloat {
+ plugins.map { ($0 as? WidthHinting)?.preferredWidth ?? W }.max() ?? W
+ }
+
@objc func layout() {
guard !draggingBadge else { return }
guard let screen = window.screen ?? NSScreen.main ?? NSScreen.screens.first else {
@@ -747,10 +833,6 @@ final class PalController: NSObject {
let f = screen.frame
if collapsed {
- // Micro chip escapes to ITS corner — TL tucks under the Apple menu,
- // TR hugs the clock, B* sit just above the Dock line — with the name
- // riding beside him. A negative gap tucks the name (the field carries
- // internal stroke/shadow pad) snug against him.
let gap: CGFloat = -3
let nat = microLabel.intrinsicContentSize
let lblW = ceil(nat.width) + 10
@@ -778,6 +860,7 @@ final class PalController: NSObject {
microChipRect = NSRect(x: 0, y: 0, width: totalW, height: micro)
clickCatcher.frame = microChipRect
content.liveRects = [microChipRect]
+ plugins.forEach { $0.setCollapsed(true) }
if let l = glyphView.layer {
l.anchorPoint = CGPoint(x: 0.5, y: 0.5)
l.position = CGPoint(x: glyphView.frame.midX, y: glyphView.frame.midY)
@@ -787,25 +870,35 @@ final class PalController: NSObject {
microLabel.isHidden = true
nameLabel.isHidden = false
+ plugins.forEach { $0.setCollapsed(false) }
if let e = glyphView as? NSTextField { e.font = NSFont.systemFont(ofSize: 60) }
- // Stack, bottom-up: name · glyph.
- let nameY: CGFloat = 12
+
+ let win = fullWidth
+ // Stack, bottom-up: [plugin rows] · name · glyph.
+ let stackH = plugins.reduce(CGFloat(0)) { $0 + $1.stackHeight(in: self) }
+ let nameY: CGFloat = 12 + stackH
let glyphY = nameY + 44 + 4
let totalH = glyphY + glyphH + 8
var o = NSPoint(x: f.minX + marginX, y: f.maxY - totalH - marginY) // TL
switch corner {
- case "TR": o = NSPoint(x: f.maxX - W - marginX, y: f.maxY - totalH - marginY)
+ case "TR": o = NSPoint(x: f.maxX - win - marginX, y: f.maxY - totalH - marginY)
case "BL": o = NSPoint(x: f.minX + marginX, y: f.minY + marginY)
- case "BR": o = NSPoint(x: f.maxX - W - marginX, y: f.minY + marginY)
+ case "BR": o = NSPoint(x: f.maxX - win - marginX, y: f.minY + marginY)
default: break
}
- window.setFrame(NSRect(origin: o, size: NSSize(width: W, height: totalH)), display: true)
- content.frame = NSRect(x: 0, y: 0, width: W, height: totalH)
- let gw: CGFloat = (glyphView is NSImageView) ? glyphW : W
- glyphView.frame = NSRect(x: (W - gw) / 2, y: glyphY, width: gw, height: glyphH)
- nameLabel.frame = NSRect(x: 0, y: nameY, width: W, height: 44)
+ window.setFrame(NSRect(origin: o, size: NSSize(width: win, height: totalH)), display: true)
+ content.frame = NSRect(x: 0, y: 0, width: win, height: totalH)
+ let gw: CGFloat = (glyphView is NSImageView) ? glyphW : win
+ glyphView.frame = NSRect(x: (win - gw) / 2, y: glyphY, width: gw, height: glyphH)
+ nameLabel.frame = NSRect(x: 0, y: nameY, width: win, height: 44)
clickCatcher.frame = glyphView.frame
content.liveRects = [glyphView.frame]
+ // Plugins lay their rows out bottom-up from y=12 and append any live rects.
+ var cursor: CGFloat = 12
+ for p in plugins {
+ p.layoutRows(in: self, originY: cursor)
+ cursor += p.stackHeight(in: self)
+ }
if let l = glyphView.layer {
l.anchorPoint = CGPoint(x: 0.5, y: 0.5)
l.position = CGPoint(x: glyphView.frame.midX, y: glyphView.frame.midY)
@@ -813,25 +906,29 @@ final class PalController: NSObject {
}
}
+// A plugin that wants a wider window than the star's default declares it.
+protocol WidthHinting { var preferredWidth: CGFloat { get } }
+
// ── About window ───────────────────────────────────────────────────────────
-// Modeled on the Menu Band About panel: a small transparent-titlebar window
-// with a centered masthead — star icon, bold "MacPal", a dedication, then the
-// quiet version + copyright footer every macOS About panel closes with. The
-// "i" in Fia wears the live system accent, tying the panel to the pal's theme.
+// A small transparent-titlebar window with a centered masthead — glyph icon,
+// bold "MacPal", a dedication, then the version + copyright footer. The "í" in
+// the dedication wears the live system accent, tying it to the pal's theme.
final class AboutWindow: NSObject, NSWindowDelegate {
static var shared: AboutWindow?
let window: NSWindow
+ let config: PalConfig
private var accentObserver: NSObjectProtocol?
- static func show() {
- let a = shared ?? AboutWindow()
+ static func show(config: PalConfig) {
+ let a = shared ?? AboutWindow(config: config)
shared = a
NSApp.activate(ignoringOtherApps: true)
a.window.center()
a.window.makeKeyAndOrderFront(nil)
}
- override init() {
+ init(config: PalConfig) {
+ self.config = config
window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 300, height: 300),
styleMask: [.titled, .closable, .fullSizeContentView],
backing: .buffered, defer: false)
@@ -843,7 +940,6 @@ final class AboutWindow: NSObject, NSWindowDelegate {
window.level = .floating
window.delegate = self
buildContent()
- // Re-color the accented "i" when the system accent changes.
accentObserver = NotificationCenter.default.addObserver(
forName: NSColor.systemColorsDidChangeNotification, object: nil, queue: .main
) { [weak self] _ in self?.buildContent() }
@@ -872,8 +968,8 @@ final class AboutWindow: NSObject, NSWindowDelegate {
stack.bottomAnchor.constraint(equalTo: content.bottomAnchor),
])
- // Masthead — the star in his current color.
- if let path = resource(glyphBase(), "svg"), let star = NSImage(contentsOfFile: path) {
+ if let path = config.posePaths(config.colorTogglable ? "gold" : "").first,
+ let star = NSImage(contentsOfFile: path) {
let iv = NSImageView()
iv.image = star
iv.wantsLayer = true
@@ -899,36 +995,34 @@ final class AboutWindow: NSObject, NSWindowDelegate {
stack.addArrangedSubview(tagline)
stack.setCustomSpacing(16, after: tagline)
- // Dedication — "This MacPal was made for Fía and is maintained by
- // @jeffrey". The í wears an acute accent mark AND the live system accent
- // color (a touch bolder) so it pops twice over; the @handle is semibold.
- let line = "This MacPal was made for Fía and is maintained by @jeffrey"
- let para = NSMutableParagraphStyle()
- para.alignment = .center; para.lineBreakMode = .byWordWrapping
- let dedication = NSMutableAttributedString(string: line, attributes: [
- .font: NSFont.systemFont(ofSize: 13),
- .foregroundColor: NSColor.labelColor,
- .paragraphStyle: para,
- ])
- let ns = line as NSString
- let fia = ns.range(of: "Fía")
- if fia.location != NSNotFound {
- let iRange = NSRange(location: fia.location + 1, length: 1) // the 'í'
- dedication.addAttribute(.foregroundColor, value: NSColor.controlAccentColor, range: iRange)
- dedication.addAttribute(.font, value: NSFont.systemFont(ofSize: 14, weight: .heavy), range: iRange)
- }
- let handle = ns.range(of: "@jeffrey")
- if handle.location != NSNotFound {
- dedication.addAttribute(.font, value: NSFont.systemFont(ofSize: 13, weight: .semibold), range: handle)
- }
- let ded = NSTextField(wrappingLabelWithString: "")
- ded.attributedStringValue = dedication
- ded.alignment = .center
- ded.translatesAutoresizingMaskIntoConstraints = false
- ded.preferredMaxLayoutWidth = 244
- ded.widthAnchor.constraint(equalToConstant: 244).isActive = true
- stack.addArrangedSubview(ded)
- stack.setCustomSpacing(20, after: ded)
+ if let line = config.aboutDedication {
+ let para = NSMutableParagraphStyle()
+ para.alignment = .center; para.lineBreakMode = .byWordWrapping
+ let dedication = NSMutableAttributedString(string: line, attributes: [
+ .font: NSFont.systemFont(ofSize: 13),
+ .foregroundColor: NSColor.labelColor,
+ .paragraphStyle: para,
+ ])
+ let ns = line as NSString
+ let fia = ns.range(of: "Fía")
+ if fia.location != NSNotFound {
+ let iRange = NSRange(location: fia.location + 1, length: 1) // the 'í'
+ dedication.addAttribute(.foregroundColor, value: NSColor.controlAccentColor, range: iRange)
+ dedication.addAttribute(.font, value: NSFont.systemFont(ofSize: 14, weight: .heavy), range: iRange)
+ }
+ let handle = ns.range(of: "@jeffrey")
+ if handle.location != NSNotFound {
+ dedication.addAttribute(.font, value: NSFont.systemFont(ofSize: 13, weight: .semibold), range: handle)
+ }
+ let ded = NSTextField(wrappingLabelWithString: "")
+ ded.attributedStringValue = dedication
+ ded.alignment = .center
+ ded.translatesAutoresizingMaskIntoConstraints = false
+ ded.preferredMaxLayoutWidth = 244
+ ded.widthAnchor.constraint(equalToConstant: 244).isActive = true
+ stack.addArrangedSubview(ded)
+ stack.setCustomSpacing(20, after: ded)
+ }
let version = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "?"
let versionLabel = NSTextField(labelWithString: "Version \(version)")
@@ -944,19 +1038,7 @@ final class AboutWindow: NSObject, NSWindowDelegate {
copyright.alignment = .center
stack.addArrangedSubview(copyright)
- // Size the window to exactly fit the stack — keeps the masthead from
- // being squeezed and leaves no dead space below the footer.
content.layoutSubtreeIfNeeded()
window.setContentSize(NSSize(width: 300, height: ceil(content.fittingSize.height)))
}
}
-
-registerLoginItemOnce()
-let pal = PalController()
-pal.build()
-// Dev affordance: `MacPal --about` opens the About window on launch (for QA /
-// screenshots without driving a right-click menu).
-if CommandLine.arguments.contains("--about") {
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { AboutWindow.show() }
-}
-app.run()
diff --git a/macpal/Sources/main.swift b/macpal/Sources/main.swift
new file mode 100644
index 000000000..13c62d9fd
--- /dev/null
+++ b/macpal/Sources/main.swift
@@ -0,0 +1,157 @@
+// MacPal — entry point. Parses the launch profile, builds a PalConfig, attaches
+// that profile's plugins, and runs.
+//
+// (no args) → the star, for Fía (+ affirmations)
+// --profile fuser --name panda \
+// --corner TL [--repo ~/Developer/fuser]
+// → the fuser machine badge
+//
+// Optional: --to (affirmations key, default "fia"),
+// --host (affirmations endpoint, default aesthetic.computer),
+// --about (open the About panel on launch, for QA).
+//
+// The avatar/plugin machinery lives in PalCore.swift; the two feature sets live
+// in AffirmationsPlugin.swift and FuserPlugin.swift. All compile together via
+// `swiftc Sources/*.swift` (see build.sh).
+
+import AppKit
+import ServiceManagement
+
+setbuf(stdout, nil)
+
+// ── arg parsing ─────────────────────────────────────────────────────────────
+let argv = CommandLine.arguments
+func argValue(_ flag: String) -> String? {
+ guard let i = argv.firstIndex(of: flag), i + 1 < argv.count else { return nil }
+ return argv[i + 1]
+}
+let profile = argValue("--profile") ?? "star"
+let recipient = argValue("--to") ?? "fia"
+let host = argValue("--host") ?? "https://aesthetic.computer"
+// 3D avatar: `--model3d` turns the pal into a SceneKit model (procedural berry
+// placeholder when no path/file is given); `--model3d ` loads a
+// specific mesh. The blueberry machine gets it automatically.
+let want3D = argv.contains("--model3d")
+let model3DArg = argValue("--model3d")
+
+let starSupport = NSString(string: "~/Library/Application Support/MacPal").expandingTildeInPath
+let fuserHome = NSString(string: "~/.local/share/desktop-badge").expandingTildeInPath
+// Menu Band's "now playing" signal dir — the existing cross-app protocol, the
+// same path for both profiles so the two ship independently and just work.
+let noteDir = fuserHome
+
+// ── build the config + plugins for the chosen profile ────────────────────────
+var config: PalConfig
+var plugins: [PalPlugin] = []
+
+if profile == "fuser" {
+ let name = argValue("--name") ?? "fuser"
+ let corner = (argValue("--corner") ?? "TL").uppercased()
+ let repo = argValue("--repo") ?? NSString(string: "~/Developer/fuser").expandingTildeInPath
+ let nameToEmoji = ["panda": "🐼", "chicken": "🐔", "neo": "🦋", "blueberry": "🫐"]
+ config = PalConfig(
+ profile: "fuser",
+ palName: name,
+ palEmoji: nameToEmoji[name.lowercased()] ?? "🖥️",
+ supportDir: fuserHome,
+ noteSignalDir: noteDir,
+ factoryCorner: corner,
+ fixedCorner: corner,
+ marginX: 16, marginY: 16, // the badge's historical resting gap
+ draggable: false,
+ colorTogglable: false,
+ registersLoginItem: false,
+ showsAbout: false,
+ aboutDedication: nil,
+ posePaths: { _ in
+ [fuserHome + "/glyph.svg", fuserHome + "/glyph-2.svg", fuserHome + "/glyph-3.svg"]
+ },
+ singPath: { _ in fuserHome + "/glyph-sing.svg" }
+ )
+ plugins = [FuserPlugin(home: fuserHome, repo: repo)]
+} else {
+ // The star, for Fía. Glyph art ships in the .app's Resources (gold/silver).
+ func base(_ color: String) -> String { color == "silver" ? "star-silver" : "star-glyph" }
+ config = PalConfig(
+ profile: "star",
+ palName: "star",
+ palEmoji: "⭐",
+ supportDir: starSupport,
+ noteSignalDir: noteDir,
+ factoryCorner: "BR",
+ fixedCorner: nil,
+ marginX: 4, marginY: 14,
+ draggable: true,
+ colorTogglable: true,
+ registersLoginItem: true,
+ showsAbout: true,
+ aboutDedication: "This MacPal was made for Fía and is maintained by @jeffrey",
+ posePaths: { color in
+ [base(color), base(color) + "-2", base(color) + "-3"].compactMap { resource($0, "svg") }
+ },
+ singPath: { color in resource(base(color) + "-sing", "svg") }
+ )
+ plugins = [AffirmationsPlugin(recipient: recipient, host: host, supportDir: starSupport)]
+}
+
+// 3D avatar opt-in, per machine: a fuser machine named uses a SceneKit
+// avatar when -3d/.usdc has been staged (installer packs it). Explicit
+// --model3d forces it. So blueberry + neo (+ any future rigged machine) light
+// up automatically once their model lands; others stay on the flat glyph.
+if profile == "fuser" {
+ let m = (argValue("--name") ?? "").lowercased()
+ let modelPath = model3DArg ?? (fuserHome + "/\(m)-3d/\(m).usdc")
+ if want3D || FileManager.default.fileExists(atPath: modelPath) {
+ config.model3D = true
+ config.model3DPath = modelPath
+ config.avatarTogglable = true // right-click → 3D ⇄ 2D
+ // 2D mode shows the model's flat thumbnail (no SVG glyph for these).
+ config.posePaths = { _ in [fuserHome + "/\(m)-3d/\(m)-thumb.png"] }
+ config.singPath = { _ in nil }
+ }
+}
+
+// Dev: `MacPal --profile fuser --name blueberry --snapshot out.png` renders the
+// 3D model offscreen to a PNG (mid-animation) and exits — for QA without the
+// tiny corner widget.
+if let snapPath = argValue("--snapshot") {
+ let t = Double(argValue("--snap-time") ?? "1.5") ?? 1.5
+ if let img = Pal3DView.snapshot(objPath: config.model3DPath,
+ size: CGSize(width: 480, height: 480), time: t),
+ let tiff = img.tiffRepresentation, let bm = NSBitmapImageRep(data: tiff),
+ let png = bm.representation(using: .png, properties: [:]) {
+ try? png.write(to: URL(fileURLWithPath: snapPath))
+ print("✓ snapshot → \(snapPath)")
+ } else {
+ print("✗ snapshot failed")
+ }
+ exit(0)
+}
+
+// ── app bootstrap ─────────────────────────────────────────────────────────
+let app = NSApplication.shared
+app.setActivationPolicy(.accessory) // no Dock icon, no menu bar item
+
+// First-run (star only): add MacPal to Login Items so the pal is always there.
+// SMAppService reflects real system state, so we only ask once — if she later
+// removes it in System Settings, we don't fight her.
+func registerLoginItemOnce() {
+ let key = "MacPal.didRegisterLoginItem"
+ guard !UserDefaults.standard.bool(forKey: key) else { return }
+ if #available(macOS 13.0, *) {
+ do { try SMAppService.mainApp.register() } catch {
+ print("MacPal: login-item register skipped — \(error)")
+ }
+ }
+ UserDefaults.standard.set(true, forKey: key)
+}
+if config.registersLoginItem { registerLoginItemOnce() }
+
+let pal = PalController(config: config)
+pal.plugins = plugins
+pal.build()
+
+if config.showsAbout, CommandLine.arguments.contains("--about") {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { AboutWindow.show(config: config) }
+}
+app.run()
diff --git a/macpal/affirm.mjs b/macpal/affirm.mjs
new file mode 100644
index 000000000..26f78a710
--- /dev/null
+++ b/macpal/affirm.mjs
@@ -0,0 +1,66 @@
+#!/usr/bin/env node
+// affirm.mjs — push an affirmation to a MacPal star.
+//
+// The star (macpal, default profile) polls /api/macpal-status?to=
+// every ~45s; this sets that message. Admin-only — uses @jeffrey's login token.
+//
+// node macpal/affirm.mjs "proud of you 💛" # → to=fia, production
+// node macpal/affirm.mjs "great work today ✨" --to fia
+// node macpal/affirm.mjs "testing" --local # → https://localhost:8888
+// node macpal/affirm.mjs --clear --to fia # blank the caption
+//
+// Auth: reads ~/.ac-token (run `node tezos/ac-login.mjs` once to create it).
+
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+const args = process.argv.slice(2);
+function flag(name) {
+ const i = args.indexOf(name);
+ return i !== -1 ? args[i + 1] : undefined;
+}
+const has = (name) => args.includes(name);
+
+const to = flag("--to") || "fia";
+const local = has("--local");
+const host = flag("--host") || (local ? "https://localhost:8888" : "https://aesthetic.computer");
+// The message is every non-flag argument joined (so quotes are optional-ish).
+const text = has("--clear")
+ ? ""
+ : args.filter((a, i) => !a.startsWith("--") && args[i - 1] !== "--to" && args[i - 1] !== "--host").join(" ");
+
+if (!has("--clear") && !text) {
+ console.error('usage: node macpal/affirm.mjs "your message" [--to fia] [--local] [--clear]');
+ process.exit(1);
+}
+
+const tokenPath = path.join(os.homedir(), ".ac-token");
+let token;
+try {
+ token = JSON.parse(fs.readFileSync(tokenPath, "utf8")).access_token;
+} catch {
+ console.error(`✗ No ~/.ac-token found. Log in first: node tezos/ac-login.mjs`);
+ process.exit(1);
+}
+if (!token) {
+ console.error("✗ ~/.ac-token has no access_token — re-run: node tezos/ac-login.mjs");
+ process.exit(1);
+}
+
+// The dev site serves a self-signed cert on localhost; trust it only there.
+if (local) process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
+
+const res = await fetch(`${host}/api/macpal-status`, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
+ body: JSON.stringify({ to, text }),
+});
+
+const out = await res.json().catch(() => ({}));
+if (!res.ok) {
+ console.error(`✗ ${res.status} — ${out.message || "failed"}`);
+ if (res.status === 401) console.error(" token expired? re-run: node tezos/ac-login.mjs");
+ process.exit(1);
+}
+console.log(`✓ ${to} ← "${out.text}" (seq ${out.seq})`);
diff --git a/macpal/bin/anim-blueberry-3d.mjs b/macpal/bin/anim-blueberry-3d.mjs
new file mode 100644
index 000000000..2942bb91b
--- /dev/null
+++ b/macpal/bin/anim-blueberry-3d.mjs
@@ -0,0 +1,153 @@
+#!/usr/bin/env node
+// macpal/bin/anim-blueberry-3d.mjs — rig + animate the blueberry pal.
+//
+// Meshy auto-rigging + multi-animation via fal (fal-ai/meshy/rigging/multi-animation).
+// Feeds the static text-to-3d GLB (from gen-blueberry-3d.mjs) back in, rigs it as
+// a humanoid, and bakes preset motion clips. Each clip comes back as its own
+// rigged GLB; we keep idle as the resting loop plus a couple of gestures.
+//
+// node macpal/bin/anim-blueberry-3d.mjs # idle + wave + cheer
+// node macpal/bin/anim-blueberry-3d.mjs --model-url URL # rig a different mesh
+// node macpal/bin/anim-blueberry-3d.mjs --ids 0,28,298 # custom action ids
+//
+// Action ids come from Meshy's library (https://docs.meshy.ai/en/api/animation-library):
+// 0 Idle · 28 Big_Wave_Hello · 298 Cheer_with_Both_Hands_Up
+//
+// Output → system/public/assets/macpal/blueberry-3d/ (DO Spaces bin, gitignored).
+// A submitted job keeps running/billing on fal even if this dies, so the queue
+// handle is persisted and a rerun RESUMES instead of paying twice.
+
+import { mkdirSync, existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
+import { resolve, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+import { falKey } from "../../pop/lib/fal-seedance.mjs";
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const ENDPOINT = "fal-ai/meshy/rigging/multi-animation";
+
+const argv = process.argv.slice(2);
+const opt = (f, d) => { const i = argv.indexOf(f); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; };
+const FORCE = argv.includes("--force");
+
+const NAME = opt("--name", "blueberry"); // per-machine model (blueberry, neo, …)
+const OUT_DIR = resolve(HERE, `../../system/public/assets/macpal/${NAME}-3d`);
+
+// The static mesh to rig. Defaults to the fal-hosted GLB from the last
+// gen-3d run for this NAME (recorded in meshy.json); pass --model-url to override.
+let MODEL_URL = opt("--model-url", null);
+if (!MODEL_URL) {
+ if (existsSync(`${OUT_DIR}/meshy.json`)) {
+ MODEL_URL = JSON.parse(readFileSync(`${OUT_DIR}/meshy.json`, "utf8"))?.model_urls?.glb?.url || null;
+ }
+}
+if (!MODEL_URL) {
+ console.error("✗ no model_url — pass --model-url, or run gen-blueberry-3d.mjs first (its meshy.json glb url is reused).");
+ process.exit(1);
+}
+
+const IDS = opt("--ids", "0,28,298").split(",").map((n) => parseInt(n.trim(), 10)).filter(Number.isFinite);
+// safety checker off — the blueberry's stubby limbs can trip the humanoid gate.
+const input = { model_url: MODEL_URL, animation_action_ids: IDS, height_meters: 1.0, enable_safety_checker: false };
+
+const QUEUE_PATH = `${OUT_DIR}/.anim-queue.json`;
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const log = (...a) => console.log(...a);
+
+mkdirSync(OUT_DIR, { recursive: true });
+
+if (existsSync(`${OUT_DIR}/${NAME}-idle.glb`) && !FORCE) {
+ log(`✓ cached → ${OUT_DIR}/${NAME}-idle.glb (use --force to re-rig)`);
+ process.exit(0);
+}
+
+let key;
+try { key = falKey(); }
+catch (e) { console.error(`✗ ${e.message}`); process.exit(1); }
+const auth = { Authorization: `Key ${key}`, "Content-Type": "application/json" };
+
+log(`▸ rig + animate · ${ENDPOINT} · ids [${IDS.join(", ")}]`);
+log(` source: ${MODEL_URL.slice(0, 72)}…`);
+
+// ── submit (or resume) ───────────────────────────────────────────────────
+let queued = existsSync(QUEUE_PATH) ? JSON.parse(readFileSync(QUEUE_PATH, "utf8")) : null;
+if (queued) log(` ↻ resuming in-flight job`);
+const MAX_SUBMIT = 4;
+for (let attempt = 1; !queued && attempt <= MAX_SUBMIT; attempt++) {
+ const res = await fetch(`https://queue.fal.run/${ENDPOINT}`, {
+ method: "POST", headers: auth, body: JSON.stringify(input),
+ });
+ if (res.ok) { queued = await res.json(); writeFileSync(QUEUE_PATH, JSON.stringify(queued, null, 2)); break; }
+ const err = await res.text();
+ const transient = res.status === 429 || res.status >= 500 || res.status === 403;
+ if (transient && attempt < MAX_SUBMIT) {
+ log(` ⚠ submit ${res.status} — retry ${attempt}/${MAX_SUBMIT - 1} in ${5 * attempt}s`);
+ await sleep(5000 * attempt); continue;
+ }
+ console.error(`✗ submit ${res.status}: ${err.slice(0, 300)}`); process.exit(1);
+}
+
+// ── poll ───────────────────────────────────────────────────────────────────
+const MAX_NET_FAILS = 8;
+let netFails = 0;
+const tryFetch = async (...args) => {
+ try { const res = await fetch(...args); netFails = 0; return res; }
+ catch (err) {
+ netFails++; log(` ⚠ ${err.cause?.code ?? err.message} — retry ${netFails}/${MAX_NET_FAILS}`);
+ if (netFails >= MAX_NET_FAILS) throw err;
+ await sleep(5000 * netFails); return null;
+ }
+};
+
+const t0 = Date.now();
+let status = "", lastTick = 0;
+while (status !== "COMPLETED") {
+ await sleep(4000);
+ const res = await tryFetch(queued.status_url, { headers: auth });
+ if (!res) continue;
+ const body = await res.json();
+ const elapsed = ((Date.now() - t0) / 1000).toFixed(0);
+ if (body.status !== status) { status = body.status; lastTick = Date.now(); log(` ${status.toLowerCase()} · ${elapsed}s`); }
+ else if (Date.now() - lastTick > 12000) { lastTick = Date.now(); log(` ${status.toLowerCase()} · ${elapsed}s`); }
+ if (status === "FAILED" || body.error) {
+ unlinkSync(QUEUE_PATH);
+ console.error(`✗ rigging failed: ${JSON.stringify(body).slice(0, 400)}`); process.exit(1);
+ }
+}
+
+let result = null;
+while (!result) { const res = await tryFetch(queued.response_url, { headers: auth }); if (res) result = await res.json(); }
+writeFileSync(`${OUT_DIR}/rig-result.json`, JSON.stringify(result, null, 2)); // raw, for debugging
+if (!(result.animations?.length) && !result.rigged_character_glb?.url) {
+ console.error(`✗ empty rig result — raw saved to rig-result.json:\n${JSON.stringify(result).slice(0, 500)}`);
+ try { unlinkSync(QUEUE_PATH); } catch {}
+ process.exit(1);
+}
+
+// ── download artifacts ─────────────────────────────────────────────────────
+const download = async (url, dest) => {
+ for (let i = 0; i < MAX_NET_FAILS; i++) {
+ const res = await tryFetch(url);
+ if (!res) continue;
+ if (!res.ok) { console.error(` ⚠ ${res.status} for ${dest.split("/").pop()}`); return false; }
+ const buf = Buffer.from(await res.arrayBuffer());
+ writeFileSync(dest, buf);
+ log(` ↓ ${dest.split("/").pop()} · ${(buf.length / 1e6).toFixed(2)}MB`);
+ return true;
+ }
+ return false;
+};
+
+const CLIP = { 0: "idle", 11: "idle", 12: "idle", 28: "wave", 298: "cheer", 49: "cheer", 44: "jump", 61: "jump" };
+if (result.rigged_character_glb?.url) await download(result.rigged_character_glb.url, `${OUT_DIR}/${NAME}-rigged.glb`);
+for (const a of result.animations || []) {
+ const nm = CLIP[a.action_id] || `anim-${a.action_id}`;
+ if (a.animation_glb?.url) await download(a.animation_glb.url, `${OUT_DIR}/${NAME}-${nm}.glb`);
+}
+
+writeFileSync(`${OUT_DIR}/anim.json`, JSON.stringify({
+ endpoint: ENDPOINT, input, rig_task_id: result.rig_task_id ?? null,
+ animations: (result.animations || []).map((a) => ({ action_id: a.action_id, glb: a.animation_glb?.url })),
+}, null, 2));
+
+try { unlinkSync(QUEUE_PATH); } catch {}
+log(`✓ ${((Date.now() - t0) / 1000).toFixed(0)}s → ${OUT_DIR} (idle/wave/cheer GLBs)`);
diff --git a/macpal/bin/gen-blueberry-3d.mjs b/macpal/bin/gen-blueberry-3d.mjs
new file mode 100644
index 000000000..cf4cd9b78
--- /dev/null
+++ b/macpal/bin/gen-blueberry-3d.mjs
@@ -0,0 +1,210 @@
+#!/usr/bin/env node
+// macpal/bin/gen-blueberry-3d.mjs — render a 3D blueberry pal as an OBJ.
+//
+// Meshy-6 text-to-3d via the fal.ai queue API (fal-ai/meshy/v6/text-to-3d).
+// Text → fully-textured mesh; we pull the OBJ + GLB + PBR texture PNGs down
+// into macpal/Resources/blueberry-3d/ so MacPal's SceneKit avatar can load it.
+//
+// node macpal/bin/gen-blueberry-3d.mjs # default cute pal
+// node macpal/bin/gen-blueberry-3d.mjs --lowpoly # game-asset look
+// node macpal/bin/gen-blueberry-3d.mjs --prompt "…" # custom prompt
+// node macpal/bin/gen-blueberry-3d.mjs --force # re-render (ignore cache)
+//
+// A submitted job keeps running (and billing) on fal even if this process
+// dies, so the queue handle is persisted beside the output — a rerun RESUMES
+// the in-flight job instead of paying twice. (Same discipline as fal-seedance.)
+
+import { mkdirSync, existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
+import { resolve, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+import { falKey } from "../../pop/lib/fal-seedance.mjs";
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const ENDPOINT = "fal-ai/meshy/v6/text-to-3d";
+
+const argv = process.argv.slice(2);
+const has = (f) => argv.includes(f);
+const opt = (f, d) => {
+ const i = argv.indexOf(f);
+ return i >= 0 && argv[i + 1] ? argv[i + 1] : d;
+};
+
+// Per-machine model name → its own folder + file stem (blueberry, neo, …).
+const NAME = opt("--name", "blueberry");
+// DO Spaces assets bin (gitignored, synced via `npm run assets:sync:up`) — the
+// 3D files are large and don't belong in the tracked tree.
+const OUT_DIR = resolve(HERE, `../../system/public/assets/macpal/${NAME}-3d`);
+
+const FORCE = has("--force");
+const LOWPOLY = has("--lowpoly");
+
+// Default is the blueberry pal; pass --prompt / --texture-prompt for another
+// machine. Keep it a clear humanoid T-pose so Meshy's auto-rig can find a
+// skeleton (anim-3d.mjs rigs + animates it afterward).
+const PROMPT = opt("--prompt",
+ "A cute anthropomorphic blueberry mascot character in a clear symmetric T-pose: a round " +
+ "deep-blue blueberry body as the torso with a friendly smiling face and two big eyes, " +
+ "clearly defined separated humanoid arms held straight out to the sides with simple rounded " +
+ "hands, two clearly separated straight legs standing slightly apart with little rounded shoes, " +
+ "a small green leaf and a five-point star calyx on top of the head, soft matte deep-blue skin " +
+ "with a dusty frosty bloom, simple friendly full-body cartoon mascot, clean game-ready humanoid " +
+ "topology, centered, symmetric, standing.");
+const TEXTURE_PROMPT = opt("--texture-prompt",
+ "soft matte blueberry skin, deep indigo-blue with a dusty powder-blue bloom, " +
+ "a subtle pale star calyx, fresh green leaf, gentle hand-illustrated look.");
+
+const input = {
+ prompt: PROMPT,
+ mode: "full", // textured, one shot
+ model_type: LOWPOLY ? "lowpoly" : "standard",
+ topology: "triangle", // ModelIO/SceneKit-friendly
+ target_polycount: LOWPOLY ? 8000 : 30000,
+ should_remesh: true,
+ symmetry_mode: "auto",
+ texture_prompt: TEXTURE_PROMPT,
+ enable_pbr: true,
+};
+
+const OBJ_OUT = `${OUT_DIR}/${NAME}.obj`;
+const QUEUE_PATH = `${OUT_DIR}/.queue.json`;
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const log = (...a) => console.log(...a);
+
+if (existsSync(OBJ_OUT) && !FORCE) {
+ log(`✓ cached → ${OBJ_OUT} (use --force to re-render)`);
+ process.exit(0);
+}
+
+mkdirSync(OUT_DIR, { recursive: true });
+
+let key;
+try {
+ key = falKey();
+} catch (e) {
+ console.error(`✗ ${e.message}`);
+ console.error(" Add FAL_KEY to aesthetic-computer-vault/.devcontainer/envs/devcontainer.env");
+ console.error(" or run with FAL_KEY=… node macpal/bin/gen-blueberry-3d.mjs");
+ process.exit(1);
+}
+const auth = { Authorization: `Key ${key}`, "Content-Type": "application/json" };
+
+log(`▸ blueberry 3D · Meshy-6 text-to-3d · ${input.model_type} · ${input.target_polycount} tris`);
+
+// ── submit (or resume) ───────────────────────────────────────────────────
+let queued = null;
+if (existsSync(QUEUE_PATH)) {
+ queued = JSON.parse(readFileSync(QUEUE_PATH, "utf8"));
+ log(` ↻ resuming in-flight job from ${QUEUE_PATH.split("/").pop()}`);
+}
+
+const MAX_SUBMIT = 4;
+for (let attempt = 1; !queued && attempt <= MAX_SUBMIT; attempt++) {
+ const res = await fetch(`https://queue.fal.run/${ENDPOINT}`, {
+ method: "POST", headers: auth, body: JSON.stringify(input),
+ });
+ if (res.ok) {
+ queued = await res.json();
+ writeFileSync(QUEUE_PATH, JSON.stringify(queued, null, 2));
+ break;
+ }
+ const err = await res.text();
+ // fal billing-state lag shows up as a spurious 403; retry transients.
+ const transient = res.status === 429 || res.status >= 500 || res.status === 403;
+ if (transient && attempt < MAX_SUBMIT) {
+ log(` ⚠ submit ${res.status} — retry ${attempt}/${MAX_SUBMIT - 1} in ${5 * attempt}s`);
+ await sleep(5000 * attempt);
+ continue;
+ }
+ console.error(`✗ submit ${res.status}: ${err.slice(0, 300)}`);
+ process.exit(1);
+}
+
+// ── poll (rides out transient network failures) ───────────────────────────
+const MAX_NET_FAILS = 8;
+let netFails = 0;
+const tryFetch = async (...args) => {
+ try {
+ const res = await fetch(...args);
+ netFails = 0;
+ return res;
+ } catch (err) {
+ netFails++;
+ log(` ⚠ ${err.cause?.code ?? err.message} — retry ${netFails}/${MAX_NET_FAILS}`);
+ if (netFails >= MAX_NET_FAILS) throw err;
+ await sleep(5000 * netFails);
+ return null;
+ }
+};
+
+const t0 = Date.now();
+let status = "";
+let lastTick = 0;
+while (status !== "COMPLETED") {
+ await sleep(4000);
+ const res = await tryFetch(queued.status_url, { headers: auth });
+ if (!res) continue;
+ const body = await res.json();
+ const elapsed = ((Date.now() - t0) / 1000).toFixed(0);
+ if (body.status !== status) {
+ status = body.status;
+ lastTick = Date.now();
+ log(` ${status.toLowerCase()} · ${elapsed}s`);
+ } else if (Date.now() - lastTick > 12000) {
+ lastTick = Date.now();
+ log(` ${status.toLowerCase()} · ${elapsed}s`);
+ }
+ if (status === "FAILED" || body.error) {
+ unlinkSync(QUEUE_PATH);
+ console.error(`✗ generation failed: ${JSON.stringify(body).slice(0, 300)}`);
+ process.exit(1);
+ }
+}
+
+let result = null;
+while (!result) {
+ const res = await tryFetch(queued.response_url, { headers: auth });
+ if (res) result = await res.json();
+}
+
+// ── download artifacts ─────────────────────────────────────────────────────
+const urls = result.model_urls || {};
+if (!urls.obj?.url) {
+ console.error(`✗ no OBJ in response: ${JSON.stringify(result).slice(0, 400)}`);
+ process.exit(1);
+}
+
+const download = async (url, dest) => {
+ for (let i = 0; i < MAX_NET_FAILS; i++) {
+ const res = await tryFetch(url);
+ if (!res) continue;
+ if (!res.ok) { console.error(` ⚠ ${res.status} for ${dest.split("/").pop()}`); return false; }
+ const buf = Buffer.from(await res.arrayBuffer());
+ writeFileSync(dest, buf);
+ log(` ↓ ${dest.split("/").pop()} · ${(buf.length / 1e6).toFixed(2)}MB`);
+ return true;
+ }
+ return false;
+};
+
+await download(urls.obj.url, OBJ_OUT);
+if (urls.glb?.url) await download(urls.glb.url, `${OUT_DIR}/${NAME}.glb`); // self-contained backup
+if (result.thumbnail?.url) await download(result.thumbnail.url, `${OUT_DIR}/${NAME}-thumb.png`);
+
+// PBR texture maps — MacPal applies base_color to the SceneKit material; the
+// rest are kept for future PBR shading.
+const tex = (result.texture_urls && result.texture_urls[0]) || {};
+for (const [kind, file] of Object.entries(tex)) {
+ if (file?.url) await download(file.url, `${OUT_DIR}/${NAME}-${kind}.png`);
+}
+
+// Record provenance so a rebuild is reproducible.
+writeFileSync(`${OUT_DIR}/meshy.json`, JSON.stringify({
+ endpoint: ENDPOINT, input, seed: result.seed ?? null,
+ actual_prompt: result.actual_prompt ?? null,
+ model_urls: urls,
+}, null, 2));
+
+try { unlinkSync(QUEUE_PATH); } catch {}
+
+log(`✓ ${((Date.now() - t0) / 1000).toFixed(0)}s → ${OUT_DIR}`);
+log(` blueberry.obj${urls.glb ? " + .glb" : ""}${tex.base_color ? " + base_color texture" : ""}`);
diff --git a/macpal/build.sh b/macpal/build.sh
index 456c17ecb..28ea018e0 100755
--- a/macpal/build.sh
+++ b/macpal/build.sh
@@ -20,16 +20,21 @@ echo "› cleaning $APP"
rm -rf "$APP"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
-echo "› compiling Sources/MacPal.swift"
+echo "› compiling Sources/*.swift"
swiftc -O \
-framework AppKit \
-framework ServiceManagement \
- Sources/MacPal.swift \
+ -framework SceneKit \
+ -framework ModelIO \
+ Sources/*.swift \
-o "$APP/Contents/MacOS/$APP_NAME"
echo "› assembling bundle"
cp Resources/Info.plist "$APP/Contents/Info.plist"
-cp Resources/*.svg "$APP/Contents/Resources/"
+# Only the star's art ships in Fía's bundle; the fuser machine glyphs in
+# Resources/ are source the fuser installer (macpal/fuser/install.sh) stages
+# on-device — they're loaded from disk, not the bundle.
+cp Resources/star-*.svg "$APP/Contents/Resources/"
[[ -f Resources/AppIcon.icns ]] && cp Resources/AppIcon.icns "$APP/Contents/Resources/"
echo "› signing ($SIGN_ID)"
diff --git a/macpal/fuser/badge-asana-sync.sh b/macpal/fuser/badge-asana-sync.sh
new file mode 120000
index 000000000..8fe109634
--- /dev/null
+++ b/macpal/fuser/badge-asana-sync.sh
@@ -0,0 +1 @@
+../../aesthetic-computer-vault/fuser/bin/desktop-badge/badge-asana-sync.sh
\ No newline at end of file
diff --git a/macpal/fuser/badge-git-sync.sh b/macpal/fuser/badge-git-sync.sh
new file mode 120000
index 000000000..37bd6feb7
--- /dev/null
+++ b/macpal/fuser/badge-git-sync.sh
@@ -0,0 +1 @@
+../../aesthetic-computer-vault/fuser/bin/desktop-badge/badge-git-sync.sh
\ No newline at end of file
diff --git a/macpal/fuser/deploy.sh b/macpal/fuser/deploy.sh
new file mode 120000
index 000000000..90299b928
--- /dev/null
+++ b/macpal/fuser/deploy.sh
@@ -0,0 +1 @@
+../../aesthetic-computer-vault/fuser/bin/desktop-badge/deploy.sh
\ No newline at end of file
diff --git a/macpal/fuser/install.sh b/macpal/fuser/install.sh
new file mode 120000
index 000000000..5f6830628
--- /dev/null
+++ b/macpal/fuser/install.sh
@@ -0,0 +1 @@
+../../aesthetic-computer-vault/fuser/bin/desktop-badge/install.sh
\ No newline at end of file
diff --git a/macpal/fuser/run-stack.sh b/macpal/fuser/run-stack.sh
new file mode 120000
index 000000000..6a02a0989
--- /dev/null
+++ b/macpal/fuser/run-stack.sh
@@ -0,0 +1 @@
+../../aesthetic-computer-vault/fuser/bin/desktop-badge/run-stack.sh
\ No newline at end of file
diff --git a/macpal/package.sh b/macpal/package.sh
index 75c7d46f9..4e5ec3a74 100755
--- a/macpal/package.sh
+++ b/macpal/package.sh
@@ -22,9 +22,11 @@ command -v rsvg-convert >/dev/null && [[ ! -f Resources/AppIcon.icns ]] && ./mak
echo "› clean build of $APP"
rm -rf "$APP"; mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
swiftc -O -framework AppKit -framework ServiceManagement \
- Sources/MacPal.swift -o "$APP/Contents/MacOS/$APP_NAME"
+ Sources/*.swift -o "$APP/Contents/MacOS/$APP_NAME"
cp Resources/Info.plist "$APP/Contents/Info.plist"
-cp Resources/*.svg "$APP/Contents/Resources/"
+# Only the star's art ships; fuser machine glyphs in Resources/ are installer
+# source (macpal/fuser), loaded from disk on the fleet, not from the bundle.
+cp Resources/star-*.svg "$APP/Contents/Resources/"
cp Resources/AppIcon.icns "$APP/Contents/Resources/"
echo "› signing with Developer ID (hardened runtime)"
diff --git a/package.json b/package.json
index 14406b4bf..2a9665a82 100644
--- a/package.json
+++ b/package.json
@@ -84,6 +84,7 @@
"site:debug": "cd system; npm run codespaces-dev-debug",
"site:live": "cd system; npm run codespaces-dev-public",
"new": "node utilities/generate-new-piece.mjs",
+ "affirm": "node macpal/affirm.mjs",
"notepat:build": "node ac-m4l/build-notepat.mjs",
"notepat:build:desktop": "node ac-m4l/build-notepat.mjs --desktop --log",
"notepat:publish": "node ac-m4l/build-notepat.mjs --sync-spaces --log",
diff --git a/system/netlify/functions/macpal-status.mjs b/system/netlify/functions/macpal-status.mjs
new file mode 100644
index 000000000..ff5cc355b
--- /dev/null
+++ b/system/netlify/functions/macpal-status.mjs
@@ -0,0 +1,66 @@
+// macpal-status, 26.06.16
+// The remote status line under a MacPal star — affirmations @jeffrey pushes to
+// Fía's desktop pal (see macpal/Sources/AffirmationsPlugin.swift).
+//
+// GET /api/macpal-status?to= → { to, text, seq, at } (public read)
+// POST /api/macpal-status → set it (admin-only: @jeffrey)
+// body: { to, text } → { to, text, seq, at, set: true }
+//
+// Stored as a JSON string in the Redis hash "macpal", keyed by recipient. Each
+// POST bumps `seq` so the pal only celebrates a genuinely new message.
+
+import { respond } from "../../backend/http.mjs";
+import { authorize, hasAdmin } from "../../backend/authorization.mjs";
+import * as KeyValue from "../../backend/kv.mjs";
+
+const COLLECTION = "macpal";
+const MAX_LEN = 240;
+
+// Recipient keys are short, lowercase slugs — keep them tame so they're safe
+// hash fields and predictable from the pal's `--to` flag.
+function cleanKey(raw) {
+ return (raw || "fia")
+ .toString()
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9_-]/g, "")
+ .slice(0, 64) || "fia";
+}
+
+export async function handler(event) {
+ if (event.httpMethod === "OPTIONS") return respond(204, null);
+
+ if (event.httpMethod === "GET") {
+ const to = cleanKey(event.queryStringParameters?.to);
+ await KeyValue.connect();
+ const raw = await KeyValue.get(COLLECTION, to);
+ await KeyValue.disconnect();
+ const stored = raw ? JSON.parse(raw) : { text: "", seq: 0, at: null };
+ return respond(200, { to, ...stored });
+ }
+
+ if (event.httpMethod === "POST") {
+ const user = await authorize(event.headers);
+ if (!user?.sub) return respond(401, { message: "Unauthorized." });
+ if (!(await hasAdmin(user))) return respond(403, { message: "Admins only." });
+
+ let body;
+ try {
+ body = JSON.parse(event.body || "{}");
+ } catch {
+ return respond(400, { message: "Bad JSON." });
+ }
+ const to = cleanKey(body.to);
+ const text = (body.text ?? "").toString().slice(0, MAX_LEN);
+
+ await KeyValue.connect();
+ const raw = await KeyValue.get(COLLECTION, to);
+ const prevSeq = raw ? JSON.parse(raw).seq || 0 : 0;
+ const next = { text, seq: prevSeq + 1, at: new Date().toISOString() };
+ await KeyValue.set(COLLECTION, to, JSON.stringify(next));
+ await KeyValue.disconnect();
+ return respond(200, { to, ...next, set: true });
+ }
+
+ return respond(405, { message: "Method Not Allowed." });
+}