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." }); +}