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>
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 340 KiB After Width: | Height: | Size: 412 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 517 B |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 987 B |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -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
|
||||||
|
|||||||
@@ -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) \
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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") }
|
||||||
|
}
|
||||||
@@ -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 "$@"
|
||||||