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:
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user