BroV look: swamp-zombie unibrow character, new app and menu bar icons, BroV naming in UI

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
maksarsanjeev
2026-10-06 19:53:47 +03:00
parent 0f77ec8704
commit 7d76986bc0
22 changed files with 422 additions and 54 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 340 KiB

After

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 517 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 987 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

+4 -4
View File
@@ -28,13 +28,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private func setupMenuBarItem() { private func setupMenuBarItem() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
guard let button = statusItem?.button else { return } guard let button = statusItem?.button else { return }
button.image = NSImage(named: "MenuBarIcon") ?? NSImage(systemSymbolName: "circle.fill", accessibilityDescription: "Coucou") button.image = NSImage(named: "MenuBarIcon") ?? NSImage(systemSymbolName: "circle.fill", accessibilityDescription: "BroV")
button.image?.size = NSSize(width: 24, height: 18) button.image?.size = NSSize(width: 24, height: 18)
button.image?.accessibilityDescription = "Coucou" button.image?.accessibilityDescription = "BroV"
button.image?.isTemplate = true button.image?.isTemplate = true
let menu = NSMenu() let menu = NSMenu()
menu.addItem(withTitle: "Open Coucou", action: #selector(openIsland), keyEquivalent: "") menu.addItem(withTitle: "Open BroV", action: #selector(openIsland), keyEquivalent: "")
menu.addItem(.separator()) menu.addItem(.separator())
menu.addItem(withTitle: "Settings…", action: #selector(openSettings), keyEquivalent: ",") menu.addItem(withTitle: "Settings…", action: #selector(openSettings), keyEquivalent: ",")
menu.addItem(.separator()) menu.addItem(.separator())
@@ -69,7 +69,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let win = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 720, height: 560), let win = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 720, height: 560),
styleMask: [.titled, .closable, .miniaturizable, .resizable], styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered, defer: false) backing: .buffered, defer: false)
win.title = "Settings — Coucou" win.title = "Settings — BroV"
let host = NSHostingView(rootView: SettingsView()) let host = NSHostingView(rootView: SettingsView())
host.sizingOptions = [.minSize] host.sizingOptions = [.minSize]
win.contentView = host win.contentView = host
+2 -2
View File
@@ -198,9 +198,9 @@ final class ClaudeService {
/// neutral otherwise — same wording as the Windows build. /// neutral otherwise — same wording as the Windows build.
private nonisolated static func makeSystemPrompt() -> String { private nonisolated static func makeSystemPrompt() -> String {
let opening = if let firstName = resolveUserFirstName() { let opening = if let firstName = resolveUserFirstName() {
"You are Mochi, \(firstName)'s personal AI assistant embedded in the notch of their Mac." "You are BroV, \(firstName)'s personal AI assistant embedded in the notch of their Mac."
} else { } else {
"You are Mochi, a personal AI assistant embedded in the notch of the user's Mac." "You are BroV, a personal AI assistant embedded in the notch of the user's Mac."
} }
return """ return """
\(opening) \ \(opening) \
+28 -28
View File
@@ -19,7 +19,7 @@ final class HookServer: @unchecked Sendable {
static var socketPath: String { static var socketPath: String {
#if APPSTORE #if APPSTORE
// Container home root keeps path ≤ 103 bytes (sun_path limit on macOS is 104 incl. NUL) // Container home root keeps path ≤ 103 bytes (sun_path limit on macOS is 104 incl. NUL)
// /Users/louis/Library/Containers/fr.louisraille.Coucou/Data/nb.sock = 66 bytes ✓ // /Users/louis/Library/Containers/fr.louisraille.BroV/Data/nb.sock = 66 bytes ✓
return NSHomeDirectory() + "/nb.sock" return NSHomeDirectory() + "/nb.sock"
#else #else
return supportDir.appendingPathComponent("nb.sock").path return supportDir.appendingPathComponent("nb.sock").path
@@ -1093,7 +1093,7 @@ final class HookServer: @unchecked Sendable {
// MARK: - Outdated hook detection // MARK: - Outdated hook detection
/// Returns true if settings.json has a Coucou hook that needs updating: /// Returns true if settings.json has a BroV hook that needs updating:
/// either a PermissionRequest hook with timeout < 120s, or the AskUserQuestion /// either a PermissionRequest hook with timeout < 120s, or the AskUserQuestion
/// PreToolUse matcher is missing (requires Claude Code 2.1.85+). /// PreToolUse matcher is missing (requires Claude Code 2.1.85+).
static func hooksNeedUpdate() -> Bool { static func hooksNeedUpdate() -> Bool {
@@ -1104,8 +1104,8 @@ final class HookServer: @unchecked Sendable {
let hooks = settings["hooks"] as? [String: Any] else { let hooks = settings["hooks"] as? [String: Any] else {
return false return false
} }
// Track whether any Coucou hook is installed at all // Track whether any BroV hook is installed at all
var hasCoucouHooks = false var hasBroVHooks = false
if let permReqHooks = hooks["PermissionRequest"] as? [[String: Any]] { if let permReqHooks = hooks["PermissionRequest"] as? [[String: Any]] {
for matcher in permReqHooks { for matcher in permReqHooks {
@@ -1113,7 +1113,7 @@ final class HookServer: @unchecked Sendable {
for hook in hookList { for hook in hookList {
if let cmd = hook["command"] as? String, if let cmd = hook["command"] as? String,
cmd.contains("NotchBuddy") || cmd.contains("coucou") { cmd.contains("NotchBuddy") || cmd.contains("coucou") {
hasCoucouHooks = true hasBroVHooks = true
if let timeout = hook["timeout"] as? Int, timeout < 120 { return true } if let timeout = hook["timeout"] as? Int, timeout < 120 { return true }
} }
} }
@@ -1122,7 +1122,7 @@ final class HookServer: @unchecked Sendable {
} }
// Check that the AskUserQuestion PreToolUse entry exists // Check that the AskUserQuestion PreToolUse entry exists
if hasCoucouHooks { if hasBroVHooks {
let preToolHooks = hooks["PreToolUse"] as? [[String: Any]] ?? [] let preToolHooks = hooks["PreToolUse"] as? [[String: Any]] ?? []
let hasAskEntry = preToolHooks.contains { m in let hasAskEntry = preToolHooks.contains { m in
(m["matcher"] as? String) == "AskUserQuestion" (m["matcher"] as? String) == "AskUserQuestion"
@@ -1540,7 +1540,7 @@ final class HookServer: @unchecked Sendable {
label: "~/.gemini/settings.json") label: "~/.gemini/settings.json")
if let raw = settings["hooks"], !(raw is [String: Any]) { if let raw = settings["hooks"], !(raw is [String: Any]) {
throw NSError(domain: "Coucou", code: 2, userInfo: [ throw NSError(domain: "Coucou", code: 2, userInfo: [
NSLocalizedDescriptionKey: "~/.gemini/settings.json: \"hooks\" has an unexpected type — Coucou has not touched it." NSLocalizedDescriptionKey: "~/.gemini/settings.json: \"hooks\" has an unexpected type — BroV has not touched it."
]) ])
} }
let base = hookBase() let base = hookBase()
@@ -1557,7 +1557,7 @@ final class HookServer: @unchecked Sendable {
for (geminiEvent, normalizedEvent, timeout) in events { for (geminiEvent, normalizedEvent, timeout) in events {
if let raw = hooks[geminiEvent], !(raw is [[String: Any]]) { if let raw = hooks[geminiEvent], !(raw is [[String: Any]]) {
throw NSError(domain: "Coucou", code: 2, userInfo: [ throw NSError(domain: "Coucou", code: 2, userInfo: [
NSLocalizedDescriptionKey: "~/.gemini/settings.json: \"hooks\"[\"\(geminiEvent)\"] has an unexpected type — Coucou has not touched it." NSLocalizedDescriptionKey: "~/.gemini/settings.json: \"hooks\"[\"\(geminiEvent)\"] has an unexpected type — BroV has not touched it."
]) ])
} }
var groups = hooks[geminiEvent] as? [[String: Any]] ?? [] var groups = hooks[geminiEvent] as? [[String: Any]] ?? []
@@ -1581,7 +1581,7 @@ final class HookServer: @unchecked Sendable {
label: "~/.gemini/settings.json") label: "~/.gemini/settings.json")
if let raw = settings["hooks"], !(raw is [String: Any]) { if let raw = settings["hooks"], !(raw is [String: Any]) {
throw NSError(domain: "Coucou", code: 2, userInfo: [ throw NSError(domain: "Coucou", code: 2, userInfo: [
NSLocalizedDescriptionKey: "~/.gemini/settings.json: \"hooks\" has an unexpected type — Coucou has not touched it." NSLocalizedDescriptionKey: "~/.gemini/settings.json: \"hooks\" has an unexpected type — BroV has not touched it."
]) ])
} }
if var hooks = settings["hooks"] as? [String: Any] { if var hooks = settings["hooks"] as? [String: Any] {
@@ -1679,12 +1679,12 @@ final class HookServer: @unchecked Sendable {
do { data = try Data(contentsOf: url) } do { data = try Data(contentsOf: url) }
catch { catch {
throw NSError(domain: "Coucou", code: 2, userInfo: [ throw NSError(domain: "Coucou", code: 2, userInfo: [
NSLocalizedDescriptionKey: "\(label) cannot be read — Coucou has not touched it." NSLocalizedDescriptionKey: "\(label) cannot be read — BroV has not touched it."
]) ])
} }
guard let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { guard let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else {
throw NSError(domain: "Coucou", code: 2, userInfo: [ throw NSError(domain: "Coucou", code: 2, userInfo: [
NSLocalizedDescriptionKey: "\(label) is not valid JSON — Coucou has not touched it." NSLocalizedDescriptionKey: "\(label) is not valid JSON — BroV has not touched it."
]) ])
} }
return obj return obj
@@ -1735,7 +1735,7 @@ final class HookServer: @unchecked Sendable {
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".codex/hooks.json") FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".codex/hooks.json")
} }
/// True when ~/.codex/hooks.json already routes Codex events to Coucou's nb-hook. /// True when ~/.codex/hooks.json already routes Codex events to BroV's nb-hook.
static func codexHooksInstalled() -> Bool { static func codexHooksInstalled() -> Bool {
guard let data = try? Data(contentsOf: codexHooksURL), guard let data = try? Data(contentsOf: codexHooksURL),
let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
@@ -1790,7 +1790,7 @@ final class HookServer: @unchecked Sendable {
var root = try Self.strictReadJSONObject(at: Self.codexHooksURL, label: "~/.codex/hooks.json") var root = try Self.strictReadJSONObject(at: Self.codexHooksURL, label: "~/.codex/hooks.json")
if let raw = root["hooks"], !(raw is [String: Any]) { if let raw = root["hooks"], !(raw is [String: Any]) {
throw NSError(domain: "Coucou", code: 2, userInfo: [ throw NSError(domain: "Coucou", code: 2, userInfo: [
NSLocalizedDescriptionKey: "~/.codex/hooks.json: \"hooks\" has an unexpected type — Coucou has not touched it." NSLocalizedDescriptionKey: "~/.codex/hooks.json: \"hooks\" has an unexpected type — BroV has not touched it."
]) ])
} }
let base = hookBase() let base = hookBase()
@@ -1800,7 +1800,7 @@ final class HookServer: @unchecked Sendable {
("SessionStart", 10, nil), ("SessionStart", 10, nil),
("UserPromptSubmit", 10, nil), ("UserPromptSubmit", 10, nil),
("PreToolUse", 10, nil), ("PreToolUse", 10, nil),
("PermissionRequest", 120, "Waiting for your answer in the notch (Coucou)"), ("PermissionRequest", 120, "Waiting for your answer in the notch (BroV)"),
("PostToolUse", 10, nil), ("PostToolUse", 10, nil),
("Stop", 10, nil), ("Stop", 10, nil),
("SubagentStart", 10, nil), ("SubagentStart", 10, nil),
@@ -1812,11 +1812,11 @@ final class HookServer: @unchecked Sendable {
for (event, timeout, statusMsg) in events { for (event, timeout, statusMsg) in events {
if let raw = hooks[event], !(raw is [[String: Any]]) { if let raw = hooks[event], !(raw is [[String: Any]]) {
throw NSError(domain: "Coucou", code: 2, userInfo: [ throw NSError(domain: "Coucou", code: 2, userInfo: [
NSLocalizedDescriptionKey: "~/.codex/hooks.json: \"hooks\"[\"\(event)\"] has an unexpected type — Coucou has not touched it." NSLocalizedDescriptionKey: "~/.codex/hooks.json: \"hooks\"[\"\(event)\"] has an unexpected type — BroV has not touched it."
]) ])
} }
var groups = hooks[event] as? [[String: Any]] ?? [] var groups = hooks[event] as? [[String: Any]] ?? []
// Remove existing Coucou entries // Remove existing BroV entries
groups = removeNbHookEntries(from: groups) groups = removeNbHookEntries(from: groups)
var hookEntry: [String: Any] = [ var hookEntry: [String: Any] = [
"type": "command", "type": "command",
@@ -1836,7 +1836,7 @@ final class HookServer: @unchecked Sendable {
var root = try Self.strictReadJSONObject(at: Self.codexHooksURL, label: "~/.codex/hooks.json") var root = try Self.strictReadJSONObject(at: Self.codexHooksURL, label: "~/.codex/hooks.json")
if let raw = root["hooks"], !(raw is [String: Any]) { if let raw = root["hooks"], !(raw is [String: Any]) {
throw NSError(domain: "Coucou", code: 2, userInfo: [ throw NSError(domain: "Coucou", code: 2, userInfo: [
NSLocalizedDescriptionKey: "~/.codex/hooks.json: \"hooks\" has an unexpected type — Coucou has not touched it." NSLocalizedDescriptionKey: "~/.codex/hooks.json: \"hooks\" has an unexpected type — BroV has not touched it."
]) ])
} }
if var hooks = root["hooks"] as? [String: Any] { if var hooks = root["hooks"] as? [String: Any] {
@@ -1874,7 +1874,7 @@ extension Notification.Name {
private let nbHookShellWrapper = """ private let nbHookShellWrapper = """
#!/bin/sh #!/bin/sh
# Coucou hook relay — always exits 0, never blocks Claude Code # BroV hook relay — always exits 0, never blocks Claude Code
HOOK_DIR="$(dirname "$0")" HOOK_DIR="$(dirname "$0")"
if xcode-select -p >/dev/null 2>&1; then if xcode-select -p >/dev/null 2>&1; then
out=$(/usr/bin/python3 "$HOOK_DIR/nb-hook.py" "$@" 2>/dev/null) out=$(/usr/bin/python3 "$HOOK_DIR/nb-hook.py" "$@" 2>/dev/null)
@@ -1890,8 +1890,8 @@ exit 0
private let nbHookPythonGitHub = """ private let nbHookPythonGitHub = """
#!/usr/bin/env python3 #!/usr/bin/env python3
# nb-hook.py — Coucou hook relay for Claude Code and third-party agents (GitHub version) # nb-hook.py — BroV hook relay for Claude Code and third-party agents (GitHub version)
# Reads JSON from stdin, forwards to Coucou via Unix socket, translates response. # Reads JSON from stdin, forwards to BroV via Unix socket, translates response.
import sys, json, os, socket import sys, json, os, socket
def normalize_event(name): def normalize_event(name):
@@ -1948,7 +1948,7 @@ def main():
'~/Library/Application Support/NotchBuddy/nb.sock' '~/Library/Application Support/NotchBuddy/nb.sock'
) )
# --statusline mode: relay rate_limits to Coucou, then delegate to saved previous # --statusline mode: relay rate_limits to BroV, then delegate to saved previous
if '--statusline' in sys.argv[1:]: if '--statusline' in sys.argv[1:]:
relay = { relay = {
'coucou_kind': 'statusline', 'coucou_kind': 'statusline',
@@ -2074,7 +2074,7 @@ def main():
# socket_path is already defined above # socket_path is already defined above
if event == 'PermissionRequest': if event == 'PermissionRequest':
# Block and wait for Coucou's decision (Claude Code allows up to 120s) # Block and wait for BroV's decision (Claude Code allows up to 120s)
try: try:
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(118) s.settimeout(118)
@@ -2115,7 +2115,7 @@ def main():
sys.stdout.flush() sys.stdout.flush()
sys.exit(0) sys.exit(0)
elif decision == 'deny': elif decision == 'deny':
out = {'hookSpecificOutput': {'hookEventName': 'PermissionRequest', 'decision': {'behavior': 'deny', 'message': 'Denied from Coucou'}}} out = {'hookSpecificOutput': {'hookEventName': 'PermissionRequest', 'decision': {'behavior': 'deny', 'message': 'Denied from BroV'}}}
sys.stdout.write(json.dumps(out) + '\\n') sys.stdout.write(json.dumps(out) + '\\n')
sys.stdout.flush() sys.stdout.flush()
sys.exit(0) sys.exit(0)
@@ -2157,7 +2157,7 @@ sys.exit(0)
private let nbHookPythonAppStore = """ private let nbHookPythonAppStore = """
#!/usr/bin/env python3 #!/usr/bin/env python3
# nb-hook.py — Coucou (App Store) hook relay for Claude Code and third-party agents # nb-hook.py — BroV (App Store) hook relay for Claude Code and third-party agents
# Socket lives inside the sandboxed container; script runs outside the sandbox. # Socket lives inside the sandboxed container; script runs outside the sandbox.
import sys, json, os, socket import sys, json, os, socket
@@ -2212,10 +2212,10 @@ def main():
return return
socket_path = os.path.expanduser( socket_path = os.path.expanduser(
'~/Library/Containers/fr.louisraille.Coucou/Data/nb.sock' '~/Library/Containers/fr.louisraille.BroV/Data/nb.sock'
) )
# --statusline mode: relay rate_limits to Coucou, then delegate to saved previous # --statusline mode: relay rate_limits to BroV, then delegate to saved previous
if '--statusline' in sys.argv[1:]: if '--statusline' in sys.argv[1:]:
relay = { relay = {
'coucou_kind': 'statusline', 'coucou_kind': 'statusline',
@@ -2340,7 +2340,7 @@ def main():
# socket_path is already defined above # socket_path is already defined above
if event == 'PermissionRequest': if event == 'PermissionRequest':
# Block and wait for Coucou's decision (Claude Code allows up to 120s) # Block and wait for BroV's decision (Claude Code allows up to 120s)
try: try:
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(118) s.settimeout(118)
@@ -2381,7 +2381,7 @@ def main():
sys.stdout.flush() sys.stdout.flush()
sys.exit(0) sys.exit(0)
elif decision == 'deny': elif decision == 'deny':
out = {'hookSpecificOutput': {'hookEventName': 'PermissionRequest', 'decision': {'behavior': 'deny', 'message': 'Denied from Coucou'}}} out = {'hookSpecificOutput': {'hookEventName': 'PermissionRequest', 'decision': {'behavior': 'deny', 'message': 'Denied from BroV'}}}
sys.stdout.write(json.dumps(out) + '\\n') sys.stdout.write(json.dumps(out) + '\\n')
sys.stdout.flush() sys.stdout.flush()
sys.exit(0) sys.exit(0)
@@ -3891,7 +3891,7 @@ struct MusicCardView: View {
.padding(.leading, 108) .padding(.leading, 108)
.padding(.trailing, 36) .padding(.trailing, 36)
Text("Allow Coucou to control Music") Text("Allow BroV to control Music")
.font(.system(size: 11)) .font(.system(size: 11))
.foregroundColor(Color(hex: "#8E939C")) .foregroundColor(Color(hex: "#8E939C"))
.padding(.leading, 108) .padding(.leading, 108)
+9 -9
View File
@@ -119,7 +119,7 @@ struct SettingsView: View {
.resizable() .resizable()
.frame(width: 32, height: 32) .frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: 1) { VStack(alignment: .leading, spacing: 1) {
Text("Coucou") Text("BroV")
.font(.system(size: 13, weight: .semibold)) .font(.system(size: 13, weight: .semibold))
Text(appVersion) Text(appVersion)
.font(.system(size: 11)) .font(.system(size: 11))
@@ -301,14 +301,14 @@ struct SettingsView: View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Toggle("Show my agent sessions on my iPhone", isOn: $iPhoneSyncEnabled) Toggle("Show my agent sessions on my iPhone", isOn: $iPhoneSyncEnabled)
.onChange(of: iPhoneSyncEnabled) { _, on in CloudProbe.shared.setEnabled(on) } .onChange(of: iPhoneSyncEnabled) { _, on in CloudProbe.shared.setEnabled(on) }
Text("Sends your sessions to your private iCloud for the Coucou iPhone app. Project names, commands and questions are encrypted with your iCloud keys. Turning it off deletes them from iCloud.") Text("Sends your sessions to your private iCloud for the BroV iPhone app. Project names, commands and questions are encrypted with your iCloud keys. Turning it off deletes them from iCloud.")
.font(.system(size: 11)) .font(.system(size: 11))
.foregroundColor(.secondary) .foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
Toggle("Move Mochi to my iPhone's Dynamic Island when my Mac is locked", isOn: $iPhoneLiveActivityEnabled) Toggle("Move Mochi to my iPhone's Dynamic Island when my Mac is locked", isOn: $iPhoneLiveActivityEnabled)
.disabled(!iPhoneSyncEnabled) .disabled(!iPhoneSyncEnabled)
.onChange(of: iPhoneLiveActivityEnabled) { _, on in LiveActivityRelay.shared.setEnabled(on) } .onChange(of: iPhoneLiveActivityEnabled) { _, on in LiveActivityRelay.shared.setEnabled(on) }
Text("Goes through the Coucou relay to Apple's push service. Only the agent's name and state are sent: no project name, command or path.") Text("Goes through the BroV relay to Apple's push service. Only the agent's name and state are sent: no project name, command or path.")
.font(.system(size: 11)) .font(.system(size: 11))
.foregroundColor(.secondary) .foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
@@ -332,7 +332,7 @@ struct SettingsView: View {
@ViewBuilder private var activePillsSection: some View { @ViewBuilder private var activePillsSection: some View {
GroupBox { GroupBox {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Text("Choose the tools you use. Coucou only shows what you declare here.") Text("Choose the tools you use. BroV only shows what you declare here.")
.font(.system(size: 11)) .font(.system(size: 11))
.foregroundColor(.secondary) .foregroundColor(.secondary)
@@ -534,7 +534,7 @@ struct SettingsView: View {
GroupBox("Plan usage") { GroupBox("Plan usage") {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Text("Shows your Claude plan usage (5-hour and weekly limits) in the notch header. Coucou adds a status line relay to ~/.claude/settings.json. If you already have a status line, it keeps working as before. Pro and Max plans only.") Text("Shows your Claude plan usage (5-hour and weekly limits) in the notch header. BroV adds a status line relay to ~/.claude/settings.json. If you already have a status line, it keeps working as before. Pro and Max plans only.")
.font(.system(size: 11)) .font(.system(size: 11))
.foregroundColor(.secondary) .foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
@@ -936,7 +936,7 @@ struct SettingsView: View {
private func installHooksAppStore() { private func installHooksAppStore() {
guard let claudeURL = pickClaudeFolder(prompt: "Select") else { return } guard let claudeURL = pickClaudeFolder(prompt: "Select") else { return }
let alert = NSAlert() let alert = NSAlert()
alert.messageText = "Install Coucou hooks in ~/.claude?" alert.messageText = "Install BroV hooks in ~/.claude?"
alert.informativeText = "Will write:\n• ~/.claude/coucou/nb-hook\n• ~/.claude/settings.json (backup created first)" alert.informativeText = "Will write:\n• ~/.claude/coucou/nb-hook\n• ~/.claude/settings.json (backup created first)"
alert.addButton(withTitle: "Install") alert.addButton(withTitle: "Install")
alert.addButton(withTitle: "Cancel") alert.addButton(withTitle: "Cancel")
@@ -1036,7 +1036,7 @@ struct SettingsView: View {
pendingGeminiJSON = try HookServer.shared.previewGeminiHooks(install: install) pendingGeminiJSON = try HookServer.shared.previewGeminiHooks(install: install)
showGeminiDiff = true showGeminiDiff = true
statusMessage = "Review the JSON below before confirming." statusMessage = "Review the JSON below before confirming."
} catch let e as NSError where e.domain == "CoucouNoop" { } catch let e as NSError where e.domain == "BroVNoop" {
statusMessage = e.localizedDescription statusMessage = e.localizedDescription
} catch { } catch {
statusMessage = "❌ \(error.localizedDescription)" statusMessage = "❌ \(error.localizedDescription)"
@@ -1063,7 +1063,7 @@ struct SettingsView: View {
pendingAgyJSON = try HookServer.shared.previewAgyHooks(install: install) pendingAgyJSON = try HookServer.shared.previewAgyHooks(install: install)
showAgyDiff = true showAgyDiff = true
statusMessage = "Review the JSON below before confirming." statusMessage = "Review the JSON below before confirming."
} catch let e as NSError where e.domain == "CoucouNoop" { } catch let e as NSError where e.domain == "BroVNoop" {
statusMessage = e.localizedDescription statusMessage = e.localizedDescription
} catch { } catch {
statusMessage = "❌ \(error.localizedDescription)" statusMessage = "❌ \(error.localizedDescription)"
@@ -1090,7 +1090,7 @@ struct SettingsView: View {
pendingCodexJSON = try HookServer.shared.previewCodexHooks(install: install) pendingCodexJSON = try HookServer.shared.previewCodexHooks(install: install)
showCodexDiff = true showCodexDiff = true
statusMessage = "Review the JSON below before confirming." statusMessage = "Review the JSON below before confirming."
} catch let e as NSError where e.domain == "CoucouNoop" { } catch let e as NSError where e.domain == "BroVNoop" {
statusMessage = e.localizedDescription statusMessage = e.localizedDescription
} catch { } catch {
statusMessage = "❌ \(error.localizedDescription)" statusMessage = "❌ \(error.localizedDescription)"
+3 -3
View File
@@ -2,7 +2,7 @@ import Foundation
// MARK: - Shortcut Action // MARK: - Shortcut Action
/// Every keyboard shortcut Coucou knows about. /// Every keyboard shortcut BroV knows about.
/// The first 10 cases are *global* (registered with Carbon and effective from any app). /// The first 10 cases are *global* (registered with Carbon and effective from any app).
/// Island-local shortcuts are documented in `ShortcutLogic.islandShortcuts` but are /// Island-local shortcuts are documented in `ShortcutLogic.islandShortcuts` but are
/// not managed here — they are handled by a local NSEvent monitor in IslandWindowController. /// not managed here — they are handled by a local NSEvent monitor in IslandWindowController.
@@ -46,7 +46,7 @@ enum ShortcutAction: String, CaseIterable, Sendable {
case .nextPill: return "Next pill" case .nextPill: return "Next pill"
case .prevPill: return "Previous pill" case .prevPill: return "Previous pill"
case .muteToggle: return "Mute / unmute sounds" case .muteToggle: return "Mute / unmute sounds"
case .desktopToggle: return "Mochi on / off desktop" case .desktopToggle: return "BroV on / off desktop"
case .wardrobeToggle: return "Open / close wardrobe" case .wardrobeToggle: return "Open / close wardrobe"
} }
} }
@@ -159,7 +159,7 @@ enum ShortcutLogic {
return c return c
} }
// MARK: - Conflict detection (within Coucou's own shortcuts) // MARK: - Conflict detection (within BroV's own shortcuts)
/// Returns the set of actions whose (keyCode, nsFlags) pair is shared with another action. /// Returns the set of actions whose (keyCode, nsFlags) pair is shared with another action.
static func duplicates(in specs: [ShortcutAction: ShortcutSpec]) -> Set<ShortcutAction> { static func duplicates(in specs: [ShortcutAction: ShortcutSpec]) -> Set<ShortcutAction> {
+215 -7
View File
@@ -79,9 +79,10 @@ enum MochiConst {
static let eyeH: CGFloat = 0.27 static let eyeH: CGFloat = 0.27
static let eyeSp: CGFloat = 0.37 static let eyeSp: CGFloat = 0.37
static let eyeP: CGFloat = -0.12 static let eyeP: CGFloat = -0.12
static let baseTop = CGColor(red: 0.929, green: 0.929, blue: 0.937, alpha: 1) // #EDEDEF // BroV: swamp-zombie skin and dried-blood ink.
static let baseBottom = CGColor(red: 0.769, green: 0.773, blue: 0.792, alpha: 1) // #C4C5CA static let baseTop = CGColor(red: 0.659, green: 0.749, blue: 0.416, alpha: 1) // #A8BF6A
static let ink = CGColor(red: 0.102, green: 0.082, blue: 0.071, alpha: 1) // #1A1412 static let baseBottom = CGColor(red: 0.357, green: 0.451, blue: 0.251, alpha: 1) // #5B7340
static let ink = CGColor(red: 0.165, green: 0.039, blue: 0.039, alpha: 1) // #2A0A0A
static let miniInk = CGColor(red: 0.063, green: 0.075, blue: 0.102, alpha: 1) // #10131A static let miniInk = CGColor(red: 0.063, green: 0.075, blue: 0.102, alpha: 1) // #10131A
} }
@@ -916,9 +917,13 @@ final class BotEngine: ObservableObject {
drawBlush(ctx: &ctx, path: bodyPath, rx: rx, ry: ry, R: R, blush: blushVal) drawBlush(ctx: &ctx, path: bodyPath, rx: rx, ry: ry, R: R, blush: blushVal)
} }
if showsBroVFace, R > 11 { drawBroVSkin(ctx: ctx, path: bodyPath, R: R, rx: rx, ry: ry) }
// Eyes // Eyes
drawEyes(ctx: &ctx, path: bodyPath, R: R, rx: rx, ry: ry) drawEyes(ctx: &ctx, path: bodyPath, R: R, rx: rx, ry: ry)
if showsBroVFace, R > 11 { drawBroVFace(ctx: ctx, path: bodyPath, R: R, rx: rx, ry: ry) }
// Mouth hole — dark pill cutout inside the box face // Mouth hole — dark pill cutout inside the box face
// Spec: left/right margins 0.10R, top margin 0.08R from box top (-0.94R) // Spec: left/right margins 0.10R, top margin 0.08R from box top (-0.94R)
if morph > 0.05 { if morph > 0.05 {
@@ -1135,12 +1140,14 @@ final class BotEngine: ObservableObject {
let tw = R * 1.0 let tw = R * 1.0
let th = R * 0.94 let th = R * 0.94
let tr = R * 0.42 let tr = R * 0.42
let lumpTime = CGFloat(CACurrentMediaTime())
var path = Path() var path = Path()
for i in 0...n { for i in 0...n {
let a = CGFloat(i) / CGFloat(n) * .pi * 2 let a = CGFloat(i) / CGFloat(n) * .pi * 2
let ca = cos(a), sa = sin(a) let ca = cos(a), sa = sin(a)
let px0 = rx * (ca >= 0 ? pow(ca, expN) : -pow(-ca, expN)) let lump = broVLump(angle: a, time: lumpTime)
let py0 = ry * (sa >= 0 ? pow(sa, expN) : -pow(-sa, expN)) let px0 = rx * lump * (ca >= 0 ? pow(ca, expN) : -pow(-ca, expN))
let py0 = ry * lump * (sa >= 0 ? pow(sa, expN) : -pow(-sa, expN))
let px: CGFloat let px: CGFloat
let py: CGFloat let py: CGFloat
if morph < 0.005 { if morph < 0.005 {
@@ -1205,8 +1212,8 @@ final class BotEngine: ObservableObject {
ctx.fill(path, with: .color(Color(cgColor: bc))) ctx.fill(path, with: .color(Color(cgColor: bc)))
} else { } else {
// Main bot: linear gradient body // Main bot: linear gradient body
let top: Color = pumpkinColors ? Color(hex: "#FFA94D") : Color(red: 0.929, green: 0.929, blue: 0.937) let top: Color = pumpkinColors ? Color(hex: "#FFA94D") : Color(cgColor: MochiConst.baseTop)
let bot: Color = pumpkinColors ? Color(hex: "#E8590C") : Color(red: 0.769, green: 0.773, blue: 0.792) let bot: Color = pumpkinColors ? Color(hex: "#E8590C") : Color(cgColor: MochiConst.baseBottom)
ctx.fill(path, with: .linearGradient( ctx.fill(path, with: .linearGradient(
Gradient(colors: [top, bot]), Gradient(colors: [top, bot]),
startPoint: CGPoint(x: rx*0.7, y: -ry*0.85), startPoint: CGPoint(x: rx*0.7, y: -ry*0.85),
@@ -1307,6 +1314,9 @@ final class BotEngine: ObservableObject {
case .wide: case .wide:
drawEyeShape(ctx: &ctx, shape: .pill, w: w*1.16, h: h*1.12, open: open, sd: sd, R: R) drawEyeShape(ctx: &ctx, shape: .pill, w: w*1.16, h: h*1.12, open: open, sd: sd, R: R)
case .pill where showsBroVFace:
drawBroVEye(ctx: &ctx, w: w, h: h, open: open, sd: sd, ink: ink)
case .pill: case .pill:
let hh = max(h * open, w * 0.3) let hh = max(h * open, w * 0.3)
var p = Path() var p = Path()
@@ -1657,3 +1667,201 @@ extension BadgeType: Equatable {
} }
} }
} }
// MARK: - BroV face
//
// BroV ("бровь") is a lumpy swamp-zombie blob: one huge hairy unibrow, a bloodshot
// mismatched pair of eyes, a toothy grin with missing teeth, drool, warts and a
// stitched scar. Everything rides on the same engine (blink, gaze, emotes, states);
// mini bots and the upload box keep the plain look.
extension BotEngine {
/// Main bot only, and not while morphing into the upload box.
var showsBroVFace: Bool { !isMini && bodyColor == nil && morph < 0.3 && outfit != .pumpkin }
/// Slow, breathing bumps on the outline (main bot only).
func broVLump(angle a: CGFloat, time t: CGFloat) -> CGFloat {
guard !isMini, bodyColor == nil else { return 1 }
let k = 1 - min(1, morph * 2)
let bumps = 0.045 * sin(3 * a + t * 1.1)
+ 0.030 * sin(5 * a - t * 0.7 + 1.3)
+ 0.018 * sin(9 * a + 2.1)
return 1 + bumps * k
}
/// Warts and the stitched scar (under the eyes).
func drawBroVSkin(ctx: GraphicsContext, path: Path, R: CGFloat, rx: CGFloat, ry: CGFloat) {
var c = ctx
c.clip(to: path)
let shift = sin(yaw) * rx * 0.8
let a = 1 - morph / 0.3
// Warts: darker bumps with a tiny highlight.
let warts: [(CGFloat, CGFloat, CGFloat)] = [(-0.78, 0.30, 0.085), (0.70, 0.52, 0.065), (-0.30, 0.68, 0.05), (0.85, -0.10, 0.045)]
for (wx, wy, wr) in warts {
let p = CGPoint(x: wx * rx + shift, y: wy * ry)
var e = Path(); e.addEllipse(in: CGRect(x: p.x - wr * R, y: p.y - wr * R, width: wr * 2 * R, height: wr * 2 * R))
c.fill(e, with: .color(Color(hex: "#3F5229").opacity(0.85 * Double(a))))
var h = Path(); h.addEllipse(in: CGRect(x: p.x - wr * R * 0.35, y: p.y - wr * R * 0.6, width: wr * R * 0.5, height: wr * R * 0.4))
c.fill(h, with: .color(Color.white.opacity(0.25 * Double(a))))
}
// Stitched scar across the upper right of the head.
let s0 = CGPoint(x: rx * 0.20 + shift, y: -ry * 0.78)
let s1 = CGPoint(x: rx * 0.72 + shift, y: -ry * 0.40)
var scar = Path(); scar.move(to: s0)
scar.addQuadCurve(to: s1, control: CGPoint(x: rx * 0.55 + shift, y: -ry * 0.75))
c.stroke(scar, with: .color(Color(hex: "#6B2B2B").opacity(Double(a))), style: StrokeStyle(lineWidth: R * 0.05, lineCap: .round))
for i in 1...4 {
let t = CGFloat(i) / 5
let mx = (1 - t) * (1 - t) * s0.x + 2 * (1 - t) * t * (rx * 0.55 + shift) + t * t * s1.x
let my = (1 - t) * (1 - t) * s0.y + 2 * (1 - t) * t * (-ry * 0.75) + t * t * s1.y
var st = Path()
st.move(to: CGPoint(x: mx - R * 0.06, y: my - R * 0.07))
st.addLine(to: CGPoint(x: mx + R * 0.06, y: my + R * 0.07))
c.stroke(st, with: .color(Color(hex: "#2A0A0A").opacity(0.8 * Double(a))), style: StrokeStyle(lineWidth: R * 0.025, lineCap: .round))
}
}
/// Bloodshot sclera + small twitchy pupil. The left eye is bigger.
func drawBroVEye(ctx: inout GraphicsContext, w: CGFloat, h: CGFloat, open: CGFloat, sd: CGFloat, ink: Color) {
let big: CGFloat = sd < 0 ? 1.28 : 0.92
let sw = w * 1.75 * big
let sh = max(h * 1.45 * big * open, w * 0.25)
let sclera = Path(ellipseIn: CGRect(x: -sw / 2, y: -sh / 2, width: sw, height: sh))
ctx.fill(sclera, with: .radialGradient(
Gradient(colors: [Color(hex: "#F4EFC8"), Color(hex: "#E2C98E"), Color(hex: "#C9785F")]),
center: .zero, startRadius: 0, endRadius: sw * 0.6))
guard open > 0.25 else {
ctx.stroke(sclera, with: .color(ink), lineWidth: w * 0.12)
return
}
var inner = ctx
inner.clip(to: sclera)
// Veins from the edge toward the pupil.
for i in 0..<6 {
let ang = CGFloat(i) * 1.05 + sd * 0.4
var v = Path()
let r0 = sw * 0.52, r1 = sw * 0.2
v.move(to: CGPoint(x: cos(ang) * r0, y: sin(ang) * r0 * sh / sw))
v.addQuadCurve(to: CGPoint(x: cos(ang + 0.15) * r1, y: sin(ang + 0.15) * r1 * sh / sw),
control: CGPoint(x: cos(ang - 0.25) * sw * 0.36, y: sin(ang - 0.25) * sw * 0.36 * sh / sw))
inner.stroke(v, with: .color(Color(hex: "#C2182B").opacity(0.75)), lineWidth: max(0.6, w * 0.06))
}
// Pupil with a jittery twitch.
let t = CGFloat(CACurrentMediaTime())
let jx = sin(t * 23 + sd * 3) * w * 0.04
let jy = cos(t * 19 + sd) * w * 0.03
let pr = w * 0.42 * (sd < 0 ? 0.8 : 1.15)
inner.fill(Path(ellipseIn: CGRect(x: -pr + jx, y: -pr + jy, width: pr * 2, height: pr * 2)), with: .color(ink))
inner.fill(Path(ellipseIn: CGRect(x: -pr * 0.3 + jx + pr * 0.35, y: -pr * 0.3 + jy - pr * 0.35, width: pr * 0.5, height: pr * 0.5)),
with: .color(Color.white.opacity(0.85)))
ctx.stroke(sclera, with: .color(Color(hex: "#3A1010").opacity(0.7)), lineWidth: max(0.8, w * 0.07))
}
/// Unibrow above the eyes and the toothy mouth below.
func drawBroVFace(ctx: GraphicsContext, path: Path, R: CGFloat, rx: CGFloat, ry: CGFloat) {
var c = ctx
c.clip(to: path)
let alpha = Double(1 - morph / 0.3)
let shift = sin(yaw) * rx * 0.8
let t = CGFloat(CACurrentMediaTime())
let eyeY = -sin(MochiConst.eyeP + pitch) * ry
let ink = Color(cgColor: MochiConst.ink).opacity(alpha)
// ── Unibrow ───────────────────────────────────────────────
// Shape by mood: angry V, worried ∧, raised on questions, droopy asleep.
let mid: CGFloat // center offset (positive = lower)
let tilt: CGFloat // extra raise of the right end
switch state {
case .approval, .error: mid = 0.10; tilt = 0
case .ratelimit: mid = -0.08; tilt = 0
case .question, .searching: mid = 0; tilt = -0.14
case .sleeping: mid = -0.02; tilt = 0.06
case .dizzy: mid = sin(t * 6) * 0.08; tilt = cos(t * 5) * 0.08
default: mid = 0.02 + sin(t * 1.7) * 0.015; tilt = 0
}
let by = eyeY - R * 0.42
let half = R * 0.78
let L = CGPoint(x: -half + shift, y: by - R * 0.04)
let M = CGPoint(x: shift, y: by + mid * R)
let Rt = CGPoint(x: half + shift, y: by - R * 0.04 + tilt * R)
var brow = Path()
brow.move(to: L)
brow.addQuadCurve(to: M, control: CGPoint(x: (L.x + M.x) / 2, y: min(L.y, M.y) - R * 0.07))
brow.addQuadCurve(to: Rt, control: CGPoint(x: (M.x + Rt.x) / 2, y: min(M.y, Rt.y) - R * 0.07))
c.stroke(brow, with: .color(ink), style: StrokeStyle(lineWidth: R * 0.17, lineCap: .round, lineJoin: .round))
// Hairs sticking out.
for i in 0..<22 {
let u = CGFloat(i) / 21
let p = u < 0.5
? quad(L, CGPoint(x: (L.x + M.x) / 2, y: min(L.y, M.y) - R * 0.07), M, u * 2)
: quad(M, CGPoint(x: (M.x + Rt.x) / 2, y: min(M.y, Rt.y) - R * 0.07), Rt, (u - 0.5) * 2)
let ang = -CGFloat.pi / 2 + sin(CGFloat(i) * 2.7) * 0.7
let len = R * (0.10 + 0.05 * abs(sin(CGFloat(i) * 1.9)))
var hair = Path()
hair.move(to: p)
hair.addLine(to: CGPoint(x: p.x + cos(ang) * len, y: p.y + sin(ang) * len))
c.stroke(hair, with: .color(ink), style: StrokeStyle(lineWidth: max(0.7, R * 0.025), lineCap: .round))
}
// ── Mouth ─────────────────────────────────────────────────
let my = eyeY + R * 0.42
let mw: CGFloat
let mh: CGFloat
switch state {
case .approval: mw = R * 0.95; mh = R * 0.34 // screaming
case .error: mw = R * 0.70; mh = R * 0.10
case .finished: mw = R * 1.00; mh = R * 0.26
case .sleeping: mw = R * 0.30; mh = R * 0.14
case .working, .thinking, .searching:
mw = R * 0.80; mh = R * (0.12 + 0.05 * abs(sin(t * 7))) // chattering
default: mw = R * 0.88; mh = R * 0.20
}
let mx = shift
var mouth = Path()
mouth.move(to: CGPoint(x: mx - mw / 2, y: my - mh * 0.2))
mouth.addQuadCurve(to: CGPoint(x: mx + mw / 2, y: my - mh * 0.2), control: CGPoint(x: mx, y: my + mh * 0.25))
mouth.addQuadCurve(to: CGPoint(x: mx - mw / 2, y: my - mh * 0.2), control: CGPoint(x: mx, y: my + mh * 1.6))
mouth.closeSubpath()
c.fill(mouth, with: .color(Color(hex: "#3A0505").opacity(alpha)))
var teethCtx = c
teethCtx.clip(to: mouth)
let teeth = 7
for i in 0..<teeth where i != 2 && i != 5 { // missing teeth
let tx = mx - mw / 2 + mw * (CGFloat(i) + 0.5) / CGFloat(teeth)
let tw = mw / CGFloat(teeth) * 0.9
let ty = my - mh * 0.2 + (abs(tx - mx) / (mw / 2)) * -mh * 0.05 + mh * 0.12
var tooth = Path()
tooth.move(to: CGPoint(x: tx - tw / 2, y: ty - mh))
tooth.addLine(to: CGPoint(x: tx + tw / 2, y: ty - mh))
tooth.addLine(to: CGPoint(x: tx + tw * 0.1, y: ty + mh * 0.32))
tooth.closeSubpath()
teethCtx.fill(tooth, with: .color(Color(hex: i == 4 ? "#C9B458" : "#EDE6C8").opacity(alpha)))
}
c.stroke(mouth, with: .color(ink), lineWidth: max(0.8, R * 0.03))
// Drool from the left corner: grows, stretches, drops, repeats.
if state != .error {
let cycle = (t * 0.45).truncatingRemainder(dividingBy: 1)
let len = R * 0.35 * cycle
let dx = mx - mw * 0.32
let dy = my + mh * 0.25
var drool = Path()
drool.move(to: CGPoint(x: dx - R * 0.03, y: dy))
drool.addQuadCurve(to: CGPoint(x: dx, y: dy + len), control: CGPoint(x: dx - R * 0.02, y: dy + len * 0.6))
drool.addQuadCurve(to: CGPoint(x: dx + R * 0.03, y: dy), control: CGPoint(x: dx + R * 0.02, y: dy + len * 0.6))
ctx.fill(drool, with: .color(Color(hex: "#C8E6B0").opacity(0.75 * alpha)))
let dr = R * (0.035 + 0.03 * cycle)
ctx.fill(Path(ellipseIn: CGRect(x: dx - dr, y: dy + len - dr * 0.6, width: dr * 2, height: dr * 2.2)),
with: .color(Color(hex: "#C8E6B0").opacity(0.8 * alpha)))
}
}
private func quad(_ a: CGPoint, _ c: CGPoint, _ b: CGPoint, _ t: CGFloat) -> CGPoint {
let u = 1 - t
return CGPoint(x: u * u * a.x + 2 * u * t * c.x + t * t * b.x,
y: u * u * a.y + 2 * u * t * c.y + t * t * b.y)
}
}
+150
View File
@@ -0,0 +1,150 @@
// RenderBroV.swift (pass any argument to render the app + menu bar icons instead) — renders the real BotEngine in every state to a PNG.
// Run: bash scripts/render-brov.sh → /tmp/brov-states.png
import Foundation
import SwiftUI
import AppKit
// MARK: - Stubs (substitutes for app-only types)
@MainActor
final class SoundEngine {
static let shared = SoundEngine()
var enabled: Bool = false
func play(_ name: String) {}
}
extension Notification.Name {
static let botDizzy = Notification.Name("notchBuddy.botDizzy")
static let botGreet = Notification.Name("notchBuddy.botGreet")
static let botBlink = Notification.Name("notchBuddy.botBlink")
static let botSetTgEs = Notification.Name("notchBuddy.botSetTgEs")
static let botGulp = Notification.Name("notchBuddy.botGulp")
static let botMorphTo = Notification.Name("notchBuddy.botMorphTo")
static let triggerEmote = Notification.Name("notchBuddy.triggerEmote")
static let triggerSlap = Notification.Name("notchBuddy.triggerSlap")
static let greetComplete = Notification.Name("notchBuddy.greetComplete")
static let greetingHover = Notification.Name("notchBuddy.greetingHover")
static let greetingInterrupt = Notification.Name("notchBuddy.greetingInterrupt")
static let islandAction = Notification.Name("notchBuddy.islandAction")
static let islandCollapse = Notification.Name("notchBuddy.islandCollapse")
static let hookReveal = Notification.Name("notchBuddy.hookReveal")
static let musicReveal = Notification.Name("notchBuddy.musicReveal")
static let openFullSettings = Notification.Name("notchBuddy.openFullSettings")
}
extension Color {
init(hex: String) {
let h = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
let val = UInt64(h, radix: 16) ?? 0
let r = Double((val >> 16) & 0xFF) / 255
let g = Double((val >> 8) & 0xFF) / 255
let b = Double( val & 0xFF) / 255
self.init(red: r, green: g, blue: b)
}
}
struct StateCell: View {
let engine: BotEngine
let label: String
let size: CGFloat
var body: some View {
VStack(spacing: 2) {
Canvas { ctx, sz in
engine.drawHandsBehind(context: ctx, size: sz)
engine.draw(context: ctx, size: sz)
engine.drawHandsAndExtras(context: ctx, size: sz)
}
.frame(width: size, height: size)
Text(label).font(.system(size: 10, design: .monospaced)).foregroundColor(.gray)
}
}
}
@main
struct RenderBroV {
@MainActor static func main() {
if CommandLine.arguments.count > 1 { renderIcons(); return }
var cells: [(BotEngine, String, CGFloat)] = []
for st in BotState.allCases {
let e = BotEngine()
e.setState(st, force: true)
for _ in 0..<90 { e.update(dt: 1.0 / 60) }
cells.append((e, st.rawValue, 170))
}
let small = BotEngine()
for _ in 0..<60 { small.update(dt: 1.0 / 60) }
cells.append((small, "notch-size", 48))
let view = VStack(alignment: .leading, spacing: 8) {
ForEach(0..<3, id: \.self) { row in
HStack(spacing: 8) {
ForEach(Array(cells.enumerated()).filter { $0.offset / 4 == row }, id: \.offset) { _, c in
StateCell(engine: c.0, label: c.1, size: c.2)
}
}
}
}
.padding(16)
.background(Color(red: 0.04, green: 0.04, blue: 0.05))
let r = ImageRenderer(content: view)
r.scale = 2
guard let img = r.nsImage, let tiff = img.tiffRepresentation,
let rep = NSBitmapImageRep(data: tiff), let png = rep.representation(using: .png, properties: [:]) else {
print("render failed"); return
}
try! png.write(to: URL(fileURLWithPath: "/tmp/brov-states.png"))
print("wrote /tmp/brov-states.png")
}
}
@MainActor
func savePNG<V: View>(_ view: V, scale: CGFloat, to path: String) {
let r = ImageRenderer(content: view)
r.scale = scale
r.isOpaque = false
guard let cg = r.cgImage else { print("render failed"); return }
let rep = NSBitmapImageRep(cgImage: cg)
try! rep.representation(using: .png, properties: [:])!.write(to: URL(fileURLWithPath: path))
print("wrote", path)
}
/// /tmp/brov-icon-1024.png (app icon) and /tmp/brov-menubar-{1,2,3}x.png (template unibrow).
@MainActor
func renderIcons() {
let e = BotEngine()
e.setState(.approval, force: true)
for _ in 0..<90 { e.update(dt: 1.0 / 60) }
let icon = ZStack {
RoundedRectangle(cornerRadius: 185, style: .continuous)
.fill(LinearGradient(colors: [Color(hex: "#2B1B2E"), Color(hex: "#0B0A10")], startPoint: .top, endPoint: .bottom))
.frame(width: 824, height: 824)
Canvas { ctx, sz in
e.draw(context: ctx, size: sz)
}
.frame(width: 900, height: 900)
.offset(y: -20)
}
.frame(width: 1024, height: 1024)
savePNG(icon, scale: 1, to: "/tmp/brov-icon-1024.png")
// Menu bar: a hairy unibrow over two eyes, black on clear (used as a template image).
let bar = Canvas { ctx, sz in
let w = sz.width, h = sz.height
var brow = Path()
brow.move(to: CGPoint(x: w * 0.06, y: h * 0.42))
brow.addQuadCurve(to: CGPoint(x: w * 0.5, y: h * 0.40), control: CGPoint(x: w * 0.28, y: h * 0.12))
brow.addQuadCurve(to: CGPoint(x: w * 0.94, y: h * 0.42), control: CGPoint(x: w * 0.72, y: h * 0.12))
ctx.stroke(brow, with: .color(.black), style: StrokeStyle(lineWidth: h * 0.2, lineCap: .round))
for i in 0..<9 {
let x = w * (0.12 + 0.76 * CGFloat(i) / 8)
var hair = Path()
hair.move(to: CGPoint(x: x, y: h * 0.28))
hair.addLine(to: CGPoint(x: x + w * 0.02, y: h * 0.10))
ctx.stroke(hair, with: .color(.black), style: StrokeStyle(lineWidth: h * 0.07, lineCap: .round))
}
ctx.fill(Path(ellipseIn: CGRect(x: w * 0.20, y: h * 0.56, width: w * 0.20, height: h * 0.34)), with: .color(.black))
ctx.fill(Path(ellipseIn: CGRect(x: w * 0.60, y: h * 0.60, width: w * 0.15, height: h * 0.26)), with: .color(.black))
}
.frame(width: 22, height: 16)
for s in [1, 2, 3] { savePNG(bar, scale: CGFloat(s), to: "/tmp/brov-menubar-\(s)x.png") }
}
+10
View File
@@ -0,0 +1,10 @@
#!/bin/bash
# Render BroV in every state → /tmp/brov-states.png
set -e
cd "$(dirname "$0")/.."
K=NotchBuddy/Sources/CoucouKit
swiftc -parse-as-library -target arm64-apple-macosx15.0 \
$K/IslandScreenGeometry.swift $K/IslandTypes.swift $K/MochiWardrobe.swift \
$K/BotEngine.swift $K/MochiOutfitDrawing.swift scripts/RenderBroV.swift \
-framework AppKit -framework SwiftUI -o /tmp/brov-render
/tmp/brov-render "$@"