BroV: single Mac target, chat brain = local claude CLI (stream-json, resume)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
maksarsanjeev
2026-10-06 19:49:32 +03:00
parent 53b4f60da1
commit 0f77ec8704
25 changed files with 471 additions and 1007 deletions
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.automation.apple-events</key>
<true/>
</dict>
</plist>
@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.files.bookmarks.app-scope</key>
<true/>
</dict>
</plist>
@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.files.bookmarks.app-scope</key>
<true/>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.fr.louisraille.Coucou</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.aps-environment</key>
<string>development</string>
</dict>
</plist>
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.fr.louisraille.Coucou</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.aps-environment</key>
<string>development</string>
</dict>
</plist>
@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.fr.louisraille.Coucou</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.fr.louisraille.Coucou</string>
</array>
</dict>
</plist>
@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.fr.louisraille.Coucou</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.icloud-container-environment</key>
<string>Production</string>
<key>com.apple.security.application-groups</key>
<array>
<string>group.fr.louisraille.Coucou</string>
</array>
</dict>
</plist>
@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.application-identifier</key>
<string>256AUJ9555.fr.louisraille.NotchBuddy</string>
<key>com.apple.developer.team-identifier</key>
<string>256AUJ9555</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.fr.louisraille.Coucou</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.icloud-container-environment</key>
<string>Production</string>
<key>com.apple.developer.aps-environment</key>
<string>production</string>
</dict>
</plist>
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.fr.louisraille.Coucou</string>
</array>
</dict>
</plist>
+7 -7
View File
@@ -5,32 +5,32 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Coucou</string>
<string>BroV</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>fr.louisraille.NotchBuddy</string>
<string>local.maksar.BroV</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Coucou</string>
<string>BroV</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.9</string>
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>10</string>
<string>1</string>
<key>LSUIElement</key>
<true/>
<key>NSAccessibilityUsageDescription</key>
<string>Pour lire le titre de la fenêtre active et l'attacher comme contexte.</string>
<string>BroV reads the front window's title to attach it as context.</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSAppleEventsUsageDescription</key>
<string>Pour sauter au terminal, envoyer des mails et piloter Musique.</string>
<string>BroV jumps to your terminal, sends mail and controls Music.</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSSupportsAutomaticGraphicsSwitching</key>
-39
View File
@@ -1,39 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Coucou</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>fr.louisraille.Coucou</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Coucou</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.1</string>
<key>CFBundleVersion</key>
<string>5</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationCategoryType</key>
<string>public.app-category.developer-tools</string>
<key>LSUIElement</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSSupportsAutomaticGraphicsSwitching</key>
<true/>
</dict>
</plist>
@@ -1,40 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Coucou</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>fr.louisraille.Coucou.NotificationContent</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>CoucouNotificationContent</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>3</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>UNNotificationExtensionCategory</key>
<string>COUCOU_APPROVAL</string>
<key>UNNotificationExtensionDefaultContentHidden</key>
<true/>
<key>UNNotificationExtensionInitialContentSizeRatio</key>
<real>0.45000000000000001</real>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.content-extension</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationViewController</string>
</dict>
</dict>
</plist>
-67
View File
@@ -1,67 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Coucou</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>fr.louisraille.Coucou</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Coucou</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>fr.louisraille.Coucou</string>
<key>CFBundleURLSchemes</key>
<array>
<string>coucou</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>3</string>
<key>CoucouAPNsEnvironment</key>
<string>$(COUCOU_APNS_ENV)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSFaceIDUsageDescription</key>
<string>Coucou asks for Face ID before allowing a command from your iPhone.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Coucou uses the microphone when you dictate an instruction for Claude Code.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Coucou turns your voice into the text of an instruction for Claude Code.</string>
<key>NSSupportsLiveActivities</key>
<true/>
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
-29
View File
@@ -1,29 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Coucou</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>fr.louisraille.Coucou.Widgets</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>CoucouWidgets</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>3</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
+26 -2
View File
@@ -89,9 +89,23 @@ final class AppState: ObservableObject {
}
// In-chat provider + model — picked via the model selector in the prompt view
@Published var chatProvider: ChatProvider = .anthropic {
@Published var chatProvider: ChatProvider = .claudeCode {
didSet { UserDefaults.standard.set(chatProvider.rawValue, forKey: "chatProvider") }
}
// Claude Code CLI chat (BroV): model alias ("" = CLI default), folder it runs in,
// optional path to the binary, and the permission mode passed to `claude`.
@Published var claudeCodeModel: String = "" {
didSet { UserDefaults.standard.set(claudeCodeModel, forKey: "claudeCodeModel") }
}
@Published var chatWorkingDirectory: String = "~" {
didSet { UserDefaults.standard.set(chatWorkingDirectory, forKey: "chatWorkingDirectory") }
}
@Published var claudeBinaryPath: String = "" {
didSet { UserDefaults.standard.set(claudeBinaryPath, forKey: "claudeBinaryPath") }
}
@Published var claudePermissionMode: String = "default" {
didSet { UserDefaults.standard.set(claudePermissionMode, forKey: "claudePermissionMode") }
}
@Published var googleChatModel: String = ChatProvider.google.defaultModel {
didSet { UserDefaults.standard.set(googleChatModel, forKey: "googleChatModel") }
}
@@ -126,6 +140,10 @@ final class AppState: ObservableObject {
func fetchModelsIfNeeded(for provider: ChatProvider) {
guard !loadingProviderModels.contains(provider),
fetchedProviderModels[provider] == nil else { return }
if provider == .claudeCode {
fetchedProviderModels[provider] = ClaudeCodeCLI.models
return
}
// Local providers: fetch from server URL (no API key needed)
if provider.isLocal {
let baseURL = provider == .ollama ? ollamaServerURL : lmstudioServerURL
@@ -170,6 +188,7 @@ final class AppState: ObservableObject {
Task {
let models: [(id: String, label: String)]
switch provider {
case .claudeCode: models = ClaudeCodeCLI.models
case .anthropic: models = await ClaudeService.fetchModels(apiKey: apiKey)
case .google: models = await ClaudeService.fetchGoogleModels(apiKey: apiKey)
case .openai: models = await ClaudeService.fetchOpenAIModels(apiKey: apiKey)
@@ -193,7 +212,7 @@ final class AppState: ObservableObject {
if !models.contains(where: { $0.id == openAIChatModel }) {
openAIChatModel = models.first(where: { $0.id.contains("mini") })?.id ?? models.first!.id
}
case .ollama, .lmstudio: break
case .ollama, .lmstudio, .claudeCode: break
}
}
}
@@ -202,6 +221,7 @@ final class AppState: ObservableObject {
/// The model currently active for chat (provider-aware).
var activeChatModel: String {
switch chatProvider {
case .claudeCode: return claudeCodeModel
case .anthropic: return claudeModel
case .google: return googleChatModel
case .openai: return openAIChatModel
@@ -409,6 +429,10 @@ final class AppState: ObservableObject {
if let v = ud.string(forKey: "claudeModel"),
!v.trimmingCharacters(in: .whitespaces).isEmpty { claudeModel = v }
if let v = ud.string(forKey: "chatProvider"), let p = ChatProvider(rawValue: v) { chatProvider = p }
if let v = ud.string(forKey: "claudeCodeModel") { claudeCodeModel = v }
if let v = ud.string(forKey: "chatWorkingDirectory"), !v.isEmpty { chatWorkingDirectory = v }
if let v = ud.string(forKey: "claudeBinaryPath") { claudeBinaryPath = v }
if let v = ud.string(forKey: "claudePermissionMode"), !v.isEmpty { claudePermissionMode = v }
if let v = ud.string(forKey: "googleChatModel"), !v.isEmpty { googleChatModel = v }
if let v = ud.string(forKey: "openAIChatModel"), !v.isEmpty { openAIChatModel = v }
if let v = ud.string(forKey: "ollamaChatModel"), !v.isEmpty { ollamaChatModel = v }
+283
View File
@@ -0,0 +1,283 @@
import Foundation
// MARK: - Claude Code CLI as the chat brain
//
// Every message runs the user's own `claude` binary headless:
// claude -p <text> --output-format stream-json --verbose --include-partial-messages
// [--resume <session id>] [--model <alias>]
// in the chat folder (Settings → Chat), on the user's Claude subscription — no API key.
// The session id from the first reply is kept, so the next message continues the same
// conversation; "New conversation" forgets it. MCP servers, skills, CLAUDE.md and memory
// all come along because it is the real CLI. Tool permissions go through the usual
// Claude Code hooks, so they show up as Allow / Deny cards in the notch.
enum ClaudeCodeCLIError: LocalizedError {
case notFound
case failed(String)
var errorDescription: String? {
switch self {
case .notFound:
"Claude Code CLI not found. Install it, or set its path in Settings → Chat."
case .failed(let message):
message
}
}
}
@MainActor
final class ClaudeCodeCLI {
static let shared = ClaudeCodeCLI()
/// Models offered in the picker. Empty id = whatever the CLI is configured to use.
static let models: [(id: String, label: String)] = [
("", "Default"),
("opus", "Opus"),
("sonnet", "Sonnet"),
("haiku", "Haiku"),
]
private(set) var sessionID: String?
private var process: Process?
var isRunning: Bool { process?.isRunning == true }
func newConversation() {
cancel()
sessionID = nil
}
func cancel() {
if let p = process, p.isRunning { p.terminate() }
process = nil
}
// MARK: Locating the binary
/// The `claude` binary: the path from Settings when set, else the usual install spots.
/// GUI apps don't get the shell's PATH, so we look ourselves.
nonisolated static func resolveBinary(custom: String) -> String? {
let fm = FileManager.default
let trimmed = (custom as NSString).expandingTildeInPath
.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return fm.isExecutableFile(atPath: trimmed) ? trimmed : nil }
let home = fm.homeDirectoryForCurrentUser.path
let candidates = [
"\(home)/.local/bin/claude",
"\(home)/.claude/local/claude",
"/opt/homebrew/bin/claude",
"/usr/local/bin/claude",
"\(home)/.npm-global/bin/claude",
]
return candidates.first { fm.isExecutableFile(atPath: $0) }
}
/// The child's environment: ours, minus the markers that make `claude` refuse to start
/// inside another Claude Code session, plus the common bin folders on PATH so MCP
/// servers (node, uvx, python…) resolve like they do in a terminal.
nonisolated static func childEnvironment() -> [String: String] {
var env = ProcessInfo.processInfo.environment
env.removeValue(forKey: "CLAUDECODE")
env.removeValue(forKey: "CLAUDE_CODE_ENTRYPOINT")
let home = FileManager.default.homeDirectoryForCurrentUser.path
let extra = ["\(home)/.local/bin", "/opt/homebrew/bin", "/opt/homebrew/sbin",
"/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]
var parts = (env["PATH"] ?? "").split(separator: ":").map(String.init)
for dir in extra where !parts.contains(dir) { parts.append(dir) }
env["PATH"] = parts.joined(separator: ":")
return env
}
// MARK: Running a turn
/// Sends `prompt` and streams the visible reply through `onUpdate` (the full text so far).
/// Returns the final text. Throws on a missing binary, a crash or a CLI-reported error.
func send(prompt: String,
model: String,
workingDirectory: String,
binaryPath: String,
permissionMode: String,
onUpdate: @escaping @MainActor (String) -> Void) async throws -> String {
cancel()
guard let binary = Self.resolveBinary(custom: binaryPath) else { throw ClaudeCodeCLIError.notFound }
var args = ["-p", prompt,
"--output-format", "stream-json",
"--verbose",
"--include-partial-messages",
"--append-system-prompt", Self.notchPrompt]
if let sessionID { args += ["--resume", sessionID] }
if !model.isEmpty { args += ["--model", model] }
if !permissionMode.isEmpty, permissionMode != "default" { args += ["--permission-mode", permissionMode] }
let p = Process()
p.executableURL = URL(fileURLWithPath: binary)
p.arguments = args
p.environment = Self.childEnvironment()
let dir = (workingDirectory as NSString).expandingTildeInPath
var isDir: ObjCBool = false
p.currentDirectoryURL = FileManager.default.fileExists(atPath: dir, isDirectory: &isDir) && isDir.boolValue
? URL(fileURLWithPath: dir)
: FileManager.default.homeDirectoryForCurrentUser
p.standardInput = FileHandle.nullDevice
let out = Pipe(), err = Pipe()
p.standardOutput = out
p.standardError = err
process = p
let stream = AsyncStream<String> { continuation in
let handle = out.fileHandleForReading
Task.detached {
do {
for try await line in handle.bytes.lines { continuation.yield(line) }
} catch {}
continuation.finish()
}
}
try p.run()
var parser = StreamParser()
for await line in stream {
if let update = parser.consume(line) {
if let id = update.sessionID { sessionID = id }
if update.textChanged { onUpdate(parser.visibleText) }
}
}
p.waitUntilExit()
if process === p { process = nil }
if let error = parser.errorMessage {
if error.localizedCaseInsensitiveContains("authenticate") || error.localizedCaseInsensitiveContains("login") {
throw ClaudeCodeCLIError.failed("Claude Code isn't logged in. Run `claude /login` in a terminal.")
}
// A resume that points at a gone session: start fresh next time.
if error.localizedCaseInsensitiveContains("No conversation found") { sessionID = nil }
throw ClaudeCodeCLIError.failed(error)
}
if p.terminationReason == .uncaughtSignal { throw CancellationError() }
if parser.finalText == nil, p.terminationStatus != 0 {
let stderr = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
let last = stderr.split(separator: "\n").last.map(String.init) ?? "exit code \(p.terminationStatus)"
throw ClaudeCodeCLIError.failed("claude: \(last)")
}
return parser.finalText ?? parser.visibleText
}
private static let notchPrompt = """
You are talking through BroV, a tiny assistant living in the notch of the user's Mac. \
The chat window is small: keep answers compact, use light Markdown (short paragraphs, lists, \
**bold**, `code`), avoid tables and big headings. Respond in the user's language.
"""
}
// MARK: - stream-json parsing
/// Turns the CLI's stream-json lines into the text shown in the chat bubble:
/// assistant text as it streams, plus a short `⚙ Tool` line for each tool call.
struct StreamParser {
struct Update {
var sessionID: String?
var textChanged = false
}
private(set) var visibleText = ""
private(set) var finalText: String?
private(set) var errorMessage: String?
/// Text of the message currently streaming (partial deltas), not yet committed.
private var liveText = ""
private var committed = ""
mutating func consume(_ line: String) -> Update? {
guard let data = line.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let type = json["type"] as? String else { return nil }
var update = Update(sessionID: json["session_id"] as? String)
switch type {
case "stream_event":
guard let event = json["event"] as? [String: Any] else { break }
if event["type"] as? String == "content_block_delta",
let delta = event["delta"] as? [String: Any],
delta["type"] as? String == "text_delta",
let text = delta["text"] as? String {
liveText += text
update.textChanged = refresh()
} else if event["type"] as? String == "message_start" {
liveText = ""
}
case "assistant":
// The complete message: commit its text (replacing the partial deltas) and tools.
guard let message = json["message"] as? [String: Any],
let content = message["content"] as? [[String: Any]] else { break }
if json["error"] != nil || message["model"] as? String == "<synthetic>" {
if let text = content.first(where: { $0["type"] as? String == "text" })?["text"] as? String {
errorMessage = text
}
break
}
var piece = ""
for block in content {
switch block["type"] as? String {
case "text":
if let t = block["text"] as? String { piece += t }
case "tool_use":
let name = block["name"] as? String ?? "tool"
piece += (piece.isEmpty ? "" : "\n\n") + "`⚙ \(Self.toolLabel(name, block["input"]))`"
default: break
}
}
if !piece.isEmpty {
committed += (committed.isEmpty ? "" : "\n\n") + piece.trimmingCharacters(in: .whitespacesAndNewlines)
}
liveText = ""
update.textChanged = refresh()
case "result":
let isError = json["is_error"] as? Bool ?? false
let result = json["result"] as? String
if isError {
if errorMessage == nil { errorMessage = result ?? "Claude Code returned an error." }
} else {
// Keep the tool trail when there was one; otherwise the clean final answer.
finalText = committed.isEmpty ? (result ?? "") : committed
}
default:
break
}
return update
}
private mutating func refresh() -> Bool {
let live = liveText.trimmingCharacters(in: .whitespacesAndNewlines)
let next = live.isEmpty ? committed : committed + (committed.isEmpty ? "" : "\n\n") + live
guard next != visibleText else { return false }
visibleText = next
return true
}
/// "Bash: git status", "Read: Foo.swift", or just the tool name.
static func toolLabel(_ name: String, _ input: Any?) -> String {
guard let input = input as? [String: Any] else { return name }
let detail: String?
if let command = input["command"] as? String {
detail = command
} else if let path = (input["file_path"] ?? input["path"] ?? input["notebook_path"]) as? String {
detail = (path as NSString).lastPathComponent
} else if let pattern = input["pattern"] as? String {
detail = pattern
} else if let url = input["url"] as? String {
detail = url
} else if let query = input["query"] as? String {
detail = query
} else {
detail = nil
}
guard var d = detail?.replacingOccurrences(of: "\n", with: " "), !d.isEmpty else { return name }
if d.count > 60 { d = String(d.prefix(57)) + "…" }
d = d.replacingOccurrences(of: "`", with: "'")
return "\(name): \(d)"
}
}
+65 -1
View File
@@ -217,6 +217,10 @@ final class ClaudeService {
// MARK: - Chat (multi-turn, natural text + web search)
func chat(query: String, context: PromptContext?, state: AppState) async {
if state.chatProvider == .claudeCode {
await chatClaudeCode(query: query, context: context, state: state)
return
}
guard state.chatProvider == .anthropic else {
await chatOpenAICompatible(query: query, context: context, state: state)
return
@@ -264,6 +268,66 @@ final class ClaudeService {
}
}
// MARK: - Claude Code CLI chat (BroV: the user's own `claude`, no API key)
func chatClaudeCode(query: String, context: PromptContext?, state: AppState) async {
let cli = ClaudeCodeCLI.shared
var prompt = query
// Context goes with the first message of a conversation, like the API chat.
if cli.sessionID == nil, let context {
switch context {
case .window(let app, let title, let url):
var prefix = "Context — App: \(app), Window: \(title)"
if let url { prefix += ", URL: \(url)" }
prompt = prefix + "\n\n" + query
case .file(let name, let fileURL):
// The CLI reads files itself: hand it the path.
if let fileURL {
prompt = "Attached file: \(fileURL.path)\n(read it before answering)\n\n" + query
} else {
prompt = "File: \(name)\n\n" + query
}
}
}
let placeholder = ChatMessage(role: .assistant, content: "")
let msgId = placeholder.id
state.chatHistory.append(placeholder)
state.stateOverride = .thinking
do {
let final = try await cli.send(
prompt: prompt,
model: state.claudeCodeModel,
workingDirectory: state.chatWorkingDirectory,
binaryPath: state.claudeBinaryPath,
permissionMode: state.claudePermissionMode
) { [state, msgId] visible in
if !visible.isEmpty, state.stateOverride == .thinking {
state.stateOverride = nil // hide typing dots on first visible text
}
if let idx = state.chatHistory.firstIndex(where: { $0.id == msgId }) {
state.chatHistory[idx].content = visible
}
}
let trimmed = final.trimmingCharacters(in: .whitespacesAndNewlines)
if let idx = state.chatHistory.firstIndex(where: { $0.id == msgId }) {
if trimmed.isEmpty { state.chatHistory.remove(at: idx) }
else { state.chatHistory[idx].content = trimmed }
}
state.stateOverride = nil
state.view = .prompt
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy)
} catch is CancellationError {
state.chatHistory.removeAll { $0.id == msgId && $0.content.isEmpty }
state.stateOverride = nil
} catch {
state.chatHistory.removeAll { $0.id == msgId && $0.content.isEmpty }
state.stateOverride = nil
await showError(error.localizedDescription, state: state)
}
}
// MARK: - OpenAI-compatible chat (Google Gemini / OpenAI / Ollama / LM Studio)
func chatOpenAICompatible(query: String, context: PromptContext?, state: AppState) async {
@@ -279,7 +343,7 @@ final class ClaudeService {
switch provider {
case .google: baseURL = "https://generativelanguage.googleapis.com/v1beta/openai"
case .openai: baseURL = "https://api.openai.com/v1"
case .anthropic, .ollama, .lmstudio: baseURL = ""
case .anthropic, .ollama, .lmstudio, .claudeCode: baseURL = ""
}
}
@@ -1243,6 +1243,7 @@ struct PromptView: View {
text = ""
state.chatHistory = []
ClaudeService.shared.clearConversation()
ClaudeCodeCLI.shared.newConversation()
focused = true
}
}
@@ -1384,6 +1385,7 @@ struct ModelPickerView: View {
ForEach(models, id: \.id) { model in
Button {
switch state.chatProvider {
case .claudeCode: state.claudeCodeModel = model.id
case .anthropic: state.claudeModel = model.id
case .google: state.googleChatModel = model.id
case .openai: state.openAIChatModel = model.id
+42 -2
View File
@@ -598,6 +598,46 @@ struct SettingsView: View {
// MARK: - Chat section
@ViewBuilder private var chatSection: some View {
GroupBox("Claude Code (your subscription, no API key)") {
VStack(alignment: .leading, spacing: 8) {
HStack {
TextField("Folder the chat runs in", text: $state.chatWorkingDirectory)
.textFieldStyle(.roundedBorder)
Button("Choose…") {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
if panel.runModal() == .OK, let url = panel.url {
state.chatWorkingDirectory = url.path
ClaudeCodeCLI.shared.newConversation()
}
}
}
Picker("Model", selection: $state.claudeCodeModel) {
ForEach(ClaudeCodeCLI.models, id: \.id) { m in
Text(m.label).tag(m.id)
}
}
Picker("Permissions", selection: $state.claudePermissionMode) {
Text("Ask in the notch").tag("default")
Text("Accept edits").tag("acceptEdits")
Text("Plan only").tag("plan")
Text("Bypass (dangerous)").tag("bypassPermissions")
}
TextField("Path to claude (empty = auto)", text: $state.claudeBinaryPath)
.textFieldStyle(.roundedBorder)
let found = ClaudeCodeCLI.resolveBinary(custom: state.claudeBinaryPath)
Text(found.map { "✓ Using \($0)" } ?? "✗ claude not found — install Claude Code or set the path.")
.font(.system(size: 11))
.foregroundColor(found == nil ? .red : .secondary)
Text("The chat runs `claude -p` in this folder and keeps one conversation until you start a new one. Your MCP servers, skills and CLAUDE.md come along.")
.font(.system(size: 11))
.foregroundColor(.secondary)
}
.padding(6)
}
GroupBox("Anthropic API") {
VStack(alignment: .leading, spacing: 8) {
SecureField("API key (sk-ant-…)", text: $apiKey)
@@ -705,7 +745,7 @@ struct SettingsView: View {
ollamaURL = ""
state.fetchedProviderModels[.ollama] = nil
state.providerModelFetchError[.ollama] = nil
if state.chatProvider == .ollama { state.chatProvider = .anthropic }
if state.chatProvider == .ollama { state.chatProvider = .claudeCode }
statusMessage = "Ollama disconnected."
}
.buttonStyle(.bordered)
@@ -740,7 +780,7 @@ struct SettingsView: View {
lmstudioURL = ""
state.fetchedProviderModels[.lmstudio] = nil
state.providerModelFetchError[.lmstudio] = nil
if state.chatProvider == .lmstudio { state.chatProvider = .anthropic }
if state.chatProvider == .lmstudio { state.chatProvider = .claudeCode }
statusMessage = "LM Studio disconnected."
}
.buttonStyle(.bordered)
@@ -71,6 +71,7 @@ enum AgentSource: Equatable {
// MARK: - Chat provider
enum ChatProvider: String, CaseIterable, Codable {
case claudeCode = "claudecode" // the user's own `claude` CLI (BroV)
case anthropic = "anthropic"
case google = "google"
case openai = "openai"
@@ -79,6 +80,7 @@ enum ChatProvider: String, CaseIterable, Codable {
var displayName: String {
switch self {
case .claudeCode: "Claude Code"
case .anthropic: "Anthropic"
case .google: "Google"
case .openai: "OpenAI"
@@ -89,6 +91,7 @@ enum ChatProvider: String, CaseIterable, Codable {
var accentHex: String {
switch self {
case .claudeCode: "#D97757"
case .anthropic: "#E07950"
case .google: "#4285F4"
case .openai: "#10A37F"
@@ -99,6 +102,7 @@ enum ChatProvider: String, CaseIterable, Codable {
var defaultModel: String {
switch self {
case .claudeCode: ""
case .anthropic: "claude-sonnet-4-6"
case .google: "gemini-2.0-flash"
case .openai: "gpt-4o"
@@ -109,6 +113,7 @@ enum ChatProvider: String, CaseIterable, Codable {
var keychainKey: String {
switch self {
case .claudeCode: ""
case .anthropic: "anthropic-api-key"
case .google: "google-api-key"
case .openai: "openai-api-key"
@@ -123,6 +128,7 @@ enum ChatProvider: String, CaseIterable, Codable {
var pillID: String {
switch self {
case .claudeCode: "ai_claudecode"
case .anthropic: "ai_anthropic"
case .google: "ai_google"
case .openai: "ai_openai"
@@ -133,6 +139,7 @@ enum ChatProvider: String, CaseIterable, Codable {
init?(pillID: String) {
switch pillID {
case "ai_claudecode": self = .claudeCode
case "ai_anthropic": self = .anthropic
case "ai_google": self = .google
case "ai_openai": self = .openai
+23 -346
View File
@@ -1,119 +1,45 @@
name: NotchBuddy
name: BroV
options:
bundleIdPrefix: fr.louisraille
bundleIdPrefix: local.maksar
deploymentTarget:
macOS: "15.0"
xcodeVersion: "27.0"
createIntermediateGroups: true
# DebugCloud = Debug, but signed with iCloud (CloudKit) and push entitlements
# and compiled with PHONE_LINK, for the iPhone link spike. ReleaseCloud = the
# same for a Developer ID build with its provisioning profile
# (scripts/cloud-test-build.sh); a Developer ID profile only allows the
# Production CloudKit environment, so the iPhone joins it with the
# CoucouPhoneProduction scheme. Release also compiles PHONE_LINK (the sync
# stays off until turned on in Settings); Debug stays unsigned without it.
# BroV — personal fork of Coucou (MIT). Mac app only, local unsigned builds.
configs:
Debug: debug
DebugCloud: debug
Release: release
ReleaseCloud: release
settings:
base:
SWIFT_VERSION: "6.0"
ENABLE_HARDENED_RUNTIME: YES
CODE_SIGN_STYLE: Manual
ENABLE_HARDENED_RUNTIME: NO
OTHER_SWIFT_FLAGS: "-strict-concurrency=complete"
configs:
Debug:
CODE_SIGNING_REQUIRED: NO
CODE_SIGNING_ALLOWED: NO
DebugCloud:
CODE_SIGNING_REQUIRED: YES
CODE_SIGNING_ALLOWED: YES
CODE_SIGN_STYLE: Automatic
CODE_SIGN_IDENTITY: "Apple Development"
DEVELOPMENT_TEAM: 256AUJ9555
Release:
CODE_SIGNING_REQUIRED: YES
CODE_SIGNING_ALLOWED: YES
CODE_SIGN_IDENTITY: "Developer ID Application"
DEVELOPMENT_TEAM: 256AUJ9555
OTHER_CODE_SIGN_FLAGS: "--timestamp"
CODE_SIGN_INJECT_BASE_ENTITLEMENTS: NO
CODE_SIGN_ENTITLEMENTS: Resources/Coucou.entitlements
ReleaseCloud:
CODE_SIGNING_REQUIRED: YES
CODE_SIGNING_ALLOWED: YES
CODE_SIGN_IDENTITY: "Developer ID Application"
DEVELOPMENT_TEAM: 256AUJ9555
OTHER_CODE_SIGN_FLAGS: "--timestamp"
CODE_SIGN_INJECT_BASE_ENTITLEMENTS: NO
# Ad-hoc signature: runs locally, no Apple Developer account needed.
CODE_SIGN_STYLE: Manual
CODE_SIGN_IDENTITY: "-"
CODE_SIGNING_REQUIRED: NO
CODE_SIGNING_ALLOWED: YES
DEVELOPMENT_TEAM: ""
schemes:
NotchBuddy:
BroV:
build:
targets:
NotchBuddy: all
BroV: all
run:
config: Debug
archive:
config: Release
CoucouAppStore:
build:
targets:
CoucouAppStore: all
run:
config: Debug
archive:
config: Release
NotchBuddyCloud:
build:
targets:
NotchBuddy: all
run:
config: DebugCloud
archive:
config: Release
CoucouAppStoreCloud:
build:
targets:
CoucouAppStore: all
run:
config: DebugCloud
archive:
config: Release
CoucouPhone:
build:
targets:
CoucouPhone: all
run:
config: Debug
archive:
config: Release
# Same iPhone app on the Production CloudKit environment, to talk to the
# Developer ID build (scripts/cloud-test-build.sh), which can only use Production.
CoucouPhoneProduction:
build:
targets:
CoucouPhone: all
run:
config: DebugCloud
archive:
config: Release
targets:
NotchBuddy:
BroV:
type: application
platform: macOS
sources:
- path: Sources
type: group
excludes:
- "Phone/**"
- "Widgets/**"
- "NotificationContent/**"
- path: Assets.xcassets
buildPhase: resources
- path: Resources/sounds
@@ -122,274 +48,25 @@ targets:
info:
path: Resources/Info.plist
properties:
CFBundleName: Coucou
CFBundleDisplayName: Coucou
CFBundleIdentifier: fr.louisraille.NotchBuddy
CFBundleVersion: "10"
CFBundleShortVersionString: "0.1.9"
CFBundleName: BroV
CFBundleDisplayName: BroV
CFBundleIdentifier: local.maksar.BroV
CFBundleVersion: "1"
CFBundleShortVersionString: "0.1.0"
CFBundlePackageType: APPL
LSUIElement: YES
NSHighResolutionCapable: YES
NSSupportsAutomaticGraphicsSwitching: YES
NSAppleEventsUsageDescription: "Pour sauter au terminal, envoyer des mails et piloter Musique."
NSAccessibilityUsageDescription: "Pour lire le titre de la fenêtre active et l'attacher comme contexte."
NSAppleEventsUsageDescription: "BroV jumps to your terminal, sends mail and controls Music."
NSAccessibilityUsageDescription: "BroV reads the front window's title to attach it as context."
NSAppTransportSecurity:
NSAllowsLocalNetworking: true
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: fr.louisraille.NotchBuddy
PRODUCT_NAME: Coucou
PRODUCT_BUNDLE_IDENTIFIER: local.maksar.BroV
PRODUCT_NAME: BroV
PRODUCT_MODULE_NAME: NotchBuddy
INFOPLIST_FILE: Resources/Info.plist
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
MACOSX_DEPLOYMENT_TARGET: "15.0"
ENABLE_SANDBOX: NO
configs:
DebugCloud:
CODE_SIGN_ENTITLEMENTS: Resources/CoucouCloud.entitlements
SWIFT_ACTIVE_COMPILATION_CONDITIONS: "DEBUG PHONE_LINK"
# The distributed build carries the iPhone sync (off until the user
# turns it on in Settings), so it needs iCloud and its Developer ID profile.
Release:
CODE_SIGN_ENTITLEMENTS: Resources/CoucouReleaseCloud.entitlements
SWIFT_ACTIVE_COMPILATION_CONDITIONS: PHONE_LINK
PROVISIONING_PROFILE_SPECIFIER: "Coucou Developer ID"
ReleaseCloud:
CODE_SIGN_ENTITLEMENTS: Resources/CoucouReleaseCloud.entitlements
SWIFT_ACTIVE_COMPILATION_CONDITIONS: PHONE_LINK
PROVISIONING_PROFILE_SPECIFIER: "Coucou Developer ID"
CoucouAppStore:
type: application
platform: macOS
sources:
- path: Sources
type: group
excludes:
- "Phone/**"
- "Widgets/**"
- "NotificationContent/**"
- path: Assets.xcassets
buildPhase: resources
- path: Resources/sounds
buildPhase: resources
type: folder
info:
path: Resources/InfoAppStore.plist
properties:
CFBundleName: Coucou
CFBundleDisplayName: Coucou
CFBundleIdentifier: fr.louisraille.Coucou
CFBundleVersion: "5"
CFBundleShortVersionString: "1.1"
CFBundlePackageType: APPL
LSUIElement: YES
NSHighResolutionCapable: YES
NSSupportsAutomaticGraphicsSwitching: YES
LSApplicationCategoryType: public.app-category.developer-tools
ITSAppUsesNonExemptEncryption: NO
NSAppTransportSecurity:
NSAllowsLocalNetworking: true
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: fr.louisraille.Coucou
PRODUCT_NAME: Coucou
INFOPLIST_FILE: Resources/InfoAppStore.plist
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
MACOSX_DEPLOYMENT_TARGET: "15.0"
CODE_SIGN_STYLE: Automatic
CODE_SIGN_IDENTITY: "Apple Development"
CODE_SIGN_ENTITLEMENTS: Resources/CoucouAppStore.entitlements
DEVELOPMENT_TEAM: 256AUJ9555
SWIFT_ACTIVE_COMPILATION_CONDITIONS: APPSTORE
CODE_SIGNING_ALLOWED: YES
configs:
DebugCloud:
CODE_SIGN_ENTITLEMENTS: Resources/CoucouAppStoreCloud.entitlements
SWIFT_ACTIVE_COMPILATION_CONDITIONS: "APPSTORE PHONE_LINK"
# App Store archive: same iCloud entitlements; Xcode switches push to
# production when exporting for the App Store.
Release:
CODE_SIGN_ENTITLEMENTS: Resources/CoucouAppStoreCloud.entitlements
SWIFT_ACTIVE_COMPILATION_CONDITIONS: "APPSTORE PHONE_LINK"
# iPhone app. Same bundle ID as the Mac App Store build, for universal purchase.
CoucouPhone:
type: application
platform: iOS
deploymentTarget: "18.0"
dependencies:
- target: CoucouWidgets
- target: CoucouNotificationContent
sources:
- path: Sources/Phone
type: group
# Shared with the Mac targets (they get it through Sources/).
- path: Sources/CoucouKit
type: group
- path: PhoneAssets.xcassets
buildPhase: resources
# Mochi's sounds for the iPhone's notifications.
- path: Resources/sounds/approval.wav
buildPhase: resources
- path: Resources/sounds/finish.wav
buildPhase: resources
- path: Resources/sounds/question.wav
buildPhase: resources
- path: Resources/sounds/error.wav
buildPhase: resources
info:
path: Resources/InfoPhone.plist
properties:
CFBundleName: Coucou
CFBundleDisplayName: Coucou
CFBundleIdentifier: fr.louisraille.Coucou
CFBundleVersion: "3"
CFBundleShortVersionString: "1.0"
CFBundlePackageType: APPL
LSRequiresIPhoneOS: YES
UILaunchScreen: {}
UIApplicationSupportsIndirectInputEvents: YES
UIBackgroundModes:
- remote-notification
# Portrait, except a diff opened full screen (PhoneAppDelegate decides).
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
ITSAppUsesNonExemptEncryption: NO
NSFaceIDUsageDescription: "Coucou asks for Face ID before allowing a command from your iPhone."
NSMicrophoneUsageDescription: "Coucou uses the microphone when you dictate an instruction for Claude Code."
NSSpeechRecognitionUsageDescription: "Coucou turns your voice into the text of an instruction for Claude Code."
NSSupportsLiveActivities: YES
# Lets Mochi move at 120 Hz on ProMotion screens, like the Mac's notch.
CADisableMinimumFrameDurationOnPhone: YES
# Steps change often while an agent works: without this, iOS holds back
# updates once its budget is spent, and "waiting for your OK" came late.
NSSupportsLiveActivitiesFrequentUpdates: YES
# coucou://session/<id>: the widgets open a session in the app.
CFBundleURLTypes:
- CFBundleURLName: fr.louisraille.Coucou
CFBundleURLSchemes:
- coucou
# Which APNs environment the Live Activity tokens belong to (read by the Mac).
CoucouAPNsEnvironment: $(COUCOU_APNS_ENV)
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: fr.louisraille.Coucou
PRODUCT_NAME: Coucou
INFOPLIST_FILE: Resources/InfoPhone.plist
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
# Mochi in other colors, picked in Settings (UIApplication.setAlternateIconName).
ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES: "AppIcon-Blue AppIcon-Green AppIcon-Purple AppIcon-Pink AppIcon-Graphite"
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS: NO
COUCOU_APNS_ENV: production
IPHONEOS_DEPLOYMENT_TARGET: "18.0"
TARGETED_DEVICE_FAMILY: "1"
SUPPORTS_MACCATALYST: NO
CODE_SIGN_STYLE: Automatic
CODE_SIGN_IDENTITY: "Apple Development"
CODE_SIGN_ENTITLEMENTS: Resources/CoucouPhone.entitlements
DEVELOPMENT_TEAM: 256AUJ9555
CODE_SIGNING_REQUIRED: YES
CODE_SIGNING_ALLOWED: YES
ENABLE_HARDENED_RUNTIME: NO
OTHER_CODE_SIGN_FLAGS: ""
configs:
Debug:
COUCOU_APNS_ENV: development
DebugCloud:
CODE_SIGN_ENTITLEMENTS: Resources/CoucouPhoneProduction.entitlements
COUCOU_APNS_ENV: development
# Home Screen and Lock Screen widgets (Solo, Team, List, accessories). They
# read the sessions the iPhone app saves to the shared App Group.
CoucouWidgets:
type: app-extension
platform: iOS
deploymentTarget: "18.0"
sources:
- path: Sources/Widgets
type: group
- path: Sources/CoucouKit
type: group
- path: Sources/Phone/SharedSessions.swift
- path: Sources/Phone/MacStubs.swift
- path: Sources/Phone/MochiActivityAttributes.swift
- path: Sources/Phone/OpenLeadMochiIntent.swift
- path: Sources/Phone/DenyApprovalIntent.swift
info:
path: Resources/InfoWidgets.plist
properties:
CFBundleName: CoucouWidgets
CFBundleDisplayName: Coucou
CFBundleIdentifier: fr.louisraille.Coucou.Widgets
CFBundleVersion: "3"
CFBundleShortVersionString: "1.0"
CFBundlePackageType: XPC!
NSExtension:
NSExtensionPointIdentifier: com.apple.widgetkit-extension
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: fr.louisraille.Coucou.Widgets
SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) WIDGET_EXTENSION"
PRODUCT_NAME: CoucouWidgets
INFOPLIST_FILE: Resources/InfoWidgets.plist
IPHONEOS_DEPLOYMENT_TARGET: "18.0"
TARGETED_DEVICE_FAMILY: "1"
SUPPORTS_MACCATALYST: NO
CODE_SIGN_STYLE: Automatic
CODE_SIGN_IDENTITY: "Apple Development"
CODE_SIGN_ENTITLEMENTS: Resources/CoucouWidgets.entitlements
DEVELOPMENT_TEAM: 256AUJ9555
CODE_SIGNING_REQUIRED: YES
CODE_SIGNING_ALLOWED: YES
ENABLE_HARDENED_RUNTIME: NO
OTHER_CODE_SIGN_FLAGS: ""
SKIP_INSTALL: YES
APPLICATION_EXTENSION_API_ONLY: YES
# The expanded approval notification: Mochi alive, with the command.
CoucouNotificationContent:
type: app-extension
platform: iOS
deploymentTarget: "18.0"
sources:
- path: Sources/NotificationContent
type: group
- path: Sources/CoucouKit
type: group
- path: Sources/Phone/MochiLive.swift
- path: Sources/Phone/MacStubs.swift
info:
path: Resources/InfoNotificationContent.plist
properties:
CFBundleName: CoucouNotificationContent
CFBundleDisplayName: Coucou
CFBundleIdentifier: fr.louisraille.Coucou.NotificationContent
CFBundleVersion: "3"
CFBundleShortVersionString: "1.0"
CFBundlePackageType: XPC!
NSExtension:
NSExtensionPointIdentifier: com.apple.usernotifications.content-extension
NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).NotificationViewController
NSExtensionAttributes:
UNNotificationExtensionCategory: COUCOU_APPROVAL
UNNotificationExtensionInitialContentSizeRatio: 0.45
UNNotificationExtensionDefaultContentHidden: true
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: fr.louisraille.Coucou.NotificationContent
PRODUCT_NAME: CoucouNotificationContent
INFOPLIST_FILE: Resources/InfoNotificationContent.plist
IPHONEOS_DEPLOYMENT_TARGET: "18.0"
TARGETED_DEVICE_FAMILY: "1"
SUPPORTS_MACCATALYST: NO
CODE_SIGN_STYLE: Automatic
CODE_SIGN_IDENTITY: "Apple Development"
DEVELOPMENT_TEAM: 256AUJ9555
CODE_SIGNING_REQUIRED: YES
CODE_SIGNING_ALLOWED: YES
ENABLE_HARDENED_RUNTIME: NO
OTHER_CODE_SIGN_FLAGS: ""
SKIP_INSTALL: YES
APPLICATION_EXTENSION_API_ONLY: YES