diff --git a/.gitignore b/.gitignore index 52c3a58..5cfd873 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,11 @@ # Build artefacts NotchBuddy/build/ -NotchBuddy/NotchBuddy.xcodeproj/project.xcworkspace/xcuserdata/ -NotchBuddy/NotchBuddy.xcodeproj/xcuserdata/ -NotchBuddy/NotchBuddy.xcodeproj/xcshareddata/ +NotchBuddy/*.xcodeproj/ *.xcuserstate - -# Xcode DerivedData (outside repo — listed for clarity) DerivedData/ # macOS .DS_Store -.AppleDouble -.LSOverride -# iCloud stubs -*.icloud - -# Local only (never published) +# Local only .claude/ -_prive/ diff --git a/CLAUDE.md b/CLAUDE.md index c666caa..887a181 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,32 +1,25 @@ -# Coucou — guide for AI coding agents +# BroV — guide for AI coding agents -Coucou is a native macOS app (`NotchBuddy/`); `windows/` is the Tauri version for Windows and Linux. Mochi, a small animated character living in the MacBook notch, shows AI coding agent sessions (Claude Code, Gemini CLI, Antigravity and more) and a few integrations, and lets the user approve, answer, chat and drop files from the notch. +BroV is a personal, never-published fork of Coucou (github.com/Louis-CFM/coucou, MIT, imported at 83708fe). Native macOS app in `NotchBuddy/`: an animated character in the MacBook notch that shows Claude Code sessions, lets the user approve/answer from the notch, and chats — with the user's own `claude` CLI as its brain (no API key). ## Where things are -- `NotchBuddy/Sources/App/` — Mac-only Swift code. `NotchBuddy/Sources/CoucouKit/` — code shared with the iPhone app (Mochi's BotEngine and outfits, pills, diff, models). `NotchBuddy/Sources/Phone/` — iPhone app (`CoucouPhone` target), with its widgets in `Sources/Widgets/` and the expanded approval notification in `Sources/NotificationContent/`. `NotchBuddy/Resources/sounds/` — the 28 WAV sounds. `NotchBuddy/project.yml` — XcodeGen project (never edit the `.xcodeproj` by hand). -- `NotchBuddy/Sources/CoucouKit/PillCatalog.swift` — single source of truth for all declared pills (workspace tools, agents, AI providers, services). Every pill ID, color, category and subtitle lives here. -- `docs/SPEC.md`, `docs/INTEGRATIONS.md` — behaviour, views, states, integrations (in French). -- `design/prototype/notch-buddy.html` — original prototype, the visual source of truth. `design/captures/` — target screenshots. -- `windows/` — the Tauri app for Windows and Linux: Rust in `src-tauri/`, TypeScript in `src/`, the `coucou-hook` relay in `hook/`. `windows/README.md` lists what differs from the Mac. -- `docs/*.html` — the GitHub Pages site (privacy, terms, support, legal notice). -- `relay/` — the Coucou relay, a stateless Cloudflare Worker that forwards the iPhone Live Activity pushes (it holds the APNs key, which must never ship in an app). See `relay/README.md`. +- `NotchBuddy/Sources/App/` — Mac app code. `NotchBuddy/Sources/CoucouKit/` — shared models, the character engine (`BotEngine.swift`), pills (`PillCatalog.swift`). +- `NotchBuddy/Sources/App/ClaudeCodeCLI.swift` — runs `claude -p … --output-format stream-json --resume ` for the chat. +- `NotchBuddy/Resources/sounds/` — WAV sounds. +- `NotchBuddy/project.yml` — XcodeGen project (never edit the `.xcodeproj` by hand; it is git-ignored and regenerated). ## Build ``` -cd NotchBuddy && xcodegen && xcodebuild -scheme NotchBuddy -configuration Debug build +cd NotchBuddy && xcodegen && xcodebuild -scheme BroV -configuration Debug -derivedDataPath build build +open build/Build/Products/Debug/BroV.app ``` -Windows and Linux: `cd windows && npm install && npm run tauri dev` ## Rules -- Swift 6, SwiftUI + AppKit. No third-party dependencies unless truly unavoidable. The character is drawn in code (`Canvas` + `TimelineView`), no Rive/Lottie/images. +- Personal use only: never publish to GitHub or any public place. The original Coucou name, Mochi character and icon are not used in BroV. +- Swift 6, SwiftUI + AppKit, no third-party dependencies. The character is drawn in code (`Canvas` + `TimelineView`). - Secrets live in the Keychain, never on disk or in git. -- No telemetry. Network calls only to services the user configured. - Never block Claude Code: if the app doesn't answer, the hook exits immediately. -- Never overwrite `~/.claude/settings.json`: dated backup, merge, show the diff, write only after the user confirms. -- Never send an email or approve a Claude Code or Codex permission without an explicit click. -- Performance: 0 % CPU when the island is hidden. -- Keep the bundle identifier `fr.louisraille.NotchBuddy` (Keychain items, preferences and permissions depend on it). -- Never restyle what already ships (pills, cards, Settings, chat…): existing views stay exactly as they are in `main`, which is the App Store build. Change the look of an existing view only when explicitly asked. -- Pill IDs are stable contract values (Keychain, UserDefaults, hook routing): never rename an existing pill ID. -- New views follow the existing app style. `design/prototype/notch-buddy.html` and `design/captures/` are references for new work, not a reason to change existing views. -- Every release adds its CHANGELOG.md section, a row in the README Versions table, and commits the regenerated Info.plist with the new version. +- Never overwrite `~/.claude/settings.json`: dated backup, merge, write only after the user confirms. +- Never approve a Claude Code permission without an explicit click. +- When spawning `claude`, strip `CLAUDECODE` from the environment and close stdin. +- Pill IDs are stable contract values: never rename an existing pill ID. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 879ac24..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,39 +0,0 @@ -# Contributing to Coucou - -Thanks for wanting to help Mochi grow up! 🫶 - -## Getting started - -```bash -brew install xcodegen -cd NotchBuddy && xcodegen && open NotchBuddy.xcodeproj -``` - -Never edit `NotchBuddy.xcodeproj` by hand: change `project.yml` and run `xcodegen`. - -Check resting island dimensions on screens with and without a notch: - -```bash -bash scripts/test-screen-geometry.sh -``` - -## Good first contributions - -- A new service integration (a poller + an entry in `PillCatalog.swift` in the `.service` category + a detail card). Look at `StripePoller.swift` for a compact example. -- A new agent: any agent already gets its own automatic pill by sending `coucou_agent` in its hook payload (see `docs/AGENTS.md`). Add an entry in `PillCatalog.swift` in the `.agent` or `.workspace` category only if you want it to be declarable in Settings → Active pills. -- A new emote or sound for Mochi. -- Bug fixes — please describe how to reproduce. - -## Rules of the house - -- Swift 6, SwiftUI + AppKit, **no third-party dependencies** unless there's really no other way. -- Secrets go in the Keychain, never on disk or in git. -- No telemetry, no network calls except to services the user configured. -- Never block Claude Code: if the app doesn't answer, the hook must exit right away. -- Never write `~/.claude/settings.json` without a backup and the user's confirmation. -- Keep it light: 0 % CPU when the island is hidden. - -## Pull requests - -- One topic per PR, with a short GIF or screenshot for anything visual. -- Build must pass with no new warnings. diff --git a/NotchBuddy/Resources/Coucou.entitlements b/NotchBuddy/Resources/Coucou.entitlements deleted file mode 100644 index cffd637..0000000 --- a/NotchBuddy/Resources/Coucou.entitlements +++ /dev/null @@ -1,8 +0,0 @@ - - - - - com.apple.security.automation.apple-events - - - diff --git a/NotchBuddy/Resources/CoucouAppStore.entitlements b/NotchBuddy/Resources/CoucouAppStore.entitlements deleted file mode 100644 index 0fc6072..0000000 --- a/NotchBuddy/Resources/CoucouAppStore.entitlements +++ /dev/null @@ -1,14 +0,0 @@ - - - - - com.apple.security.app-sandbox - - com.apple.security.network.client - - com.apple.security.files.user-selected.read-write - - com.apple.security.files.bookmarks.app-scope - - - diff --git a/NotchBuddy/Resources/CoucouAppStoreCloud.entitlements b/NotchBuddy/Resources/CoucouAppStoreCloud.entitlements deleted file mode 100644 index 8cb503f..0000000 --- a/NotchBuddy/Resources/CoucouAppStoreCloud.entitlements +++ /dev/null @@ -1,24 +0,0 @@ - - - - - com.apple.security.app-sandbox - - com.apple.security.network.client - - com.apple.security.files.user-selected.read-write - - com.apple.security.files.bookmarks.app-scope - - com.apple.developer.icloud-container-identifiers - - iCloud.fr.louisraille.Coucou - - com.apple.developer.icloud-services - - CloudKit - - com.apple.developer.aps-environment - development - - diff --git a/NotchBuddy/Resources/CoucouCloud.entitlements b/NotchBuddy/Resources/CoucouCloud.entitlements deleted file mode 100644 index 4ffd53e..0000000 --- a/NotchBuddy/Resources/CoucouCloud.entitlements +++ /dev/null @@ -1,18 +0,0 @@ - - - - - com.apple.security.automation.apple-events - - com.apple.developer.icloud-container-identifiers - - iCloud.fr.louisraille.Coucou - - com.apple.developer.icloud-services - - CloudKit - - com.apple.developer.aps-environment - development - - diff --git a/NotchBuddy/Resources/CoucouPhone.entitlements b/NotchBuddy/Resources/CoucouPhone.entitlements deleted file mode 100644 index 6c70513..0000000 --- a/NotchBuddy/Resources/CoucouPhone.entitlements +++ /dev/null @@ -1,20 +0,0 @@ - - - - - aps-environment - development - com.apple.developer.icloud-container-identifiers - - iCloud.fr.louisraille.Coucou - - com.apple.developer.icloud-services - - CloudKit - - com.apple.security.application-groups - - group.fr.louisraille.Coucou - - - diff --git a/NotchBuddy/Resources/CoucouPhoneProduction.entitlements b/NotchBuddy/Resources/CoucouPhoneProduction.entitlements deleted file mode 100644 index b40e790..0000000 --- a/NotchBuddy/Resources/CoucouPhoneProduction.entitlements +++ /dev/null @@ -1,22 +0,0 @@ - - - - - aps-environment - development - com.apple.developer.icloud-container-identifiers - - iCloud.fr.louisraille.Coucou - - com.apple.developer.icloud-services - - CloudKit - - com.apple.developer.icloud-container-environment - Production - com.apple.security.application-groups - - group.fr.louisraille.Coucou - - - diff --git a/NotchBuddy/Resources/CoucouReleaseCloud.entitlements b/NotchBuddy/Resources/CoucouReleaseCloud.entitlements deleted file mode 100644 index 90d7976..0000000 --- a/NotchBuddy/Resources/CoucouReleaseCloud.entitlements +++ /dev/null @@ -1,24 +0,0 @@ - - - - - com.apple.security.automation.apple-events - - com.apple.application-identifier - 256AUJ9555.fr.louisraille.NotchBuddy - com.apple.developer.team-identifier - 256AUJ9555 - com.apple.developer.icloud-container-identifiers - - iCloud.fr.louisraille.Coucou - - com.apple.developer.icloud-services - - CloudKit - - com.apple.developer.icloud-container-environment - Production - com.apple.developer.aps-environment - production - - diff --git a/NotchBuddy/Resources/CoucouWidgets.entitlements b/NotchBuddy/Resources/CoucouWidgets.entitlements deleted file mode 100644 index e6893e8..0000000 --- a/NotchBuddy/Resources/CoucouWidgets.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.security.application-groups - - group.fr.louisraille.Coucou - - - diff --git a/NotchBuddy/Resources/Info.plist b/NotchBuddy/Resources/Info.plist index ce0f538..cab49d8 100644 --- a/NotchBuddy/Resources/Info.plist +++ b/NotchBuddy/Resources/Info.plist @@ -5,32 +5,32 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Coucou + BroV CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier - fr.louisraille.NotchBuddy + local.maksar.BroV CFBundleInfoDictionaryVersion 6.0 CFBundleName - Coucou + BroV CFBundlePackageType APPL CFBundleShortVersionString - 0.1.9 + 0.1.0 CFBundleVersion - 10 + 1 LSUIElement NSAccessibilityUsageDescription - Pour lire le titre de la fenêtre active et l'attacher comme contexte. + BroV reads the front window's title to attach it as context. NSAppTransportSecurity NSAllowsLocalNetworking NSAppleEventsUsageDescription - Pour sauter au terminal, envoyer des mails et piloter Musique. + BroV jumps to your terminal, sends mail and controls Music. NSHighResolutionCapable NSSupportsAutomaticGraphicsSwitching diff --git a/NotchBuddy/Resources/InfoAppStore.plist b/NotchBuddy/Resources/InfoAppStore.plist deleted file mode 100644 index c529d48..0000000 --- a/NotchBuddy/Resources/InfoAppStore.plist +++ /dev/null @@ -1,39 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Coucou - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - fr.louisraille.Coucou - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - Coucou - CFBundlePackageType - APPL - CFBundleShortVersionString - 1.1 - CFBundleVersion - 5 - ITSAppUsesNonExemptEncryption - - LSApplicationCategoryType - public.app-category.developer-tools - LSUIElement - - NSAppTransportSecurity - - NSAllowsLocalNetworking - - - NSHighResolutionCapable - - NSSupportsAutomaticGraphicsSwitching - - - diff --git a/NotchBuddy/Resources/InfoNotificationContent.plist b/NotchBuddy/Resources/InfoNotificationContent.plist deleted file mode 100644 index acf7642..0000000 --- a/NotchBuddy/Resources/InfoNotificationContent.plist +++ /dev/null @@ -1,40 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Coucou - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - fr.louisraille.Coucou.NotificationContent - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - CoucouNotificationContent - CFBundlePackageType - XPC! - CFBundleShortVersionString - 1.0 - CFBundleVersion - 3 - NSExtension - - NSExtensionAttributes - - UNNotificationExtensionCategory - COUCOU_APPROVAL - UNNotificationExtensionDefaultContentHidden - - UNNotificationExtensionInitialContentSizeRatio - 0.45000000000000001 - - NSExtensionPointIdentifier - com.apple.usernotifications.content-extension - NSExtensionPrincipalClass - $(PRODUCT_MODULE_NAME).NotificationViewController - - - diff --git a/NotchBuddy/Resources/InfoPhone.plist b/NotchBuddy/Resources/InfoPhone.plist deleted file mode 100644 index 9fb3925..0000000 --- a/NotchBuddy/Resources/InfoPhone.plist +++ /dev/null @@ -1,67 +0,0 @@ - - - - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Coucou - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - fr.louisraille.Coucou - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - Coucou - CFBundlePackageType - APPL - CFBundleShortVersionString - 1.0 - CFBundleURLTypes - - - CFBundleURLName - fr.louisraille.Coucou - CFBundleURLSchemes - - coucou - - - - CFBundleVersion - 3 - CoucouAPNsEnvironment - $(COUCOU_APNS_ENV) - ITSAppUsesNonExemptEncryption - - LSRequiresIPhoneOS - - 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 - - NSSupportsLiveActivitiesFrequentUpdates - - UIApplicationSupportsIndirectInputEvents - - UIBackgroundModes - - remote-notification - - UILaunchScreen - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - - diff --git a/NotchBuddy/Resources/InfoWidgets.plist b/NotchBuddy/Resources/InfoWidgets.plist deleted file mode 100644 index ff071e1..0000000 --- a/NotchBuddy/Resources/InfoWidgets.plist +++ /dev/null @@ -1,29 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Coucou - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - fr.louisraille.Coucou.Widgets - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - CoucouWidgets - CFBundlePackageType - XPC! - CFBundleShortVersionString - 1.0 - CFBundleVersion - 3 - NSExtension - - NSExtensionPointIdentifier - com.apple.widgetkit-extension - - - diff --git a/NotchBuddy/Sources/App/AppState.swift b/NotchBuddy/Sources/App/AppState.swift index 4a1047e..55ff5bd 100644 --- a/NotchBuddy/Sources/App/AppState.swift +++ b/NotchBuddy/Sources/App/AppState.swift @@ -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 } diff --git a/NotchBuddy/Sources/App/ClaudeCodeCLI.swift b/NotchBuddy/Sources/App/ClaudeCodeCLI.swift new file mode 100644 index 0000000..d1d78df --- /dev/null +++ b/NotchBuddy/Sources/App/ClaudeCodeCLI.swift @@ -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 --output-format stream-json --verbose --include-partial-messages +// [--resume ] [--model ] +// 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 { 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 == "" { + 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)" + } +} diff --git a/NotchBuddy/Sources/App/ClaudeService.swift b/NotchBuddy/Sources/App/ClaudeService.swift index 7ebeee1..405c3f2 100644 --- a/NotchBuddy/Sources/App/ClaudeService.swift +++ b/NotchBuddy/Sources/App/ClaudeService.swift @@ -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 = "" } } diff --git a/NotchBuddy/Sources/App/IslandViewContent.swift b/NotchBuddy/Sources/App/IslandViewContent.swift index 3947f0d..7eaa27a 100644 --- a/NotchBuddy/Sources/App/IslandViewContent.swift +++ b/NotchBuddy/Sources/App/IslandViewContent.swift @@ -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 diff --git a/NotchBuddy/Sources/App/SettingsView.swift b/NotchBuddy/Sources/App/SettingsView.swift index 9a3d040..b2920b6 100644 --- a/NotchBuddy/Sources/App/SettingsView.swift +++ b/NotchBuddy/Sources/App/SettingsView.swift @@ -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) diff --git a/NotchBuddy/Sources/CoucouKit/IslandTypes.swift b/NotchBuddy/Sources/CoucouKit/IslandTypes.swift index 60fbbe1..95f1889 100644 --- a/NotchBuddy/Sources/CoucouKit/IslandTypes.swift +++ b/NotchBuddy/Sources/CoucouKit/IslandTypes.swift @@ -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 diff --git a/NotchBuddy/project.yml b/NotchBuddy/project.yml index 114433f..cc00606 100644 --- a/NotchBuddy/project.yml +++ b/NotchBuddy/project.yml @@ -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/: 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 diff --git a/scripts/cloud-test-build.sh b/scripts/cloud-test-build.sh deleted file mode 100755 index 7491313..0000000 --- a/scripts/cloud-test-build.sh +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env bash -# iPhone plan, step 2: builds the GitHub app (fr.louisraille.NotchBuddy) the -# way release.sh does — Release optimizations, Developer ID signature — plus -# the "Coucou Developer ID" provisioning profile, the iCloud entitlements and -# the PHONE_LINK probe. Not notarized, not published: it only proves that a -# Developer ID build can talk to iCloud. A Developer ID profile only allows the -# Production CloudKit environment: the Ping/Pong schema must be deployed to -# Production in the CloudKit Console first. -# -# ./scripts/cloud-test-build.sh -# -# The normal release (scripts/release.sh, Release configuration) is unchanged. -set -euo pipefail - -REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -BUILD_DIR="/tmp/coucou-cloud-test" -APP="$BUILD_DIR/Coucou.app" -PROFILE_NAME="Coucou Developer ID" -APP_ID="256AUJ9555.fr.louisraille.NotchBuddy" -CONTAINER="iCloud.fr.louisraille.Coucou" - -die() { echo "error: $*" >&2; exit 1; } - -# ── 1. Developer ID identity ────────────────────────────────────────────────── -IDENTITY=$(security find-identity -v -p codesigning | grep "Developer ID Application" | head -1 | sed 's/.*"\(Developer ID Application[^"]*\)".*/\1/' || true) -[ -n "$IDENTITY" ] || die "no 'Developer ID Application' certificate found. Install it via Xcode → Settings → Accounts." -echo "Signing with: $IDENTITY" - -# ── 2. Provisioning profile ─────────────────────────────────────────────────── -# Xcode looks in both folders depending on its version. Double-clicking a -# downloaded .provisionprofile on macOS doesn't reliably put it in either. -TMP_PLIST=$(mktemp) -trap 'rm -f "$TMP_PLIST"' EXIT -FOUND="" -for dir in "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" "$HOME/Library/MobileDevice/Provisioning Profiles"; do - [ -d "$dir" ] || continue - for f in "$dir"/*.provisionprofile; do - [ -f "$f" ] || continue - security cms -D -i "$f" > "$TMP_PLIST" 2>/dev/null || continue - NAME=$(/usr/libexec/PlistBuddy -c "Print :Name" "$TMP_PLIST" 2>/dev/null || true) - [ "$NAME" = "$PROFILE_NAME" ] || continue - FOUND="$f" - break 2 - done -done -[ -n "$FOUND" ] || die "no provisioning profile named '$PROFILE_NAME' is installed. - developer.apple.com → Profiles → + → Developer ID → App ID fr.louisraille.NotchBuddy, - name it exactly '$PROFILE_NAME' and download it. Then copy it for Xcode: - mkdir -p ~/Library/Developer/Xcode/UserData/Provisioning\\ Profiles - cp ~/Downloads/*.provisionprofile ~/Library/Developer/Xcode/UserData/Provisioning\\ Profiles/" -echo "Profile: $FOUND" - -PROFILE_APP_ID=$(/usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.application-identifier" "$TMP_PLIST" 2>/dev/null || true) -[ "$PROFILE_APP_ID" = "$APP_ID" ] || die "the profile is for '$PROFILE_APP_ID', not $APP_ID" -/usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.developer.icloud-container-identifiers" "$TMP_PLIST" 2>/dev/null | grep -q "$CONTAINER" \ - || die "the profile has no iCloud container $CONTAINER. Turn on iCloud (CloudKit, with that container) on the App ID fr.louisraille.NotchBuddy, then regenerate and reinstall the profile." -EXPIRY=$(/usr/libexec/PlistBuddy -c "Print :ExpirationDate" "$TMP_PLIST") -echo "Profile expires: $EXPIRY" - -# ── 3. Build ────────────────────────────────────────────────────────────────── -cd "$REPO_ROOT/NotchBuddy" -xcodegen generate -rm -rf "$BUILD_DIR" && mkdir -p "$BUILD_DIR" -xcodebuild \ - -project NotchBuddy.xcodeproj \ - -scheme NotchBuddy \ - -configuration ReleaseCloud \ - build \ - CODE_SIGN_IDENTITY="$IDENTITY" \ - CONFIGURATION_BUILD_DIR="$BUILD_DIR" \ - -quiet \ - || die "the build failed (see the errors above)" - -# ── 4. Checks ───────────────────────────────────────────────────────────────── -[ -f "$APP/Contents/embedded.provisionprofile" ] || die "the profile was not embedded in the app" -codesign --verify --deep --strict "$APP" || die "the signature does not verify" -echo -echo "Entitlements of the built app:" -codesign -d --entitlements - --xml "$APP" 2>/dev/null | plutil -p - || true - -echo -echo "✓ Built $APP (Developer ID, not notarized)." -echo -echo "To test:" -echo " 1. Quit any other Coucou (menu bar icon → Quit) and stop it in Xcode." -echo " 2. open $APP" -echo " 3. tail -f ~/Library/Logs/NotchBuddy/nb.log | grep PhoneLink" -echo " 4. Developer ID builds use the Production CloudKit environment: run the iPhone" -echo " app with the CoucouPhoneProduction scheme, pull to refresh, then tap Send pong." diff --git a/scripts/release.sh b/scripts/release.sh deleted file mode 100755 index bd3fe4b..0000000 --- a/scripts/release.sh +++ /dev/null @@ -1,173 +0,0 @@ -#!/usr/bin/env bash -# Builds, notarizes and publishes Coucou for macOS (GitHub build). -# -# ./scripts/release.sh 0.1.2 build, sign, notarize, staple, tag, publish -# ./scripts/release.sh 0.1.2 --finish finish after an interrupted notarization wait -# -# Run it from a clean checkout of main. CFBundleShortVersionString in -# NotchBuddy/project.yml must match the version, and CHANGELOG.md needs a -# "## " section: it becomes the release notes. -set -euo pipefail - -VERSION="${1:?Usage: $0 [--finish]}" -MODE="${2:-}" -REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -BUILD_DIR="/tmp/coucou-release-$VERSION" -APP="$BUILD_DIR/Coucou.app" -ZIP="$BUILD_DIR/Coucou.zip" -COMMIT_FILE="$BUILD_DIR/commit" -TAG="v$VERSION" - -die() { echo "error: $*" >&2; exit 1; } - -case "$MODE" in - ""|--finish) ;; - *) die "unknown option '$MODE' (the only option is --finish)" ;; -esac - -cd "$REPO_ROOT" - -# ── Checks shared by both modes ─────────────────────────────────────────────── -command -v gh >/dev/null || die "gh is not installed (brew install gh)" - -# The "## " section of CHANGELOG.md, without leading or trailing blank lines. -CHANGES=$(awk -v head="## $VERSION" ' - index($0, head " ") == 1 || $0 == head { found = 1; next } - found && /^## / { exit } - found { - if ($0 ~ /^[ \t\r]*$/) { if (started) blanks++; next } - while (blanks > 0) { print ""; blanks-- } - print; started = 1 - } -' CHANGELOG.md) -[ -n "$CHANGES" ] || die "CHANGELOG.md has no '## $VERSION' section" - -grep -q "| \[$VERSION\]" README.md || die "README.md has no row for $VERSION in the Versions table" - -if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then - die "tag $TAG already exists here" -fi -if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then - die "tag $TAG already exists on GitHub" -fi - -if [ "$MODE" != "--finish" ]; then - # The release must match a commit: no uncommitted changes to tracked files. - if ! git diff --quiet || ! git diff --cached --quiet; then - die "commit or stash your changes first, the release has to match a commit" - fi - COMMIT=$(git rev-parse HEAD) - - # ── 1. Developer ID identity ──────────────────────────────────────────────── - # "|| true": with pipefail, grep finding nothing would end the script silently. - IDENTITY=$(security find-identity -v -p codesigning | grep "Developer ID Application" | head -1 | sed 's/.*"\(Developer ID Application[^"]*\)".*/\1/' || true) - [ -n "$IDENTITY" ] || die "no 'Developer ID Application' certificate found. Install it via Xcode → Settings → Accounts." - echo "Signing with: $IDENTITY" - - # ── 1b. Developer ID provisioning profile (iCloud for the iPhone sync) ────── - PROFILE_NAME="Coucou Developer ID" - PROFILE_FOUND="" - TMP_PLIST=$(mktemp) - for dir in "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" "$HOME/Library/MobileDevice/Provisioning Profiles"; do - [ -d "$dir" ] || continue - for f in "$dir"/*.provisionprofile; do - [ -f "$f" ] || continue - security cms -D -i "$f" > "$TMP_PLIST" 2>/dev/null || continue - [ "$(/usr/libexec/PlistBuddy -c "Print :Name" "$TMP_PLIST" 2>/dev/null || true)" = "$PROFILE_NAME" ] || continue - PROFILE_FOUND="$f" - break 2 - done - done - [ -n "$PROFILE_FOUND" ] || { rm -f "$TMP_PLIST"; die "no provisioning profile named '$PROFILE_NAME'. developer.apple.com → Profiles → Developer ID (Mac) for fr.louisraille.NotchBuddy, then copy it to ~/Library/Developer/Xcode/UserData/Provisioning Profiles/"; } - EXPIRY=$(/usr/libexec/PlistBuddy -c "Print :ExpirationDate" "$TMP_PLIST") - rm -f "$TMP_PLIST" - echo "Profile: $PROFILE_FOUND (expires $EXPIRY)" - - # ── 2. xcodegen + Release build ───────────────────────────────────────────── - cd "$REPO_ROOT/NotchBuddy" - PLIST_VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" Resources/Info.plist 2>/dev/null || true) - [ "$PLIST_VERSION" = "$VERSION" ] || die "NotchBuddy/Resources/Info.plist is version $PLIST_VERSION, not $VERSION: run xcodegen and commit Info.plist" - xcodegen generate - PLIST_VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" Resources/Info.plist) - [ "$PLIST_VERSION" = "$VERSION" ] || die "project.yml says $PLIST_VERSION, not $VERSION: update CFBundleShortVersionString in NotchBuddy/project.yml" - - rm -rf "$BUILD_DIR" && mkdir -p "$BUILD_DIR" - echo "$COMMIT" > "$COMMIT_FILE" - - xcodebuild \ - -project NotchBuddy.xcodeproj \ - -scheme NotchBuddy \ - -configuration Release \ - build \ - CODE_SIGN_IDENTITY="$IDENTITY" \ - CODE_SIGNING_REQUIRED=YES \ - CODE_SIGNING_ALLOWED=YES \ - CONFIGURATION_BUILD_DIR="$BUILD_DIR" - - [ -f "$APP/Contents/embedded.provisionprofile" ] || die "the provisioning profile was not embedded in the app" - codesign -d --entitlements - --xml "$APP" 2>/dev/null | grep -q "iCloud.fr.louisraille.Coucou" \ - || die "the app is not signed with the iCloud entitlements" - - # ── 3. Zip + notarize ─────────────────────────────────────────────────────── - ditto -c -k --keepParent "$APP" "$ZIP" - echo - echo "Sending to Apple for notarization. If you stop the wait (Ctrl+C), Apple keeps going:" - echo " check it with xcrun notarytool history --keychain-profile coucou-notary" - echo " once Accepted ./scripts/release.sh $VERSION --finish" - echo - xcrun notarytool submit "$ZIP" --keychain-profile coucou-notary --wait \ - || die "notarization failed: see xcrun notarytool log --keychain-profile coucou-notary (the id is printed above)" - cd "$REPO_ROOT" -else - [ -d "$APP" ] && [ -f "$COMMIT_FILE" ] || die "nothing to finish in $BUILD_DIR, run ./scripts/release.sh $VERSION first" - COMMIT=$(cat "$COMMIT_FILE") -fi - -# ── 4. Staple + verify ──────────────────────────────────────────────────────── -xcrun stapler staple "$APP" \ - || die "stapling failed: Apple has not accepted the build yet. Check xcrun notarytool history --keychain-profile coucou-notary, then run ./scripts/release.sh $VERSION --finish" -spctl -a -vv "$APP" - -# ── 5. Re-zip (with the stapled app) ────────────────────────────────────────── -rm -f "$ZIP" -ditto -c -k --keepParent "$APP" "$ZIP" -echo "Release zip ready: $ZIP" - -# ── 6. Tag the built commit + GitHub release ────────────────────────────────── -NOTES="Coucou $VERSION for macOS 15 or later (Apple silicon and Intel). - -Signed with a Developer ID and notarized by Apple. - -## What's new - -$CHANGES - -## Install - -1. Download Coucou.zip below and unzip it. -2. Move Coucou.app to your Applications folder, replacing the old one if you have it. -3. Launch it, and click Open when macOS asks you to confirm. - -Linux and Windows: see the [README](https://github.com/Louis-CFM/coucou#readme)." - -echo -echo "──────── Release notes ────────" -echo "$NOTES" -echo "───────────────────────────────" -echo -read -r -p "Tag $(git rev-parse --short "$COMMIT") as $TAG and publish this release? [y/N] " ANSWER -case "$ANSWER" in - y|Y|yes|oui|o|O) ;; - *) die "stopped before tagging. Run ./scripts/release.sh $VERSION --finish to publish later." ;; -esac - -git tag "$TAG" "$COMMIT" -git push origin "$TAG" - -gh release create "$TAG" "$ZIP" \ - --repo Louis-CFM/coucou \ - --title "Coucou $VERSION" \ - --latest \ - --notes "$NOTES" - -echo "✓ $TAG released: https://github.com/Louis-CFM/coucou/releases/tag/$TAG"