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:
+2
-12
@@ -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/
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>com.apple.security.automation.apple-events</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>com.apple.security.app-sandbox</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.network.client</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.files.user-selected.read-write</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.files.bookmarks.app-scope</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>com.apple.security.app-sandbox</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.network.client</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.files.user-selected.read-write</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.files.bookmarks.app-scope</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
|
||||||
<array>
|
|
||||||
<string>iCloud.fr.louisraille.Coucou</string>
|
|
||||||
</array>
|
|
||||||
<key>com.apple.developer.icloud-services</key>
|
|
||||||
<array>
|
|
||||||
<string>CloudKit</string>
|
|
||||||
</array>
|
|
||||||
<key>com.apple.developer.aps-environment</key>
|
|
||||||
<string>development</string>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>com.apple.security.automation.apple-events</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
|
||||||
<array>
|
|
||||||
<string>iCloud.fr.louisraille.Coucou</string>
|
|
||||||
</array>
|
|
||||||
<key>com.apple.developer.icloud-services</key>
|
|
||||||
<array>
|
|
||||||
<string>CloudKit</string>
|
|
||||||
</array>
|
|
||||||
<key>com.apple.developer.aps-environment</key>
|
|
||||||
<string>development</string>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>aps-environment</key>
|
|
||||||
<string>development</string>
|
|
||||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
|
||||||
<array>
|
|
||||||
<string>iCloud.fr.louisraille.Coucou</string>
|
|
||||||
</array>
|
|
||||||
<key>com.apple.developer.icloud-services</key>
|
|
||||||
<array>
|
|
||||||
<string>CloudKit</string>
|
|
||||||
</array>
|
|
||||||
<key>com.apple.security.application-groups</key>
|
|
||||||
<array>
|
|
||||||
<string>group.fr.louisraille.Coucou</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>aps-environment</key>
|
|
||||||
<string>development</string>
|
|
||||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
|
||||||
<array>
|
|
||||||
<string>iCloud.fr.louisraille.Coucou</string>
|
|
||||||
</array>
|
|
||||||
<key>com.apple.developer.icloud-services</key>
|
|
||||||
<array>
|
|
||||||
<string>CloudKit</string>
|
|
||||||
</array>
|
|
||||||
<key>com.apple.developer.icloud-container-environment</key>
|
|
||||||
<string>Production</string>
|
|
||||||
<key>com.apple.security.application-groups</key>
|
|
||||||
<array>
|
|
||||||
<string>group.fr.louisraille.Coucou</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>com.apple.security.automation.apple-events</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.application-identifier</key>
|
|
||||||
<string>256AUJ9555.fr.louisraille.NotchBuddy</string>
|
|
||||||
<key>com.apple.developer.team-identifier</key>
|
|
||||||
<string>256AUJ9555</string>
|
|
||||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
|
||||||
<array>
|
|
||||||
<string>iCloud.fr.louisraille.Coucou</string>
|
|
||||||
</array>
|
|
||||||
<key>com.apple.developer.icloud-services</key>
|
|
||||||
<array>
|
|
||||||
<string>CloudKit</string>
|
|
||||||
</array>
|
|
||||||
<key>com.apple.developer.icloud-container-environment</key>
|
|
||||||
<string>Production</string>
|
|
||||||
<key>com.apple.developer.aps-environment</key>
|
|
||||||
<string>production</string>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>com.apple.security.application-groups</key>
|
|
||||||
<array>
|
|
||||||
<string>group.fr.louisraille.Coucou</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -5,32 +5,32 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<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>
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
|
||||||
<key>CFBundleDisplayName</key>
|
|
||||||
<string>Coucou</string>
|
|
||||||
<key>CFBundleExecutable</key>
|
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
|
||||||
<key>CFBundleIdentifier</key>
|
|
||||||
<string>fr.louisraille.Coucou</string>
|
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
|
||||||
<string>6.0</string>
|
|
||||||
<key>CFBundleName</key>
|
|
||||||
<string>Coucou</string>
|
|
||||||
<key>CFBundlePackageType</key>
|
|
||||||
<string>APPL</string>
|
|
||||||
<key>CFBundleShortVersionString</key>
|
|
||||||
<string>1.1</string>
|
|
||||||
<key>CFBundleVersion</key>
|
|
||||||
<string>5</string>
|
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
|
||||||
<false/>
|
|
||||||
<key>LSApplicationCategoryType</key>
|
|
||||||
<string>public.app-category.developer-tools</string>
|
|
||||||
<key>LSUIElement</key>
|
|
||||||
<true/>
|
|
||||||
<key>NSAppTransportSecurity</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSAllowsLocalNetworking</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
<key>NSHighResolutionCapable</key>
|
|
||||||
<true/>
|
|
||||||
<key>NSSupportsAutomaticGraphicsSwitching</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
|
||||||
<key>CFBundleDisplayName</key>
|
|
||||||
<string>Coucou</string>
|
|
||||||
<key>CFBundleExecutable</key>
|
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
|
||||||
<key>CFBundleIdentifier</key>
|
|
||||||
<string>fr.louisraille.Coucou.NotificationContent</string>
|
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
|
||||||
<string>6.0</string>
|
|
||||||
<key>CFBundleName</key>
|
|
||||||
<string>CoucouNotificationContent</string>
|
|
||||||
<key>CFBundlePackageType</key>
|
|
||||||
<string>XPC!</string>
|
|
||||||
<key>CFBundleShortVersionString</key>
|
|
||||||
<string>1.0</string>
|
|
||||||
<key>CFBundleVersion</key>
|
|
||||||
<string>3</string>
|
|
||||||
<key>NSExtension</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSExtensionAttributes</key>
|
|
||||||
<dict>
|
|
||||||
<key>UNNotificationExtensionCategory</key>
|
|
||||||
<string>COUCOU_APPROVAL</string>
|
|
||||||
<key>UNNotificationExtensionDefaultContentHidden</key>
|
|
||||||
<true/>
|
|
||||||
<key>UNNotificationExtensionInitialContentSizeRatio</key>
|
|
||||||
<real>0.45000000000000001</real>
|
|
||||||
</dict>
|
|
||||||
<key>NSExtensionPointIdentifier</key>
|
|
||||||
<string>com.apple.usernotifications.content-extension</string>
|
|
||||||
<key>NSExtensionPrincipalClass</key>
|
|
||||||
<string>$(PRODUCT_MODULE_NAME).NotificationViewController</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
|
||||||
<true/>
|
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
|
||||||
<key>CFBundleDisplayName</key>
|
|
||||||
<string>Coucou</string>
|
|
||||||
<key>CFBundleExecutable</key>
|
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
|
||||||
<key>CFBundleIdentifier</key>
|
|
||||||
<string>fr.louisraille.Coucou</string>
|
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
|
||||||
<string>6.0</string>
|
|
||||||
<key>CFBundleName</key>
|
|
||||||
<string>Coucou</string>
|
|
||||||
<key>CFBundlePackageType</key>
|
|
||||||
<string>APPL</string>
|
|
||||||
<key>CFBundleShortVersionString</key>
|
|
||||||
<string>1.0</string>
|
|
||||||
<key>CFBundleURLTypes</key>
|
|
||||||
<array>
|
|
||||||
<dict>
|
|
||||||
<key>CFBundleURLName</key>
|
|
||||||
<string>fr.louisraille.Coucou</string>
|
|
||||||
<key>CFBundleURLSchemes</key>
|
|
||||||
<array>
|
|
||||||
<string>coucou</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</array>
|
|
||||||
<key>CFBundleVersion</key>
|
|
||||||
<string>3</string>
|
|
||||||
<key>CoucouAPNsEnvironment</key>
|
|
||||||
<string>$(COUCOU_APNS_ENV)</string>
|
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
|
||||||
<false/>
|
|
||||||
<key>LSRequiresIPhoneOS</key>
|
|
||||||
<true/>
|
|
||||||
<key>NSFaceIDUsageDescription</key>
|
|
||||||
<string>Coucou asks for Face ID before allowing a command from your iPhone.</string>
|
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
|
||||||
<string>Coucou uses the microphone when you dictate an instruction for Claude Code.</string>
|
|
||||||
<key>NSSpeechRecognitionUsageDescription</key>
|
|
||||||
<string>Coucou turns your voice into the text of an instruction for Claude Code.</string>
|
|
||||||
<key>NSSupportsLiveActivities</key>
|
|
||||||
<true/>
|
|
||||||
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
|
|
||||||
<true/>
|
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
|
||||||
<true/>
|
|
||||||
<key>UIBackgroundModes</key>
|
|
||||||
<array>
|
|
||||||
<string>remote-notification</string>
|
|
||||||
</array>
|
|
||||||
<key>UILaunchScreen</key>
|
|
||||||
<dict/>
|
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
|
||||||
<array>
|
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
|
||||||
<key>CFBundleDisplayName</key>
|
|
||||||
<string>Coucou</string>
|
|
||||||
<key>CFBundleExecutable</key>
|
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
|
||||||
<key>CFBundleIdentifier</key>
|
|
||||||
<string>fr.louisraille.Coucou.Widgets</string>
|
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
|
||||||
<string>6.0</string>
|
|
||||||
<key>CFBundleName</key>
|
|
||||||
<string>CoucouWidgets</string>
|
|
||||||
<key>CFBundlePackageType</key>
|
|
||||||
<string>XPC!</string>
|
|
||||||
<key>CFBundleShortVersionString</key>
|
|
||||||
<string>1.0</string>
|
|
||||||
<key>CFBundleVersion</key>
|
|
||||||
<string>3</string>
|
|
||||||
<key>NSExtension</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSExtensionPointIdentifier</key>
|
|
||||||
<string>com.apple.widgetkit-extension</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -89,9 +89,23 @@ final class AppState: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// In-chat provider + model — picked via the model selector in the prompt view
|
// 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 }
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Claude Code CLI as the chat brain
|
||||||
|
//
|
||||||
|
// Every message runs the user's own `claude` binary headless:
|
||||||
|
// claude -p <text> --output-format stream-json --verbose --include-partial-messages
|
||||||
|
// [--resume <session id>] [--model <alias>]
|
||||||
|
// in the chat folder (Settings → Chat), on the user's Claude subscription — no API key.
|
||||||
|
// The session id from the first reply is kept, so the next message continues the same
|
||||||
|
// conversation; "New conversation" forgets it. MCP servers, skills, CLAUDE.md and memory
|
||||||
|
// all come along because it is the real CLI. Tool permissions go through the usual
|
||||||
|
// Claude Code hooks, so they show up as Allow / Deny cards in the notch.
|
||||||
|
|
||||||
|
enum ClaudeCodeCLIError: LocalizedError {
|
||||||
|
case notFound
|
||||||
|
case failed(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .notFound:
|
||||||
|
"Claude Code CLI not found. Install it, or set its path in Settings → Chat."
|
||||||
|
case .failed(let message):
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ClaudeCodeCLI {
|
||||||
|
static let shared = ClaudeCodeCLI()
|
||||||
|
|
||||||
|
/// Models offered in the picker. Empty id = whatever the CLI is configured to use.
|
||||||
|
static let models: [(id: String, label: String)] = [
|
||||||
|
("", "Default"),
|
||||||
|
("opus", "Opus"),
|
||||||
|
("sonnet", "Sonnet"),
|
||||||
|
("haiku", "Haiku"),
|
||||||
|
]
|
||||||
|
|
||||||
|
private(set) var sessionID: String?
|
||||||
|
private var process: Process?
|
||||||
|
|
||||||
|
var isRunning: Bool { process?.isRunning == true }
|
||||||
|
|
||||||
|
func newConversation() {
|
||||||
|
cancel()
|
||||||
|
sessionID = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
if let p = process, p.isRunning { p.terminate() }
|
||||||
|
process = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Locating the binary
|
||||||
|
|
||||||
|
/// The `claude` binary: the path from Settings when set, else the usual install spots.
|
||||||
|
/// GUI apps don't get the shell's PATH, so we look ourselves.
|
||||||
|
nonisolated static func resolveBinary(custom: String) -> String? {
|
||||||
|
let fm = FileManager.default
|
||||||
|
let trimmed = (custom as NSString).expandingTildeInPath
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !trimmed.isEmpty { return fm.isExecutableFile(atPath: trimmed) ? trimmed : nil }
|
||||||
|
let home = fm.homeDirectoryForCurrentUser.path
|
||||||
|
let candidates = [
|
||||||
|
"\(home)/.local/bin/claude",
|
||||||
|
"\(home)/.claude/local/claude",
|
||||||
|
"/opt/homebrew/bin/claude",
|
||||||
|
"/usr/local/bin/claude",
|
||||||
|
"\(home)/.npm-global/bin/claude",
|
||||||
|
]
|
||||||
|
return candidates.first { fm.isExecutableFile(atPath: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The child's environment: ours, minus the markers that make `claude` refuse to start
|
||||||
|
/// inside another Claude Code session, plus the common bin folders on PATH so MCP
|
||||||
|
/// servers (node, uvx, python…) resolve like they do in a terminal.
|
||||||
|
nonisolated static func childEnvironment() -> [String: String] {
|
||||||
|
var env = ProcessInfo.processInfo.environment
|
||||||
|
env.removeValue(forKey: "CLAUDECODE")
|
||||||
|
env.removeValue(forKey: "CLAUDE_CODE_ENTRYPOINT")
|
||||||
|
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||||||
|
let extra = ["\(home)/.local/bin", "/opt/homebrew/bin", "/opt/homebrew/sbin",
|
||||||
|
"/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]
|
||||||
|
var parts = (env["PATH"] ?? "").split(separator: ":").map(String.init)
|
||||||
|
for dir in extra where !parts.contains(dir) { parts.append(dir) }
|
||||||
|
env["PATH"] = parts.joined(separator: ":")
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Running a turn
|
||||||
|
|
||||||
|
/// Sends `prompt` and streams the visible reply through `onUpdate` (the full text so far).
|
||||||
|
/// Returns the final text. Throws on a missing binary, a crash or a CLI-reported error.
|
||||||
|
func send(prompt: String,
|
||||||
|
model: String,
|
||||||
|
workingDirectory: String,
|
||||||
|
binaryPath: String,
|
||||||
|
permissionMode: String,
|
||||||
|
onUpdate: @escaping @MainActor (String) -> Void) async throws -> String {
|
||||||
|
cancel()
|
||||||
|
guard let binary = Self.resolveBinary(custom: binaryPath) else { throw ClaudeCodeCLIError.notFound }
|
||||||
|
|
||||||
|
var args = ["-p", prompt,
|
||||||
|
"--output-format", "stream-json",
|
||||||
|
"--verbose",
|
||||||
|
"--include-partial-messages",
|
||||||
|
"--append-system-prompt", Self.notchPrompt]
|
||||||
|
if let sessionID { args += ["--resume", sessionID] }
|
||||||
|
if !model.isEmpty { args += ["--model", model] }
|
||||||
|
if !permissionMode.isEmpty, permissionMode != "default" { args += ["--permission-mode", permissionMode] }
|
||||||
|
|
||||||
|
let p = Process()
|
||||||
|
p.executableURL = URL(fileURLWithPath: binary)
|
||||||
|
p.arguments = args
|
||||||
|
p.environment = Self.childEnvironment()
|
||||||
|
let dir = (workingDirectory as NSString).expandingTildeInPath
|
||||||
|
var isDir: ObjCBool = false
|
||||||
|
p.currentDirectoryURL = FileManager.default.fileExists(atPath: dir, isDirectory: &isDir) && isDir.boolValue
|
||||||
|
? URL(fileURLWithPath: dir)
|
||||||
|
: FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
p.standardInput = FileHandle.nullDevice
|
||||||
|
let out = Pipe(), err = Pipe()
|
||||||
|
p.standardOutput = out
|
||||||
|
p.standardError = err
|
||||||
|
process = p
|
||||||
|
|
||||||
|
let stream = AsyncStream<String> { continuation in
|
||||||
|
let handle = out.fileHandleForReading
|
||||||
|
Task.detached {
|
||||||
|
do {
|
||||||
|
for try await line in handle.bytes.lines { continuation.yield(line) }
|
||||||
|
} catch {}
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try p.run()
|
||||||
|
|
||||||
|
var parser = StreamParser()
|
||||||
|
for await line in stream {
|
||||||
|
if let update = parser.consume(line) {
|
||||||
|
if let id = update.sessionID { sessionID = id }
|
||||||
|
if update.textChanged { onUpdate(parser.visibleText) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.waitUntilExit()
|
||||||
|
if process === p { process = nil }
|
||||||
|
|
||||||
|
if let error = parser.errorMessage {
|
||||||
|
if error.localizedCaseInsensitiveContains("authenticate") || error.localizedCaseInsensitiveContains("login") {
|
||||||
|
throw ClaudeCodeCLIError.failed("Claude Code isn't logged in. Run `claude /login` in a terminal.")
|
||||||
|
}
|
||||||
|
// A resume that points at a gone session: start fresh next time.
|
||||||
|
if error.localizedCaseInsensitiveContains("No conversation found") { sessionID = nil }
|
||||||
|
throw ClaudeCodeCLIError.failed(error)
|
||||||
|
}
|
||||||
|
if p.terminationReason == .uncaughtSignal { throw CancellationError() }
|
||||||
|
if parser.finalText == nil, p.terminationStatus != 0 {
|
||||||
|
let stderr = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
||||||
|
let last = stderr.split(separator: "\n").last.map(String.init) ?? "exit code \(p.terminationStatus)"
|
||||||
|
throw ClaudeCodeCLIError.failed("claude: \(last)")
|
||||||
|
}
|
||||||
|
return parser.finalText ?? parser.visibleText
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let notchPrompt = """
|
||||||
|
You are talking through BroV, a tiny assistant living in the notch of the user's Mac. \
|
||||||
|
The chat window is small: keep answers compact, use light Markdown (short paragraphs, lists, \
|
||||||
|
**bold**, `code`), avoid tables and big headings. Respond in the user's language.
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - stream-json parsing
|
||||||
|
|
||||||
|
/// Turns the CLI's stream-json lines into the text shown in the chat bubble:
|
||||||
|
/// assistant text as it streams, plus a short `⚙ Tool` line for each tool call.
|
||||||
|
struct StreamParser {
|
||||||
|
struct Update {
|
||||||
|
var sessionID: String?
|
||||||
|
var textChanged = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private(set) var visibleText = ""
|
||||||
|
private(set) var finalText: String?
|
||||||
|
private(set) var errorMessage: String?
|
||||||
|
/// Text of the message currently streaming (partial deltas), not yet committed.
|
||||||
|
private var liveText = ""
|
||||||
|
private var committed = ""
|
||||||
|
|
||||||
|
mutating func consume(_ line: String) -> Update? {
|
||||||
|
guard let data = line.data(using: .utf8),
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let type = json["type"] as? String else { return nil }
|
||||||
|
var update = Update(sessionID: json["session_id"] as? String)
|
||||||
|
|
||||||
|
switch type {
|
||||||
|
case "stream_event":
|
||||||
|
guard let event = json["event"] as? [String: Any] else { break }
|
||||||
|
if event["type"] as? String == "content_block_delta",
|
||||||
|
let delta = event["delta"] as? [String: Any],
|
||||||
|
delta["type"] as? String == "text_delta",
|
||||||
|
let text = delta["text"] as? String {
|
||||||
|
liveText += text
|
||||||
|
update.textChanged = refresh()
|
||||||
|
} else if event["type"] as? String == "message_start" {
|
||||||
|
liveText = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
case "assistant":
|
||||||
|
// The complete message: commit its text (replacing the partial deltas) and tools.
|
||||||
|
guard let message = json["message"] as? [String: Any],
|
||||||
|
let content = message["content"] as? [[String: Any]] else { break }
|
||||||
|
if json["error"] != nil || message["model"] as? String == "<synthetic>" {
|
||||||
|
if let text = content.first(where: { $0["type"] as? String == "text" })?["text"] as? String {
|
||||||
|
errorMessage = text
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
var piece = ""
|
||||||
|
for block in content {
|
||||||
|
switch block["type"] as? String {
|
||||||
|
case "text":
|
||||||
|
if let t = block["text"] as? String { piece += t }
|
||||||
|
case "tool_use":
|
||||||
|
let name = block["name"] as? String ?? "tool"
|
||||||
|
piece += (piece.isEmpty ? "" : "\n\n") + "`⚙ \(Self.toolLabel(name, block["input"]))`"
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !piece.isEmpty {
|
||||||
|
committed += (committed.isEmpty ? "" : "\n\n") + piece.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
liveText = ""
|
||||||
|
update.textChanged = refresh()
|
||||||
|
|
||||||
|
case "result":
|
||||||
|
let isError = json["is_error"] as? Bool ?? false
|
||||||
|
let result = json["result"] as? String
|
||||||
|
if isError {
|
||||||
|
if errorMessage == nil { errorMessage = result ?? "Claude Code returned an error." }
|
||||||
|
} else {
|
||||||
|
// Keep the tool trail when there was one; otherwise the clean final answer.
|
||||||
|
finalText = committed.isEmpty ? (result ?? "") : committed
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return update
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func refresh() -> Bool {
|
||||||
|
let live = liveText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let next = live.isEmpty ? committed : committed + (committed.isEmpty ? "" : "\n\n") + live
|
||||||
|
guard next != visibleText else { return false }
|
||||||
|
visibleText = next
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Bash: git status", "Read: Foo.swift", or just the tool name.
|
||||||
|
static func toolLabel(_ name: String, _ input: Any?) -> String {
|
||||||
|
guard let input = input as? [String: Any] else { return name }
|
||||||
|
let detail: String?
|
||||||
|
if let command = input["command"] as? String {
|
||||||
|
detail = command
|
||||||
|
} else if let path = (input["file_path"] ?? input["path"] ?? input["notebook_path"]) as? String {
|
||||||
|
detail = (path as NSString).lastPathComponent
|
||||||
|
} else if let pattern = input["pattern"] as? String {
|
||||||
|
detail = pattern
|
||||||
|
} else if let url = input["url"] as? String {
|
||||||
|
detail = url
|
||||||
|
} else if let query = input["query"] as? String {
|
||||||
|
detail = query
|
||||||
|
} else {
|
||||||
|
detail = nil
|
||||||
|
}
|
||||||
|
guard var d = detail?.replacingOccurrences(of: "\n", with: " "), !d.isEmpty else { return name }
|
||||||
|
if d.count > 60 { d = String(d.prefix(57)) + "…" }
|
||||||
|
d = d.replacingOccurrences(of: "`", with: "'")
|
||||||
|
return "\(name): \(d)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -217,6 +217,10 @@ final class ClaudeService {
|
|||||||
// MARK: - Chat (multi-turn, natural text + web search)
|
// 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
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
|
|||||||
@@ -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."
|
|
||||||
@@ -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"
|
|
||||||
Reference in New Issue
Block a user