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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
maksarsanjeev
2026-10-06 19:49:32 +03:00
parent 53b4f60da1
commit 0f77ec8704
25 changed files with 471 additions and 1007 deletions
+2 -12
View File
@@ -1,21 +1,11 @@
# Build artefacts # Build artefacts
NotchBuddy/build/ NotchBuddy/build/
NotchBuddy/NotchBuddy.xcodeproj/project.xcworkspace/xcuserdata/ NotchBuddy/*.xcodeproj/
NotchBuddy/NotchBuddy.xcodeproj/xcuserdata/
NotchBuddy/NotchBuddy.xcodeproj/xcshareddata/
*.xcuserstate *.xcuserstate
# Xcode DerivedData (outside repo — listed for clarity)
DerivedData/ DerivedData/
# macOS # macOS
.DS_Store .DS_Store
.AppleDouble
.LSOverride
# iCloud stubs # Local only
*.icloud
# Local only (never published)
.claude/ .claude/
_prive/
+14 -21
View File
@@ -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 ## 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/App/` — Mac app code. `NotchBuddy/Sources/CoucouKit/` — shared models, the character engine (`BotEngine.swift`), pills (`PillCatalog.swift`).
- `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. - `NotchBuddy/Sources/App/ClaudeCodeCLI.swift` — runs `claude -p … --output-format stream-json --resume <id>` for the chat.
- `docs/SPEC.md`, `docs/INTEGRATIONS.md` — behaviour, views, states, integrations (in French). - `NotchBuddy/Resources/sounds/` — WAV sounds.
- `design/prototype/notch-buddy.html` — original prototype, the visual source of truth. `design/captures/` — target screenshots. - `NotchBuddy/project.yml` — XcodeGen project (never edit the `.xcodeproj` by hand; it is git-ignored and regenerated).
- `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`.
## Build ## 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 ## 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. - 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 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 overwrite `~/.claude/settings.json`: dated backup, merge, write only after the user confirms.
- Never send an email or approve a Claude Code or Codex permission without an explicit click. - Never approve a Claude Code permission without an explicit click.
- Performance: 0 % CPU when the island is hidden. - When spawning `claude`, strip `CLAUDECODE` from the environment and close stdin.
- Keep the bundle identifier `fr.louisraille.NotchBuddy` (Keychain items, preferences and permissions depend on it). - Pill IDs are stable contract values: never rename an existing pill ID.
- 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.
-39
View File
@@ -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.
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.automation.apple-events</key>
<true/>
</dict>
</plist>
@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.files.bookmarks.app-scope</key>
<true/>
</dict>
</plist>
@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.files.bookmarks.app-scope</key>
<true/>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.fr.louisraille.Coucou</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.aps-environment</key>
<string>development</string>
</dict>
</plist>
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.fr.louisraille.Coucou</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.aps-environment</key>
<string>development</string>
</dict>
</plist>
@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.fr.louisraille.Coucou</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.fr.louisraille.Coucou</string>
</array>
</dict>
</plist>
@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.fr.louisraille.Coucou</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.icloud-container-environment</key>
<string>Production</string>
<key>com.apple.security.application-groups</key>
<array>
<string>group.fr.louisraille.Coucou</string>
</array>
</dict>
</plist>
@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.application-identifier</key>
<string>256AUJ9555.fr.louisraille.NotchBuddy</string>
<key>com.apple.developer.team-identifier</key>
<string>256AUJ9555</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.fr.louisraille.Coucou</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.icloud-container-environment</key>
<string>Production</string>
<key>com.apple.developer.aps-environment</key>
<string>production</string>
</dict>
</plist>
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.fr.louisraille.Coucou</string>
</array>
</dict>
</plist>
+7 -7
View File
@@ -5,32 +5,32 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Coucou</string> <string>BroV</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>fr.louisraille.NotchBuddy</string> <string>local.maksar.BroV</string>
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>Coucou</string> <string>BroV</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>0.1.9</string> <string>0.1.0</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>10</string> <string>1</string>
<key>LSUIElement</key> <key>LSUIElement</key>
<true/> <true/>
<key>NSAccessibilityUsageDescription</key> <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> <key>NSAppTransportSecurity</key>
<dict> <dict>
<key>NSAllowsLocalNetworking</key> <key>NSAllowsLocalNetworking</key>
<true/> <true/>
</dict> </dict>
<key>NSAppleEventsUsageDescription</key> <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> <key>NSHighResolutionCapable</key>
<true/> <true/>
<key>NSSupportsAutomaticGraphicsSwitching</key> <key>NSSupportsAutomaticGraphicsSwitching</key>
-39
View File
@@ -1,39 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Coucou</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>fr.louisraille.Coucou</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Coucou</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.1</string>
<key>CFBundleVersion</key>
<string>5</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationCategoryType</key>
<string>public.app-category.developer-tools</string>
<key>LSUIElement</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSSupportsAutomaticGraphicsSwitching</key>
<true/>
</dict>
</plist>
@@ -1,40 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Coucou</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>fr.louisraille.Coucou.NotificationContent</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>CoucouNotificationContent</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>3</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>UNNotificationExtensionCategory</key>
<string>COUCOU_APPROVAL</string>
<key>UNNotificationExtensionDefaultContentHidden</key>
<true/>
<key>UNNotificationExtensionInitialContentSizeRatio</key>
<real>0.45000000000000001</real>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.content-extension</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationViewController</string>
</dict>
</dict>
</plist>
-67
View File
@@ -1,67 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Coucou</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>fr.louisraille.Coucou</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Coucou</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>fr.louisraille.Coucou</string>
<key>CFBundleURLSchemes</key>
<array>
<string>coucou</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>3</string>
<key>CoucouAPNsEnvironment</key>
<string>$(COUCOU_APNS_ENV)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSFaceIDUsageDescription</key>
<string>Coucou asks for Face ID before allowing a command from your iPhone.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Coucou uses the microphone when you dictate an instruction for Claude Code.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Coucou turns your voice into the text of an instruction for Claude Code.</string>
<key>NSSupportsLiveActivities</key>
<true/>
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
-29
View File
@@ -1,29 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Coucou</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>fr.louisraille.Coucou.Widgets</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>CoucouWidgets</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>3</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
+26 -2
View File
@@ -89,9 +89,23 @@ final class AppState: ObservableObject {
} }
// In-chat provider + model — picked via the model selector in the prompt view // 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") } 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 { @Published var googleChatModel: String = ChatProvider.google.defaultModel {
didSet { UserDefaults.standard.set(googleChatModel, forKey: "googleChatModel") } didSet { UserDefaults.standard.set(googleChatModel, forKey: "googleChatModel") }
} }
@@ -126,6 +140,10 @@ final class AppState: ObservableObject {
func fetchModelsIfNeeded(for provider: ChatProvider) { func fetchModelsIfNeeded(for provider: ChatProvider) {
guard !loadingProviderModels.contains(provider), guard !loadingProviderModels.contains(provider),
fetchedProviderModels[provider] == nil else { return } fetchedProviderModels[provider] == nil else { return }
if provider == .claudeCode {
fetchedProviderModels[provider] = ClaudeCodeCLI.models
return
}
// Local providers: fetch from server URL (no API key needed) // Local providers: fetch from server URL (no API key needed)
if provider.isLocal { if provider.isLocal {
let baseURL = provider == .ollama ? ollamaServerURL : lmstudioServerURL let baseURL = provider == .ollama ? ollamaServerURL : lmstudioServerURL
@@ -170,6 +188,7 @@ final class AppState: ObservableObject {
Task { Task {
let models: [(id: String, label: String)] let models: [(id: String, label: String)]
switch provider { switch provider {
case .claudeCode: models = ClaudeCodeCLI.models
case .anthropic: models = await ClaudeService.fetchModels(apiKey: apiKey) case .anthropic: models = await ClaudeService.fetchModels(apiKey: apiKey)
case .google: models = await ClaudeService.fetchGoogleModels(apiKey: apiKey) case .google: models = await ClaudeService.fetchGoogleModels(apiKey: apiKey)
case .openai: models = await ClaudeService.fetchOpenAIModels(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 }) { if !models.contains(where: { $0.id == openAIChatModel }) {
openAIChatModel = models.first(where: { $0.id.contains("mini") })?.id ?? models.first!.id 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). /// The model currently active for chat (provider-aware).
var activeChatModel: String { var activeChatModel: String {
switch chatProvider { switch chatProvider {
case .claudeCode: return claudeCodeModel
case .anthropic: return claudeModel case .anthropic: return claudeModel
case .google: return googleChatModel case .google: return googleChatModel
case .openai: return openAIChatModel case .openai: return openAIChatModel
@@ -409,6 +429,10 @@ final class AppState: ObservableObject {
if let v = ud.string(forKey: "claudeModel"), if let v = ud.string(forKey: "claudeModel"),
!v.trimmingCharacters(in: .whitespaces).isEmpty { claudeModel = v } !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: "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: "googleChatModel"), !v.isEmpty { googleChatModel = v }
if let v = ud.string(forKey: "openAIChatModel"), !v.isEmpty { openAIChatModel = v } if let v = ud.string(forKey: "openAIChatModel"), !v.isEmpty { openAIChatModel = v }
if let v = ud.string(forKey: "ollamaChatModel"), !v.isEmpty { ollamaChatModel = v } if let v = ud.string(forKey: "ollamaChatModel"), !v.isEmpty { ollamaChatModel = v }
+283
View File
@@ -0,0 +1,283 @@
import Foundation
// MARK: - Claude Code CLI as the chat brain
//
// Every message runs the user's own `claude` binary headless:
// claude -p <text> --output-format stream-json --verbose --include-partial-messages
// [--resume <session id>] [--model <alias>]
// in the chat folder (Settings → Chat), on the user's Claude subscription — no API key.
// The session id from the first reply is kept, so the next message continues the same
// conversation; "New conversation" forgets it. MCP servers, skills, CLAUDE.md and memory
// all come along because it is the real CLI. Tool permissions go through the usual
// Claude Code hooks, so they show up as Allow / Deny cards in the notch.
enum ClaudeCodeCLIError: LocalizedError {
case notFound
case failed(String)
var errorDescription: String? {
switch self {
case .notFound:
"Claude Code CLI not found. Install it, or set its path in Settings → Chat."
case .failed(let message):
message
}
}
}
@MainActor
final class ClaudeCodeCLI {
static let shared = ClaudeCodeCLI()
/// Models offered in the picker. Empty id = whatever the CLI is configured to use.
static let models: [(id: String, label: String)] = [
("", "Default"),
("opus", "Opus"),
("sonnet", "Sonnet"),
("haiku", "Haiku"),
]
private(set) var sessionID: String?
private var process: Process?
var isRunning: Bool { process?.isRunning == true }
func newConversation() {
cancel()
sessionID = nil
}
func cancel() {
if let p = process, p.isRunning { p.terminate() }
process = nil
}
// MARK: Locating the binary
/// The `claude` binary: the path from Settings when set, else the usual install spots.
/// GUI apps don't get the shell's PATH, so we look ourselves.
nonisolated static func resolveBinary(custom: String) -> String? {
let fm = FileManager.default
let trimmed = (custom as NSString).expandingTildeInPath
.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return fm.isExecutableFile(atPath: trimmed) ? trimmed : nil }
let home = fm.homeDirectoryForCurrentUser.path
let candidates = [
"\(home)/.local/bin/claude",
"\(home)/.claude/local/claude",
"/opt/homebrew/bin/claude",
"/usr/local/bin/claude",
"\(home)/.npm-global/bin/claude",
]
return candidates.first { fm.isExecutableFile(atPath: $0) }
}
/// The child's environment: ours, minus the markers that make `claude` refuse to start
/// inside another Claude Code session, plus the common bin folders on PATH so MCP
/// servers (node, uvx, python…) resolve like they do in a terminal.
nonisolated static func childEnvironment() -> [String: String] {
var env = ProcessInfo.processInfo.environment
env.removeValue(forKey: "CLAUDECODE")
env.removeValue(forKey: "CLAUDE_CODE_ENTRYPOINT")
let home = FileManager.default.homeDirectoryForCurrentUser.path
let extra = ["\(home)/.local/bin", "/opt/homebrew/bin", "/opt/homebrew/sbin",
"/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]
var parts = (env["PATH"] ?? "").split(separator: ":").map(String.init)
for dir in extra where !parts.contains(dir) { parts.append(dir) }
env["PATH"] = parts.joined(separator: ":")
return env
}
// MARK: Running a turn
/// Sends `prompt` and streams the visible reply through `onUpdate` (the full text so far).
/// Returns the final text. Throws on a missing binary, a crash or a CLI-reported error.
func send(prompt: String,
model: String,
workingDirectory: String,
binaryPath: String,
permissionMode: String,
onUpdate: @escaping @MainActor (String) -> Void) async throws -> String {
cancel()
guard let binary = Self.resolveBinary(custom: binaryPath) else { throw ClaudeCodeCLIError.notFound }
var args = ["-p", prompt,
"--output-format", "stream-json",
"--verbose",
"--include-partial-messages",
"--append-system-prompt", Self.notchPrompt]
if let sessionID { args += ["--resume", sessionID] }
if !model.isEmpty { args += ["--model", model] }
if !permissionMode.isEmpty, permissionMode != "default" { args += ["--permission-mode", permissionMode] }
let p = Process()
p.executableURL = URL(fileURLWithPath: binary)
p.arguments = args
p.environment = Self.childEnvironment()
let dir = (workingDirectory as NSString).expandingTildeInPath
var isDir: ObjCBool = false
p.currentDirectoryURL = FileManager.default.fileExists(atPath: dir, isDirectory: &isDir) && isDir.boolValue
? URL(fileURLWithPath: dir)
: FileManager.default.homeDirectoryForCurrentUser
p.standardInput = FileHandle.nullDevice
let out = Pipe(), err = Pipe()
p.standardOutput = out
p.standardError = err
process = p
let stream = AsyncStream<String> { continuation in
let handle = out.fileHandleForReading
Task.detached {
do {
for try await line in handle.bytes.lines { continuation.yield(line) }
} catch {}
continuation.finish()
}
}
try p.run()
var parser = StreamParser()
for await line in stream {
if let update = parser.consume(line) {
if let id = update.sessionID { sessionID = id }
if update.textChanged { onUpdate(parser.visibleText) }
}
}
p.waitUntilExit()
if process === p { process = nil }
if let error = parser.errorMessage {
if error.localizedCaseInsensitiveContains("authenticate") || error.localizedCaseInsensitiveContains("login") {
throw ClaudeCodeCLIError.failed("Claude Code isn't logged in. Run `claude /login` in a terminal.")
}
// A resume that points at a gone session: start fresh next time.
if error.localizedCaseInsensitiveContains("No conversation found") { sessionID = nil }
throw ClaudeCodeCLIError.failed(error)
}
if p.terminationReason == .uncaughtSignal { throw CancellationError() }
if parser.finalText == nil, p.terminationStatus != 0 {
let stderr = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
let last = stderr.split(separator: "\n").last.map(String.init) ?? "exit code \(p.terminationStatus)"
throw ClaudeCodeCLIError.failed("claude: \(last)")
}
return parser.finalText ?? parser.visibleText
}
private static let notchPrompt = """
You are talking through BroV, a tiny assistant living in the notch of the user's Mac. \
The chat window is small: keep answers compact, use light Markdown (short paragraphs, lists, \
**bold**, `code`), avoid tables and big headings. Respond in the user's language.
"""
}
// MARK: - stream-json parsing
/// Turns the CLI's stream-json lines into the text shown in the chat bubble:
/// assistant text as it streams, plus a short `⚙ Tool` line for each tool call.
struct StreamParser {
struct Update {
var sessionID: String?
var textChanged = false
}
private(set) var visibleText = ""
private(set) var finalText: String?
private(set) var errorMessage: String?
/// Text of the message currently streaming (partial deltas), not yet committed.
private var liveText = ""
private var committed = ""
mutating func consume(_ line: String) -> Update? {
guard let data = line.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let type = json["type"] as? String else { return nil }
var update = Update(sessionID: json["session_id"] as? String)
switch type {
case "stream_event":
guard let event = json["event"] as? [String: Any] else { break }
if event["type"] as? String == "content_block_delta",
let delta = event["delta"] as? [String: Any],
delta["type"] as? String == "text_delta",
let text = delta["text"] as? String {
liveText += text
update.textChanged = refresh()
} else if event["type"] as? String == "message_start" {
liveText = ""
}
case "assistant":
// The complete message: commit its text (replacing the partial deltas) and tools.
guard let message = json["message"] as? [String: Any],
let content = message["content"] as? [[String: Any]] else { break }
if json["error"] != nil || message["model"] as? String == "<synthetic>" {
if let text = content.first(where: { $0["type"] as? String == "text" })?["text"] as? String {
errorMessage = text
}
break
}
var piece = ""
for block in content {
switch block["type"] as? String {
case "text":
if let t = block["text"] as? String { piece += t }
case "tool_use":
let name = block["name"] as? String ?? "tool"
piece += (piece.isEmpty ? "" : "\n\n") + "`⚙ \(Self.toolLabel(name, block["input"]))`"
default: break
}
}
if !piece.isEmpty {
committed += (committed.isEmpty ? "" : "\n\n") + piece.trimmingCharacters(in: .whitespacesAndNewlines)
}
liveText = ""
update.textChanged = refresh()
case "result":
let isError = json["is_error"] as? Bool ?? false
let result = json["result"] as? String
if isError {
if errorMessage == nil { errorMessage = result ?? "Claude Code returned an error." }
} else {
// Keep the tool trail when there was one; otherwise the clean final answer.
finalText = committed.isEmpty ? (result ?? "") : committed
}
default:
break
}
return update
}
private mutating func refresh() -> Bool {
let live = liveText.trimmingCharacters(in: .whitespacesAndNewlines)
let next = live.isEmpty ? committed : committed + (committed.isEmpty ? "" : "\n\n") + live
guard next != visibleText else { return false }
visibleText = next
return true
}
/// "Bash: git status", "Read: Foo.swift", or just the tool name.
static func toolLabel(_ name: String, _ input: Any?) -> String {
guard let input = input as? [String: Any] else { return name }
let detail: String?
if let command = input["command"] as? String {
detail = command
} else if let path = (input["file_path"] ?? input["path"] ?? input["notebook_path"]) as? String {
detail = (path as NSString).lastPathComponent
} else if let pattern = input["pattern"] as? String {
detail = pattern
} else if let url = input["url"] as? String {
detail = url
} else if let query = input["query"] as? String {
detail = query
} else {
detail = nil
}
guard var d = detail?.replacingOccurrences(of: "\n", with: " "), !d.isEmpty else { return name }
if d.count > 60 { d = String(d.prefix(57)) + "…" }
d = d.replacingOccurrences(of: "`", with: "'")
return "\(name): \(d)"
}
}
+65 -1
View File
@@ -217,6 +217,10 @@ final class ClaudeService {
// MARK: - Chat (multi-turn, natural text + web search) // MARK: - Chat (multi-turn, natural text + web search)
func chat(query: String, context: PromptContext?, state: AppState) async { 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 { guard state.chatProvider == .anthropic else {
await chatOpenAICompatible(query: query, context: context, state: state) await chatOpenAICompatible(query: query, context: context, state: state)
return 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) // MARK: - OpenAI-compatible chat (Google Gemini / OpenAI / Ollama / LM Studio)
func chatOpenAICompatible(query: String, context: PromptContext?, state: AppState) async { func chatOpenAICompatible(query: String, context: PromptContext?, state: AppState) async {
@@ -279,7 +343,7 @@ final class ClaudeService {
switch provider { switch provider {
case .google: baseURL = "https://generativelanguage.googleapis.com/v1beta/openai" case .google: baseURL = "https://generativelanguage.googleapis.com/v1beta/openai"
case .openai: baseURL = "https://api.openai.com/v1" 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 = "" text = ""
state.chatHistory = [] state.chatHistory = []
ClaudeService.shared.clearConversation() ClaudeService.shared.clearConversation()
ClaudeCodeCLI.shared.newConversation()
focused = true focused = true
} }
} }
@@ -1384,6 +1385,7 @@ struct ModelPickerView: View {
ForEach(models, id: \.id) { model in ForEach(models, id: \.id) { model in
Button { Button {
switch state.chatProvider { switch state.chatProvider {
case .claudeCode: state.claudeCodeModel = model.id
case .anthropic: state.claudeModel = model.id case .anthropic: state.claudeModel = model.id
case .google: state.googleChatModel = model.id case .google: state.googleChatModel = model.id
case .openai: state.openAIChatModel = model.id case .openai: state.openAIChatModel = model.id
+42 -2
View File
@@ -598,6 +598,46 @@ struct SettingsView: View {
// MARK: - Chat section // MARK: - Chat section
@ViewBuilder private var chatSection: some View { @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") { GroupBox("Anthropic API") {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
SecureField("API key (sk-ant-…)", text: $apiKey) SecureField("API key (sk-ant-…)", text: $apiKey)
@@ -705,7 +745,7 @@ struct SettingsView: View {
ollamaURL = "" ollamaURL = ""
state.fetchedProviderModels[.ollama] = nil state.fetchedProviderModels[.ollama] = nil
state.providerModelFetchError[.ollama] = nil state.providerModelFetchError[.ollama] = nil
if state.chatProvider == .ollama { state.chatProvider = .anthropic } if state.chatProvider == .ollama { state.chatProvider = .claudeCode }
statusMessage = "Ollama disconnected." statusMessage = "Ollama disconnected."
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
@@ -740,7 +780,7 @@ struct SettingsView: View {
lmstudioURL = "" lmstudioURL = ""
state.fetchedProviderModels[.lmstudio] = nil state.fetchedProviderModels[.lmstudio] = nil
state.providerModelFetchError[.lmstudio] = nil state.providerModelFetchError[.lmstudio] = nil
if state.chatProvider == .lmstudio { state.chatProvider = .anthropic } if state.chatProvider == .lmstudio { state.chatProvider = .claudeCode }
statusMessage = "LM Studio disconnected." statusMessage = "LM Studio disconnected."
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
@@ -71,6 +71,7 @@ enum AgentSource: Equatable {
// MARK: - Chat provider // MARK: - Chat provider
enum ChatProvider: String, CaseIterable, Codable { enum ChatProvider: String, CaseIterable, Codable {
case claudeCode = "claudecode" // the user's own `claude` CLI (BroV)
case anthropic = "anthropic" case anthropic = "anthropic"
case google = "google" case google = "google"
case openai = "openai" case openai = "openai"
@@ -79,6 +80,7 @@ enum ChatProvider: String, CaseIterable, Codable {
var displayName: String { var displayName: String {
switch self { switch self {
case .claudeCode: "Claude Code"
case .anthropic: "Anthropic" case .anthropic: "Anthropic"
case .google: "Google" case .google: "Google"
case .openai: "OpenAI" case .openai: "OpenAI"
@@ -89,6 +91,7 @@ enum ChatProvider: String, CaseIterable, Codable {
var accentHex: String { var accentHex: String {
switch self { switch self {
case .claudeCode: "#D97757"
case .anthropic: "#E07950" case .anthropic: "#E07950"
case .google: "#4285F4" case .google: "#4285F4"
case .openai: "#10A37F" case .openai: "#10A37F"
@@ -99,6 +102,7 @@ enum ChatProvider: String, CaseIterable, Codable {
var defaultModel: String { var defaultModel: String {
switch self { switch self {
case .claudeCode: ""
case .anthropic: "claude-sonnet-4-6" case .anthropic: "claude-sonnet-4-6"
case .google: "gemini-2.0-flash" case .google: "gemini-2.0-flash"
case .openai: "gpt-4o" case .openai: "gpt-4o"
@@ -109,6 +113,7 @@ enum ChatProvider: String, CaseIterable, Codable {
var keychainKey: String { var keychainKey: String {
switch self { switch self {
case .claudeCode: ""
case .anthropic: "anthropic-api-key" case .anthropic: "anthropic-api-key"
case .google: "google-api-key" case .google: "google-api-key"
case .openai: "openai-api-key" case .openai: "openai-api-key"
@@ -123,6 +128,7 @@ enum ChatProvider: String, CaseIterable, Codable {
var pillID: String { var pillID: String {
switch self { switch self {
case .claudeCode: "ai_claudecode"
case .anthropic: "ai_anthropic" case .anthropic: "ai_anthropic"
case .google: "ai_google" case .google: "ai_google"
case .openai: "ai_openai" case .openai: "ai_openai"
@@ -133,6 +139,7 @@ enum ChatProvider: String, CaseIterable, Codable {
init?(pillID: String) { init?(pillID: String) {
switch pillID { switch pillID {
case "ai_claudecode": self = .claudeCode
case "ai_anthropic": self = .anthropic case "ai_anthropic": self = .anthropic
case "ai_google": self = .google case "ai_google": self = .google
case "ai_openai": self = .openai case "ai_openai": self = .openai
+23 -346
View File
@@ -1,119 +1,45 @@
name: NotchBuddy name: BroV
options: options:
bundleIdPrefix: fr.louisraille bundleIdPrefix: local.maksar
deploymentTarget: deploymentTarget:
macOS: "15.0" macOS: "15.0"
xcodeVersion: "27.0" xcodeVersion: "27.0"
createIntermediateGroups: true createIntermediateGroups: true
# DebugCloud = Debug, but signed with iCloud (CloudKit) and push entitlements # BroV — personal fork of Coucou (MIT). Mac app only, local unsigned builds.
# 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.
configs: configs:
Debug: debug Debug: debug
DebugCloud: debug
Release: release Release: release
ReleaseCloud: release
settings: settings:
base: base:
SWIFT_VERSION: "6.0" SWIFT_VERSION: "6.0"
ENABLE_HARDENED_RUNTIME: YES ENABLE_HARDENED_RUNTIME: NO
CODE_SIGN_STYLE: Manual
OTHER_SWIFT_FLAGS: "-strict-concurrency=complete" OTHER_SWIFT_FLAGS: "-strict-concurrency=complete"
configs: # Ad-hoc signature: runs locally, no Apple Developer account needed.
Debug: CODE_SIGN_STYLE: Manual
CODE_SIGNING_REQUIRED: NO CODE_SIGN_IDENTITY: "-"
CODE_SIGNING_ALLOWED: NO CODE_SIGNING_REQUIRED: NO
DebugCloud: CODE_SIGNING_ALLOWED: YES
CODE_SIGNING_REQUIRED: YES DEVELOPMENT_TEAM: ""
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
schemes: schemes:
NotchBuddy: BroV:
build: build:
targets: targets:
NotchBuddy: all BroV: all
run: run:
config: Debug config: Debug
archive: archive:
config: Release 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: targets:
NotchBuddy: BroV:
type: application type: application
platform: macOS platform: macOS
sources: sources:
- path: Sources - path: Sources
type: group type: group
excludes:
- "Phone/**"
- "Widgets/**"
- "NotificationContent/**"
- path: Assets.xcassets - path: Assets.xcassets
buildPhase: resources buildPhase: resources
- path: Resources/sounds - path: Resources/sounds
@@ -122,274 +48,25 @@ targets:
info: info:
path: Resources/Info.plist path: Resources/Info.plist
properties: properties:
CFBundleName: Coucou CFBundleName: BroV
CFBundleDisplayName: Coucou CFBundleDisplayName: BroV
CFBundleIdentifier: fr.louisraille.NotchBuddy CFBundleIdentifier: local.maksar.BroV
CFBundleVersion: "10" CFBundleVersion: "1"
CFBundleShortVersionString: "0.1.9" CFBundleShortVersionString: "0.1.0"
CFBundlePackageType: APPL CFBundlePackageType: APPL
LSUIElement: YES LSUIElement: YES
NSHighResolutionCapable: YES NSHighResolutionCapable: YES
NSSupportsAutomaticGraphicsSwitching: YES NSSupportsAutomaticGraphicsSwitching: YES
NSAppleEventsUsageDescription: "Pour sauter au terminal, envoyer des mails et piloter Musique." NSAppleEventsUsageDescription: "BroV jumps to your terminal, sends mail and controls Music."
NSAccessibilityUsageDescription: "Pour lire le titre de la fenêtre active et l'attacher comme contexte." NSAccessibilityUsageDescription: "BroV reads the front window's title to attach it as context."
NSAppTransportSecurity: NSAppTransportSecurity:
NSAllowsLocalNetworking: true NSAllowsLocalNetworking: true
settings: settings:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: fr.louisraille.NotchBuddy PRODUCT_BUNDLE_IDENTIFIER: local.maksar.BroV
PRODUCT_NAME: Coucou PRODUCT_NAME: BroV
PRODUCT_MODULE_NAME: NotchBuddy
INFOPLIST_FILE: Resources/Info.plist INFOPLIST_FILE: Resources/Info.plist
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
MACOSX_DEPLOYMENT_TARGET: "15.0" MACOSX_DEPLOYMENT_TARGET: "15.0"
ENABLE_SANDBOX: NO 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
-89
View File
@@ -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."
-173
View File
@@ -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
# "## <version>" section: it becomes the release notes.
set -euo pipefail
VERSION="${1:?Usage: $0 <version> [--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 "## <version>" 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 <id> --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"