commit 53b4f60da1a43b1d717a133fa7d336551c59792e Author: maksarsanjeev Date: Tue Oct 6 19:34:32 2026 +0300 Import Coucou 0.1.9 (Louis-CFM/coucou@83708fe), Mac app only Co-Authored-By: Claude Opus 5.5 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3598ec2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +docs/** linguist-documentation +design/** linguist-documentation diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..52c3a58 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Build artefacts +NotchBuddy/build/ +NotchBuddy/NotchBuddy.xcodeproj/project.xcworkspace/xcuserdata/ +NotchBuddy/NotchBuddy.xcodeproj/xcuserdata/ +NotchBuddy/NotchBuddy.xcodeproj/xcshareddata/ +*.xcuserstate + +# Xcode DerivedData (outside repo — listed for clarity) +DerivedData/ + +# macOS +.DS_Store +.AppleDouble +.LSOverride + +# iCloud stubs +*.icloud + +# Local only (never published) +.claude/ +_prive/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b9c1b1f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,97 @@ +# Changelog + +## 0.1.9 — October 6, 2026 + +- Services up close on the iPhone: tap a service and your Mac fetches live data from its API — Vercel, GitHub, Stripe, Resend, Cal.com, n8n and Notion. The keys never leave the Mac; the detail is written to your iCloud encrypted (#251) +- Act from the iPhone: Vercel (redeploy, promote to production, cancel a build), GitHub (re-run failed jobs, approve, squash and merge), n8n (activate, deactivate, retry a failed run). Each action runs only if it was offered on an item in the last detail the Mac published for that service, is used once, and must be less than 5 minutes old. Nothing that moves money or sends an email (#251) +- The Live Activity starts 20 seconds after the Mac locks, not immediately, so a quick lock and unlock doesn't spend one of iOS's hourly starts. It starts right away when an agent is waiting for a permission or has a question (#251) +- After unlocking, the Live Activity waits 30 seconds before ending, in case the Mac locks again — useful on a laptop that goes to sleep the moment you put it down (#251) +- If the iPhone has no update token yet (iOS held back the start), and an approval or question is waiting, the Mac starts the activity again once for that specific request (#251) +- Cal.com upcoming bookings work again: the API v2 expects `afterStart` / `beforeEnd`, not `start` / `end`, so the bookings page was empty (#251) + +## 0.1.8 — October 5, 2026 + +- Coucou on iPhone: turn on Settings → General → iPhone (off by default) and your agent sessions show up live in the Coucou iPhone app and its widgets, through your own private iCloud. Project names, commands and questions are encrypted with your iCloud keys; turning it off deletes them (#209, #211, #212, #213) +- Allow or deny a permission from the iPhone: a notification with the command, Deny right from it, Allow behind Face ID. Your Mac only applies a decision meant for the exact request it is waiting on, and the request expires after 2 minutes. The iPhone keeps a history of your decisions (#220) +- Lock your Mac while an agent works and Mochi moves to your iPhone's Lock Screen and Dynamic Island, then comes back to the notch when you unlock. Turn it on under Settings → General → iPhone. It goes through a small relay that only sees the agent's name and state (#221) +- Mochi, the pills and the diff engine now live in a shared package used by both apps; nothing changes in the notch (#210) +- The iPhone sees more of what your Mac sees: every service Mochi (GitHub, Stripe, Vercel, Resend, Cal.com, n8n, Notion) with its latest items, and the last turn of each session with its commands and diffs, all encrypted with your iCloud keys. No API key ever leaves the Mac (#224) +- Send the next instruction to Claude Code from the iPhone (GitHub build, off by default): your Mac picks it up within 15 seconds and continues the session in its own folder (#224) +- Answer Claude's questions from the iPhone: your Mac applies an answer only if it matches the question still waiting (#241) +- The Live Activity counts the time since Mochi left, and shows Allow and Deny while a command waits for you (#232, #241) +- A new coucou sound for Mochi's greeting (#241) + +## 0.1.7 — October 4, 2026 + +- Keyboard shortcuts from anywhere: ⌃⌥Space opens the chat, ⌃⌥A jumps to a waiting permission or question, ⌃⌥T brings your terminal forward, ⌃⌥] and ⌃⌥[ switch pills, ⌃⌥M mutes Mochi, ⌃⌥D sends him to the desktop and back, ⌃⌥G opens the wardrobe, and ⌃⌥W attaches the front window to the chat (GitHub build) (#205) +- In the open island: ⌘← ⌘→ and ⌘1–9 switch pills, ⌘↑ ⌘↓ and ⌘O move through a card's list, ⌘E opens the diff, ⌘↩ sends, ⌘K starts a new chat, ⌘P pins the island (#205) +- Every global shortcut can be changed or turned off in Settings → Shortcuts, which also flags combinations another app already uses. They need no Accessibility permission (#205) +- ⌘⇧N now opens and closes the island (#205) + +## 0.1.6 — October 4, 2026 + +- Mochi on the desktop: drag him out of the notch and drop him anywhere on your desktop. He hangs out there, follows your cursor with his eyes, wears his outfit and dances to your music (#198) +- When Claude needs you, he flies back to the notch with the permission or the question, then returns to his spot once you answer. He does a happy jump when a task finishes (#198) +- Click him to poke him, right-click for the wardrobe, drop him on a window to attach it to the chat (GitHub build), and drop him on the notch or double-click him to bring him home (#198) +- He falls asleep when nothing is going on, and remembers his spot between launches (#198) + +## 0.1.5 — October 4, 2026 + +- Dress Mochi up: right-click him to open the wardrobe and pick a party hat, beanie, crown, witch hat, Santa hat, bunny ears, bow, sunglasses, round glasses, scarf or pumpkin, all drawn in code (#195) +- Auto mode dresses Mochi for the seasons on his own (#195) +- Outfits follow his head in 3D, glasses stay on his eyes, soft parts react when you tap or move him, and outfits come and go with a transition. Only the main Mochi wears them (#195) +- A new launch greeting: Mochi drops into the island, bounces, slides to the side and waves hello with a quick little hand, then comes back, with a new soft whisper of a sound (#196) +- Mochi's body is no longer clipped at two corners during the greeting (#196) + +## 0.1.4 — October 3, 2026 + +- See what Claude is editing, live: each file edit shows up in the session ticker with its +N −M lines, and a click opens the diff right in the notch (#177) +- When Claude finishes, the session card shows its final message instead of the last step, without the shimmer (#177, #179) +- GitHub pill: your open pull requests with their CI status, the pull requests waiting for your review, and the CI of the default branch of your recent repos. Click a row for the list, then an item to open it on github.com (#181) +- GitHub alerts: a badge and a sound when the CI of one of your pull requests turns red or green, when a default branch breaks, or when someone requests your review. Fast CI runs are caught too, and the card refreshes when you open it (#181, #185) +- Your GitHub contribution grid: the last 7 days in the GitHub card header, click it for the past 23 weeks, and click a day for its count (#187) +- The GitHub token needs read access to pull requests and CI: a classic token with the repo scope, or a fine-grained token with read access to Pull requests, Commit statuses and Actions (#181) +- The finished view no longer overflows the card (#179) + +## 0.1.3 — October 3, 2026 + +- Answer Claude's questions from the notch: when Claude Code asks a multiple-choice question, pick an option or type your own answer right in the island, and Reply in terminal hands it back. Update your hooks in Settings to turn it on (#165) — thanks @Vega8991 for the idea (#94) +- Claude plan usage (GitHub build): turn on Settings → Agents → Plan usage to see your 5-hour and weekly limits in a small pill in the notch header, and click it for the details and reset times. Pro and Max plans; your current status line keeps working (#159) +- Chat with local models through Ollama or LM Studio, no API key needed: connect them in Settings → Chat → Local models. Answers stream in, and thinking blocks stay hidden (#156) +- Markdown in chat answers: bold, lists, headings, quotes, and code blocks with a copy button. Links open only when they are web links (#156) +- Apple Music (GitHub build): see what is playing in the notch, play, pause and skip on hover, and Mochi dances along (#144, #153) +- Settings are now organized in a sidebar (#153) +- The chat greets you by your own first name (#154) + +## 0.1.2 — October 2, 2026 + +- Codex support (GitHub build): sessions show up live on the Codex pill, and permission requests get Allow and Deny in the notch. Install from Settings → Codex Hooks, then trust the hooks once with /hooks in Codex (#130) — thanks @lacatu5 +- Cursor: Claude Code started in Cursor's terminal shows up on the Cursor pill, and you can answer its permission requests from the notch (#120). +- Pick your main coding tool in Settings → Active pills: VS Code, Cursor, Codex or Antigravity (Codex and Antigravity: GitHub build). It stays on and no longer takes one of the 4 slots (#120). +- The permission card stays in the notch until you answer it: the mouse no longer folds it, and reopening the island shows the request again (#117). +- The permission card also shows when the island is already open, and the pill you were on comes back once you answer (#120). + +## 0.1.1 — October 2, 2026 + +- Declare the tools you use in Settings: Gemini CLI, Antigravity, Anthropic, Google AI and OpenAI pills join the existing ones (Cursor and Codex pills are coming soon), and you pick the main pill. +- Chat now supports Google AI (Gemini) and OpenAI in addition to Anthropic; switch provider and model by clicking the model name in the chat view, on macOS. +- Linux version: the Tauri app now builds for Linux too (AppImage, .deb, .rpm), with the island as a layer-shell overlay on Wayland and Claude Code hooks over a private Unix socket (#21) — thanks @Davy133 +- Compact island on screens without a notch (#22) — thanks @Kamasoutra +- Only web links (http/https) open from the notch; other kinds of links from Claude or integrations are ignored (#16) — thanks @Cris1670 +- Hook socket limited to your own user account, with size and time limits; logs no longer keep commands, n8n data or full URLs, and stay under 1 MB (#16) — thanks @Cris1670 and @Vignesh-Thangamariappan +- The island always reopens after folding, and Settings opens below it, resizable — thanks @rouderz +- Choose the Claude model for the chat in Settings; the list comes from your Anthropic account, and Claude Sonnet 4.6 stays the default — thanks @rouderz +- Windows build artifacts are now downloadable from a manual CI run — thanks @MysJofR +- Any agent can talk to Mochi: tag a hook payload with `coucou_agent` (e.g. `nb-hook --agent my-agent`) and it gets its own pill in the island (#7, #9) — thanks @lacatu5 +- Gemini CLI and Antigravity (agy) hook support on macOS: install from Settings and their sessions show up in the island — thanks @corefusiion + +## 0.1.0 — September 27, 2026 + +- First release: Mochi lives in your notch, breathing, blinking, with eyes that follow your cursor +- Claude Code sessions: live steps, approve permissions, answer questions, jump to the terminal +- Chat with Claude from the notch +- Drop a file on the notch to ask a question about it or send it by email +- Drag Mochi onto any window to attach it as context +- Integrations: Stripe, n8n, GitHub, Vercel, Resend, Notion, Cal.com +- 28 handcrafted sounds +- Hides when idle, peeks out when you hover diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c666caa --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,32 @@ +# Coucou — 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. + +## Where things are +- `NotchBuddy/Sources/App/` — Mac-only Swift code. `NotchBuddy/Sources/CoucouKit/` — code shared with the iPhone app (Mochi's BotEngine and outfits, pills, diff, models). `NotchBuddy/Sources/Phone/` — iPhone app (`CoucouPhone` target), with its widgets in `Sources/Widgets/` and the expanded approval notification in `Sources/NotificationContent/`. `NotchBuddy/Resources/sounds/` — the 28 WAV sounds. `NotchBuddy/project.yml` — XcodeGen project (never edit the `.xcodeproj` by hand). +- `NotchBuddy/Sources/CoucouKit/PillCatalog.swift` — single source of truth for all declared pills (workspace tools, agents, AI providers, services). Every pill ID, color, category and subtitle lives here. +- `docs/SPEC.md`, `docs/INTEGRATIONS.md` — behaviour, views, states, integrations (in French). +- `design/prototype/notch-buddy.html` — original prototype, the visual source of truth. `design/captures/` — target screenshots. +- `windows/` — the Tauri app for Windows and Linux: Rust in `src-tauri/`, TypeScript in `src/`, the `coucou-hook` relay in `hook/`. `windows/README.md` lists what differs from the Mac. +- `docs/*.html` — the GitHub Pages site (privacy, terms, support, legal notice). +- `relay/` — the Coucou relay, a stateless Cloudflare Worker that forwards the iPhone Live Activity pushes (it holds the APNs key, which must never ship in an app). See `relay/README.md`. + +## Build +``` +cd NotchBuddy && xcodegen && xcodebuild -scheme NotchBuddy -configuration Debug build +``` +Windows and Linux: `cd windows && npm install && npm run tauri dev` + +## Rules +- Swift 6, SwiftUI + AppKit. No third-party dependencies unless truly unavoidable. The character is drawn in code (`Canvas` + `TimelineView`), no Rive/Lottie/images. +- Secrets live in the Keychain, never on disk or in git. +- No telemetry. Network calls only to services the user configured. +- Never block Claude Code: if the app doesn't answer, the hook exits immediately. +- Never overwrite `~/.claude/settings.json`: dated backup, merge, show the diff, write only after the user confirms. +- Never send an email or approve a Claude Code or Codex permission without an explicit click. +- Performance: 0 % CPU when the island is hidden. +- Keep the bundle identifier `fr.louisraille.NotchBuddy` (Keychain items, preferences and permissions depend on it). +- Never restyle what already ships (pills, cards, Settings, chat…): existing views stay exactly as they are in `main`, which is the App Store build. Change the look of an existing view only when explicitly asked. +- Pill IDs are stable contract values (Keychain, UserDefaults, hook routing): never rename an existing pill ID. +- New views follow the existing app style. `design/prototype/notch-buddy.html` and `design/captures/` are references for new work, not a reason to change existing views. +- Every release adds its CHANGELOG.md section, a row in the README Versions table, and commits the regenerated Info.plist with the new version. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..879ac24 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,39 @@ +# Contributing to Coucou + +Thanks for wanting to help Mochi grow up! 🫶 + +## Getting started + +```bash +brew install xcodegen +cd NotchBuddy && xcodegen && open NotchBuddy.xcodeproj +``` + +Never edit `NotchBuddy.xcodeproj` by hand: change `project.yml` and run `xcodegen`. + +Check resting island dimensions on screens with and without a notch: + +```bash +bash scripts/test-screen-geometry.sh +``` + +## Good first contributions + +- A new service integration (a poller + an entry in `PillCatalog.swift` in the `.service` category + a detail card). Look at `StripePoller.swift` for a compact example. +- A new agent: any agent already gets its own automatic pill by sending `coucou_agent` in its hook payload (see `docs/AGENTS.md`). Add an entry in `PillCatalog.swift` in the `.agent` or `.workspace` category only if you want it to be declarable in Settings → Active pills. +- A new emote or sound for Mochi. +- Bug fixes — please describe how to reproduce. + +## Rules of the house + +- Swift 6, SwiftUI + AppKit, **no third-party dependencies** unless there's really no other way. +- Secrets go in the Keychain, never on disk or in git. +- No telemetry, no network calls except to services the user configured. +- Never block Claude Code: if the app doesn't answer, the hook must exit right away. +- Never write `~/.claude/settings.json` without a backup and the user's confirmation. +- Keep it light: 0 % CPU when the island is hidden. + +## Pull requests + +- One topic per PR, with a short GIF or screenshot for anything visual. +- Build must pass with no new warnings. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8efefc7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Louis Raillé + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/LICENSE-ASSETS.md b/LICENSE-ASSETS.md new file mode 100644 index 0000000..2a4d22d --- /dev/null +++ b/LICENSE-ASSETS.md @@ -0,0 +1,28 @@ +# Coucou — name, character and artwork + +Copyright (c) 2026 Louis Raillé. All rights reserved, except as stated below. + +The [MIT License](LICENSE) covers the **source code** of Coucou. It does **not** cover the brand and the artwork listed here, which remain the property of Louis Raillé: + +- the names **“Coucou”** and **“Mochi”**; +- the **Mochi character** — its design, look, expressions and animations as a character; +- the **app icon** and **menu bar icon** (`NotchBuddy/Assets.xcassets/`); +- the **sounds** (`NotchBuddy/Resources/sounds/`); +- the **images, GIFs and videos** in `docs/media/` and `design/`. + +## What you can do + +- Build and run Coucou from this repository, for yourself, as it is. +- Fork it and contribute back with pull requests. +- Show, review, write or talk about Coucou (articles, videos, posts), including screenshots and the demo media. + +## What you can't do without written permission + +- Publish or distribute an app, a fork or a derivative work under the name “Coucou” or “Mochi”, or with the Coucou icon, the Mochi character or the Coucou sounds — on the App Store, on GitHub releases, or anywhere else. +- Use any of these assets commercially, or in a way that suggests your project is Coucou or is made or endorsed by its author. + +If you fork Coucou to ship your own app, that's welcome under the MIT License: just give it **your own name, icon, character and sounds**. + +## Questions or permission requests + +Open an issue on [GitHub](https://github.com/Louis-CFM/coucou/issues) or write to raillelouis@gmail.com. diff --git a/NotchBuddy/Assets.xcassets/AppIcon.appiconset/Contents.json b/NotchBuddy/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..1717346 --- /dev/null +++ b/NotchBuddy/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images": [ + { + "idiom": "mac", + "size": "16x16", + "scale": "1x", + "filename": "icon_16x16.png" + }, + { + "idiom": "mac", + "size": "16x16", + "scale": "2x", + "filename": "icon_16x16@2x.png" + }, + { + "idiom": "mac", + "size": "32x32", + "scale": "1x", + "filename": "icon_32x32.png" + }, + { + "idiom": "mac", + "size": "32x32", + "scale": "2x", + "filename": "icon_32x32@2x.png" + }, + { + "idiom": "mac", + "size": "128x128", + "scale": "1x", + "filename": "icon_128x128.png" + }, + { + "idiom": "mac", + "size": "128x128", + "scale": "2x", + "filename": "icon_128x128@2x.png" + }, + { + "idiom": "mac", + "size": "256x256", + "scale": "1x", + "filename": "icon_256x256.png" + }, + { + "idiom": "mac", + "size": "256x256", + "scale": "2x", + "filename": "icon_256x256@2x.png" + }, + { + "idiom": "mac", + "size": "512x512", + "scale": "1x", + "filename": "icon_512x512.png" + }, + { + "idiom": "mac", + "size": "512x512", + "scale": "2x", + "filename": "icon_512x512@2x.png" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/NotchBuddy/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/NotchBuddy/Assets.xcassets/AppIcon.appiconset/icon_128x128.png new file mode 100644 index 0000000..80358b5 Binary files /dev/null and b/NotchBuddy/Assets.xcassets/AppIcon.appiconset/icon_128x128.png differ diff --git a/NotchBuddy/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/NotchBuddy/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png new file mode 100644 index 0000000..4aaea44 Binary files /dev/null and b/NotchBuddy/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png differ diff --git a/NotchBuddy/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/NotchBuddy/Assets.xcassets/AppIcon.appiconset/icon_16x16.png new file mode 100644 index 0000000..4ab622d Binary files /dev/null and b/NotchBuddy/Assets.xcassets/AppIcon.appiconset/icon_16x16.png differ diff --git a/NotchBuddy/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/NotchBuddy/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png new file mode 100644 index 0000000..f678cb2 Binary files /dev/null and b/NotchBuddy/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png differ diff --git a/NotchBuddy/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/NotchBuddy/Assets.xcassets/AppIcon.appiconset/icon_256x256.png new file mode 100644 index 0000000..78f8ba1 Binary files /dev/null and b/NotchBuddy/Assets.xcassets/AppIcon.appiconset/icon_256x256.png differ diff --git a/NotchBuddy/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/NotchBuddy/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png new file mode 100644 index 0000000..5bd5779 Binary files /dev/null and b/NotchBuddy/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png differ diff --git a/NotchBuddy/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/NotchBuddy/Assets.xcassets/AppIcon.appiconset/icon_32x32.png new file mode 100644 index 0000000..49d72b9 Binary files /dev/null and b/NotchBuddy/Assets.xcassets/AppIcon.appiconset/icon_32x32.png differ diff --git a/NotchBuddy/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/NotchBuddy/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png new file mode 100644 index 0000000..c618f66 Binary files /dev/null and b/NotchBuddy/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png differ diff --git a/NotchBuddy/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/NotchBuddy/Assets.xcassets/AppIcon.appiconset/icon_512x512.png new file mode 100644 index 0000000..0594e4b Binary files /dev/null and b/NotchBuddy/Assets.xcassets/AppIcon.appiconset/icon_512x512.png differ diff --git a/NotchBuddy/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/NotchBuddy/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png new file mode 100644 index 0000000..af79fdc Binary files /dev/null and b/NotchBuddy/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ diff --git a/NotchBuddy/Assets.xcassets/Contents.json b/NotchBuddy/Assets.xcassets/Contents.json new file mode 100644 index 0000000..74d6a72 --- /dev/null +++ b/NotchBuddy/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/NotchBuddy/Assets.xcassets/MenuBarIcon.imageset/Contents.json b/NotchBuddy/Assets.xcassets/MenuBarIcon.imageset/Contents.json new file mode 100644 index 0000000..42133dd --- /dev/null +++ b/NotchBuddy/Assets.xcassets/MenuBarIcon.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images": [ + { + "idiom": "universal", + "scale": "1x", + "filename": "menubar.png" + }, + { + "idiom": "universal", + "scale": "2x", + "filename": "menubar@2x.png" + }, + { + "idiom": "universal", + "scale": "3x", + "filename": "menubar@3x.png" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "template-rendering-intent": "template" + } +} \ No newline at end of file diff --git a/NotchBuddy/Assets.xcassets/MenuBarIcon.imageset/menubar.png b/NotchBuddy/Assets.xcassets/MenuBarIcon.imageset/menubar.png new file mode 100644 index 0000000..b89ab1d Binary files /dev/null and b/NotchBuddy/Assets.xcassets/MenuBarIcon.imageset/menubar.png differ diff --git a/NotchBuddy/Assets.xcassets/MenuBarIcon.imageset/menubar@2x.png b/NotchBuddy/Assets.xcassets/MenuBarIcon.imageset/menubar@2x.png new file mode 100644 index 0000000..b2ffb99 Binary files /dev/null and b/NotchBuddy/Assets.xcassets/MenuBarIcon.imageset/menubar@2x.png differ diff --git a/NotchBuddy/Assets.xcassets/MenuBarIcon.imageset/menubar@3x.png b/NotchBuddy/Assets.xcassets/MenuBarIcon.imageset/menubar@3x.png new file mode 100644 index 0000000..12c49ec Binary files /dev/null and b/NotchBuddy/Assets.xcassets/MenuBarIcon.imageset/menubar@3x.png differ diff --git a/NotchBuddy/Resources/Coucou.entitlements b/NotchBuddy/Resources/Coucou.entitlements new file mode 100644 index 0000000..cffd637 --- /dev/null +++ b/NotchBuddy/Resources/Coucou.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.automation.apple-events + + + diff --git a/NotchBuddy/Resources/CoucouAppStore.entitlements b/NotchBuddy/Resources/CoucouAppStore.entitlements new file mode 100644 index 0000000..0fc6072 --- /dev/null +++ b/NotchBuddy/Resources/CoucouAppStore.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + com.apple.security.files.user-selected.read-write + + com.apple.security.files.bookmarks.app-scope + + + diff --git a/NotchBuddy/Resources/CoucouAppStoreCloud.entitlements b/NotchBuddy/Resources/CoucouAppStoreCloud.entitlements new file mode 100644 index 0000000..8cb503f --- /dev/null +++ b/NotchBuddy/Resources/CoucouAppStoreCloud.entitlements @@ -0,0 +1,24 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + com.apple.security.files.user-selected.read-write + + com.apple.security.files.bookmarks.app-scope + + com.apple.developer.icloud-container-identifiers + + iCloud.fr.louisraille.Coucou + + com.apple.developer.icloud-services + + CloudKit + + com.apple.developer.aps-environment + development + + diff --git a/NotchBuddy/Resources/CoucouCloud.entitlements b/NotchBuddy/Resources/CoucouCloud.entitlements new file mode 100644 index 0000000..4ffd53e --- /dev/null +++ b/NotchBuddy/Resources/CoucouCloud.entitlements @@ -0,0 +1,18 @@ + + + + + com.apple.security.automation.apple-events + + com.apple.developer.icloud-container-identifiers + + iCloud.fr.louisraille.Coucou + + com.apple.developer.icloud-services + + CloudKit + + com.apple.developer.aps-environment + development + + diff --git a/NotchBuddy/Resources/CoucouPhone.entitlements b/NotchBuddy/Resources/CoucouPhone.entitlements new file mode 100644 index 0000000..6c70513 --- /dev/null +++ b/NotchBuddy/Resources/CoucouPhone.entitlements @@ -0,0 +1,20 @@ + + + + + aps-environment + development + com.apple.developer.icloud-container-identifiers + + iCloud.fr.louisraille.Coucou + + com.apple.developer.icloud-services + + CloudKit + + com.apple.security.application-groups + + group.fr.louisraille.Coucou + + + diff --git a/NotchBuddy/Resources/CoucouPhoneProduction.entitlements b/NotchBuddy/Resources/CoucouPhoneProduction.entitlements new file mode 100644 index 0000000..b40e790 --- /dev/null +++ b/NotchBuddy/Resources/CoucouPhoneProduction.entitlements @@ -0,0 +1,22 @@ + + + + + aps-environment + development + com.apple.developer.icloud-container-identifiers + + iCloud.fr.louisraille.Coucou + + com.apple.developer.icloud-services + + CloudKit + + com.apple.developer.icloud-container-environment + Production + com.apple.security.application-groups + + group.fr.louisraille.Coucou + + + diff --git a/NotchBuddy/Resources/CoucouReleaseCloud.entitlements b/NotchBuddy/Resources/CoucouReleaseCloud.entitlements new file mode 100644 index 0000000..90d7976 --- /dev/null +++ b/NotchBuddy/Resources/CoucouReleaseCloud.entitlements @@ -0,0 +1,24 @@ + + + + + com.apple.security.automation.apple-events + + com.apple.application-identifier + 256AUJ9555.fr.louisraille.NotchBuddy + com.apple.developer.team-identifier + 256AUJ9555 + com.apple.developer.icloud-container-identifiers + + iCloud.fr.louisraille.Coucou + + com.apple.developer.icloud-services + + CloudKit + + com.apple.developer.icloud-container-environment + Production + com.apple.developer.aps-environment + production + + diff --git a/NotchBuddy/Resources/CoucouWidgets.entitlements b/NotchBuddy/Resources/CoucouWidgets.entitlements new file mode 100644 index 0000000..e6893e8 --- /dev/null +++ b/NotchBuddy/Resources/CoucouWidgets.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.fr.louisraille.Coucou + + + diff --git a/NotchBuddy/Resources/Info.plist b/NotchBuddy/Resources/Info.plist new file mode 100644 index 0000000..ce0f538 --- /dev/null +++ b/NotchBuddy/Resources/Info.plist @@ -0,0 +1,39 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Coucou + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + fr.louisraille.NotchBuddy + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Coucou + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.9 + CFBundleVersion + 10 + LSUIElement + + NSAccessibilityUsageDescription + Pour lire le titre de la fenêtre active et l'attacher comme contexte. + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + NSAppleEventsUsageDescription + Pour sauter au terminal, envoyer des mails et piloter Musique. + NSHighResolutionCapable + + NSSupportsAutomaticGraphicsSwitching + + + diff --git a/NotchBuddy/Resources/InfoAppStore.plist b/NotchBuddy/Resources/InfoAppStore.plist new file mode 100644 index 0000000..c529d48 --- /dev/null +++ b/NotchBuddy/Resources/InfoAppStore.plist @@ -0,0 +1,39 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Coucou + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + fr.louisraille.Coucou + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Coucou + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.1 + CFBundleVersion + 5 + ITSAppUsesNonExemptEncryption + + LSApplicationCategoryType + public.app-category.developer-tools + LSUIElement + + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + NSHighResolutionCapable + + NSSupportsAutomaticGraphicsSwitching + + + diff --git a/NotchBuddy/Resources/InfoNotificationContent.plist b/NotchBuddy/Resources/InfoNotificationContent.plist new file mode 100644 index 0000000..acf7642 --- /dev/null +++ b/NotchBuddy/Resources/InfoNotificationContent.plist @@ -0,0 +1,40 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Coucou + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + fr.louisraille.Coucou.NotificationContent + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + CoucouNotificationContent + CFBundlePackageType + XPC! + CFBundleShortVersionString + 1.0 + CFBundleVersion + 3 + NSExtension + + NSExtensionAttributes + + UNNotificationExtensionCategory + COUCOU_APPROVAL + UNNotificationExtensionDefaultContentHidden + + UNNotificationExtensionInitialContentSizeRatio + 0.45000000000000001 + + NSExtensionPointIdentifier + com.apple.usernotifications.content-extension + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationViewController + + + diff --git a/NotchBuddy/Resources/InfoPhone.plist b/NotchBuddy/Resources/InfoPhone.plist new file mode 100644 index 0000000..9fb3925 --- /dev/null +++ b/NotchBuddy/Resources/InfoPhone.plist @@ -0,0 +1,67 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Coucou + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + fr.louisraille.Coucou + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Coucou + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleURLTypes + + + CFBundleURLName + fr.louisraille.Coucou + CFBundleURLSchemes + + coucou + + + + CFBundleVersion + 3 + CoucouAPNsEnvironment + $(COUCOU_APNS_ENV) + ITSAppUsesNonExemptEncryption + + LSRequiresIPhoneOS + + NSFaceIDUsageDescription + Coucou asks for Face ID before allowing a command from your iPhone. + NSMicrophoneUsageDescription + Coucou uses the microphone when you dictate an instruction for Claude Code. + NSSpeechRecognitionUsageDescription + Coucou turns your voice into the text of an instruction for Claude Code. + NSSupportsLiveActivities + + NSSupportsLiveActivitiesFrequentUpdates + + UIApplicationSupportsIndirectInputEvents + + UIBackgroundModes + + remote-notification + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/NotchBuddy/Resources/InfoWidgets.plist b/NotchBuddy/Resources/InfoWidgets.plist new file mode 100644 index 0000000..ff071e1 --- /dev/null +++ b/NotchBuddy/Resources/InfoWidgets.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Coucou + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + fr.louisraille.Coucou.Widgets + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + CoucouWidgets + CFBundlePackageType + XPC! + CFBundleShortVersionString + 1.0 + CFBundleVersion + 3 + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/NotchBuddy/Resources/sounds/annoyed.wav b/NotchBuddy/Resources/sounds/annoyed.wav new file mode 100644 index 0000000..04e7653 Binary files /dev/null and b/NotchBuddy/Resources/sounds/annoyed.wav differ diff --git a/NotchBuddy/Resources/sounds/approval.wav b/NotchBuddy/Resources/sounds/approval.wav new file mode 100644 index 0000000..df865d3 Binary files /dev/null and b/NotchBuddy/Resources/sounds/approval.wav differ diff --git a/NotchBuddy/Resources/sounds/approve.wav b/NotchBuddy/Resources/sounds/approve.wav new file mode 100644 index 0000000..ee3d7a4 Binary files /dev/null and b/NotchBuddy/Resources/sounds/approve.wav differ diff --git a/NotchBuddy/Resources/sounds/attach.wav b/NotchBuddy/Resources/sounds/attach.wav new file mode 100644 index 0000000..33cc68a Binary files /dev/null and b/NotchBuddy/Resources/sounds/attach.wav differ diff --git a/NotchBuddy/Resources/sounds/blip.wav b/NotchBuddy/Resources/sounds/blip.wav new file mode 100644 index 0000000..8359a94 Binary files /dev/null and b/NotchBuddy/Resources/sounds/blip.wav differ diff --git a/NotchBuddy/Resources/sounds/close.wav b/NotchBuddy/Resources/sounds/close.wav new file mode 100644 index 0000000..39c0271 Binary files /dev/null and b/NotchBuddy/Resources/sounds/close.wav differ diff --git a/NotchBuddy/Resources/sounds/dizzy.wav b/NotchBuddy/Resources/sounds/dizzy.wav new file mode 100644 index 0000000..598b5d9 Binary files /dev/null and b/NotchBuddy/Resources/sounds/dizzy.wav differ diff --git a/NotchBuddy/Resources/sounds/error.wav b/NotchBuddy/Resources/sounds/error.wav new file mode 100644 index 0000000..89738c9 Binary files /dev/null and b/NotchBuddy/Resources/sounds/error.wav differ diff --git a/NotchBuddy/Resources/sounds/finish.wav b/NotchBuddy/Resources/sounds/finish.wav new file mode 100644 index 0000000..c430e46 Binary files /dev/null and b/NotchBuddy/Resources/sounds/finish.wav differ diff --git a/NotchBuddy/Resources/sounds/greet.wav b/NotchBuddy/Resources/sounds/greet.wav new file mode 100644 index 0000000..7fa6d0c Binary files /dev/null and b/NotchBuddy/Resources/sounds/greet.wav differ diff --git a/NotchBuddy/Resources/sounds/greeting.wav b/NotchBuddy/Resources/sounds/greeting.wav new file mode 100644 index 0000000..27c807d Binary files /dev/null and b/NotchBuddy/Resources/sounds/greeting.wav differ diff --git a/NotchBuddy/Resources/sounds/gulp.wav b/NotchBuddy/Resources/sounds/gulp.wav new file mode 100644 index 0000000..c1680ac Binary files /dev/null and b/NotchBuddy/Resources/sounds/gulp.wav differ diff --git a/NotchBuddy/Resources/sounds/hover.wav b/NotchBuddy/Resources/sounds/hover.wav new file mode 100644 index 0000000..8d06940 Binary files /dev/null and b/NotchBuddy/Resources/sounds/hover.wav differ diff --git a/NotchBuddy/Resources/sounds/love.wav b/NotchBuddy/Resources/sounds/love.wav new file mode 100644 index 0000000..8c5b474 Binary files /dev/null and b/NotchBuddy/Resources/sounds/love.wav differ diff --git a/NotchBuddy/Resources/sounds/open.wav b/NotchBuddy/Resources/sounds/open.wav new file mode 100644 index 0000000..5a169d0 Binary files /dev/null and b/NotchBuddy/Resources/sounds/open.wav differ diff --git a/NotchBuddy/Resources/sounds/peek.wav b/NotchBuddy/Resources/sounds/peek.wav new file mode 100644 index 0000000..33dc5f3 Binary files /dev/null and b/NotchBuddy/Resources/sounds/peek.wav differ diff --git a/NotchBuddy/Resources/sounds/pop.wav b/NotchBuddy/Resources/sounds/pop.wav new file mode 100644 index 0000000..e551c18 Binary files /dev/null and b/NotchBuddy/Resources/sounds/pop.wav differ diff --git a/NotchBuddy/Resources/sounds/proud.wav b/NotchBuddy/Resources/sounds/proud.wav new file mode 100644 index 0000000..d6ad468 Binary files /dev/null and b/NotchBuddy/Resources/sounds/proud.wav differ diff --git a/NotchBuddy/Resources/sounds/question.wav b/NotchBuddy/Resources/sounds/question.wav new file mode 100644 index 0000000..9b6e025 Binary files /dev/null and b/NotchBuddy/Resources/sounds/question.wav differ diff --git a/NotchBuddy/Resources/sounds/rate.wav b/NotchBuddy/Resources/sounds/rate.wav new file mode 100644 index 0000000..2a0cc03 Binary files /dev/null and b/NotchBuddy/Resources/sounds/rate.wav differ diff --git a/NotchBuddy/Resources/sounds/search.wav b/NotchBuddy/Resources/sounds/search.wav new file mode 100644 index 0000000..eac1079 Binary files /dev/null and b/NotchBuddy/Resources/sounds/search.wav differ diff --git a/NotchBuddy/Resources/sounds/send.wav b/NotchBuddy/Resources/sounds/send.wav new file mode 100644 index 0000000..f493f0c Binary files /dev/null and b/NotchBuddy/Resources/sounds/send.wav differ diff --git a/NotchBuddy/Resources/sounds/slap.wav b/NotchBuddy/Resources/sounds/slap.wav new file mode 100644 index 0000000..d82a676 Binary files /dev/null and b/NotchBuddy/Resources/sounds/slap.wav differ diff --git a/NotchBuddy/Resources/sounds/sleep.wav b/NotchBuddy/Resources/sounds/sleep.wav new file mode 100644 index 0000000..41453cc Binary files /dev/null and b/NotchBuddy/Resources/sounds/sleep.wav differ diff --git a/NotchBuddy/Resources/sounds/think.wav b/NotchBuddy/Resources/sounds/think.wav new file mode 100644 index 0000000..49c1dde Binary files /dev/null and b/NotchBuddy/Resources/sounds/think.wav differ diff --git a/NotchBuddy/Resources/sounds/tick.wav b/NotchBuddy/Resources/sounds/tick.wav new file mode 100644 index 0000000..6039f66 Binary files /dev/null and b/NotchBuddy/Resources/sounds/tick.wav differ diff --git a/NotchBuddy/Resources/sounds/wink.wav b/NotchBuddy/Resources/sounds/wink.wav new file mode 100644 index 0000000..61d71fc Binary files /dev/null and b/NotchBuddy/Resources/sounds/wink.wav differ diff --git a/NotchBuddy/Resources/sounds/work.wav b/NotchBuddy/Resources/sounds/work.wav new file mode 100644 index 0000000..523ffc6 Binary files /dev/null and b/NotchBuddy/Resources/sounds/work.wav differ diff --git a/NotchBuddy/Resources/sounds/yawn.wav b/NotchBuddy/Resources/sounds/yawn.wav new file mode 100644 index 0000000..c96c66d Binary files /dev/null and b/NotchBuddy/Resources/sounds/yawn.wav differ diff --git a/NotchBuddy/Sources/App/AppDelegate.swift b/NotchBuddy/Sources/App/AppDelegate.swift new file mode 100644 index 0000000..bca82b9 --- /dev/null +++ b/NotchBuddy/Sources/App/AppDelegate.swift @@ -0,0 +1,123 @@ +import AppKit +import SwiftUI + +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate { + var statusItem: NSStatusItem? + private(set) var islandController: IslandWindowController? + + func applicationWillTerminate(_ notification: Notification) { + HotKeyCenter.shared.unregisterAll() + } + + func applicationDidFinishLaunching(_ notification: Notification) { + // Ignore SIGPIPE — prevents crash when nb-hook closes socket before we write response + signal(SIGPIPE, SIG_IGN) + // Warm up Keychain cache on main thread BEFORE any poller or view touches it + _ = KeychainStore.shared + NSApp.setActivationPolicy(.accessory) + setupMenuBarItem() + setupIsland() + #if PHONE_LINK + CloudProbe.shared.startIfEnabled() + #endif + } + + // MARK: - Menu bar + + private func setupMenuBarItem() { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + guard let button = statusItem?.button else { return } + button.image = NSImage(named: "MenuBarIcon") ?? NSImage(systemSymbolName: "circle.fill", accessibilityDescription: "Coucou") + button.image?.size = NSSize(width: 24, height: 18) + button.image?.accessibilityDescription = "Coucou" + button.image?.isTemplate = true + + let menu = NSMenu() + menu.addItem(withTitle: "Open Coucou", action: #selector(openIsland), keyEquivalent: "") + menu.addItem(.separator()) + menu.addItem(withTitle: "Settings…", action: #selector(openSettings), keyEquivalent: ",") + menu.addItem(.separator()) + menu.addItem(withTitle: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + + statusItem?.menu = menu + } + + // MARK: - Actions + + @objc private func openIsland() { + islandController?.expand(to: .overview) + } + + private var settingsWindow: NSWindow? + + @objc private func openSettingsFromNotification(_ notification: Notification) { + if let section = notification.object as? String { + UserDefaults.standard.set(section, forKey: "settingsSection") + } + openSettings() + } + + @objc private func openSettings() { + // The island floats above every window; fold it away so it can't cover Settings. + if AppState.shared.mode == .expanded { islandController?.collapse() } + + if let w = settingsWindow, w.isVisible { + placeBelowIsland(w) + w.makeKeyAndOrderFront(nil); NSApp.activate(ignoringOtherApps: true); return + } + let win = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 720, height: 560), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, defer: false) + win.title = "Settings — Coucou" + let host = NSHostingView(rootView: SettingsView()) + host.sizingOptions = [.minSize] + win.contentView = host + win.contentMinSize = NSSize(width: 640, height: 420) + win.isReleasedWhenClosed = false + placeBelowIsland(win) + settingsWindow = win + win.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + /// Centres the window horizontally and keeps its title bar clear of the island panel + /// (320 pt tall at the top of the notch screen), shrinking it to fit if needed. + private func placeBelowIsland(_ win: NSWindow) { + let screen = IslandWindowController.notchScreen() ?? NSScreen.main ?? win.screen + guard let screen else { win.center(); return } + let visible = screen.visibleFrame + let islandBottom = screen.frame.maxY - 320 - 12 // island panel height + margin + let top = min(visible.maxY, islandBottom) + var frame = win.frame + frame.size.height = min(frame.height, max(top - visible.minY - 12, win.minSize.height)) + frame.origin.x = visible.midX - frame.width / 2 + frame.origin.y = max(visible.minY + 12, top - frame.height) + win.setFrame(frame, display: true) + } + + // MARK: - Island setup + + private func setupIsland() { + islandController = IslandWindowController() + islandController?.showWindow(nil) + islandController?.fsm.launch() + HookServer.shared.start() + N8nPoller.shared.start() + VercelPoller.shared.start() + ResendPoller.shared.start() + GithubPoller.shared.start() + StripePoller.shared.start() + CalcomPoller.shared.start() + NotionPoller.shared.start() + NotificationCenter.default.addObserver(self, selector: #selector(openSettingsFromNotification(_:)), + name: .openFullSettings, object: nil) + // After the greeting ends, fly Mochi back to the desktop if it was there at last quit + NotificationCenter.default.addObserver(forName: .greetComplete, object: nil, queue: .main) { _ in + DesktopMochiController.shared.launchFlyIfNeeded() + } + #if !APPSTORE + _ = MusicController.shared + #endif + } +} diff --git a/NotchBuddy/Sources/App/AppLog.swift b/NotchBuddy/Sources/App/AppLog.swift new file mode 100644 index 0000000..64797a6 --- /dev/null +++ b/NotchBuddy/Sources/App/AppLog.swift @@ -0,0 +1,49 @@ +import Foundation + +// MARK: - Shared diagnostic log helpers + +/// Escapes ASCII control characters so log lines can't inject terminal sequences. +func escapedForLog(_ s: String) -> String { + s.unicodeScalars.map { sc -> String in + let v = sc.value + return (v < 0x20 || v == 0x7F) ? "\\u\(String(format: "%04X", v))" : String(sc) + }.joined() +} + +/// Appends one timestamped line to `~/Library/Logs/NotchBuddy/`. +/// - Log directory is created at mode 0700. +/// - Log file is set to mode 0600 on first creation and after each rotation. +/// - File is rotated (truncated) when it reaches 1 MB. +func appendAppLog(_ fileName: String, _ message: String, + timestampFormat: String = "yyyy-MM-dd HH:mm:ss") { + let fm = FileManager.default + let logsDir = fm.urls(for: .libraryDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Logs/NotchBuddy") + try? fm.createDirectory(at: logsDir, withIntermediateDirectories: true) + try? fm.setAttributes([.posixPermissions: 0o700 as NSNumber], ofItemAtPath: logsDir.path) + let logFile = logsDir.appendingPathComponent(fileName) + let f = DateFormatter(); f.dateFormat = timestampFormat + let line = "\(f.string(from: Date())) \(escapedForLog(message))\n" + guard let data = line.data(using: .utf8) else { return } + let maxLogBytes = 1_048_576 // 1 MB + if fm.fileExists(atPath: logFile.path) { + // Rotate when the file reaches the limit + let size = (try? fm.attributesOfItem(atPath: logFile.path)[.size] as? Int) ?? 0 + if size >= maxLogBytes { + try? fm.removeItem(at: logFile) + try? data.write(to: logFile, options: .atomic) + try? fm.setAttributes([.posixPermissions: 0o600 as NSNumber], ofItemAtPath: logFile.path) + return + } + if let handle = try? FileHandle(forWritingTo: logFile) { + handle.seekToEndOfFile() + handle.write(data) + try? handle.close() + } + // Ensure permissions even on existing files (idempotent) + try? fm.setAttributes([.posixPermissions: 0o600 as NSNumber], ofItemAtPath: logFile.path) + } else { + try? data.write(to: logFile, options: .atomic) + try? fm.setAttributes([.posixPermissions: 0o600 as NSNumber], ofItemAtPath: logFile.path) + } +} diff --git a/NotchBuddy/Sources/App/AppState.swift b/NotchBuddy/Sources/App/AppState.swift new file mode 100644 index 0000000..4a1047e --- /dev/null +++ b/NotchBuddy/Sources/App/AppState.swift @@ -0,0 +1,775 @@ +import Foundation +import SwiftUI +import Combine + + +@MainActor +final class AppState: ObservableObject { + static let shared = AppState() + + // Island state + @Published var mode: IslandMode = .hidden + @Published var view: IslandView = .overview + + // Tasks + @Published var tasks: [AgentTask] = [] + @Published var focusId: String? = nil + + // Bot state override + @Published var stateOverride: BotState? = nil + + // Real notch dimensions (set by IslandWindowController on launch) + var notchWidth: CGFloat = IslandConst.notchWidth + var notchHeight: CGFloat = IslandConst.notchHeight + var hasNotch = true + + // Last app active before NotchBuddy (for window context capture) + var lastExternalApp: NSRunningApplication? = nil + + // Bot drag-attach state (hides original bot while ghost follows cursor) + @Published var isDraggingBot: Bool = false + + // Desktop Mochi: true while Mochi lives on the desktop instead of the notch + @Published var mochiOnDesktop: Bool = false + + // Mouse tracking + var mousePosition: CGPoint = .zero + var lastMouseMove: Date = .now + var lastActivity: Date = .now + var isPresent: Bool = true + + // Pinned (alerts that stay open, never auto-close) + var isPinned: Bool = false + + // Keyboard navigation — index of the selected item within the current card's list (nil = none) + @Published var cardSelection: Int? = nil + // Number of navigable items in the card currently on screen (0 = no list) + @Published var cardItemCount: Int = 0 + + // Upload progress (0-1) — set to 1.0 only at completion; animation is time-based + @Published var uploadProgress: Double = 0 + + // Upload animation timing (non-published — TimelineViews read these directly) + var uploadStartTime: Date? + var uploadDuration: Double = 2.4 + + // File drag-over state (mailbox morph glow + mouth spring) + @Published var fileDragOver: Bool = false + + // Sound enabled — persisted + @Published var soundEnabled: Bool = true { + didSet { UserDefaults.standard.set(soundEnabled, forKey: "soundEnabled") } + } + + // Mochi outfit selection — persisted + @Published var mochiOutfitSelection: Outfit = .auto { + didSet { Outfit.stored = mochiOutfitSelection } + } + // Transient: outfit preview while hovering in wardrobe (overrides resolvedOutfit in BotCanvasView) + var wardrobePreviewOutfit: Outfit? = nil + // Per-day seasonal cache — avoids recomputing Easter and date math on every frame + private var _seasonalCache: (dayOfYear: Int, year: Int, outfit: Outfit)? + var resolvedOutfit: Outfit { + if let preview = wardrobePreviewOutfit { return preview } + guard mochiOutfitSelection == .auto else { return mochiOutfitSelection } + let cal = Calendar.current + let now = Date() + let day = cal.ordinality(of: .day, in: .year, for: now) ?? 0 + let year = cal.component(.year, from: now) + if let c = _seasonalCache, c.dayOfYear == day && c.year == year { return c.outfit } + let outfit = Outfit.seasonal(for: now, calendar: cal) + _seasonalCache = (dayOfYear: day, year: year, outfit: outfit) + return outfit + } + + // Claude model used by the chat and the search — persisted + static let defaultClaudeModel = "claude-sonnet-4-6" + @Published var claudeModel: String = AppState.defaultClaudeModel { + didSet { UserDefaults.standard.set(claudeModel, forKey: "claudeModel") } + } + + // In-chat provider + model — picked via the model selector in the prompt view + @Published var chatProvider: ChatProvider = .anthropic { + didSet { UserDefaults.standard.set(chatProvider.rawValue, forKey: "chatProvider") } + } + @Published var googleChatModel: String = ChatProvider.google.defaultModel { + didSet { UserDefaults.standard.set(googleChatModel, forKey: "googleChatModel") } + } + @Published var openAIChatModel: String = ChatProvider.openai.defaultModel { + didSet { UserDefaults.standard.set(openAIChatModel, forKey: "openAIChatModel") } + } + @Published var ollamaChatModel: String = ChatProvider.ollama.defaultModel { + didSet { UserDefaults.standard.set(ollamaChatModel, forKey: "ollamaChatModel") } + } + @Published var lmstudioChatModel: String = ChatProvider.lmstudio.defaultModel { + didSet { UserDefaults.standard.set(lmstudioChatModel, forKey: "lmstudioChatModel") } + } + @Published var ollamaServerURL: String = "" { + didSet { UserDefaults.standard.set(ollamaServerURL, forKey: "ollamaServerURL") } + } + @Published var lmstudioServerURL: String = "" { + didSet { UserDefaults.standard.set(lmstudioServerURL, forKey: "lmstudioServerURL") } + } + + // The always-on workspace pill (default: VS Code). Persisted. + @Published var mainPillId: String = PillCatalog.defaultMainPillId { + didSet { UserDefaults.standard.set(mainPillId, forKey: "mainPill") } + } + + // Dynamically fetched model lists for the in-chat picker (keyed by provider) + @Published var fetchedProviderModels: [ChatProvider: [(id: String, label: String)]] = [:] + @Published var providerModelFetchError: [ChatProvider: String] = [:] + @Published var loadingProviderModels: Set = [] + + /// Fetches models for `provider` if not already loaded or loading. + /// Sets `providerModelFetchError` if the key is absent or the request fails. + func fetchModelsIfNeeded(for provider: ChatProvider) { + guard !loadingProviderModels.contains(provider), + fetchedProviderModels[provider] == nil else { return } + // Local providers: fetch from server URL (no API key needed) + if provider.isLocal { + let baseURL = provider == .ollama ? ollamaServerURL : lmstudioServerURL + let normalised = LocalChat.normaliseURL(baseURL) + guard !normalised.isEmpty else { + providerModelFetchError[provider] = provider == .ollama + ? "Connect Ollama in Settings → Chat first." + : "Connect LM Studio in Settings → Chat first." + return + } + loadingProviderModels.insert(provider) + providerModelFetchError.removeValue(forKey: provider) + Task { + let result = await LocalChat.fetchModelsResult(baseURL: normalised) + loadingProviderModels.remove(provider) + switch result { + case .success(let models) where models.isEmpty: + providerModelFetchError[provider] = provider == .ollama + ? "No models yet. Download one in Ollama first." + : "No models yet. Download one in LM Studio first." + case .success(let models): + fetchedProviderModels[provider] = models + let current = provider == .ollama ? ollamaChatModel : lmstudioChatModel + if !models.contains(where: { $0.id == current }) { + let first = models.first!.id + if provider == .ollama { ollamaChatModel = first } + else { lmstudioChatModel = first } + } + case .failure: + providerModelFetchError[provider] = "Cannot reach \(normalised). Is the server running?" + } + } + return + } + // Remote providers: require API key + guard let apiKey = KeychainStore.shared.get(provider.keychainKey), !apiKey.isEmpty else { + providerModelFetchError[provider] = "No API key — add it in Settings." + return + } + loadingProviderModels.insert(provider) + providerModelFetchError.removeValue(forKey: provider) + Task { + let models: [(id: String, label: String)] + switch provider { + case .anthropic: models = await ClaudeService.fetchModels(apiKey: apiKey) + case .google: models = await ClaudeService.fetchGoogleModels(apiKey: apiKey) + case .openai: models = await ClaudeService.fetchOpenAIModels(apiKey: apiKey) + case .ollama, .lmstudio: models = [] // handled above + } + loadingProviderModels.remove(provider) + if models.isEmpty { + providerModelFetchError[provider] = "Failed to load models. Check your API key." + } else { + fetchedProviderModels[provider] = models + switch provider { + case .anthropic: + if !models.contains(where: { $0.id == claudeModel }) { + claudeModel = models.first(where: { $0.id.contains("sonnet") })?.id ?? models.first!.id + } + case .google: + if !models.contains(where: { $0.id == googleChatModel }) { + googleChatModel = models.first(where: { $0.id.contains("flash") })?.id ?? models.first!.id + } + case .openai: + if !models.contains(where: { $0.id == openAIChatModel }) { + openAIChatModel = models.first(where: { $0.id.contains("mini") })?.id ?? models.first!.id + } + case .ollama, .lmstudio: break + } + } + } + } + + /// The model currently active for chat (provider-aware). + var activeChatModel: String { + switch chatProvider { + case .anthropic: return claudeModel + case .google: return googleChatModel + case .openai: return openAIChatModel + case .ollama: return ollamaChatModel + case .lmstudio: return lmstudioChatModel + } + } + + // Sound volume (0–0.2) — persisted, synced to SoundEngine + @Published var soundVolume: Double = 0.12 { + didSet { + UserDefaults.standard.set(soundVolume, forKey: "soundVolume") + SoundEngine.shared.volume = Float(soundVolume) + } + } + + // Context for prompt (window attach / file) + @Published var promptContext: PromptContext? = nil + + // Dropped file (set during upload flow) + @Published var droppedFile: DroppedFile? = nil + + // Short note message (shown in NoteView) + @Published var noteMessage: String? = nil + + // Auto-close delay — persisted + @Published var autoCloseInterval: TimeInterval = 15 { + didSet { UserDefaults.standard.set(autoCloseInterval, forKey: "autoCloseInterval") } + } + + // Absence interval — persisted + var absenceInterval: TimeInterval = 3 * 60 { + didSet { UserDefaults.standard.set(absenceInterval, forKey: "absenceInterval") } + } + + // Greeting threshold — how long hidden before greeting on reappear (default 2 min) + var greetThresholdSeconds: TimeInterval = 120 { + didSet { UserDefaults.standard.set(greetThresholdSeconds, forKey: "greetThreshold") } + } + + // Hotkey to show island (e.g. ⌘⇧N) + @Published var hotkeyEnabled: Bool = false { + didSet { UserDefaults.standard.set(hotkeyEnabled, forKey: "hotkeyEnabled") } + } + var hotkeyFlags: UInt = NSEvent.ModifierFlags([.command, .shift]).rawValue { + didSet { UserDefaults.standard.set(Int(hotkeyFlags), forKey: "hotkeyFlags") } + } + var hotkeyCode: UInt16 = 45 { // 'n' + didSet { UserDefaults.standard.set(Int(hotkeyCode), forKey: "hotkeyCode") } + } + + // Vercel project filter — empty = watch all projects + @Published var vercelProjectFilter: Set = [] { + didSet { + if let data = try? JSONEncoder().encode(Array(vercelProjectFilter)) { + UserDefaults.standard.set(data, forKey: "vercelProjectFilter") + } + } + } + + // n8n workflow filter — empty = watch all workflows + @Published var n8nWorkflowFilter: Set = [] { + didSet { + if let data = try? JSONEncoder().encode(Array(n8nWorkflowFilter)) { + UserDefaults.standard.set(data, forKey: "n8nWorkflowFilter") + } + } + } + + // Active integration pills (main workspace pill excluded). Max 4. + @Published var activeIntegrations: Set = ["integration_resend", "integration_n8n", "integration_vercel", "integration_github"] { + didSet { + if let data = try? JSONEncoder().encode(Array(activeIntegrations)) { + UserDefaults.standard.set(data, forKey: "activeIntegrations") + } + // Clear stale GitHub data when the integration is disabled + if !activeIntegrations.contains("integration_github") && oldValue.contains("integration_github") { + githubPulse = nil + githubActivity = nil + } + } + } + + // Pending API result + @Published var searchResult: SearchResult? = nil + + // Vercel deployments (populated by VercelPoller) + @Published var vercelDeployments: [VercelDeployment] = [] + + // Resend emails (populated by ResendPoller) + @Published var resendEmails: [ResendEmail] = [] + @Published var resendTotal: Int? = nil + + // GitHub stats + pulse + activity (populated by GithubPoller) + @Published var githubStats: GitHubStats? = nil + @Published var githubPulse: GitHubPulse? = nil + @Published var githubActivity: GitHubActivity? = nil + + // Stripe (populated by StripePoller) + @Published var stripePayments: [StripePayment] = [] + @Published var stripeBalance: Int = 0 // raw balance in cents + @Published var stripeDisplayBalance: Int = 0 // animated balance target + @Published var stripeCurrency: String = "eur" + @Published var stripeLoaded: Bool = false // true after first successful poll + @Published var stripeError: String? = nil // last API error (nil = ok) + + // Cal.com (populated by CalcomPoller) + @Published var calcomBookings: [CalcomBooking] = [] + @Published var calcomLoaded: Bool = false + @Published var calcomError: String? = nil + + // Notion (populated by NotionPoller) + @Published var notionPages: [NotionPage] = [] + @Published var notionLoaded: Bool = false + @Published var notionError: String? = nil + + // n8n — the last executions, newest first (for the iPhone; the notch shows only the latest) + @Published var n8nRuns: [N8nRun] = [] + + // Chat conversation history + @Published var chatHistory: [ChatMessage] = [] + + // Pending approval request from Claude Code hook + @Published var pendingApproval: ApprovalInfo? = nil + + // Pending AskUserQuestion from Claude Code hook + @Published var pendingQuestion: AskQuestion? = nil + + // Per-pill flat list of FileDiffs, in order of reception. + // Not @Published — steps[] changes already trigger redraws. + var sessionDiffs: [String: [FileDiff]] = [:] + private var sessionDiffTimers: [String: DispatchWorkItem] = [:] + // Monotonically increasing — never reset, not even in clearSessionDiffs. + private var nextDiffId: Int = 0 + + @discardableResult + func appendSessionDiff(_ diff: FileDiff, for pillId: String) -> Int { + var d = diff + d.id = nextDiffId + nextDiffId += 1 + if sessionDiffs[pillId] == nil { sessionDiffs[pillId] = [] } + sessionDiffs[pillId]!.append(d) + // Keep at most 50 diffs per pill; drop oldest first + while sessionDiffs[pillId]!.count > 50 { + sessionDiffs[pillId]!.removeFirst() + } + resetSessionDiffTimer(for: pillId) + return d.id + } + + func clearSessionDiffs(for pillId: String) { + sessionDiffTimers[pillId]?.cancel() + sessionDiffTimers.removeValue(forKey: pillId) + sessionDiffs.removeValue(forKey: pillId) + // nextDiffId intentionally NOT reset — ids remain unique across sessions + } + + private func resetSessionDiffTimer(for pillId: String) { + sessionDiffTimers[pillId]?.cancel() + let work = DispatchWorkItem { [weak self] in + DispatchQueue.main.async { self?.clearSessionDiffs(for: pillId) } + } + sessionDiffTimers[pillId] = work + DispatchQueue.global().asyncAfter(deadline: .now() + 3600, execute: work) + } + + #if !APPSTORE + @Published var musicPlaying: Bool = false + @Published var musicAutomationDenied: Bool = false + #endif + + // Claude plan gauge (from statusline hook) + @Published var claudePlanUsage: PlanUsage? = nil { + didSet { + if let u = claudePlanUsage, + let data = try? JSONEncoder().encode(u) { + UserDefaults.standard.set(data, forKey: "claudePlanUsage") + } + } + } + + // Plan gauge: show pill in notch header — persisted + #if !APPSTORE + @Published var showPlanInNotch: Bool = false { + didSet { UserDefaults.standard.set(showPlanInNotch, forKey: "showPlanInNotch") } + } + // Cached relay-installed state — updated at launch, after install/uninstall, on Settings open + @Published var planRelayInstalled: Bool = false + // Transient — reset when island closes or view changes + @Published var showingPlanDetail: Bool = false + + func refreshPlanRelayState() { + planRelayInstalled = HookServer.statusLineInstalled() + } + #endif + + // MARK: - Init (loads persisted settings) + + private init() { + let ud = UserDefaults.standard + + if let v = ud.object(forKey: "soundEnabled") as? Bool { soundEnabled = v } + if let v = ud.object(forKey: "soundVolume") as? Double { soundVolume = v } + mochiOutfitSelection = Outfit.stored + if let v = ud.string(forKey: "claudeModel"), + !v.trimmingCharacters(in: .whitespaces).isEmpty { claudeModel = v } + if let v = ud.string(forKey: "chatProvider"), let p = ChatProvider(rawValue: v) { chatProvider = p } + if let v = ud.string(forKey: "googleChatModel"), !v.isEmpty { googleChatModel = v } + if let v = ud.string(forKey: "openAIChatModel"), !v.isEmpty { openAIChatModel = v } + if let v = ud.string(forKey: "ollamaChatModel"), !v.isEmpty { ollamaChatModel = v } + if let v = ud.string(forKey: "lmstudioChatModel"), !v.isEmpty { lmstudioChatModel = v } + if let v = ud.string(forKey: "ollamaServerURL"), !v.isEmpty { ollamaServerURL = v } + if let v = ud.string(forKey: "lmstudioServerURL"), !v.isEmpty { lmstudioServerURL = v } + // Migrate old 60s default → 15s + if let v = ud.object(forKey: "autoCloseInterval") as? Double { + autoCloseInterval = (v == 60) ? 15 : v + } + if let v = ud.object(forKey: "absenceInterval") as? Double { absenceInterval = v } + if let v = ud.object(forKey: "greetThreshold") as? Double { greetThresholdSeconds = v } + if let v = ud.object(forKey: "hotkeyEnabled") as? Bool { hotkeyEnabled = v } + if let v = ud.object(forKey: "hotkeyFlags") as? Int { hotkeyFlags = UInt(v) } + if let v = ud.object(forKey: "hotkeyCode") as? Int { hotkeyCode = UInt16(v) } + if let d = ud.data(forKey: "vercelProjectFilter"), + let a = try? JSONDecoder().decode([String].self, from: d) { vercelProjectFilter = Set(a) } + if let d = ud.data(forKey: "n8nWorkflowFilter"), + let a = try? JSONDecoder().decode([String].self, from: d) { n8nWorkflowFilter = Set(a) } + if let d = ud.data(forKey: "activeIntegrations"), + let a = try? JSONDecoder().decode([String].self, from: d) { activeIntegrations = Set(a) } + if let v = ud.string(forKey: "mainPill"), !v.isEmpty, + PillCatalog.available.contains(where: { $0.id == v && $0.category == .workspace && !$0.comingSoon }) { + mainPillId = v + } + if let d = ud.data(forKey: "claudePlanUsage"), + let u = try? JSONDecoder().decode(PlanUsage.self, from: d) { claudePlanUsage = u } + #if !APPSTORE + if let v = ud.object(forKey: "showPlanInNotch") as? Bool { showPlanInNotch = v } + planRelayInstalled = HookServer.statusLineInstalled() + #endif + + // Sync SoundEngine volume on launch + SoundEngine.shared.volume = Float(soundVolume) + + // Always load integration pills + loadIntegrationTasks() + } + + // MARK: - Computed + + var focusTask: AgentTask? { + tasks.first { $0.id == focusId } ?? tasks.first + } + + var effectiveState: BotState { + stateOverride ?? focusTask?.state ?? .idle + } + + // MARK: - Task management + + func addTask(_ task: AgentTask) { + guard !tasks.contains(where: { $0.id == task.id }) else { return } + tasks.append(task) + if focusId == nil { focusId = task.id } + syncMode() + syncView() + } + + func removeTask(id: String) { + // mainPillId: always reset, never remove (the active workspace tool) + // activeIntegrations: also reset (user declared it active, keep it as idle) + let isProtected = id == mainPillId + let isActiveDecl = PillCatalog.definition(for: id) != nil && activeIntegrations.contains(id) + if isProtected || isActiveDecl { + if let idx = tasks.firstIndex(where: { $0.id == id }) { + let catalogName = PillCatalog.definition(for: id)?.name + tasks[idx].state = .idle + tasks[idx].steps = [] + tasks[idx].stepIndex = 0 + tasks[idx].pillBadge = nil + if let n = catalogName { tasks[idx].name = n } + } + return + } + // Undeclared or declared-but-not-active: remove + tasks.removeAll { $0.id == id } + if focusId == id { focusId = mainPillId } + syncMode() + syncView() + } + + func updateTask(id: String, state: BotState) { + guard let idx = tasks.firstIndex(where: { $0.id == id }) else { return } + tasks[idx].state = state + } + + func setFocus(_ id: String) { + guard let idx = tasks.firstIndex(where: { $0.id == id }) else { return } + focusId = id + tasks[idx].pillBadge = nil // clear badge when user brings task to focus + } + + func setPillBadge(_ badge: PillBadge, for id: String) { + guard let idx = tasks.firstIndex(where: { $0.id == id }) else { return } + tasks[idx].pillBadge = badge + } + + /// Called on main thread after each GitHub pulse poll. Fires badge + sound based on events. + func handleGitHubEvents(_ events: [GitHubEvent]) { + guard !events.isEmpty else { return } + // Priority: error > question (reviewRequested) > finish (ciPassed) + var level = 0 // 0 = none, 1 = finish, 2 = question, 3 = error + var badge: PillBadge? + var sound: String? + for event in events { + switch event { + case .ciFailed, .mainFailed: + if level < 3 { level = 3; badge = .error; sound = "error" } + case .reviewRequested: + if level < 2 { level = 2; badge = .finished; sound = "question" } + case .ciPassed: + if level < 1 { level = 1; badge = .finished; sound = "finish" } + } + } + // Only set badge when the GitHub pill is not currently in focus + if let b = badge, focusId != "integration_github" { setPillBadge(b, for: "integration_github") } + if let s = sound { SoundEngine.shared.play(s) } + } + + func syncMode() { + // If no tasks and not expanded/peek, go hidden + if tasks.isEmpty && mode == .compact { + mode = .hidden + } else if !tasks.isEmpty && mode == .hidden && isPresent { + mode = .compact + } + } + + func syncView() { + guard mode == .expanded else { return } + if view == .empty && !tasks.isEmpty { view = .overview } + else if view == .overview && tasks.isEmpty { view = .empty } + } + + /// Load catalog pills into tasks, respecting activeIntegrations. Safe to call multiple times. + func loadIntegrationTasks() { + let catalog = PillCatalog.available + // Sanitize: remove saved IDs not in catalog + let catalogIds = Set(catalog.map { $0.id }) + activeIntegrations = activeIntegrations.filter { catalogIds.contains($0) } + // Validate mainPillId: must be a non-comingSoon workspace pill in the catalog + if !PillCatalog.available.contains(where: { $0.id == mainPillId && $0.category == .workspace && !$0.comingSoon }) { + mainPillId = PillCatalog.defaultMainPillId + } + // mainPillId must never be in activeIntegrations (migration + invariant) + activeIntegrations.remove(mainPillId) + for def in catalog { + // mainPillId always loads; activeIntegrations load + let shouldLoad = def.id == mainPillId || activeIntegrations.contains(def.id) + let loaded = tasks.contains(where: { $0.id == def.id }) + if shouldLoad && !loaded { + let task = AgentTask(id: def.id, name: def.name, color: def.color, + state: .idle, steps: [], source: def.source, isIntegration: true) + tasks.append(task) + } + if !shouldLoad && loaded { + tasks.removeAll { $0.id == def.id } + } + } + sortTasksByCatalog() + if focusId == nil { focusId = mainPillId } + syncMode() + } + + /// Toggle a catalog pill on/off. + /// mainPillId: never toggleable (change via the Main picker first). + /// Max 4 non-main pills active at once. + func toggleIntegration(_ id: String) { + guard id != mainPillId else { return } + guard PillCatalog.available.contains(where: { $0.id == id }) else { return } + if activeIntegrations.contains(id) { + activeIntegrations.remove(id) + tasks.removeAll { $0.id == id } + if focusId == id { focusId = mainPillId } + } else { + guard activeIntegrations.count < 4 else { return } + activeIntegrations.insert(id) + if let def = PillCatalog.available.first(where: { $0.id == id }), + !tasks.contains(where: { $0.id == id }) { + let task = AgentTask(id: def.id, name: def.name, color: def.color, + state: .idle, steps: [], source: def.source, isIntegration: true) + tasks.append(task) + sortTasksByCatalog() + } + } + syncMode() + } + + /// Sort tasks so catalog pills are in catalog order, undeclared pills sit right after + /// integration_claude (matching HookServer insertion behaviour), and the rest follows. + private func sortTasksByCatalog() { + let order = PillCatalog.available.enumerated() + .reduce(into: [String: Int]()) { $0[$1.element.id] = $1.offset } + let catalogPills = tasks.filter { order[$0.id] != nil } + let undeclaredPills = tasks.filter { order[$0.id] == nil } + let sortedCatalog = catalogPills.sorted { (order[$0.id] ?? 0) < (order[$1.id] ?? 0) } + if let claudeIdx = sortedCatalog.firstIndex(where: { $0.id == "integration_claude" }) { + var result: [AgentTask] = Array(sortedCatalog[...claudeIdx]) + result.append(contentsOf: undeclaredPills) + if claudeIdx + 1 < sortedCatalog.count { + result.append(contentsOf: sortedCatalog[(claudeIdx + 1)...]) + } + tasks = result + } else { + tasks = undeclaredPills + sortedCatalog + } + } + +} + +// MARK: - Supporting types + +enum PromptContext { + case window(appName: String, title: String, url: String?) + case file(name: String, fileURL: URL?) +} + +struct DroppedFile { + var url: URL + var name: String +} + +struct SearchResult { + var title: String + var items: [ResultItem] + var note: String? +} + +struct ResultItem { + var label: String + var detail: String + var url: String? +} + +// MARK: - Vercel + +struct VercelDeployment: Identifiable { + let id: String + let projectName: String + let url: String + let state: String // "READY", "ERROR", "CANCELED" + let createdAt: Date + let commitMessage: String? + let branch: String? + + var isSuccess: Bool { state == "READY" } + var statusLabel: String { isSuccess ? "Ready" : (state == "CANCELED" ? "Canceled" : "Error") } + var timeAgo: String { + let diff = Date().timeIntervalSince(createdAt) + if diff < 60 { return "just now" } + if diff < 3600 { return "\(Int(diff/60))m" } + if diff < 86400 { return "\(Int(diff/3600))h" } + return "\(Int(diff/86400))d" + } +} + +// MARK: - Resend + +struct ResendEmail: Identifiable { + let id: String + let to: [String] + let subject: String + let createdAt: Date + let lastEvent: String // "delivered", "bounced", "complained", "opened", etc. + + var recipientShort: String { + guard let first = to.first else { return "?" } + return first.components(separatedBy: "@").first ?? first + } + var timeAgo: String { + let diff = Date().timeIntervalSince(createdAt) + if diff < 60 { return "just now" } + if diff < 3600 { return "\(Int(diff/60))m" } + if diff < 86400 { return "\(Int(diff/3600))h" } + return "\(Int(diff/86400))d" + } + var isDelivered: Bool { lastEvent == "delivered" } +} + +// MARK: - GitHub + +struct GitHubStats { + let totalRepos: Int + let totalStars: Int +} + +// MARK: - Stripe + +struct StripePayment: Identifiable, Equatable { + let id: String + let amount: Int // in cents/smallest unit + let currency: String + let description: String? + let createdAt: Date + let status: String // "succeeded", "pending", "failed" + + var amountFormatted: String { String(format: "%.2f", Double(amount) / 100.0) } + var isSuccess: Bool { status == "succeeded" } + var timeAgo: String { + let diff = Date().timeIntervalSince(createdAt) + if diff < 60 { return "just now" } + if diff < 3600 { return "\(Int(diff/60))m" } + if diff < 86400 { return "\(Int(diff/3600))h" } + return "\(Int(diff/86400))d" + } +} + +// MARK: - Cal.com + +struct CalcomBooking: Identifiable, Equatable { + let id: Int + let title: String + let startTime: Date + let endTime: Date + let status: String + let attendeeName: String? + let attendeeEmail: String? + let attendeeNotes: String? + + var isActive: Bool { status == "ACCEPTED" || status == "PENDING" } + var timeLabel: String { + let f = DateFormatter(); f.dateFormat = "HH:mm"; return f.string(from: startTime) + } + var dayKey: String { + let c = Calendar.current.dateComponents([.year, .month, .day], from: startTime) + return "\(c.year!)-\(String(format: "%02d", c.month!))-\(String(format: "%02d", c.day!))" + } +} + +// MARK: - Notion + +struct N8nRun: Equatable { + let workflow: String + let detail: String? + let success: Bool + let date: Date +} + +struct NotionPage: Identifiable { + let id: String + let title: String + let emoji: String? + let lastEditedAt: Date + let url: String + + var timeAgo: String { + let diff = Date().timeIntervalSince(lastEditedAt) + if diff < 60 { return "now" } + if diff < 3600 { return "\(Int(diff/60))m" } + if diff < 86400 { return "\(Int(diff/3600))h" } + return "\(Int(diff/86400))d" + } +} + +// MARK: - Chat + +enum ChatRole { case user, assistant } + +struct ChatMessage: Identifiable, Equatable { + let id = UUID() + let role: ChatRole + var content: String // var for streaming updates +} diff --git a/NotchBuddy/Sources/App/AskQuestion.swift b/NotchBuddy/Sources/App/AskQuestion.swift new file mode 100644 index 0000000..e3e7266 --- /dev/null +++ b/NotchBuddy/Sources/App/AskQuestion.swift @@ -0,0 +1,60 @@ +import Foundation + +// MARK: - AskUserQuestion model (Foundation-only, testable) + +struct AskQuestionOption: Equatable { + var label: String + var description: String +} + +struct AskQuestionItem: Equatable { + var question: String // full question text + var header: String // ≤12 chars; used as section label + var options: [AskQuestionOption] // 2–4 choices + var multiSelect: Bool +} + +struct AskQuestion: Equatable { + var questions: [AskQuestionItem] // 1–4 questions + + // MARK: - Parse from tool_input dict + // Returns nil if the payload is malformed (fallback → Allow/Deny card). + static func parse(toolInput: [String: Any]) -> AskQuestion? { + guard let rawQs = toolInput["questions"] as? [[String: Any]], + !rawQs.isEmpty, rawQs.count <= 4 else { return nil } + var items: [AskQuestionItem] = [] + for raw in rawQs { + guard let question = raw["question"] as? String, !question.isEmpty, + let rawOpts = raw["options"] as? [[String: Any]], + rawOpts.count >= 2, rawOpts.count <= 4 else { return nil } + var opts: [AskQuestionOption] = [] + for opt in rawOpts { + guard let label = opt["label"] as? String, !label.isEmpty else { return nil } + let desc = opt["description"] as? String ?? "" + opts.append(AskQuestionOption(label: label, description: desc)) + } + let rawHeader = raw["header"] as? String ?? "" + let header = String(rawHeader.prefix(12)) + let multi = raw["multiSelect"] as? Bool ?? false + items.append(AskQuestionItem(question: question, header: header, options: opts, multiSelect: multi)) + } + return AskQuestion(questions: items) + } + + // MARK: - Build answers dict + // selections[i] = list of labels selected for question[i]. + // Single-select / Other…: one label → String value (Claude Code 2.1.85+). + // Multi-select: array of labels → [String] value (Claude Code 2.1.136+). + static func buildAnswers(questions: [AskQuestionItem], selections: [[String]]) -> [String: Any] { + var answers: [String: Any] = [:] + for (i, item) in questions.enumerated() { + guard i < selections.count, !selections[i].isEmpty else { continue } + if item.multiSelect { + answers[item.question] = selections[i] // array for multi-select + } else { + answers[item.question] = selections[i][0] // string for single-select + } + } + return answers + } +} diff --git a/NotchBuddy/Sources/App/BotCanvasView.swift b/NotchBuddy/Sources/App/BotCanvasView.swift new file mode 100644 index 0000000..38f5598 --- /dev/null +++ b/NotchBuddy/Sources/App/BotCanvasView.swift @@ -0,0 +1,231 @@ +import SwiftUI + +/// SwiftUI wrapper: TimelineView drives a Canvas that calls BotEngine.draw(). +/// Uses a shared engine per-task; the main bot uses AppState's shared engine. +struct BotCanvasView: View { + @ObservedObject var state: AppState + var particleOverhang: CGFloat = 0 + /// When set, overrides island-based eye-tracking (used by desktop Mochi). + /// CGPoint in the same coord space as state.mousePosition (y-down from screen top). + var lookOriginOverride: CGPoint? = nil + + // One engine per view instance (main bot) + @StateObject private var engine = BotEngine() + + var body: some View { + TimelineView(.animation(paused: state.mode == .hidden)) { timeline in + Canvas { context, size in + let now = timeline.date.timeIntervalSinceReferenceDate + let dtRaw = min(0.05, now - engine.lastTime) + let dt = dtRaw + engine.lookX = lookX(state: state, size: size) + engine.lookY = lookY(state: state, size: size) + engine.particleOverhang = particleOverhang + // Widen slot when file is hovering over the mailbox (morph > 0.5) + // Open mouth (hover=0.20R) when file dragged over box; close when not + if engine.morph > 0.3 { + engine.slotHTarget = state.fileDragOver ? 0.20 : 0 + } else { + engine.slotHTarget = 0 + if engine.morph < 0.05 { engine.slotH = 0; engine.slotHVel = 0 } + } + // Integration pills have a fixed brand color → use it as bodyColor. + // Claude Code tasks use state-based gradient (working=blue, thinking=purple, etc.). + #if !APPSTORE + if state.showingPlanDetail { + let hex = ClaudePlanGauge.color(for: state.claudePlanUsage.flatMap { ClaudePlanGauge.dominantPct($0) }) + engine.bodyColor = cgColorFromHex(hex) + } else { + engine.bodyColor = (state.focusTask?.isIntegration == true) + ? cgColorFromHex(state.focusTask!.color) + : nil + } + #else + engine.bodyColor = (state.focusTask?.isIntegration == true) + ? cgColorFromHex(state.focusTask!.color) + : nil + #endif + + // Compute shouldDance per-frame (no observer lag) + let dancing: Bool = { + #if !APPSTORE + guard AppState.shared.musicPlaying else { return false } + guard AppState.shared.activeIntegrations.contains("integration_music") else { return false } + let allowed: Set = [.idle, .working, .thinking, .searching, .finished] + guard allowed.contains(state.effectiveState) else { return false } + if state.mode == .compact { return true } + return state.mode == .expanded && state.view == .overview && state.focusId == "integration_music" + #else + return false + #endif + }() + engine.setDancing(dancing) + let isWardrobe = state.mode == .expanded && state.view == .wardrobe + let isFocusMain = state.focusId == state.mainPillId || state.focusId == nil + let showOutfit = isFocusMain || state.mode != .expanded || isWardrobe + engine.setOutfit(showOutfit ? state.resolvedOutfit : .none, + animated: state.view != .wardrobe) + + engine.update(dt: dt) + var ctx = context + engine.applyDance(&ctx, size: size) + // Rigid-roll: when Mochi wears an outfit (presence > 0.05) and is rolling, + // rotate the entire body+accessories context around the body center so the + // whole character genuinely turns. Particles/badge (drawHandsAndExtras) are + // drawn outside the rotated context and do not spin. + if engine.outfit != .none && engine.outfitPresence > 0.05 && abs(engine.roll) > 0.001 { + let center = engine.bodyCenter(size: size) + var rigidCtx = ctx + rigidCtx.translateBy(x: center.x, y: center.y) + rigidCtx.rotate(by: .radians(engine.roll)) + rigidCtx.translateBy(x: -center.x, y: -center.y) + engine.drawHandsBehind(context: rigidCtx, size: size) + engine.drawOutfitBehind(context: rigidCtx, size: size) + engine.draw(context: rigidCtx, size: size) + engine.drawOutfitFront(context: rigidCtx, size: size) + } else { + engine.drawHandsBehind(context: ctx, size: size) + engine.drawOutfitBehind(context: ctx, size: size) + engine.draw(context: ctx, size: size) + engine.drawOutfitFront(context: ctx, size: size) + } + engine.drawHandsAndExtras(context: ctx, size: size) + } + } + .onChange(of: state.effectiveState) { _, newState in + engine.setState(newState) + } + .onChange(of: state.view) { _, newView in + // Morph up when upload view is active + if state.mode == .expanded && newView == .upload { + engine.anim("morph", keys: [TweenKey(target: 1, duration: 550, ease: Ease.inOut)]) + } else if newView != .upload && newView != .uploading && engine.morph > 0.01 { + // Any other view (not mid-gulp): morph back + engine.anim("morph", keys: [TweenKey(target: 0, duration: 550, ease: Ease.inOut)]) + } + } + .onChange(of: state.mode) { _, newMode in + // Hard-reset morph when island collapses + if newMode != .expanded { + engine.tweens.removeValue(forKey: "morph") + engine.locks.remove("morph") + engine.morph = 0 + } + } + .onReceive(NotificationCenter.default.publisher(for: .triggerEmote)) { notif in + if let emote = notif.object as? BotEmote { + engine.triggerEmote(emote) + } + } + .onReceive(NotificationCenter.default.publisher(for: .triggerSlap)) { _ in + engine.slap() + } + .onReceive(NotificationCenter.default.publisher(for: .botBlink)) { _ in + engine.blink() + } + .onReceive(NotificationCenter.default.publisher(for: .botSetTgEs)) { notif in + if let v = notif.object as? CGFloat { + engine.tgEs = v + } + } + .onReceive(NotificationCenter.default.publisher(for: .botGulp)) { _ in + engine.gulp() + } + .onReceive(NotificationCenter.default.publisher(for: .botMorphTo)) { notif in + if let target = notif.object as? CGFloat { + let dur: CGFloat = target > 0.5 ? 550 : 650 + engine.anim("morph", keys: [TweenKey(target: target, duration: dur, ease: Ease.inOut)]) + } + } + .onReceive(NotificationCenter.default.publisher(for: .botGreet)) { _ in + engine.greet() + } + .onAppear { + engine.setState(state.effectiveState, force: true) + let isWardrobe = state.mode == .expanded && state.view == .wardrobe + let isFocusMain = state.focusId == state.mainPillId || state.focusId == nil + let showOutfit = isFocusMain || state.mode != .expanded || isWardrobe + engine.setOutfit(showOutfit ? state.resolvedOutfit : .none, animated: false) + } + } + + private func lookX(state: AppState, size: CGSize) -> CGFloat { + if let origin = lookOriginOverride { + return tanh((state.mousePosition.x - origin.x) / 260) + } + let screen = NSScreen.main ?? NSScreen.screens[0] + let (islandW, islandH) = islandSize(mode: state.mode, view: state.view, + progress: state.uploadProgress, + nw: state.notchWidth, nh: state.notchHeight) + let (botCx, _, _, _) = botPosition(mode: state.mode, view: state.view, + islandW: islandW, islandH: islandH, + uploadProgress: state.uploadProgress) + // Island is centered on screen; bot is at botCx within island coords + let botScreenX = screen.frame.midX - islandW / 2 + botCx + return tanh((state.mousePosition.x - botScreenX) / 260) + } + + private func lookY(state: AppState, size: CGSize) -> CGFloat { + if let origin = lookOriginOverride { + return -tanh((state.mousePosition.y - origin.y) / 200) + } + let (islandW, islandH) = islandSize(mode: state.mode, view: state.view, + progress: state.uploadProgress, + nw: state.notchWidth, nh: state.notchHeight) + let actualH: CGFloat = (state.mode == .expanded && state.view == .prompt) + ? min(300, 240 + CGFloat(state.chatHistory.count) * 40) + : islandH + let (_, botCy, _, _) = botPosition(mode: state.mode, view: state.view, + islandW: islandW, islandH: actualH, + uploadProgress: state.uploadProgress) + // Island top = screen top → bot screen Y = botCy from island top + return -tanh((state.mousePosition.y - botCy) / 200) + } +} + +/// Mini bot canvas (for agent pills/column) +struct MiniBotCanvasView: View { + let task: AgentTask + var isDancing: Bool = false + @StateObject private var engine: BotEngine + + init(task: AgentTask, isDancing: Bool = false) { + self.task = task + self.isDancing = isDancing + _engine = StateObject(wrappedValue: { + let e = BotEngine() + e.isMini = true + e.bodyColor = cgColorFromHex(task.color) + return e + }()) + } + + var body: some View { + TimelineView(.animation) { timeline in + Canvas { context, size in + let now = timeline.date.timeIntervalSinceReferenceDate + let dt = min(0.05, now - engine.lastTime) + engine.setDancing(isDancing) + engine.update(dt: dt) + var ctx = context + engine.applyDance(&ctx, size: size) + engine.draw(context: ctx, size: size) + } + } + .onChange(of: task.state) { _, newState in + engine.setState(newState) + } + .onAppear { + engine.setState(task.state, force: true) + if let emote = task.emote { + engine.setPermanentEmote(emote) + } + // Direct eye override takes priority (e.g. .wide eyes for Research) + if let eye = task.miniEye { + engine.permanentEye = eye + engine.eyeOverride = eye + engine.eyeOverrideUntil = .greatestFiniteMagnitude + } + } + } +} diff --git a/NotchBuddy/Sources/App/CalcomPoller.swift b/NotchBuddy/Sources/App/CalcomPoller.swift new file mode 100644 index 0000000..8f49b3d --- /dev/null +++ b/NotchBuddy/Sources/App/CalcomPoller.swift @@ -0,0 +1,104 @@ +import Foundation + +final class CalcomPoller: @unchecked Sendable { + static let shared = CalcomPoller() + private var timer: DispatchSourceTimer? + private init() {} + + func start() { + guard timer == nil else { return } + let t = DispatchSource.makeTimerSource(queue: .global(qos: .background)) + t.schedule(deadline: .now() + 8, repeating: 300) + t.setEventHandler { [weak self] in self?.poll() } + t.resume() + timer = t + } + + func pollNow() { poll() } + + private func poll() { + guard let key = KeychainStore.shared.get("calcom-api-key") else { return } + let cal = Calendar.current + let today = cal.startOfDay(for: Date()) + let future = cal.date(byAdding: .day, value: 60, to: today)! + let iso = ISO8601DateFormatter(); iso.formatOptions = [.withInternetDateTime] + let startStr = iso.string(from: today) + let endStr = iso.string(from: future) + + // API v2 (2024-08-13) filters on afterStart / beforeEnd; `start` and `end` aren't + // query parameters there, so they returned nothing. + var components = URLComponents(string: "https://api.cal.com/v2/bookings")! + components.queryItems = [URLQueryItem(name: "status", value: "upcoming"), + URLQueryItem(name: "afterStart", value: startStr), + URLQueryItem(name: "beforeEnd", value: endStr), + URLQueryItem(name: "take", value: "50")] + guard let url = components.url else { return } + var req = URLRequest(url: url, timeoutInterval: 10) + req.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") + req.setValue("2024-08-13", forHTTPHeaderField: "cal-api-version") + + URLSession.shared.dataTask(with: req) { [weak self] data, response, error in + guard let self else { return } + let code = (response as? HTTPURLResponse)?.statusCode ?? 0 + if code != 200 { + let msg: String + if code == 401 { msg = "Invalid API key (401)" } + else if code == 0 { msg = error?.localizedDescription ?? "No connection" } + else { msg = "API error \(code)" } + DispatchQueue.main.async { AppState.shared.calcomError = msg } + return + } + guard let data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let rawList = json["data"] as? [[String: Any]] else { return } + + let parsed = rawList.compactMap { self.parseBooking($0) } + DispatchQueue.main.async { + AppState.shared.calcomError = nil + AppState.shared.calcomBookings = parsed + AppState.shared.calcomLoaded = true + } + }.resume() + } + + private func parseBooking(_ b: [String: Any]) -> CalcomBooking? { + let id: Int + if let i = b["id"] as? Int { id = i } + else if let s = b["id"] as? String, let i = Int(s) { id = i } + else { return nil } + + let title = (b["title"] as? String) ?? "Meeting" + let status = (b["status"] as? String) ?? "accepted" + guard let startStr = (b["start"] as? String) ?? (b["startTime"] as? String) else { return nil } + + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + var startTime = iso.date(from: startStr) + if startTime == nil { + let iso2 = ISO8601DateFormatter(); iso2.formatOptions = [.withInternetDateTime] + startTime = iso2.date(from: startStr) + } + guard let startTime else { return nil } + + let endStr = (b["end"] as? String) ?? (b["endTime"] as? String) ?? "" + let endTime: Date = iso.date(from: endStr) ?? startTime + + let attendees = b["attendees"] as? [[String: Any]] ?? [] + let first = attendees.first + let name = first?["name"] as? String + let email = first?["email"] as? String + + var notes: String? = nil + if let responses = b["responses"] as? [String: Any], + let notesObj = responses["notes"] as? [String: Any] { + notes = notesObj["value"] as? String + } + if (notes == nil || notes!.isEmpty), let desc = b["description"] as? String, !desc.isEmpty { + notes = desc + } + + return CalcomBooking(id: id, title: title, startTime: startTime, endTime: endTime, + status: status, attendeeName: name, attendeeEmail: email, + attendeeNotes: notes) + } +} diff --git a/NotchBuddy/Sources/App/ChatMarkdown.swift b/NotchBuddy/Sources/App/ChatMarkdown.swift new file mode 100644 index 0000000..506f41f --- /dev/null +++ b/NotchBuddy/Sources/App/ChatMarkdown.swift @@ -0,0 +1,118 @@ +import Foundation + +// MARK: - Markdown block types + +enum MDBlock { + case heading(level: Int, text: String) + case paragraph(text: String) + case codeBlock(lang: String, code: String) + /// prefix: "•" for unordered, "1." / "2." etc for ordered; indent: nesting level (0-based) + case listItem(prefix: String, text: String, indent: Int) + case quote(text: String) + case rule +} + +// MARK: - Parser (Foundation only) + +enum ChatMarkdown { + + static func parse(_ input: String) -> [MDBlock] { + var blocks: [MDBlock] = [] + let lines = input.components(separatedBy: "\n") + var i = 0 + while i < lines.count { + let line = lines[i] + + // Fenced code block + if line.hasPrefix("```") { + let lang = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces) + var code: [String] = [] + i += 1 + while i < lines.count && !lines[i].hasPrefix("```") { + code.append(lines[i]) + i += 1 + } + blocks.append(.codeBlock(lang: lang, code: code.joined(separator: "\n"))) + i += 1 + continue + } + + // ATX heading — MUST have space after the #s + if line.hasPrefix("#") { + var level = 0 + var rest = line + while rest.hasPrefix("#") { level += 1; rest = String(rest.dropFirst()) } + // Require a space (or end of line) after the hashes + if rest.hasPrefix(" ") || rest.isEmpty { + let text = rest.trimmingCharacters(in: .whitespaces) + if !text.isEmpty { blocks.append(.heading(level: min(level, 6), text: text)) } + i += 1 + continue + } + // else fall through to paragraph + } + + // Horizontal rule + let stripped = line.trimmingCharacters(in: .whitespaces) + if stripped == "---" || stripped == "***" || stripped == "___" { + blocks.append(.rule) + i += 1 + continue + } + + // Blockquote + if stripped.hasPrefix("> ") || stripped == ">" { + let text = stripped.hasPrefix("> ") ? String(stripped.dropFirst(2)) : "" + blocks.append(.quote(text: text)) + i += 1 + continue + } + + // List item — detect indent level by leading spaces, then match marker + let leadingSpaces = line.prefix(while: { $0 == " " }).count + let indent = leadingSpaces / 2 + if stripped.hasPrefix("- ") || stripped.hasPrefix("* ") || stripped.hasPrefix("+ ") { + let text = String(stripped.dropFirst(2)) + blocks.append(.listItem(prefix: "•", text: text, indent: indent)) + i += 1 + continue + } + // Ordered list item + if let range = stripped.range(of: "^(\\d+)\\.\\s+", options: .regularExpression) { + let number = String(stripped[.. ") || nextStripped == ">" { break } + if nextStripped.hasPrefix("- ") || nextStripped.hasPrefix("* ") || nextStripped.hasPrefix("+ ") { break } + if nextStripped == "---" || nextStripped == "***" || nextStripped == "___" { break } + // Break before ordered list items too + if nextStripped.range(of: "^\\d+\\.\\s+", options: .regularExpression) != nil { break } + paragraphLines.append(next) + i += 1 + } + blocks.append(.paragraph(text: paragraphLines.joined(separator: "\n"))) + } + return blocks + } +} diff --git a/NotchBuddy/Sources/App/ChatMarkdownView.swift b/NotchBuddy/Sources/App/ChatMarkdownView.swift new file mode 100644 index 0000000..b8962a6 --- /dev/null +++ b/NotchBuddy/Sources/App/ChatMarkdownView.swift @@ -0,0 +1,117 @@ +import SwiftUI + +// MARK: - Markdown renderer for chat assistant messages + +struct ChatMarkdownView: View { + let markdown: String + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(ChatMarkdown.parse(markdown).enumerated()), id: \.offset) { _, block in + blockView(block) + } + } + .environment(\.openURL, OpenURLAction { url in + guard let safe = safeWebURL(url.absoluteString) else { return .discarded } + NSWorkspace.shared.open(safe) + return .handled + }) + } + + @ViewBuilder + private func blockView(_ block: MDBlock) -> some View { + switch block { + case .heading(let level, let text): + Text(inlineAttributed(text)) + .font(.system(size: level <= 2 ? 14 : 13, weight: level <= 2 ? .bold : .semibold)) + .foregroundColor(Color(hex: "#F1F2F4")) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + + case .paragraph(let text): + Text(inlineAttributed(text)) + .font(.system(size: 12.5)) + .foregroundColor(Color(hex: "#B0B5BE")) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + + case .codeBlock(_, let code): + HStack(alignment: .top) { + Text(code) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(Color(hex: "#C8CDD4")) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + CopyButton(text: code) + } + .padding(10) + .background(Color(hex: "#0D0E12")) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.white.opacity(0.08), lineWidth: 1)) + + case .listItem(let prefix, let text, let indent): + HStack(alignment: .top, spacing: 6) { + Text(prefix) + .font(.system(size: 12.5)) + .foregroundColor(Color(hex: "#6B7079")) + .frame(minWidth: prefix.count > 2 ? 20 : 10, alignment: .leading) + Text(inlineAttributed(text)) + .font(.system(size: 12.5)) + .foregroundColor(Color(hex: "#B0B5BE")) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } + .padding(.leading, CGFloat(indent) * 12) + + case .quote(let text): + HStack(alignment: .top, spacing: 8) { + Rectangle() + .fill(Color(hex: "#4B5563")) + .frame(width: 2) + .clipShape(Capsule()) + Text(inlineAttributed(text)) + .font(.system(size: 12.5)) + .foregroundColor(Color(hex: "#8A8F98")) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } + + case .rule: + Divider().opacity(0.3) + } + } + + private func inlineAttributed(_ text: String) -> AttributedString { + var options = AttributedString.MarkdownParsingOptions( + interpretedSyntax: .inlineOnlyPreservingWhitespace) + options.failurePolicy = .returnPartiallyParsedIfPossible + return (try? AttributedString(markdown: text, options: options)) ?? AttributedString(text) + } +} + +// MARK: - Copy button + +private struct CopyButton: View { + let text: String + @State private var copied = false + + var body: some View { + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + withAnimation { copied = true } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + withAnimation { copied = false } + } + } label: { + Image(systemName: copied ? "checkmark" : "doc.on.doc") + .font(.system(size: 10)) + .foregroundColor(copied ? Color(hex: "#22C55E") : Color(hex: "#6B7079")) + } + .buttonStyle(.plain) + .frame(width: 22, height: 22) + .background(Color.white.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } +} diff --git a/NotchBuddy/Sources/App/ClaudePlanCardView.swift b/NotchBuddy/Sources/App/ClaudePlanCardView.swift new file mode 100644 index 0000000..e17bbce --- /dev/null +++ b/NotchBuddy/Sources/App/ClaudePlanCardView.swift @@ -0,0 +1,128 @@ +import SwiftUI + +// MARK: - Claude Plan Card View + +struct ClaudePlanCardView: View { + let usage: PlanUsage? // nil = installed, no data yet + + @State private var now = Date() + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header + HStack(spacing: 6) { + let dominant = usage.flatMap { ClaudePlanGauge.dominantPct($0) } + Circle() + .fill(Color(hex: ClaudePlanGauge.color(for: dominant))) + .frame(width: 7, height: 7) + Text("Claude plan") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Color(hex: "#F5F6F8")) + Text(subtitleText) + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#8E939C")) + .lineLimit(1) + .fixedSize() + } + .padding(.top, 6) + .padding(.leading, 108) + .padding(.trailing, 36) + + // Gauge rows + VStack(alignment: .leading, spacing: 5) { + GaugeRowView(label: "5 hours", window: usage?.fiveHour, now: now) + GaugeRowView(label: "Week", window: usage?.sevenDay, now: now, weekly: true) + } + .padding(.top, 8) + .padding(.leading, 108) + .padding(.trailing, 12) + } + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.top, 4) + // Update countdown every 30s, only while visible + .background( + TimelineView(.periodic(from: .now, by: 30)) { ctx in + Color.clear.onChange(of: ctx.date) { _, d in now = d } + } + ) + } + + private var subtitleText: String { + guard let usage else { return "Waiting for a Claude Code reply" } + let diff = now.timeIntervalSince(usage.updatedAt) + if diff < 60 { return "just now" } + let mins = Int(diff / 60) + if mins < 60 { return "\(mins) min ago" } + return "\(mins / 60) h ago" + } +} + +// MARK: - Gauge Row + +// DateFormatter created once, in English, for the weekly reset label +private let weeklyResetFormatter: DateFormatter = { + let fmt = DateFormatter() + fmt.locale = Locale(identifier: "en_US_POSIX") + fmt.dateFormat = "EEE H:mm" + return fmt +}() + +private struct GaugeRowView: View { + let label: String + let window: PlanWindow? + let now: Date + var weekly: Bool = false + + var body: some View { + HStack(spacing: 5) { + Text(label) + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#6B7079")) + .frame(width: 40, alignment: .leading) + if let w = window { + let pct = ClaudePlanGauge.effectivePct(w) + let accent = Color(hex: ClaudePlanGauge.color(for: pct)) + // Fixed-width bar (~50pt) + ZStack(alignment: .leading) { + Capsule() + .fill(Color.white.opacity(0.08)) + .frame(width: 50, height: 4) + Capsule() + .fill(accent) + .frame(width: max(0, 50 * CGFloat(pct / 100)), height: 4) + } + .frame(width: 50, height: 4) + Text("\(Int(pct.rounded()))%") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(Color(hex: "#C5C8CD")) + .monospacedDigit() + .frame(width: 30, alignment: .trailing) + Image(systemName: "arrow.clockwise") + .font(.system(size: 8)) + .foregroundColor(Color(hex: "#6B7079")) + Text(resetLabel(w)) + .font(.system(size: 10)) + .foregroundColor(Color(hex: "#6B7079")) + .fixedSize() + } else { + Text("—") + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#6B7079")) + Spacer() + } + } + } + + private func resetLabel(_ w: PlanWindow) -> String { + let secs = w.resetsAt.timeIntervalSince(now) + guard secs > 0 else { return "Resetting…" } + if weekly { + return weeklyResetFormatter.string(from: w.resetsAt) + } else { + let h = Int(secs / 3600) + let m = Int((secs.truncatingRemainder(dividingBy: 3600)) / 60) + if h > 0 { return "in \(h) h \(m)" } + return "in \(m) min" + } + } +} diff --git a/NotchBuddy/Sources/App/ClaudePlanGauge.swift b/NotchBuddy/Sources/App/ClaudePlanGauge.swift new file mode 100644 index 0000000..e9258ad --- /dev/null +++ b/NotchBuddy/Sources/App/ClaudePlanGauge.swift @@ -0,0 +1,80 @@ +import Foundation + +// MARK: - Plan window + +struct PlanWindow: Codable { + let usedPct: Double // 0–100, clamped + let resetsAt: Date +} + +// MARK: - Plan usage + +struct PlanUsage: Codable { + var fiveHour: PlanWindow? + var sevenDay: PlanWindow? + var updatedAt: Date +} + +// MARK: - Parsing + helpers (Foundation-only, no AppKit/SwiftUI) + +enum ClaudePlanGauge { + + /// Parses rate_limits from a statusline payload dict. Returns nil if absent/malformed. + static func parse(payload: [String: Any]) -> PlanUsage? { + guard let limits = payload["rate_limits"] as? [String: Any] else { return nil } + let fh = parseWindow(limits["five_hour"]) + let sd = parseWindow(limits["seven_day"]) + guard fh != nil || sd != nil else { return nil } + return PlanUsage(fiveHour: fh, sevenDay: sd, updatedAt: Date()) + } + + private static func parseWindow(_ raw: Any?) -> PlanWindow? { + guard let d = raw as? [String: Any] else { return nil } + let rawPct: Double + if let v = d["used_percentage"] as? Double { rawPct = v } + else if let v = d["used_percentage"] as? Int { rawPct = Double(v) } + else { return nil } + guard rawPct >= 0, rawPct <= 200 else { return nil } + let pct = min(100, rawPct) // clamp 100–200 down to 100 + let rawEpoch: Double + if let v = d["resets_at"] as? Double { rawEpoch = v } + else if let v = d["resets_at"] as? Int { rawEpoch = Double(v) } + else { return nil } + guard rawEpoch > 0 else { return nil } + // Reject if more than 400 days in the future (likely milliseconds, not seconds) + let maxEpoch = Date().timeIntervalSince1970 + 400 * 86400 + guard rawEpoch <= maxEpoch else { return nil } + return PlanWindow(usedPct: pct, resetsAt: Date(timeIntervalSince1970: rawEpoch)) + } + + /// Effective % for display: 0 if the reset time is in the past. + static func effectivePct(_ w: PlanWindow) -> Double { + w.resetsAt <= Date() ? 0 : w.usedPct + } + + /// The higher of the two effective percentages. nil if both windows absent. + static func dominantPct(_ usage: PlanUsage) -> Double? { + let p1 = usage.fiveHour.map { effectivePct($0) } + let p2 = usage.sevenDay.map { effectivePct($0) } + switch (p1, p2) { + case (.none, .none): return nil + case (let x?, .none): return x + case (.none, let y?): return y + case (let x?, let y?): return max(x, y) + } + } + + /// Hex color for a given percentage (nil → grey). + static func color(for pct: Double?) -> String { + guard let p = pct else { return "#6B7079" } + if p < 50 { return "#22C55E" } + if p < 80 { return "#F59E0B" } + return "#F4505E" + } + + /// Label shown in the island pill chip. + static func pillLabel(_ usage: PlanUsage?) -> String { + guard let usage, let pct = dominantPct(usage) else { return "Claude plan" } + return "Claude \(Int(pct.rounded()))%" + } +} diff --git a/NotchBuddy/Sources/App/ClaudeService.swift b/NotchBuddy/Sources/App/ClaudeService.swift new file mode 100644 index 0000000..7ebeee1 --- /dev/null +++ b/NotchBuddy/Sources/App/ClaudeService.swift @@ -0,0 +1,639 @@ +import Foundation +import Security + +// MARK: - Keychain helpers + +enum Keychain { + static let service = "fr.louisraille.NotchBuddy" + + static func save(key: String, value: String) { + guard let data = value.data(using: .utf8) else { return } + // Delete existing item first (update pattern) + let lookup: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + ] + SecItemDelete(lookup as CFDictionary) + // Add with strictest access control: + // WhenUnlockedThisDeviceOnly = accessible only while Mac is unlocked, + // never synced to iCloud, never migrated to another device. + let item: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + kSecAttrSynchronizable as String: kCFBooleanFalse!, + ] + SecItemAdd(item as CFDictionary, nil) + } + + static func load(key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: AnyObject? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + static func delete(key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + ] + SecItemDelete(query as CFDictionary) + } +} + +// MARK: - Keychain cache (reads each key ONCE at launch; all subsequent access via dict) + +final class KeychainStore: @unchecked Sendable { + static let shared = KeychainStore() + private var cache: [String: String] = [:] + private let lock = NSLock() + + private static let allKeys = [ + "anthropic-api-key", + "google-api-key", + "openai-api-key", + "resend-api-key", "resend-from", + "n8n-url", "n8n-api-key", + "vercel-token", + "github-token", + "stripe-api-key", + "calcom-api-key", + "notion-api-key", + ] + + private init() { + // Called once, on main thread (AppDelegate triggers shared at launch). + for key in Self.allKeys { + if let v = Keychain.load(key: key) { cache[key] = v } + } + } + + /// Thread-safe read — never touches the Keychain. + func get(_ key: String) -> String? { + lock.withLock { cache[key] } + } + + /// Updates cache + persists to Keychain. + func set(_ key: String, value: String) { + lock.withLock { cache[key] = value } + Keychain.save(key: key, value: value) + } + + /// Removes from cache + Keychain only if the key was previously set. + func remove(_ key: String) { + let had = lock.withLock { () -> Bool in + let exists = cache[key] != nil + cache[key] = nil + return exists + } + if had { Keychain.delete(key: key) } + } +} + +// MARK: - Claude API + +@MainActor +final class ClaudeService { + static let shared = ClaudeService() + + private let endpoint = URL(string: "https://api.anthropic.com/v1/messages")! + private let anthropicVersion = "2023-06-01" + + // MARK: - Model list + + /// Fetches available models from the Anthropic API in the order the API returns them + /// (newest first). Returns an empty array on any error — callers fall back to a static list. + static func fetchModels(apiKey: String) async -> [(id: String, label: String)] { + guard let url = URL(string: "https://api.anthropic.com/v1/models?limit=100") else { return [] } + var req = URLRequest(url: url, timeoutInterval: 10) + req.setValue(apiKey, forHTTPHeaderField: "x-api-key") + req.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + guard let (data, response) = try? await URLSession.shared.data(for: req), + (response as? HTTPURLResponse)?.statusCode == 200, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["data"] as? [[String: Any]] else { return [] } + return items.compactMap { item in + guard let id = item["id"] as? String, + let name = item["display_name"] as? String else { return nil } + return (id: id, label: name) + } + } + + /// Fetches Gemini models via the OpenAI-compatible endpoint. + /// Strips the "models/" prefix that the API sometimes returns and filters non-chat models. + static func fetchGoogleModels(apiKey: String) async -> [(id: String, label: String)] { + guard let url = URL(string: "https://generativelanguage.googleapis.com/v1beta/openai/models") else { return [] } + var req = URLRequest(url: url, timeoutInterval: 10) + req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + guard let (data, response) = try? await URLSession.shared.data(for: req), + (response as? HTTPURLResponse)?.statusCode == 200, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["data"] as? [[String: Any]] else { return [] } + let excluded = ["embed", "imagen", "veo", "aqa", "tts", "audio", "live"] + return items.compactMap { item in + guard let raw = item["id"] as? String else { return nil } + let id = raw.hasPrefix("models/") ? String(raw.dropFirst(7)) : raw + let lower = id.lowercased() + guard !excluded.contains(where: { lower.contains($0) }) else { return nil } + return (id: id, label: id) + } + } + + /// Fetches chat models from the OpenAI API, sorted newest-first by creation date. + /// Excludes non-chat model families. + static func fetchOpenAIModels(apiKey: String) async -> [(id: String, label: String)] { + guard let url = URL(string: "https://api.openai.com/v1/models") else { return [] } + var req = URLRequest(url: url, timeoutInterval: 10) + req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + guard let (data, response) = try? await URLSession.shared.data(for: req), + (response as? HTTPURLResponse)?.statusCode == 200, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["data"] as? [[String: Any]] else { return [] } + let excluded = ["embed", "tts", "whisper", "dall-e", "audio", "realtime", "moderat", + "codex", "computer-use", "transcribe", "image", "sora", + "babbage", "davinci", "instruct"] + return items + .compactMap { item -> (id: String, created: Int)? in + guard let id = item["id"] as? String else { return nil } + let lower = id.lowercased() + guard !excluded.contains(where: { lower.contains($0) }) else { return nil } + return (id: id, created: item["created"] as? Int ?? 0) + } + .sorted { $0.created > $1.created } + .map { (id: $0.id, label: $0.id) } + } + + /// Chosen in Settings; falls back to the default when the field is left empty. + private var model: String { + let m = AppState.shared.claudeModel.trimmingCharacters(in: .whitespacesAndNewlines) + return m.isEmpty ? AppState.defaultClaudeModel : m + } + + var apiKey: String? { KeychainStore.shared.get("anthropic-api-key") } + + // Multi-turn conversation messages (for API) + private var conversationMessages: [[String: Any]] = [] + + func clearConversation() { + conversationMessages = [] + } + + /// Resolved once: NSFullUserName() is a system call, and the name cannot change under us + /// while the app runs. + private let systemPrompt = ClaudeService.makeSystemPrompt() + + /// Greets the user by their macOS first name when there is one worth using, and stays + /// neutral otherwise — same wording as the Windows build. + private nonisolated static func makeSystemPrompt() -> String { + let opening = if let firstName = resolveUserFirstName() { + "You are Mochi, \(firstName)'s personal AI assistant embedded in the notch of their Mac." + } else { + "You are Mochi, a personal AI assistant embedded in the notch of the user's Mac." + } + return """ + \(opening) \ + You have web search access and can help with absolutely anything — research, coding, finding places, recommendations, tasks, questions. \ + Respond in the user's language. Be thorough and complete — use as much detail as the task requires. \ + Use light Markdown when it helps: short paragraphs, bullet lists, **bold**, `inline code` and fenced code blocks. Avoid tables and big headings: the chat window is small. + """ + } + + private let webSearchTools: [[String: Any]] = [ + ["type": "web_search_20250305", "name": "web_search", "max_uses": 5] + ] + + // MARK: - Chat (multi-turn, natural text + web search) + + func chat(query: String, context: PromptContext?, state: AppState) async { + guard state.chatProvider == .anthropic else { + await chatOpenAICompatible(query: query, context: context, state: state) + return + } + guard let key = apiKey, !key.isEmpty else { + await showError("API key missing. Open settings.", state: state) + return + } + + // Build user content for this turn + var userContent: [[String: Any]] = [] + + // Add file/window context on first message only + if conversationMessages.isEmpty, let context = context { + switch context { + case .window(let app, let title, let url): + var text = "Context — App: \(app), Window: \(title)" + if let url = url { text += ", URL: \(url)" } + userContent.append(["type": "text", "text": text]) + case .file(let name, let fileURL): + if let fileURL = fileURL, let block = readFileAsBlock(url: fileURL) { + userContent.append(block) + } + userContent.append(["type": "text", "text": "File: \(name)"]) + } + } + userContent.append(["type": "text", "text": query]) + + conversationMessages.append(["role": "user", "content": userContent]) + + let body: [String: Any] = [ + "model": model, + "max_tokens": 4096, + "tools": webSearchTools, + "system": systemPrompt, + "messages": conversationMessages, + ] + + do { + let data = try await callAPI(body: body, key: key, beta: "web-search-2025-03-05") + await handleChatResult(data, state: state) + } catch { + conversationMessages.removeLast() + await showError(error.localizedDescription, state: state) + } + } + + // MARK: - OpenAI-compatible chat (Google Gemini / OpenAI / Ollama / LM Studio) + + func chatOpenAICompatible(query: String, context: PromptContext?, state: AppState) async { + let provider = state.chatProvider + guard provider != .anthropic else { return } + + let baseURL: String + if provider == .ollama { + baseURL = LocalChat.normaliseURL(state.ollamaServerURL) + } else if provider == .lmstudio { + baseURL = LocalChat.normaliseURL(state.lmstudioServerURL) + } else { + switch provider { + case .google: baseURL = "https://generativelanguage.googleapis.com/v1beta/openai" + case .openai: baseURL = "https://api.openai.com/v1" + case .anthropic, .ollama, .lmstudio: baseURL = "" + } + } + + guard !baseURL.isEmpty else { + if provider.isLocal { + let name = provider == .ollama ? "Ollama" : "LM Studio" + await showError("Connect \(name) in Settings → Chat first.", state: state) + } + return + } + guard let url = URL(string: "\(baseURL)/chat/completions") else { return } + + // Auth header + let authHeader: String + if provider.isLocal { + authHeader = "Bearer ollama" + } else { + guard let key = KeychainStore.shared.get(provider.keychainKey), !key.isEmpty else { + await showError("\(provider.displayName) API key missing. Configure it in Settings.", state: state) + return + } + authHeader = "Bearer \(key)" + } + + // Build messages + var msgs: [[String: Any]] = [["role": "system", "content": systemPrompt]] + for m in conversationMessages { + var simplified = m + if let content = m["content"] as? [[String: Any]], + let textBlock = content.first(where: { ($0["type"] as? String) == "text" }), + let text = textBlock["text"] as? String { + simplified["content"] = text + } + msgs.append(simplified) + } + var userText = query + if conversationMessages.isEmpty, let ctx = context { + switch ctx { + case .window(let app, let title, let url): + var prefix = "Context — App: \(app), Window: \(title)" + if let u = url { prefix += ", URL: \(u)" } + userText = prefix + "\n\n" + query + case .file(let name, let fileURL): + if provider.isLocal, let fileURL = fileURL { + let ext = fileURL.pathExtension.lowercased() + let binaryExts = ["pdf", "jpg", "jpeg", "png", "gif", "webp"] + if !binaryExts.contains(ext), + let text = try? String(contentsOf: fileURL, encoding: .utf8), !text.isEmpty { + let truncated = text.count > 24_000 + ? String(text.prefix(24_000)) + "\n[truncated]" + : text + userText = "File: \(name)\n\n\(truncated)\n\n" + query + } else { + userText = "File: \(name)\n\n" + query + } + } else { + userText = "File: \(name)\n\n" + query + } + } + } + msgs.append(["role": "user", "content": userText]) + conversationMessages.append(["role": "user", "content": userText]) + + let useStream = provider.isLocal + var body: [String: Any] = [ + "model": state.activeChatModel, + "max_tokens": 4096, + "messages": msgs, + ] + if useStream { body["stream"] = true } + + var req = URLRequest(url: url, timeoutInterval: useStream ? 120 : 30) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue(authHeader, forHTTPHeaderField: "Authorization") + req.httpBody = try? JSONSerialization.data(withJSONObject: body) + + if useStream { + // Add placeholder (hidden until first token via ChatBubble empty-content guard) + let placeholder = ChatMessage(role: .assistant, content: "") + let msgId = placeholder.id + state.chatHistory.append(placeholder) + state.stateOverride = .thinking + let modelCopy = state.activeChatModel + let streamBody: [String: Any] = [ + "model": modelCopy, + "messages": msgs, + "stream": true, + "max_tokens": 4096, + ] + let encodedBody = (try? JSONSerialization.data(withJSONObject: streamBody)) ?? Data() + do { + let final = try await LocalChat.streamChat( + baseURL: baseURL, + encodedBody: encodedBody, + model: modelCopy + ) { [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 + } + } + conversationMessages.append(["role": "assistant", "content": final]) + if let idx = state.chatHistory.firstIndex(where: { $0.id == msgId }) { + state.chatHistory[idx].content = final + } + state.stateOverride = nil + state.view = .prompt + NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy) + } catch let e as LocalChatError { + conversationMessages.removeLast() + state.chatHistory.removeAll { $0.id == msgId } + state.stateOverride = nil + let msg: String + switch e { + case .serverUnreachable: + msg = provider == .ollama + ? "Ollama isn't running. Open it, then ask again." + : "Start the local server in LM Studio, then ask again." + case .modelNotFound(let m): + msg = "\(m) isn't installed. Pick another model above the chat box." + case .serverError(let s): + msg = s + } + await showError(msg, state: state) + } catch { + conversationMessages.removeLast() + state.chatHistory.removeAll { $0.id == msgId } + state.stateOverride = nil + await showError(error.localizedDescription, state: state) + } + } else { + // Non-streaming (Google, OpenAI) + do { + let (data, response) = try await URLSession.shared.data(for: req) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let err = (json["error"] as? [String: Any])?["message"] as? String { + throw NSError(domain: "ChatAPI", code: 0, userInfo: [NSLocalizedDescriptionKey: err]) + } + throw NSError(domain: "ChatAPI", code: 0, userInfo: [NSLocalizedDescriptionKey: "HTTP \((response as? HTTPURLResponse)?.statusCode ?? 0)"]) + } + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let choices = json["choices"] as? [[String: Any]], + let message = choices.first?["message"] as? [String: Any], + let content = message["content"] as? String else { + throw NSError(domain: "ChatAPI", code: 0, userInfo: [NSLocalizedDescriptionKey: "Unexpected response format"]) + } + let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) + conversationMessages.append(["role": "assistant", "content": trimmed]) + state.chatHistory.append(ChatMessage(role: .assistant, content: trimmed)) + state.stateOverride = nil + state.view = .prompt + NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy) + } catch { + conversationMessages.removeLast() + await showError(error.localizedDescription, state: state) + } + } + } + + // MARK: - Structured search (M8 — window attach + web search) + + func search(query: String, context: PromptContext?, state: AppState) async { + guard let key = apiKey, !key.isEmpty else { + await showError("Anthropic API key missing. Open settings to configure it.", state: state) + return + } + + var userContent: [[String: Any]] = [] + switch context { + case .window(let appName, let title, let url): + var text = "App: \(appName)\nWindow title: \(title)" + if let url = url { text += "\nURL: \(url)" } + text += "\n\nRequest: \(query)" + userContent.append(["type": "text", "text": text]) + case .file(let name, let fileURL): + if let fileURL = fileURL, let fileBlock = readFileAsBlock(url: fileURL) { + userContent.append(fileBlock) + } + userContent.append(["type": "text", "text": "File: \(name)\n\nRequest: \(query)"]) + case nil: + userContent.append(["type": "text", "text": query]) + } + + let system = """ + You are an assistant built into the notch of a Mac. Reply in English, short and precise. + Reply ONLY with valid JSON in this exact format: + {"title":"...","items":[{"label":"...","detail":"...","url":"..."}],"note":"..."} + Maximum 3 items. "url" is optional. "note" is optional. + """ + + let tools: [[String: Any]] = [ + ["type": "web_search_20250305", "name": "web_search", "max_uses": 3] + ] + + let body: [String: Any] = [ + "model": model, + "max_tokens": 1024, + "tools": tools, + "system": system, + "messages": [["role": "user", "content": userContent]], + ] + + do { + let result = try await callAPI(body: body, key: key, beta: "web-search-2025-03-05") + await handleResult(result, state: state) + } catch { + await showError(error.localizedDescription, state: state) + } + } + + // MARK: - API call + + private func callAPI(body: [String: Any], key: String, beta: String? = nil) async throws -> Data { + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.setValue(key, forHTTPHeaderField: "x-api-key") + request.setValue(anthropicVersion, forHTTPHeaderField: "anthropic-version") + request.setValue("application/json", forHTTPHeaderField: "content-type") + if let beta { request.setValue(beta, forHTTPHeaderField: "anthropic-beta") } + request.httpBody = try JSONSerialization.data(withJSONObject: body) + request.timeoutInterval = 45 + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + // Parse Anthropic error format: {"type":"error","error":{"type":"…","message":"…"}} + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let err = json["error"] as? [String: Any], + let errType = err["type"] as? String, + let errMsg = err["message"] as? String { + if errType == "not_found_error" { + let id = AppState.shared.claudeModel + throw NSError(domain: "Claude", code: 0, + userInfo: [NSLocalizedDescriptionKey: + "Model not found: \(id). Pick another one in Settings."]) + } + throw NSError(domain: "Claude", code: 0, + userInfo: [NSLocalizedDescriptionKey: errMsg]) + } + let msg = String(data: data, encoding: .utf8) ?? "unknown error" + throw NSError(domain: "Claude", code: 0, userInfo: [NSLocalizedDescriptionKey: msg]) + } + return data + } + + // MARK: - Chat result handler + + private func handleChatResult(_ data: Data, state: AppState) async { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let content = json["content"] as? [[String: Any]] else { + await showError("Unexpected API response.", state: state) + return + } + + // Store full content (includes tool_use/tool_result blocks) for correct multi-turn context + conversationMessages.append(["role": "assistant", "content": content]) + + guard let textBlock = content.first(where: { $0["type"] as? String == "text" }), + let text = textBlock["text"] as? String, !text.isEmpty else { + await showError("No response text.", state: state) + return + } + + // Add to display history + state.chatHistory.append(ChatMessage(role: .assistant, content: text.trimmingCharacters(in: .whitespacesAndNewlines))) + + state.stateOverride = nil + state.view = .prompt + NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy) + } + + // MARK: - Structured result handler + + private func handleResult(_ data: Data, state: AppState) async { + // Extract text from Anthropic response (may contain tool_use / web_search_tool_result blocks) + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let content = json["content"] as? [[String: Any]], + let textBlock = content.first(where: { $0["type"] as? String == "text" }), + let text = textBlock["text"] as? String else { + await showError("Unexpected API response.", state: state) + return + } + + // Strip markdown code fences if present, then extract JSON object + let cleanText: String + if let start = text.firstIndex(of: "{"), let end = text.lastIndex(of: "}") { + cleanText = String(text[start...end]) + } else { + cleanText = text + } + + // Try to parse as our JSON format + if let resultData = cleanText.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: resultData) as? [String: Any] { + let title = parsed["title"] as? String ?? "Result" + let note = parsed["note"] as? String + var items: [ResultItem] = [] + if let rawItems = parsed["items"] as? [[String: Any]] { + for item in rawItems.prefix(3) { + items.append(ResultItem( + label: item["label"] as? String ?? "", + detail: item["detail"] as? String ?? "", + url: item["url"] as? String + )) + } + } + state.searchResult = SearchResult(title: title, items: items, note: note) + } else { + // Fallback: show raw text in 3-line chunks + let lines = cleanText.components(separatedBy: "\n").filter { !$0.isEmpty }.prefix(3) + state.searchResult = SearchResult( + title: "Claude's response", + items: lines.map { ResultItem(label: $0, detail: "", url: nil) }, + note: nil + ) + } + + state.stateOverride = nil + state.view = .result + NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.proud) + } + + private func showError(_ message: String, state: AppState) async { + state.stateOverride = .error + state.noteMessage = message + state.view = .note + } + + // MARK: - File content block builder + + private func readFileAsBlock(url: URL) -> [String: Any]? { + guard let data = try? Data(contentsOf: url) else { return nil } + let ext = url.pathExtension.lowercased() + let base64 = data.base64EncodedString() + + if ext == "pdf" { + return ["type": "document", "source": ["type": "base64", "media_type": "application/pdf", "data": base64]] + } else if ["jpg", "jpeg"].contains(ext) { + return ["type": "image", "source": ["type": "base64", "media_type": "image/jpeg", "data": base64]] + } else if ext == "png" { + return ["type": "image", "source": ["type": "base64", "media_type": "image/png", "data": base64]] + } else if ext == "gif" { + return ["type": "image", "source": ["type": "base64", "media_type": "image/gif", "data": base64]] + } else if ext == "webp" { + return ["type": "image", "source": ["type": "base64", "media_type": "image/webp", "data": base64]] + } else { + // Text/code — inline as text if <= 200 KB + guard data.count <= 200_000, + let text = String(data: data, encoding: .utf8) else { return nil } + return ["type": "text", "text": "File contents:\n\(text)"] + } + } +} diff --git a/NotchBuddy/Sources/App/DesktopMochi.swift b/NotchBuddy/Sources/App/DesktopMochi.swift new file mode 100644 index 0000000..b810365 --- /dev/null +++ b/NotchBuddy/Sources/App/DesktopMochi.swift @@ -0,0 +1,744 @@ +import AppKit +import SwiftUI +import Combine + +// MARK: - Desktop bot view state + +/// Observable bridge so DesktopMochiController can update view-level state without +/// coupling to SwiftUI @State. +@MainActor +final class DesktopBotViewState: ObservableObject { + /// Drop to 10 fps when sleeping (saves energy). + @Published var isSleeping: Bool = false + /// Pause entirely (screen sleep / lock). + @Published var paused: Bool = false + /// Bot center in the same coord space as AppState.mousePosition (y-down from screen top). + /// Updated every poll frame; Canvas reads it inside TimelineView — @Published not needed. + var lookOrigin: CGPoint = .zero +} + +// MARK: - Desktop bot view + +/// Full Mochi character rendered inside the desktop floating panel. +struct DesktopBotView: View { + @ObservedObject var appState: AppState + /// Engine owned by DesktopMochiController; controller calls methods on it directly. + let engine: BotEngine + @ObservedObject var viewState: DesktopBotViewState + + var body: some View { + TimelineView(.animation( + minimumInterval: viewState.isSleeping ? 1.0 / 10.0 : 1.0 / 30.0, + paused: viewState.paused + )) { timeline in + Canvas { ctx, size in + let now = timeline.date.timeIntervalSinceReferenceDate + let dt = min(0.05, now - engine.lastTime) + + // Eye tracking based on the panel's own screen position + engine.lookX = tanh((appState.mousePosition.x - viewState.lookOrigin.x) / 260) + engine.lookY = -tanh((appState.mousePosition.y - viewState.lookOrigin.y) / 200) + + // Desktop Mochi is always the "main" Mochi — always dressed + engine.setOutfit(appState.resolvedOutfit, animated: true) + + // Dance when music plays (same rules as compact mode) + let dancing: Bool = { + #if !APPSTORE + guard appState.musicPlaying else { return false } + guard appState.activeIntegrations.contains("integration_music") else { return false } + let allowed: Set = [.idle, .working, .thinking, .searching, .finished] + return allowed.contains(appState.effectiveState) + #else + return false + #endif + }() + engine.setDancing(dancing) + engine.update(dt: dt) + + var c = ctx + engine.applyDance(&c, size: size) + + // Rigid roll when outfit is present (matches BotCanvasView) + if engine.outfit != .none && engine.outfitPresence > 0.05 && abs(engine.roll) > 0.001 { + let center = engine.bodyCenter(size: size) + var rigidCtx = c + rigidCtx.translateBy(x: center.x, y: center.y) + rigidCtx.rotate(by: .radians(engine.roll)) + rigidCtx.translateBy(x: -center.x, y: -center.y) + engine.drawHandsBehind(context: rigidCtx, size: size) + engine.drawOutfitBehind(context: rigidCtx, size: size) + engine.draw(context: rigidCtx, size: size) + engine.drawOutfitFront(context: rigidCtx, size: size) + } else { + engine.drawHandsBehind(context: c, size: size) + engine.drawOutfitBehind(context: c, size: size) + engine.draw(context: c, size: size) + engine.drawOutfitFront(context: c, size: size) + } + engine.drawHandsAndExtras(context: c, size: size) + } + } + .onChange(of: appState.effectiveState) { _, newState in + engine.setState(newState) + } + .onAppear { + engine.setState(appState.effectiveState, force: true) + engine.setOutfit(appState.resolvedOutfit, animated: false) + } + } +} + +// MARK: - Desktop Mochi controller + +/// Manages the "Mochi on the desktop" floating panel. +/// +/// Life cycle: +/// - **Install from drag**: `IslandWindowController.finishDrag` calls `install(ghostPanel:at:)`. +/// - **Launch restore**: `AppDelegate` observes `.greetComplete` → `launchFlyIfNeeded()`. +/// - **Alert**: `pendingApproval`/`pendingQuestion` goes non-nil → surprised emote → +/// `retractForAlert()` (panel gone, flag stays true) → both nil → `launchFlyIfNeeded()`. +/// - **User flies home**: double-click → `flyHome()` → full teardown. +@MainActor +final class DesktopMochiController { + static let shared = DesktopMochiController() + private init() { + observeScreenSleep() + observeScreenLock() + observeAlerts() // permanent — lives for the lifetime of the singleton + } + + private var panel: NSPanel? + private var engine: BotEngine? + private var viewState: DesktopBotViewState? + private var frameTimer: Timer? + + // Alert state machine + private var phase: DesktopPhase = .home + + // Desktop drag repositioning + private var isDragging = false + private var dragMouseStart: NSPoint = .zero + private var dragOriginAtStart: NSPoint = .zero + + // Deferred single-click slap + private var pendingSlapWorkItem: DispatchWorkItem? + + // Sleep detection + private var lastAgentActive: Date = .distantPast + private var isSleeping = false + + // Screen sleep / lock + private var screenSleeping = false + + // Lifecycle subscriptions (cleared on retractForAlert + fullTearDown) + private var cancellables: Set = [] + // Alert subscription — permanent, only released with the singleton + private var alertSubscription: AnyCancellable? + + // Event monitors + private var mouseDownMonitor: Any? + private var mouseDraggedMonitor: Any? + private var mouseUpMonitor: Any? + private var globalMouseUpMonitor: Any? + private var rightClickMonitor: Any? + + // UserDefaults keys + private static let posXKey = "desktopMochiX" + private static let posYKey = "desktopMochiY" + private static let enabledKey = "mochiOnDesktop" + + static let panelSize: CGFloat = DesktopMochiLogic.panelSize + + // MARK: - Keyboard shortcut toggle (⌃⌥D) + + /// Fly Mochi to the desktop if not there, or bring him back if he is. + func flyOutOrHome() { + if phase == .home { + UserDefaults.standard.set(true, forKey: DesktopMochiController.enabledKey) + launchFlyIfNeeded() + } else if phase == .onDesktop { + flyHome() + } + } + + // MARK: - Install (from drag-drop) + + /// Promote `ghostPanel` (the drag ghost) or create a fresh panel as the desktop Mochi, + /// centered on `screenPoint`. Called by `IslandWindowController.finishDrag`. + func install(ghostPanel: NSPanel?, at screenPoint: NSPoint) { + guard panel == nil, phase == .home else { ghostPanel?.close(); return } + let s = DesktopMochiController.panelSize + + let p: NSPanel + if let ghost = ghostPanel { + p = ghost + } else { + p = makeBlankPanel() + p.setFrame(NSRect(x: screenPoint.x - s/2, y: screenPoint.y - s/2, width: s, height: s), + display: false) + } + + // Build engine + hosting view (autoresizingMask lets it grow with the panel animation) + let eng = BotEngine() + eng.setState(AppState.shared.effectiveState, force: true) + eng.setOutfit(AppState.shared.resolvedOutfit, animated: false) + self.engine = eng + + let vs = DesktopBotViewState() + vs.lookOrigin = lookOriginFor(panel: p) + vs.paused = screenSleeping + self.viewState = vs + + let hosting = NSHostingView(rootView: + DesktopBotView(appState: AppState.shared, engine: eng, viewState: vs)) + hosting.frame = CGRect(origin: .zero, size: p.frame.size) + hosting.autoresizingMask = [.width, .height] + p.contentView = hosting + + p.level = .floating + p.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle, .fullScreenAuxiliary] + p.ignoresMouseEvents = true + if !p.isVisible { p.orderFront(nil) } + + // Squash emote + sound on landing + eng.triggerEmote(.happy, duration: 0.6, silent: true) + SoundEngine.shared.play("pop") + + // Animate from current (ghost) size to 120 × 120, centered on drop point + let targetOrigin = clampToVisibleFrame(NSPoint(x: screenPoint.x - s/2, y: screenPoint.y - s/2)) + NSAnimationContext.runAnimationGroup({ ctx in + ctx.duration = 0.25 + ctx.timingFunction = CAMediaTimingFunction(controlPoints: 0.17, 0.67, 0.38, 1.3) + p.animator().setFrame(NSRect(origin: targetOrigin, size: CGSize(width: s, height: s)), + display: true) + }, completionHandler: { + Task { @MainActor in + self.panel = p + self.phase = .onDesktop + AppState.shared.mochiOnDesktop = true + UserDefaults.standard.set(true, forKey: DesktopMochiController.enabledKey) + self.persistPosition() + // Alert may have fired during the animation (observeAlerts skipped: phase wasn't .onDesktop) + let alertNow = AppState.shared.pendingApproval != nil || AppState.shared.pendingQuestion != nil + if DesktopMochiLogic.shouldRetractOnLanding(alertActive: alertNow) { + self.engine?.triggerEmote(.surprised) + self.phase = .retracting + DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { [weak self] in + guard let self else { return } + switch self.phase { + case .retracting: self.retractForAlert() + case .alertResolvedDuringRetract: self.phase = .home; self.launchFlyIfNeeded() + default: break + } + } + } else { + self.startPolling() + self.addEventMonitors() + self.observeLifecycle() + } + } + }) + } + + // MARK: - Launch fly (app-start restore or alert return) + + /// Fly a new panel from the notch to the saved desktop position. + /// Called by AppDelegate after `.greetComplete`, and by the alert-return path. + func launchFlyIfNeeded() { + guard UserDefaults.standard.bool(forKey: DesktopMochiController.enabledKey) else { return } + guard phase == .home else { return } + guard panel == nil else { return } + // Alert active: don't fly yet — park in .atNotchForAlert so observeAlerts restores us when it clears + if AppState.shared.pendingApproval != nil || AppState.shared.pendingQuestion != nil { + phase = .atNotchForAlert + return + } + + phase = .flyingOut + let s = DesktopMochiController.panelSize + let screen = IslandWindowController.notchScreen() ?? NSScreen.main! + let startOrigin = NSPoint(x: screen.frame.midX - s/2, y: screen.frame.maxY - s) + let target = loadSavedPosition() + + let p = makeBlankPanel() + p.setFrame(NSRect(origin: startOrigin, size: CGSize(width: s, height: s)), display: false) + + let eng = BotEngine() + eng.setState(AppState.shared.effectiveState, force: true) + eng.setOutfit(AppState.shared.resolvedOutfit, animated: false) + self.engine = eng + + let vs = DesktopBotViewState() + vs.lookOrigin = lookOriginFor(panel: p) + vs.paused = screenSleeping + self.viewState = vs + + let hosting = NSHostingView(rootView: + DesktopBotView(appState: AppState.shared, engine: eng, viewState: vs)) + hosting.frame = CGRect(x: 0, y: 0, width: s, height: s) + hosting.autoresizingMask = [.width, .height] + p.contentView = hosting + p.alphaValue = 0 + p.orderFront(nil) + + AppState.shared.mochiOnDesktop = true + + NSAnimationContext.runAnimationGroup({ ctx in + ctx.duration = 0.45 + ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + p.animator().alphaValue = 1 + p.animator().setFrame(NSRect(origin: target, size: CGSize(width: s, height: s)), display: true) + }, completionHandler: { + Task { @MainActor in + self.panel = p + self.phase = .onDesktop + UserDefaults.standard.set(true, forKey: DesktopMochiController.enabledKey) + self.persistPosition() + // Alert may have fired during the flight (observeAlerts skipped: phase was .flyingOut) + let alertNow = AppState.shared.pendingApproval != nil || AppState.shared.pendingQuestion != nil + if DesktopMochiLogic.shouldRetractOnLanding(alertActive: alertNow) { + self.engine?.triggerEmote(.surprised) + self.phase = .retracting + DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { [weak self] in + guard let self else { return } + switch self.phase { + case .retracting: self.retractForAlert() + case .alertResolvedDuringRetract: self.phase = .home; self.launchFlyIfNeeded() + default: break + } + } + } else { + self.startPolling() + self.addEventMonitors() + self.observeLifecycle() + } + } + }) + } + + // MARK: - Fly home (user-initiated: double-click) + + /// Animate panel to notch then fully tear down. + func flyHome() { + guard let p = panel else { return } + phase = .home + pendingSlapWorkItem?.cancel() + stopPolling() + removeEventMonitors() + cancellables.removeAll() + isSleeping = false + let s = DesktopMochiController.panelSize + let screen = IslandWindowController.notchScreen() ?? NSScreen.main! + let targetOrigin = NSPoint(x: screen.frame.midX - s/2, y: screen.frame.maxY - s) + NSAnimationContext.runAnimationGroup({ ctx in + ctx.duration = 0.45 + ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + p.animator().setFrame(NSRect(origin: targetOrigin, size: CGSize(width: s, height: s)), + display: true) + }, completionHandler: { + Task { @MainActor in + SoundEngine.shared.play("peek") + self.fullTearDown() + } + }) + } + + // MARK: - Retract for alert (panel flies home; comes back after alert resolves) + + /// Close panel and show notch Mochi for the alert. UserDefaults flag stays true so + /// `launchFlyIfNeeded` restores Mochi once the alert is dismissed. + private func retractForAlert() { + guard let p = panel else { return } + stopPolling() + removeEventMonitors() + cancellables.removeAll() + pendingSlapWorkItem?.cancel() + isSleeping = false + + let s = DesktopMochiController.panelSize + let screen = IslandWindowController.notchScreen() ?? NSScreen.main! + let targetOrigin = NSPoint(x: screen.frame.midX - s/2, y: screen.frame.maxY - s) + NSAnimationContext.runAnimationGroup({ ctx in + ctx.duration = 0.45 + ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + p.animator().setFrame(NSRect(origin: targetOrigin, size: CGSize(width: s, height: s)), + display: true) + }, completionHandler: { + Task { @MainActor in + p.close() + self.panel = nil + self.engine = nil + self.viewState = nil + self.isDragging = false + AppState.shared.mochiOnDesktop = false + // UserDefaults flag stays TRUE so launchFlyIfNeeded works + if self.phase == .alertResolvedDuringRetract { + self.phase = .home + self.launchFlyIfNeeded() + } else { + self.phase = .atNotchForAlert + } + } + }) + } + + // MARK: - Uninstall (immediate, no animation) + + func uninstall() { + stopPolling() + removeEventMonitors() + fullTearDown() + } + + private func fullTearDown() { + phase = .home + cancellables.removeAll() + pendingSlapWorkItem?.cancel() + panel?.close() + panel = nil + engine = nil + viewState = nil + isDragging = false + isSleeping = false + AppState.shared.mochiOnDesktop = false + UserDefaults.standard.set(false, forKey: DesktopMochiController.enabledKey) + } + + // MARK: - Panel factory + + private func makeBlankPanel() -> NSPanel { + let p = NSPanel(contentRect: .zero, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, defer: false) + p.backgroundColor = .clear + p.isOpaque = false + p.hasShadow = false + return p + } + + // MARK: - Lifecycle observation (active while panel is live on desktop) + + private func observeLifecycle() { + cancellables.removeAll() + + // effectiveState → .finished: joy jump (only when on desktop, not retracting) + Publishers.CombineLatest(AppState.shared.$stateOverride, AppState.shared.$tasks) + .map { _, _ in AppState.shared.effectiveState } + .removeDuplicates() + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { [weak self] newState in + guard let self, self.phase == .onDesktop else { return } + if newState == .finished { + self.engine?.triggerEmote(.happy, duration: 1.2, silent: true) + } + } + .store(in: &cancellables) + } + + // MARK: - Alert observation (permanent — installed once at init) + + private func observeAlerts() { + alertSubscription = Publishers.CombineLatest( + AppState.shared.$pendingApproval, + AppState.shared.$pendingQuestion + ) + .map { a, q in a != nil || q != nil } + .removeDuplicates() + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { [weak self] alertActive in + guard let self else { return } + + if alertActive { + guard self.phase == .onDesktop else { return } + self.engine?.triggerEmote(.surprised) + self.phase = .retracting + DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { [weak self] in + guard let self else { return } + switch self.phase { + case .retracting: + self.retractForAlert() + case .alertResolvedDuringRetract: + // Alert cleared before animation started — no need to retract + self.phase = .home + self.launchFlyIfNeeded() + default: + break + } + } + } else { + switch self.phase { + case .atNotchForAlert: + self.phase = .home + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in + self?.launchFlyIfNeeded() + } + case .retracting: + // Alert resolved while waiting to retract — mark it + self.phase = .alertResolvedDuringRetract + default: + break + } + } + } + } + + // MARK: - 60 Hz polling (only while panel is live) + + private func startPolling() { + frameTimer?.invalidate() + frameTimer = Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] _ in + guard let self else { return } + Task { @MainActor in self.pollFrame() } + } + RunLoop.main.add(frameTimer!, forMode: .common) + } + + private func stopPolling() { + frameTimer?.invalidate() + frameTimer = nil + } + + private func pollFrame() { + guard let p = panel else { return } + let mouse = NSEvent.mouseLocation + let pf = p.frame + let local = CGPoint(x: mouse.x - pf.minX, y: mouse.y - pf.minY) + let s = DesktopMochiController.panelSize + + // Toggle click-through + let overBody = DesktopMochiLogic.isOverBody(localPoint: local, panelSize: s) + let needsMouse = overBody || isDragging + if p.ignoresMouseEvents == needsMouse { + p.ignoresMouseEvents = !needsMouse + } + + // Update eye-tracking origin every frame + viewState?.lookOrigin = lookOriginFor(panel: p) + + // Sleep detection + let agentActive = AppState.shared.effectiveState != .idle && + AppState.shared.effectiveState != .sleeping + if agentActive { lastAgentActive = .now } + let dist = hypot(mouse.x - pf.midX, mouse.y - pf.midY) + let interval = Date.now.timeIntervalSince(lastAgentActive) + let shouldSleep = DesktopMochiLogic.shouldSleep(lastAgentActiveInterval: interval, + mouseDistanceToPanelCenter: dist) + if shouldSleep != isSleeping { + isSleeping = shouldSleep + viewState?.isSleeping = shouldSleep + engine?.setState(isSleeping ? .sleeping : AppState.shared.effectiveState) + } + } + + // MARK: - Event monitors + + private func addEventMonitors() { + mouseDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in + guard let self else { return event } + MainActor.assumeIsolated { + guard event.window === self.panel else { return } + self.dragMouseStart = NSEvent.mouseLocation + self.dragOriginAtStart = self.panel?.frame.origin ?? .zero + } + return event + } + + mouseDraggedMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDragged) { [weak self] event in + guard let self else { return event } + MainActor.assumeIsolated { + let m = NSEvent.mouseLocation + if !self.isDragging { + let dist = hypot(m.x - self.dragMouseStart.x, m.y - self.dragMouseStart.y) + guard self.dragMouseStart != .zero, dist > 3 else { return } + self.isDragging = true + } + guard let p = self.panel else { return } + let dx = m.x - self.dragMouseStart.x + let dy = m.y - self.dragMouseStart.y + let newOrigin = self.clampToVisibleFrame( + NSPoint(x: self.dragOriginAtStart.x + dx, y: self.dragOriginAtStart.y + dy)) + p.setFrameOrigin(newOrigin) + self.viewState?.lookOrigin = self.lookOriginFor(panel: p) + } + return event + } + + // Local mouseUp (cursor still within panel) + mouseUpMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { [weak self] event in + guard let self else { return event } + MainActor.assumeIsolated { + let wasDragging = self.isDragging + self.isDragging = false + self.dragMouseStart = .zero + if wasDragging { + self.handleDragRelease(at: NSEvent.mouseLocation) + } else if event.window === self.panel { + self.handleClick(clickCount: event.clickCount) + } + } + return event + } + + // Global mouseUp (cursor moved outside panel during drag) + globalMouseUpMonitor = NSEvent.addGlobalMonitorForEvents(matching: .leftMouseUp) { [weak self] _ in + Task { @MainActor in + guard let self, self.isDragging else { return } + self.isDragging = false + self.dragMouseStart = .zero + self.handleDragRelease(at: NSEvent.mouseLocation) + } + } + + rightClickMonitor = NSEvent.addLocalMonitorForEvents(matching: .rightMouseDown) { [weak self] event in + guard let self else { return event } + MainActor.assumeIsolated { + guard event.window === self.panel else { return } + NotificationCenter.default.post(name: .openWardrobeFromDesktop, object: nil) + } + return event + } + } + + // MARK: - Click / drag helpers + + private func handleClick(clickCount: Int) { + if clickCount >= 2 { + pendingSlapWorkItem?.cancel() + flyHome() + } else { + pendingSlapWorkItem?.cancel() + let item = DispatchWorkItem { [weak self] in self?.engine?.slap() } + pendingSlapWorkItem = item + DispatchQueue.main.asyncAfter(deadline: .now() + NSEvent.doubleClickInterval, execute: item) + } + } + + private func handleDragRelease(at mouse: NSPoint) { + let islandController = (NSApp.delegate as? AppDelegate)?.islandController + let inNotchZone = islandController?.window?.frame.contains(mouse) == true + + if inNotchZone { + flyHome() + return + } + + #if !APPSTORE + if let ctx = islandController?.windowContextAtPoint(mouse) { + // Attach window context; Mochi returns to pre-drag position + AppState.shared.promptContext = ctx + SoundEngine.shared.play("approve") + engine?.triggerEmote(.happy, duration: 0.6, silent: true) + let origin = clampToVisibleFrame(dragOriginAtStart) + panel?.setFrameOrigin(origin) + persistPosition() + islandController?.expand(to: .prompt) + return + } + #endif + // Elsewhere: keep new position + persistPosition() + } + + private func removeEventMonitors() { + if let m = mouseDownMonitor { NSEvent.removeMonitor(m); mouseDownMonitor = nil } + if let m = mouseDraggedMonitor { NSEvent.removeMonitor(m); mouseDraggedMonitor = nil } + if let m = mouseUpMonitor { NSEvent.removeMonitor(m); mouseUpMonitor = nil } + if let m = globalMouseUpMonitor { NSEvent.removeMonitor(m); globalMouseUpMonitor = nil } + if let m = rightClickMonitor { NSEvent.removeMonitor(m); rightClickMonitor = nil } + } + + // MARK: - Screen sleep / wake + + private func observeScreenSleep() { + NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.screensDidSleepNotification, object: nil, queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.screenSleeping = true + self?.viewState?.paused = true + } + } + NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.screensDidWakeNotification, object: nil, queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.screenSleeping = false + self?.viewState?.paused = false + } + } + } + + // MARK: - Screen lock / unlock + + private func observeScreenLock() { + DistributedNotificationCenter.default().addObserver( + forName: NSNotification.Name("com.apple.screenIsLocked"), object: nil, queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.screenSleeping = true + self?.viewState?.paused = true + } + } + DistributedNotificationCenter.default().addObserver( + forName: NSNotification.Name("com.apple.screenIsUnlocked"), object: nil, queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.screenSleeping = false + self?.viewState?.paused = false + } + } + } + + // MARK: - Position helpers + + private func lookOriginFor(panel: NSPanel) -> CGPoint { + let screen = panel.screen ?? NSScreen.main! + return DesktopMochiLogic.lookOrigin( + panelMinX: panel.frame.minX, + panelMinY: panel.frame.minY, + screenMinX: screen.frame.minX, + screenHeight: screen.frame.height, + panelSize: DesktopMochiController.panelSize) + } + + private func clampToVisibleFrame(_ origin: NSPoint) -> NSPoint { + let screen = NSScreen.screens.min(by: { + let da = hypot(origin.x - $0.visibleFrame.midX, origin.y - $0.visibleFrame.midY) + let db = hypot(origin.x - $1.visibleFrame.midX, origin.y - $1.visibleFrame.midY) + return da < db + }) ?? NSScreen.main! + let pt = DesktopMochiLogic.clampOrigin( + CGPoint(x: origin.x, y: origin.y), + panelSize: DesktopMochiController.panelSize, + visibleFrame: screen.visibleFrame, + margin: DesktopMochiLogic.clampMargin) + return NSPoint(x: pt.x, y: pt.y) + } + + private func loadSavedPosition() -> NSPoint { + let ud = UserDefaults.standard + guard ud.object(forKey: DesktopMochiController.posXKey) != nil else { + return defaultPosition() + } + let x = CGFloat(ud.double(forKey: DesktopMochiController.posXKey)) + let y = CGFloat(ud.double(forKey: DesktopMochiController.posYKey)) + return clampToVisibleFrame(NSPoint(x: x, y: y)) + } + + private func defaultPosition() -> NSPoint { + let s = DesktopMochiController.panelSize + let margin = DesktopMochiLogic.clampMargin + let vf = (NSScreen.main ?? NSScreen.screens[0]).visibleFrame + return NSPoint(x: vf.maxX - s - margin, y: vf.minY + margin) + } + + private func persistPosition() { + guard let p = panel else { return } + let o = p.frame.origin + UserDefaults.standard.set(Double(o.x), forKey: DesktopMochiController.posXKey) + UserDefaults.standard.set(Double(o.y), forKey: DesktopMochiController.posYKey) + } +} diff --git a/NotchBuddy/Sources/App/DesktopMochiLogic.swift b/NotchBuddy/Sources/App/DesktopMochiLogic.swift new file mode 100644 index 0000000..3f7fad7 --- /dev/null +++ b/NotchBuddy/Sources/App/DesktopMochiLogic.swift @@ -0,0 +1,73 @@ +import Foundation +import CoreGraphics + +// MARK: - Alert state machine phase + +/// Phase of the desktop Mochi lifecycle. +enum DesktopPhase: Equatable { + /// No panel on screen; `UserDefaults["mochiOnDesktop"]` is `false`. + case home + /// Panel animating from notch to saved desktop position. + case flyingOut + /// Panel live on the desktop — normal operating state. + case onDesktop + /// `pendingApproval`/`pendingQuestion` just went non-nil; panel animating toward notch. + case retracting + /// Alert cleared while retract animation was still running. + case alertResolvedDuringRetract + /// Retract complete; notch Mochi is showing the alert. + case atNotchForAlert +} + +// MARK: - Pure geometry / logic (no AppKit — fully unit-testable) + +/// Stateless helpers for `DesktopMochiController`. +enum DesktopMochiLogic { + static let panelSize: CGFloat = 120 + static let sleepTimeout: TimeInterval = 120 + static let sleepMouseDistance: CGFloat = 150 + static let clampMargin: CGFloat = 24 + static let bodyRadiusFraction: CGFloat = 0.24 + + /// Whether Mochi should enter sleeping state. + static func shouldSleep(lastAgentActiveInterval: TimeInterval, + mouseDistanceToPanelCenter: CGFloat) -> Bool { + lastAgentActiveInterval > sleepTimeout && mouseDistanceToPanelCenter >= sleepMouseDistance + } + + /// Hit-test the circular body inside a square panel (AppKit y-up local coords). + static func isOverBody(localPoint: CGPoint, panelSize: CGFloat) -> Bool { + let cx = panelSize / 2 + let cy = panelSize / 2 + let r = panelSize * bodyRadiusFraction + let dx = localPoint.x - cx + let dy = localPoint.y - cy + return dx * dx + dy * dy <= r * r + } + + /// Eye-tracking origin: panel center in screen-space with y-down from top of screen. + /// Matches the coordinate space of `AppState.mousePosition`. + static func lookOrigin(panelMinX: CGFloat, + panelMinY: CGFloat, + screenMinX: CGFloat, + screenHeight: CGFloat, + panelSize: CGFloat) -> CGPoint { + let cx = panelMinX + panelSize / 2 + let cy = panelMinY + panelSize / 2 + return CGPoint(x: cx - screenMinX, y: screenHeight - cy) + } + + /// Whether Mochi should immediately retract after landing (alert was active during the flight). + static func shouldRetractOnLanding(alertActive: Bool) -> Bool { alertActive } + + /// Clamp a panel origin so the panel stays inside `visibleFrame` with `margin` on each side. + static func clampOrigin(_ origin: CGPoint, + panelSize: CGFloat, + visibleFrame: CGRect, + margin: CGFloat) -> CGPoint { + CGPoint( + x: min(max(origin.x, visibleFrame.minX + margin), visibleFrame.maxX - panelSize - margin), + y: min(max(origin.y, visibleFrame.minY + margin), visibleFrame.maxY - panelSize - margin) + ) + } +} diff --git a/NotchBuddy/Sources/App/FileDropView.swift b/NotchBuddy/Sources/App/FileDropView.swift new file mode 100644 index 0000000..9a47a33 --- /dev/null +++ b/NotchBuddy/Sources/App/FileDropView.swift @@ -0,0 +1,124 @@ +import AppKit +import SwiftUI + +// MARK: - NSView drag destination +// Wired at the AppKit level in IslandWindowController (not via SwiftUI NSViewRepresentable) +// so it never interferes with SwiftUI hit-testing. + +final class FileDropNSView: NSView { + var onDragEntered: ((CGPoint) -> Void)? + var onDragUpdated: ((CGPoint) -> Void)? + var onDragExited: (() -> Void)? + var onFilesDropped: (([URL]) -> Void)? + + override init(frame: NSRect) { + super.init(frame: frame) + registerForDraggedTypes([.fileURL]) + } + required init?(coder: NSCoder) { fatalError() } + + // Pass all mouse events through — drag-drop uses NSDraggingDestination, not hitTest + override func hitTest(_ point: NSPoint) -> NSView? { nil } + + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + onDragEntered?(sender.draggingLocation) + return .copy + } + override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { + onDragUpdated?(sender.draggingLocation) + return .copy + } + override func draggingExited(_ sender: NSDraggingInfo?) { onDragExited?() } + + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + guard let urls = sender.draggingPasteboard.readObjects( + forClasses: [NSURL.self], + options: [.urlReadingFileURLsOnly: true] + ) as? [URL], !urls.isEmpty else { return false } + onFilesDropped?(urls) + return true + } +} + +// MARK: - File drop handler + +enum FileDropHandler { + @MainActor + static func handle(urls: [URL], state: AppState) async { + guard let url = urls.first else { return } + let name = url.lastPathComponent + + // Start animation immediately — do NOT block on file copy. + // Use original URL first; swap to inbox copy once background copy finishes. + state.droppedFile = DroppedFile(url: url, name: name) + state.uploadProgress = 0 + state.fileDragOver = false + state.promptContext = .file(name: name, fileURL: url) + + let dur = 2.4 + UploadSequenceEngine.shared.performDrop(uploadDuration: dur) + + // Copy to inbox in background — update state when done + let inbox = HookServer.supportDir.appendingPathComponent("inbox") + Task.detached { + try? FileManager.default.createDirectory(at: inbox, withIntermediateDirectories: true) + let dest = inbox.appendingPathComponent(name) + try? FileManager.default.removeItem(at: dest) + if (try? FileManager.default.copyItem(at: url, to: dest)) != nil { + await MainActor.run { + state.droppedFile = DroppedFile(url: dest, name: name) + state.promptContext = .file(name: name, fileURL: dest) + } + } + } + + // Drop feedback + NotificationCenter.default.post(name: .botGulp, object: nil) + SoundEngine.shared.play("approve") + NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy) + NotificationCenter.default.post(name: .botMorphTo, object: CGFloat(0)) + + state.uploadDuration = dur + state.uploadStartTime = Date() + state.view = .uploading // canvas stays active: uploadActive covers .uploading + + // Canvas timeline from drop: + // T_DROP → T_PROG_START : ≈1.30s gulp + shrink + bar reveal + // T_PROG_START → progEnd: dur progress bar fills + // progEnd → growEnd : 0.70s Mochi grows back to choose position + let preProgress = USC.T_PROG_START - USC.T_DROP // ≈1.30s + + // Tick sounds — delayed to sync with canvas progress start + Task { @MainActor in + var lastTens = 0 + let progStart = Date().addingTimeInterval(preProgress) + while lastTens < 9 { + try? await Task.sleep(nanoseconds: 80_000_000) + let approxP = min(1.0, max(0, Date().timeIntervalSince(progStart) / dur)) + let tens = Int(approxP * 10) + if tens > lastTens { + SoundEngine.shared.play("tick") + lastTens = tens + } + } + } + + // Wait for canvas progress to complete (gulp/shrink phase + upload duration) + try? await Task.sleep(nanoseconds: UInt64((preProgress + dur) * 1_000_000_000)) + + // Canvas shows checkmark at this point + SoundEngine.shared.play("approve") + NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy) + + // Wait for grow-back animation + choose overlay settle + try? await Task.sleep(nanoseconds: UInt64(1_000_000_000)) + + // Clean up upload state + state.uploadProgress = 0 + state.uploadStartTime = nil + + // Switch to choose — canvas stays active (uploadActive covers .choose). + // Engine deactivates when user clicks a canvas choose button or navigates away. + state.view = .choose + } +} diff --git a/NotchBuddy/Sources/App/GitHubActivity.swift b/NotchBuddy/Sources/App/GitHubActivity.swift new file mode 100644 index 0000000..0ce7fab --- /dev/null +++ b/NotchBuddy/Sources/App/GitHubActivity.swift @@ -0,0 +1,72 @@ +import Foundation + +// MARK: - ContributionDay + +struct ContributionDay: Equatable { + var date: String // YYYY-MM-DD + var count: Int + var level: Int // 0…4 (NONE, FIRST_QUARTILE, SECOND_QUARTILE, THIRD_QUARTILE, FOURTH_QUARTILE) + var weekday: Int // 0 = Sunday … 6 = Saturday +} + +// MARK: - GitHubActivity + +struct GitHubActivity: Equatable { + var total: Int + var weeks: [[ContributionDay]] + var fetchedAt: Date + + // MARK: - Parse + + static func parse(_ data: Data) -> GitHubActivity? { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let dataNode = root["data"] as? [String: Any], + let viewer = dataNode["viewer"] as? [String: Any], + let cc = viewer["contributionsCollection"] as? [String: Any], + let cal = cc["contributionCalendar"] as? [String: Any], + let total = cal["totalContributions"] as? Int, + let weeksRaw = cal["weeks"] as? [[String: Any]] else { return nil } + + var weeks: [[ContributionDay]] = [] + for weekRaw in weeksRaw { + guard let daysRaw = weekRaw["contributionDays"] as? [[String: Any]] else { continue } + var days: [ContributionDay] = [] + for dayRaw in daysRaw { + guard let date = dayRaw["date"] as? String, + let count = dayRaw["contributionCount"] as? Int, + let levelStr = dayRaw["contributionLevel"] as? String, + let weekday = dayRaw["weekday"] as? Int else { continue } + let level: Int + switch levelStr { + case "FIRST_QUARTILE": level = 1 + case "SECOND_QUARTILE": level = 2 + case "THIRD_QUARTILE": level = 3 + case "FOURTH_QUARTILE": level = 4 + default: level = 0 // NONE or unknown + } + days.append(ContributionDay(date: date, count: count, level: level, weekday: weekday)) + } + if !days.isEmpty { weeks.append(days) } + } + + return GitHubActivity(total: total, weeks: weeks, fetchedAt: Date()) + } + + // MARK: - Helpers + + /// Returns the last n weeks in chronological order (oldest first). + /// The last week may be incomplete (current week in progress). + func lastWeeks(_ n: Int) -> [[ContributionDay]] { + guard n > 0 else { return [] } + let start = max(0, weeks.count - n) + return Array(weeks[start...]) + } + + /// Returns the last n days in chronological order (oldest first). + func lastDays(_ n: Int) -> [ContributionDay] { + guard n > 0 else { return [] } + let all = weeks.flatMap { $0 } + let start = max(0, all.count - n) + return Array(all[start...]) + } +} diff --git a/NotchBuddy/Sources/App/GitHubPulse.swift b/NotchBuddy/Sources/App/GitHubPulse.swift new file mode 100644 index 0000000..6cb21d4 --- /dev/null +++ b/NotchBuddy/Sources/App/GitHubPulse.swift @@ -0,0 +1,244 @@ +import Foundation + +// MARK: - CIState + +enum CIState: String, Equatable { + case pending, success, failure, unknown + + init(rawGitHub: String?) { + guard let s = rawGitHub else { self = .unknown; return } + switch s.uppercased() { + case "PENDING", "EXPECTED": self = .pending + case "SUCCESS": self = .success + case "ERROR", "FAILURE": self = .failure + default: self = .unknown + } + } +} + +// MARK: - ReviewState + +enum ReviewState: String, Equatable { + case approved, changesRequested, pending, unknown + + init(rawGitHub: String?) { + guard let s = rawGitHub else { self = .unknown; return } + switch s.uppercased() { + case "APPROVED": self = .approved + case "CHANGES_REQUESTED": self = .changesRequested + case "REVIEW_REQUIRED": self = .pending + default: self = .unknown + } + } +} + +// MARK: - GitHubPR + +struct GitHubPR: Equatable { + var id: String // "owner/repo#num" + var title: String + var url: String + var repo: String + var number: Int + var isDraft: Bool + var ci: CIState + var review: ReviewState + var headSha: String? = nil // oid of last commit; nil if not fetched +} + +// MARK: - GitHubRepoCI + +struct GitHubRepoCI: Equatable { + var repo: String + var url: String + var branch: String + var ci: CIState + var headSha: String? = nil // oid of HEAD commit; nil if not fetched +} + +// MARK: - GitHubDetailSection + +enum GitHubDetailSection { case myPRs, toReview, mainCI, activity } + +// MARK: - GitHubEvent + +enum GitHubEvent: Equatable { + case ciFailed(prId: String) + case ciPassed(prId: String) + case mainFailed(repo: String) + case reviewRequested(prId: String) +} + +// MARK: - GitHubPulse + +struct GitHubPulse: Equatable { + var login: String + var myPRs: [GitHubPR] + var toReview: [GitHubPR] + var mainCI: [GitHubRepoCI] + var fetchedAt: Date + + /// True when at least one PR CI or default-branch CI is pending — triggers 60 s poll cadence. + var hasPending: Bool { + myPRs.contains { $0.ci == .pending } || + mainCI.contains { $0.ci == .pending } + } + + // MARK: - Parse + + static func parse(_ data: Data) -> GitHubPulse? { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let dataNode = root["data"] as? [String: Any], + let viewer = dataNode["viewer"] as? [String: Any] else { return nil } + + let login = viewer["login"] as? String ?? "" + + // My PRs + var myPRs: [GitHubPR] = [] + if let prConn = viewer["pullRequests"] as? [String: Any], + let nodes = prConn["nodes"] as? [[String: Any]] { + var seenPR = Set() + for node in nodes { + guard let number = node["number"] as? Int, + let title = node["title"] as? String, + let url = node["url"] as? String, + let repoNode = node["repository"] as? [String: Any], + let repo = repoNode["nameWithOwner"] as? String else { continue } + let id = "\(repo)#\(number)" + guard seenPR.insert(id).inserted else { continue } // skip duplicates + let isDraft = node["isDraft"] as? Bool ?? false + let reviewDecision = node["reviewDecision"] as? String + let (ciRaw, headSha): (String?, String?) = { + guard let commits = node["commits"] as? [String: Any], + let cNodes = commits["nodes"] as? [[String: Any]], + let last = cNodes.last, + let commit = last["commit"] as? [String: Any] else { return (nil, nil) } + let rollup = commit["statusCheckRollup"] as? [String: Any] + return (rollup?["state"] as? String, commit["oid"] as? String) + }() + myPRs.append(GitHubPR( + id: id, title: title, url: url, repo: repo, number: number, + isDraft: isDraft, ci: CIState(rawGitHub: ciRaw), + review: ReviewState(rawGitHub: reviewDecision), headSha: headSha + )) + } + } + + // Main CI for recently-pushed repos (client-side filter: skip archived) + var mainCI: [GitHubRepoCI] = [] + if let repoConn = viewer["repositories"] as? [String: Any], + let nodes = repoConn["nodes"] as? [[String: Any]] { + for node in nodes { + let isArchived = node["isArchived"] as? Bool ?? false + if isArchived { continue } + guard let repo = node["nameWithOwner"] as? String, + let url = node["url"] as? String, + let branchRef = node["defaultBranchRef"] as? [String: Any], + let branch = branchRef["name"] as? String else { continue } + let (ciRaw, headSha): (String?, String?) = { + guard let target = branchRef["target"] as? [String: Any] else { return (nil, nil) } + let rollup = target["statusCheckRollup"] as? [String: Any] + return (rollup?["state"] as? String, target["oid"] as? String) + }() + mainCI.append(GitHubRepoCI(repo: repo, url: url, branch: branch, + ci: CIState(rawGitHub: ciRaw), headSha: headSha)) + } + } + + // To review + var toReview: [GitHubPR] = [] + if let searchResult = dataNode["reviewRequested"] as? [String: Any], + let nodes = searchResult["nodes"] as? [[String: Any]] { + var seenReview = Set() + for node in nodes { + guard let number = node["number"] as? Int, + let title = node["title"] as? String, + let url = node["url"] as? String, + let repoNode = node["repository"] as? [String: Any], + let repo = repoNode["nameWithOwner"] as? String else { continue } + let id = "\(repo)#\(number)" + guard seenReview.insert(id).inserted else { continue } // skip duplicates + let isDraft = node["isDraft"] as? Bool ?? false + toReview.append(GitHubPR( + id: id, + title: title, + url: url, + repo: repo, + number: number, + isDraft: isDraft, + ci: .unknown, + review: .pending + )) + } + } + + return GitHubPulse(login: login, myPRs: myPRs, toReview: toReview, + mainCI: mainCI, fetchedAt: Date()) + } + + // MARK: - Events + + /// Returns events comparing old → new. If old is nil (first poll after launch) returns empty — no + /// alerts on initial load, only on subsequent changes. + /// + /// headSha logic (catches fast CIs missed between polls): + /// - Same SHA (or both nil): classic transition rules apply. + /// - Different SHA or PR/repo absent in old: fire immediately if CI is already done. + /// If still pending, nothing — next poll with the same SHA will catch the result. + /// - Main CI: no "green" alert, only .mainFailed on new failure. + static func events(old: GitHubPulse?, new: GitHubPulse) -> [GitHubEvent] { + guard let old else { return [] } + + var result: [GitHubEvent] = [] + + // PR CI transitions + let oldPRmap = Dictionary(old.myPRs.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) + for pr in new.myPRs { + if let prev = oldPRmap[pr.id], prev.headSha == pr.headSha { + // Same commit: classic state-transition rules + if pr.ci == .failure && prev.ci != .failure { + result.append(.ciFailed(prId: pr.id)) + } else if pr.ci == .success && prev.ci == .pending { + result.append(.ciPassed(prId: pr.id)) + } + } else { + // New PR or new commit: alert if CI already finished + if pr.ci == .success { result.append(.ciPassed(prId: pr.id)) } + else if pr.ci == .failure { result.append(.ciFailed(prId: pr.id)) } + // pending → nothing; same-SHA rule catches it next poll + } + } + + // Default-branch CI + let oldRepoMap = Dictionary(old.mainCI.map { ($0.repo, $0) }, uniquingKeysWith: { a, _ in a }) + for repo in new.mainCI { + if let prev = oldRepoMap[repo.repo], prev.headSha == repo.headSha { + // Same commit: classic rule (failure transition only) + if repo.ci == .failure && prev.ci != .failure { + result.append(.mainFailed(repo: repo.repo)) + } + } else { + // New repo or new commit: only alert on failure (no "green" event for main) + if repo.ci == .failure { result.append(.mainFailed(repo: repo.repo)) } + } + } + + // New review requests + let oldReviewIds = Set(old.toReview.map { $0.id }) + for pr in new.toReview { + if !oldReviewIds.contains(pr.id) { + result.append(.reviewRequested(prId: pr.id)) + } + } + + return result + } + + // MARK: - Staleness + + /// Pure predicate: true when fetchedAt is nil or older than maxAge seconds before now. + static func isStale(fetchedAt: Date?, now: Date = Date(), maxAge: TimeInterval) -> Bool { + guard let t = fetchedAt else { return true } + return now.timeIntervalSince(t) > maxAge + } +} diff --git a/NotchBuddy/Sources/App/GithubPoller.swift b/NotchBuddy/Sources/App/GithubPoller.swift new file mode 100644 index 0000000..4cfb81d --- /dev/null +++ b/NotchBuddy/Sources/App/GithubPoller.swift @@ -0,0 +1,346 @@ +import Foundation + +final class GithubPoller: @unchecked Sendable { + static let shared = GithubPoller() + private var timer: DispatchSourceTimer? + // All properties below are accessed only on the main thread. + private var pulseInFlight = false + private var nextPulse: DispatchWorkItem? + private var tokenGeneration = 0 + private var activityInFlight = false + private var nextActivity: DispatchWorkItem? + private init() {} + + func start() { + guard timer == nil else { return } + // Stats poll: every 5 minutes, starting 7 s after launch + let t = DispatchSource.makeTimerSource(queue: .global(qos: .background)) + t.schedule(deadline: .now() + 7, repeating: 300) + t.setEventHandler { [weak self] in self?.pollStats() } + t.resume() + timer = t + // First pulse: 10 s after launch + DispatchQueue.main.async { [weak self] in + self?.scheduleNextPulse(hasPending: false, delay: 10) + } + // First activity: 15 s after launch, then every 30 min + DispatchQueue.main.async { [weak self] in + self?.scheduleNextActivity(delay: 15) + } + } + + /// Pull requests, CI and activity are fetched when the GitHub pill is in + /// the notch, or when the iPhone sync is on (the iPhone shows GitHub even + /// when its pill isn't in the notch). + @MainActor private static var isWanted: Bool { + if AppState.shared.activeIntegrations.contains("integration_github") { return true } + #if PHONE_LINK + return UserDefaults.standard.bool(forKey: "iPhoneSyncEnabled") + #else + return false + #endif + } + + // MARK: - Stats (unchanged logic) + + private func pollStats() { + guard let token = KeychainStore.shared.get("github-token") else { return } + fetchUser(token: token) + } + + private func fetchUser(token: String) { + guard let url = URL(string: "https://api.github.com/user") else { return } + var req = URLRequest(url: url, timeoutInterval: 10) + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + req.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + + URLSession.shared.dataTask(with: req) { [weak self] data, response, _ in + guard let self else { return } + let code = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard let data, code == 200, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return } + + let publicRepos = (json["public_repos"] as? Int) ?? 0 + let privateOwned = (json["owned_private_repos"] as? Int) + ?? (json["total_private_repos"] as? Int) + ?? 0 + self.fetchStars(token: token, totalRepos: publicRepos + privateOwned) + }.resume() + } + + private func fetchStars(token: String, totalRepos: Int) { + guard let url = URL(string: "https://api.github.com/user/repos?per_page=100&affiliation=owner&sort=pushed") else { return } + var req = URLRequest(url: url, timeoutInterval: 15) + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + req.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + + URLSession.shared.dataTask(with: req) { data, response, _ in + let code = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard let data, code == 200, + let repos = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return } + + let totalStars = repos.reduce(0) { $0 + (($1["stargazers_count"] as? Int) ?? 0) } + DispatchQueue.main.async { + AppState.shared.githubStats = GitHubStats(totalRepos: totalRepos, totalStars: totalStars) + } + }.resume() + } + + // MARK: - Pulse (GraphQL, single chain) + + /// Dispatches guards + state reads to main, then fires network on background. + private func pollPulse() { + DispatchQueue.main.async { [weak self] in + guard let self, !self.pulseInFlight else { return } + guard let token = KeychainStore.shared.get("github-token"), + Self.isWanted else { + self.scheduleNextPulse(hasPending: false) + return + } + self.pulseInFlight = true + let gen = self.tokenGeneration + DispatchQueue.global(qos: .background).async { self.fetchPulse(token: token, generation: gen) } + } + } + + private func fetchPulse(token: String, generation: Int) { + guard let url = URL(string: "https://api.github.com/graphql") else { + finishPulse(hasPending: false); return + } + var req = URLRequest(url: url, timeoutInterval: 15) + req.httpMethod = "POST" + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + guard let body = try? JSONSerialization.data(withJSONObject: ["query": Self.graphQLQuery]) else { + finishPulse(hasPending: false); return + } + req.httpBody = body + + URLSession.shared.dataTask(with: req) { [weak self] data, response, _ in + guard let self else { return } + let code = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard let data, code == 200 else { + self.nbLog("pulse HTTP \(code)") + self.finishPulse(hasPending: false) + return + } + // Partial GraphQL errors: if "data" is present, parse anyway and log error count. + // Only discard if "data" is absent or null. + if let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + if let errors = root["errors"] as? [[String: Any]], !errors.isEmpty { + self.nbLog("pulse GraphQL errors: \(errors.count)") + } + guard root["data"] is [String: Any] else { + self.finishPulse(hasPending: false); return + } + } + guard let pulse = GitHubPulse.parse(data) else { + self.finishPulse(hasPending: false); return + } + DispatchQueue.main.async { + // Discard stale response if token changed while request was in flight + guard self.tokenGeneration == generation else { return } + let old = AppState.shared.githubPulse + let events = GitHubPulse.events(old: old, new: pulse) + AppState.shared.githubPulse = pulse + // Badge and sound only for the pill in the notch, not when the + // fetch only feeds the iPhone. + if AppState.shared.activeIntegrations.contains("integration_github") { + AppState.shared.handleGitHubEvents(events) + } + } + self.finishPulse(hasPending: pulse.hasPending) + }.resume() + } + + /// Refreshes pulse data immediately if the last fetch is older than maxAge seconds (or absent). + /// No-op when a request is already in flight. Safe to call from any thread. + func refreshIfStale(maxAge: TimeInterval = 60) { + DispatchQueue.main.async { [weak self] in + guard let self, !self.pulseInFlight else { return } + guard GitHubPulse.isStale(fetchedAt: AppState.shared.githubPulse?.fetchedAt, + maxAge: maxAge) else { return } + self.nextPulse?.cancel() + self.nextPulse = nil + self.pollPulse() + } + } + + /// Cancels any scheduled next poll, increments tokenGeneration to invalidate in-flight + /// responses, then fires pollPulse immediately. If a request is already in flight, + /// does not launch another — finishPulse will reschedule. + /// Safe to call from any thread. + func triggerPulseNow() { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.tokenGeneration += 1 + self.nextPulse?.cancel() + self.nextPulse = nil + guard !self.pulseInFlight else { return } + self.pollPulse() + } + } + + private func nbLog(_ message: String) { + appendAppLog("github.log", message) + } + + /// Called from any thread; dispatches cleanup to main then schedules next poll. + private func finishPulse(hasPending: Bool) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.pulseInFlight = false + self.scheduleNextPulse(hasPending: hasPending) + } + } + + /// Cancels any pending scheduled poll and schedules a new one on the main queue. + /// Must run on the main thread. + private func scheduleNextPulse(hasPending: Bool, delay: Double? = nil) { + nextPulse?.cancel() + let work = DispatchWorkItem { [weak self] in self?.pollPulse() } + nextPulse = work + let d = delay ?? (hasPending ? 60.0 : 300.0) + DispatchQueue.main.asyncAfter(deadline: .now() + d, execute: work) + } + + // MARK: - Activity (contribution calendar, 30 min cadence) + + private func pollActivity() { + DispatchQueue.main.async { [weak self] in + guard let self, !self.activityInFlight else { return } + guard let token = KeychainStore.shared.get("github-token"), + Self.isWanted else { + self.scheduleNextActivity() + return + } + self.activityInFlight = true + let gen = self.tokenGeneration + DispatchQueue.global(qos: .background).async { self.fetchActivity(token: token, generation: gen) } + } + } + + private func fetchActivity(token: String, generation: Int) { + guard let url = URL(string: "https://api.github.com/graphql") else { + finishActivity(); return + } + var req = URLRequest(url: url, timeoutInterval: 15) + req.httpMethod = "POST" + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + guard let body = try? JSONSerialization.data(withJSONObject: ["query": Self.activityQuery]) else { + finishActivity(); return + } + req.httpBody = body + + URLSession.shared.dataTask(with: req) { [weak self] data, response, _ in + guard let self else { return } + let code = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard let data, code == 200 else { + self.nbLog("activity HTTP \(code)") + self.finishActivity() + return + } + if let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + if let errors = root["errors"] as? [[String: Any]], !errors.isEmpty { + self.nbLog("activity GraphQL errors: \(errors.count)") + } + guard root["data"] is [String: Any] else { + self.finishActivity(); return + } + } + guard let activity = GitHubActivity.parse(data) else { + self.finishActivity(); return + } + DispatchQueue.main.async { + guard self.tokenGeneration == generation else { return } + AppState.shared.githubActivity = activity + } + self.finishActivity() + }.resume() + } + + /// Refreshes activity data immediately if stale. Safe to call from any thread. + func refreshActivityIfStale(maxAge: TimeInterval = 300) { + DispatchQueue.main.async { [weak self] in + guard let self, !self.activityInFlight else { return } + guard GitHubPulse.isStale(fetchedAt: AppState.shared.githubActivity?.fetchedAt, + maxAge: maxAge) else { return } + self.nextActivity?.cancel() + self.nextActivity = nil + self.pollActivity() + } + } + + private func finishActivity() { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.activityInFlight = false + self.scheduleNextActivity() + } + } + + private func scheduleNextActivity(delay: Double? = nil) { + nextActivity?.cancel() + let work = DispatchWorkItem { [weak self] in self?.pollActivity() } + nextActivity = work + let d = delay ?? 1800.0 // 30 minutes + DispatchQueue.main.asyncAfter(deadline: .now() + d, execute: work) + } + + // MARK: - GraphQL queries + + private static let graphQLQuery = """ + query { + viewer { + login + pullRequests(states: OPEN, first: 20, orderBy: {field: UPDATED_AT, direction: DESC}) { + nodes { + number title url isDraft reviewDecision + repository { nameWithOwner url } + commits(last: 1) { + nodes { commit { oid statusCheckRollup { state } } } + } + } + } + repositories(first: 10, ownerAffiliations: [OWNER], orderBy: {field: PUSHED_AT, direction: DESC}) { + nodes { + nameWithOwner url isArchived + defaultBranchRef { + name + target { ... on Commit { oid statusCheckRollup { state } } } + } + } + } + } + reviewRequested: search(query: "is:pr is:open review-requested:@me archived:false", type: ISSUE, first: 20) { + issueCount + nodes { + ... on PullRequest { + number title url isDraft + author { login } + repository { nameWithOwner url } + } + } + } + } + """ + + private static let activityQuery = """ + query { + viewer { + login + contributionsCollection { + contributionCalendar { + totalContributions + weeks { + contributionDays { + date contributionCount contributionLevel weekday + } + } + } + } + } + } + """ +} diff --git a/NotchBuddy/Sources/App/GreetingCanvasView.swift b/NotchBuddy/Sources/App/GreetingCanvasView.swift new file mode 100644 index 0000000..db0c59c --- /dev/null +++ b/NotchBuddy/Sources/App/GreetingCanvasView.swift @@ -0,0 +1,689 @@ +import SwiftUI + +// MARK: - Timing constants + +private enum GT { + static let pop0: Double = 1.30 + static let pop1: Double = 1.45 + static let content0: Double = 2.40 + static let tuck0: Double = 2.45 + static let tuck1: Double = 2.70 + static let badge: Double = 2.72 + static let down0: Double = 2.85 + static let down1: Double = 3.45 + static let blink2: Double = 3.70 + static let tint0: Double = 3.85 + static let tint1: Double = 4.15 + static let end: Double = 4.60 + static let autoLeave:Double = 4.90 + static let COLLAPSE: Double = 0.34 +} + +// MARK: - Geometry constants (640×150 reference space) + +private let GC0 = CGPoint(x: 320, y: 90) +private let GHB: CGFloat = 58 +private let GASP: CGFloat = 1.34 +private let GEAR_X: CGFloat = 40 +private let GEAR_HB:CGFloat = 17 +private let GCARD = CGRect(x: 10, y: 36, width: 620, height: 104) +private let GCARD_R:CGFloat = 20 + +// MARK: - Easing + +private enum GE { + static func out(_ t: Double) -> Double { 1 - pow(1 - t, 3) } + static func easeIn(_ t: Double) -> Double { t * t * t } + static func inOut(_ t: Double) -> Double { + t < 0.5 ? 4*t*t*t : 1 - pow(-2*t+2, 3)/2 + } + static func back(_ t: Double) -> Double { + let c1=1.70158, c3=c1+1 + return 1 + c3*pow(t-1,3) + c1*pow(t-1,2) + } +} + +private func gClamp(_ v: Double, _ a: Double, _ b: Double) -> Double { max(a, min(b, v)) } +private func gLerp(_ a: Double, _ b: Double, _ t: Double) -> Double { a + (b - a) * t } +private func gSeg(_ t: Double, _ a: Double, _ b: Double) -> Double { gClamp((t-a)/(b-a), 0, 1) } +private func gLerpF(_ a: CGFloat, _ b: CGFloat, _ t: CGFloat) -> CGFloat { a + (b-a)*t } + +// MARK: - Pose + +private enum GEyeType { case dot, happy, content } + +private struct GreetPose { + var hb, x, y, sx, sy, tilt: Double + var eye: GEyeType; var open, eyeRoll: Double + var lookX, lookY: Double + var handL, handR, wave: Double + var badge, tint, halo, haloBlue, minis, fx: Double + var header, card: Double + var iw, ih: Double +} + +// MARK: - Particle data (seeded LCG, seed=7) + +private struct GWarpStreak { let xNorm, speed, len, thick, alpha, t0: Double } +private struct GRingDot { let a, j, s, al: Double } + +private let greetParticles: (warps: [GWarpStreak], ring: [GRingDot]) = { + var seed: UInt32 = 7 + func rnd() -> Double { + seed = (seed &* 1103515245 &+ 12345) & 0x7fffffff + return Double(seed) / Double(0x7fff_ffff) + } + // ~70 white warp streaks for the fall-in (0 → 0.55 s) + let warps = (0..<70).map { _ in + GWarpStreak(xNorm: rnd(), + speed: 400 + rnd() * 300, + len: 6 + rnd() * 16, + thick: 1 + rnd() * 0.5, + alpha: 0.25 + rnd() * 0.55, + t0: rnd() * 0.35) + } + // Single burst ring at 0.45 s, ~90 dots, white + let ring = (0..<90).map { _ in + GRingDot(a: rnd() * .pi * 2, j: (rnd()-0.5)*0.22, + s: 0.7+rnd()*0.9, al: 0.45+rnd()*0.55) + } + return (warps, ring) +}() + +// MARK: - Pose computation + +private func greetPose(_ t: Double, compact: IslandRestingLayout) -> GreetPose { + // Island size (reference for clip / warp spread) + let gx = gSeg(t, 0, 0.5) + let g = sin(.pi*gx/2) + 0.04*sin(.pi*gx)*gx + let iw = gLerp(Double(compact.width - 160), 640, g) + let ih = gLerp(Double(compact.height), 150, g) + + let GH = Double(GHB) // 58 + let cx = Double(GC0.x) // 320 + let cy = Double(GC0.y) // 90 + + // Body height: invisible before 0.20, grows 0.15→1.0 with back ease + let hb: Double = t < 0.20 ? 0 : gLerp(GH * 0.15, GH, GE.back(gSeg(t, 0.20, 0.60))) + + // Landmark y positions (absolute, GC0-relative in multiples of GH) + let restY = cy + let landY = cy + 0.12 * GH // landing: y=+0.12 + let peakY = cy - 0.15 * GH // bounce peak: y=−0.15 + let dipY = cy + 0.36 * GH // plunge low: y=+0.36 + let springY = cy - 0.10 * GH // spring high: y=−0.10 + let sinkY = cy + 0.30 * GH // sink for tuck: y=+0.30 + + // Landmark x positions + let restX = cx + let drift1 = cx - 0.16 * GH + let drift2 = cx - 0.45 * GH + let drift3 = cx - 0.57 * GH + let drift4 = cx - 0.85 * GH + + // --- X (lateral travel) --- + var x: Double + if t < 0.85 { + x = restX + } else if t < 1.20 { + x = gLerp(restX, drift1, GE.inOut(gSeg(t, 0.85, 1.20))) + } else if t < 1.30 { + x = gLerp(drift1, drift2, GE.easeIn(gSeg(t, 1.20, 1.30))) + } else if t < 1.45 { + x = gLerp(drift2, drift3, GE.inOut(gSeg(t, 1.30, 1.45))) + } else if t < 2.40 { + x = gLerp(drift3, drift4, GE.inOut(gSeg(t, 1.45, 2.40))) + } else if t < 2.85 { + x = drift4 + } else { + x = gLerp(drift4, restX, GE.inOut(gSeg(t, 2.85, 3.45))) + } + + // --- Y (vertical travel, base without bob) --- + var y: Double + if t < 0.20 { + y = Double(compact.botCenterY) + } else if t < 0.60 { + y = gLerp(Double(compact.botCenterY), landY, GE.easeIn(gSeg(t, 0.20, 0.60))) + } else if t < 0.73 { + y = gLerp(landY, peakY, GE.out(gSeg(t, 0.60, 0.73))) + } else if t < 0.90 { + y = gLerp(peakY, restY, GE.inOut(gSeg(t, 0.73, 0.90))) + } else if t < 1.20 { + y = restY + } else if t < 1.30 { + y = gLerp(restY, dipY, GE.easeIn(gSeg(t, 1.20, 1.30))) + } else if t < 1.45 { + y = gLerp(dipY, springY, GE.out(gSeg(t, 1.30, 1.45))) + } else if t < 1.60 { + y = gLerp(springY, restY, GE.inOut(gSeg(t, 1.45, 1.60))) + } else if t < 2.40 { + y = restY + } else if t < 2.70 { + y = gLerp(restY, sinkY, GE.inOut(gSeg(t, 2.40, 2.70))) + } else if t < 2.85 { + y = sinkY + } else { + y = gLerp(sinkY, restY, GE.inOut(gSeg(t, 2.85, 3.45))) + } + + // Body bob: in phase with hand wave, active pop1 → tuck0, ramp-in 0.08 s + if t >= GT.pop1 && t < GT.tuck0 { + let w = t - GT.pop1 + let rampIn = gClamp(w / 0.08, 0, 1) + y += sin(w * 2 * .pi * 5.0) * 0.02 * GH * rampIn + } + + // --- Scale --- + var sx = 1.0, sy = 1.0 + + // Landing squash (peak at 0.60, pulse 0.52→0.68) + let landSqK = (t >= 0.52 && t < 0.68) ? sin(.pi * gSeg(t, 0.52, 0.68)) : 0 + // Bounce stretch (peak at 0.73, resolves 0.62→0.84) + let bounceK = (t >= 0.62 && t < 0.84) ? sin(.pi * gSeg(t, 0.62, 0.84)) : 0 + sx = 1.0 + 0.14 * landSqK - 0.10 * bounceK + sy = 1.0 - 0.14 * landSqK + 0.18 * bounceK + + // Plunge squash (peaks at 1.30, pulse 1.18→1.42) + let plungeK = (t >= 1.18 && t < 1.42) ? sin(.pi * gSeg(t, 1.18, 1.42)) : 0 + // Spring stretch (peak at 1.38, resolves 1.30→1.46) + let springK = (t >= 1.30 && t < 1.46) ? sin(.pi * gSeg(t, 1.30, 1.46)) : 0 + sx += 0.12 * plungeK - 0.18 * springK + sy -= 0.12 * plungeK - 0.25 * springK + + // Sink squash (peak at 2.70) + let sinkK: Double + if t >= 2.38 && t < 2.70 { + sinkK = GE.inOut(gSeg(t, 2.38, 2.70)) + } else if t >= 2.70 && t < 2.85 { + sinkK = 1 - GE.inOut(gSeg(t, 2.70, 2.85)) + } else { + sinkK = 0 + } + sx += 0.18 * sinkK + sy -= 0.14 * sinkK + + // Micro squash at 3.70 (with blink) + let microK = (t >= 3.70 && t < 3.82) ? sin(.pi * gSeg(t, 3.70, 3.82)) : 0 + sx += 0.08 * microK + sy -= 0.07 * microK + + // --- Eyes --- + var eye: GEyeType = .dot + if t >= 0.55 && t < 0.80 { eye = .happy } + if t >= GT.content0 && t < GT.tuck1 { eye = .content } + + let blink: (Double) -> Double = { tb in + let k = gSeg(t, tb, tb + 0.12) + return (k > 0 && k < 1) ? 1 - sin(.pi * k) * 0.94 : 1 + } + let openVal = min(blink(1.95), min(blink(3.05), blink(GT.blink2))) + + // --- Look --- + var lookX = 0.0, lookY = 0.0 + if t >= GT.pop1 && t < GT.content0 { // wave: up-right + lookX = 0.55; lookY = -0.45 + } else if t >= GT.content0 && t < GT.down0 { // tuck: down-left + lookX = -0.3; lookY = 0.6 + } else if t >= GT.down0 && t < GT.down1 { // slide: down-right (returning) + lookX = 0.3; lookY = 0.6 + } else if t >= GT.down1 { + let k = GE.inOut(gSeg(t, GT.down1, GT.down1 + 0.35)) + lookX = gLerp(0.3, 0, k); lookY = gLerp(0.6, 0, k) + } + + // --- Hands --- + let handL = t < GT.tuck0 + ? GE.back(gSeg(t, GT.pop0, GT.pop0 + 0.14)) + : 1 - GE.easeIn(gSeg(t, GT.tuck0, GT.tuck1 - 0.03)) + let handR = t < GT.tuck0 + ? GE.back(gSeg(t, GT.pop0 + 0.04, GT.pop0 + 0.18)) + : 1 - GE.easeIn(gSeg(t, GT.tuck0 + 0.03, GT.tuck1)) + let wave = (t >= GT.pop1 && t < GT.tuck0) ? t - GT.pop1 : -1.0 + + return GreetPose( + hb: hb, x: x, y: y, sx: sx, sy: sy, tilt: 0, + eye: eye, open: openVal, eyeRoll: 0, + lookX: lookX, lookY: lookY, + handL: handL, handR: handR, wave: wave, + badge: GE.back(gSeg(t, GT.badge, GT.badge + 0.28)), + tint: 0.6 * GE.inOut(gSeg(t, GT.tint0, GT.tint1)), + halo: GE.out(gSeg(t, 0.3, 0.7)), + haloBlue: gSeg(t, GT.tint0, GT.tint1), + minis: 0, fx: 1, + header: gSeg(t, 0.35, 0.6), card: gSeg(t, 0.18, 0.45), + iw: iw, ih: ih + ) +} + +private func smallPose(_ compact: IslandRestingLayout) -> GreetPose { + let sw = Double(compact.width) + return GreetPose( + hb: Double(GEAR_HB * compact.botDiameter / 20), + x: 320 - sw/2 + Double(GEAR_X), + y: Double(compact.botCenterY), + sx: 1, sy: 1, tilt: 0, + eye: .dot, open: 1, eyeRoll: 0, + lookX: 0, lookY: 0, + handL: 0, handR: 0, wave: -1, + badge: 1, tint: 0.6, halo: 0.6, haloBlue: 1, + minis: 1, fx: 1, + header: 0, card: 0, + iw: sw, ih: Double(compact.height) + ) +} + +private func pose(_ t: Double, tc: Double, compact: IslandRestingLayout) -> GreetPose { + if t < tc { return greetPose(min(t, GT.end + 10), compact: compact) } + let a = greetPose(tc, compact: compact) + let b = smallPose(compact) + let e = GE.inOut(gSeg(t, tc, tc + GT.COLLAPSE)) + var p = a + p.iw = gLerp(a.iw, b.iw, e); p.ih = gLerp(a.ih, b.ih, e) + p.x = gLerp(a.x, b.x, e); p.y = gLerp(a.y, b.y, e) + p.hb = gLerp(a.hb, b.hb, e) + p.badge = gLerp(a.badge, b.badge, e) + p.tint = gLerp(a.tint, b.tint, e) + p.halo = gLerp(a.halo, b.halo, e) + p.haloBlue = gLerp(a.haloBlue, b.haloBlue, e) + p.header = a.header * (1 - gSeg(t, tc, tc+0.1)) + p.card = a.card * (1 - gSeg(t, tc, tc+0.18)) + p.handL = a.handL * (1 - gSeg(t, tc, tc+0.15)) + p.handR = a.handR * (1 - gSeg(t, tc, tc+0.15)) + p.wave = a.wave >= 0 ? a.wave : -1 + p.tilt = a.tilt * (1 - e) + p.sx = gLerp(a.sx, 1, e); p.sy = gLerp(a.sy, 1, e) + p.eyeRoll = a.eyeRoll * (1 - e) + let bk = gSeg(t, tc+0.14, tc+0.26) + p.eye = .dot; p.open = (bk > 0 && bk < 1) ? 1 - sin(.pi*bk)*0.94 : 1 + p.lookX = a.lookX*(1-e); p.lookY = a.lookY*(1-e) + p.minis = GE.back(gSeg(t, tc+0.24, tc+0.42)) + p.fx = 1 - gSeg(t, tc, tc+0.2) + return p +} + +// MARK: - Drawing helpers + +private func gHex(_ hex: String, alpha: CGFloat = 1) -> CGColor { + let h = hex.trimmingCharacters(in: CharacterSet(charactersIn:"#")) + let v = UInt64(h, radix: 16) ?? 0 + return CGColor(red: CGFloat((v>>16)&0xFF)/255, + green: CGFloat((v>>8)&0xFF)/255, + blue: CGFloat(v&0xFF)/255, alpha: alpha) +} + +private func gRR(_ ctx: CGContext, _ x: CGFloat, _ y: CGFloat, + _ w: CGFloat, _ h: CGFloat, _ r: CGFloat) { + let r = max(0, min(r, w/2, h/2)) + ctx.beginPath() + ctx.move(to: CGPoint(x: x+r, y: y)) + ctx.addArc(tangent1End: CGPoint(x: x+w, y: y), tangent2End: CGPoint(x: x+w, y: y+h), radius: r) + ctx.addArc(tangent1End: CGPoint(x: x+w, y: y+h), tangent2End: CGPoint(x: x, y: y+h), radius: r) + ctx.addArc(tangent1End: CGPoint(x: x, y: y+h), tangent2End: CGPoint(x: x, y: y), radius: r) + ctx.addArc(tangent1End: CGPoint(x: x, y: y), tangent2End: CGPoint(x: x+r, y: y), radius: r) + ctx.closePath() +} + +private func mochiPath(hw: CGFloat, hh: CGFloat) -> CGPath { + let n: CGFloat = 3.2 + let path = CGMutablePath() + let steps = 96 + for i in 0...steps { + let a = CGFloat(i)/CGFloat(steps)*2 * .pi + let ca = cos(a), sa = sin(a) + let px = hw * (ca < 0 ? -1 : 1) * pow(abs(ca), 2/n) + let py = hh * (sa < 0 ? -1 : 1) * pow(abs(sa), 2/n) + if i == 0 { path.move(to: CGPoint(x: px, y: py)) } + else { path.addLine(to: CGPoint(x: px, y: py)) } + } + path.closeSubpath(); return path +} + +private func whiteFill(_ ctx: CGContext, _ path: CGPath, + x0: CGFloat, y0: CGFloat, x1: CGFloat, y1: CGFloat) { + let cs = CGColorSpaceCreateDeviceRGB() + let c0 = CGColor(red: 251/255, green: 251/255, blue: 252/255, alpha: 1) + let c1 = CGColor(red: 231/255, green: 233/255, blue: 236/255, alpha: 1) + guard let g = CGGradient(colorsSpace: cs, colors: [c0,c1] as CFArray, locations: [0,1]) else { return } + ctx.saveGState() + ctx.addPath(path); ctx.clip() + ctx.drawLinearGradient(g, start: CGPoint(x: x0, y: y0), + end: CGPoint(x: x1, y: y1), + options: [.drawsBeforeStartLocation, .drawsAfterEndLocation]) + ctx.restoreGState() +} + +// Left hand: ball, waves (bobs vertically) +private func drawHandL(_ ctx: CGContext, hw: CGFloat, hh: CGFloat, p: GreetPose) { + let k = CGFloat(p.handL); guard k > 0.01 else { return } + let hb = hh * 2 + let r = hb * 0.15 * k + let rx = gLerpF(-hw * 0.35, -hw - hb * 0.22, k) + let ry0 = gLerpF(hh * 0.85, hh * 0.62, k) + var ry = Double(ry0) + if p.wave >= 0 { + let w = p.wave + // Ramp in 0.08 s after pop1; ramp out over tuck0→tuck1 + let rampIn = gClamp(w / 0.08, 0, 1) + let waveEnd = GT.tuck0 - GT.pop1 // = 1.00 + let rampOut = 1 - gClamp((w - waveEnd) / (GT.tuck1 - GT.tuck0), 0, 1) + let ramp = rampIn * rampOut + ry += sin(w * 2 * .pi * 5.0) * Double(hb) * 0.14 * ramp + } + ctx.saveGState() + ctx.translateBy(x: rx, y: CGFloat(ry)) + let circ = CGPath(ellipseIn: CGRect(x: -r, y: -r, width: r*2, height: r*2), transform: nil) + whiteFill(ctx, circ, x0: r, y0: -r, x1: -r, y1: r) + ctx.addEllipse(in: CGRect(x: -r, y: -r, width: r*2, height: r*2)) + ctx.setStrokeColor(CGColor(red: 0, green: 0, blue: 0, alpha: 0.08)) + ctx.setLineWidth(0.8); ctx.strokePath() + ctx.restoreGState() +} + +// Right hand: capsule, quasi-static with breathing rotation ±0.04 rad at 2.5 Hz +private func drawHandR(_ ctx: CGContext, hw: CGFloat, hh: CGFloat, p: GreetPose) { + let k = CGFloat(p.handR); guard k > 0.01 else { return } + let hb = hh * 2, L = hb * 0.40 * k, T2 = hb * 0.22 * k + let rx0 = gLerpF(hw * 0.35, hw + hb * 0.20, k) + let ry0 = gLerpF(hh * 0.85, hh * 0.20, k) + // Breathing rotation ±0.04 rad at 2.5 Hz, only while wave is active; no displacement + let ang: CGFloat = p.wave >= 0 + ? -0.61 + CGFloat(sin(p.wave * 2 * .pi * 2.5)) * 0.04 + : -0.61 + ctx.saveGState() + ctx.translateBy(x: rx0, y: ry0) + ctx.rotate(by: ang) + let cap = CGMutablePath() + gRR(ctx, -L/2, -T2/2, L, T2, T2/2) + cap.addPath(ctx.path!) + ctx.beginPath() + whiteFill(ctx, cap, x0: L/2, y0: -T2/2, x1: -L/2, y1: T2/2) + gRR(ctx, -L/2, -T2/2, L, T2, T2/2) + ctx.setStrokeColor(CGColor(red: 0, green: 0, blue: 0, alpha: 0.08)) + ctx.setLineWidth(0.8); ctx.strokePath() + ctx.restoreGState() +} + +private func drawMochi(_ ctx: CGContext, p: GreetPose) { + let hh = CGFloat(p.hb/2), hw = hh*GASP; guard hh > 0.4 else { return } + + // Halo (golden → blue) + if p.halo > 0 { + let bl = CGFloat(p.haloBlue) + let cr = gLerpF(232/255, 59/255, bl) + let cg = gLerpF(195/255, 158/255, bl) + let cb = gLerpF(154/255, 255/255, bl) + let cs = CGColorSpaceCreateDeviceRGB() + let px = CGFloat(p.x), py = CGFloat(p.y) + let R1 = hw * 2.6 + let ic1 = CGColor(red: cr, green: cg, blue: cb, alpha: CGFloat(0.18 * p.halo)) + let oc1 = CGColor(red: cr, green: cg, blue: cb, alpha: 0) + if let g1 = CGGradient(colorsSpace: cs, colors: [ic1,oc1] as CFArray, locations: [0,1]) { + ctx.saveGState() + ctx.addEllipse(in: CGRect(x: px-R1, y: py-R1, width: R1*2, height: R1*2)) + ctx.clip() + ctx.drawRadialGradient(g1, startCenter: CGPoint(x: px,y: py), startRadius: 0, + endCenter: CGPoint(x: px,y: py), endRadius: R1, options: []) + ctx.restoreGState() + } + let R2 = hw * 4.2 + let ic2 = CGColor(red: cr, green: cg, blue: cb, alpha: CGFloat(0.07 * p.halo)) + let oc2 = CGColor(red: cr, green: cg, blue: cb, alpha: 0) + if let g2 = CGGradient(colorsSpace: cs, colors: [ic2,oc2] as CFArray, locations: [0,1]) { + ctx.saveGState() + ctx.addEllipse(in: CGRect(x: px-R2, y: py-R2, width: R2*2, height: R2*2)) + ctx.clip() + ctx.drawRadialGradient(g2, startCenter: CGPoint(x: px,y: py), startRadius: 0, + endCenter: CGPoint(x: px,y: py), endRadius: R2, options: []) + ctx.restoreGState() + } + } + + ctx.saveGState() + ctx.translateBy(x: CGFloat(p.x), y: CGFloat(p.y)) + ctx.rotate(by: CGFloat(p.tilt)) + ctx.scaleBy(x: CGFloat(p.sx), y: CGFloat(p.sy)) + + drawHandL(ctx, hw: hw, hh: hh, p: p) + drawHandR(ctx, hw: hw, hh: hh, p: p) + + let mpath = mochiPath(hw: hw, hh: hh) + whiteFill(ctx, mpath, x0: hw*0.6, y0: -hh, x1: -hw*0.6, y1: hh) + + if p.tint > 0 { + let cs = CGColorSpaceCreateDeviceRGB() + let c0 = CGColor(red: 127/255, green: 180/255, blue: 234/255, alpha: CGFloat(p.tint)) + let c1 = CGColor(red: 127/255, green: 180/255, blue: 234/255, alpha: 0) + if let g = CGGradient(colorsSpace: cs, colors: [c0,c1] as CFArray, locations: [0,1]) { + ctx.saveGState() + ctx.addPath(mpath); ctx.clip() + ctx.drawLinearGradient(g, start: CGPoint(x: 0, y: hh), + end: CGPoint(x: 0, y: -hh*0.1), options: []) + ctx.restoreGState() + } + } + + ctx.saveGState() + ctx.addPath(mpath); ctx.clip() + ctx.setFillColor(gHex("#16171A")) + ctx.setStrokeColor(gHex("#16171A")) + let er = CGFloat(p.hb*0.06) + let sp = CGFloat(p.hb*0.19) + let lx = CGFloat(p.lookX)*hw*0.42 + let ly = CGFloat(p.lookY)*hh*0.28 + hh*0.12 + CGFloat(p.eyeRoll)*hh*1.25 + for sd: CGFloat in [-1, 1] { + ctx.saveGState() + ctx.translateBy(x: sd*sp+lx, y: ly) + if p.eye == .happy { + ctx.setLineWidth(er*0.95); ctx.setLineCap(.round) + ctx.beginPath() + ctx.addArc(center: CGPoint(x: 0, y: er*0.6), radius: er*1.25, + startAngle: .pi*1.15, endAngle: .pi*1.85, clockwise: false) + ctx.strokePath() + } else if p.eye == .content { + ctx.setLineWidth(er*0.95); ctx.setLineCap(.round) + ctx.beginPath() + ctx.addArc(center: CGPoint(x: 0, y: -er*0.5), radius: er*1.25, + startAngle: .pi*0.15, endAngle: .pi*0.85, clockwise: false) + ctx.strokePath() + } else { + ctx.scaleBy(x: 1, y: max(0.12, CGFloat(p.open))) + ctx.addEllipse(in: CGRect(x: -er, y: -er, width: er*2, height: er*2)) + ctx.fillPath() + } + ctx.restoreGState() + } + ctx.restoreGState() + + // Activity badge + if p.badge > 0.01 { + let bs = CGFloat(p.badge) + let br = hh*0.3 + ctx.saveGState() + ctx.translateBy(x: -hw*0.78, y: -hh*0.72) + ctx.scaleBy(x: bs, y: bs) + ctx.setFillColor(gHex("#000000")) + ctx.addEllipse(in: CGRect(x: -(br+hh*0.07), y: -(br+hh*0.07), + width: (br+hh*0.07)*2, height: (br+hh*0.07)*2)) + ctx.fillPath() + ctx.setFillColor(gHex("#3BA0F5")) + ctx.addEllipse(in: CGRect(x: -br, y: -br, width: br*2, height: br*2)) + ctx.fillPath() + ctx.setFillColor(gHex("#0B1B3A")) + for i: CGFloat in [-1, 0, 1] { + ctx.addEllipse(in: CGRect(x: i*br*0.5-br*0.17, y: -br*0.17, + width: br*0.34, height: br*0.34)) + ctx.fillPath() + } + ctx.restoreGState() + } + + ctx.restoreGState() +} + +private func drawParticles(_ ctx: CGContext, t: Double, tc: Double, p: GreetPose) { + guard p.card > 0 || p.fx < 1 else { return } + let fx = p.fx + + // WARP STREAKS: white vertical lines, fall-in 0 → 0.55 s + if t < 0.55 { + for s in greetParticles.warps { + guard t >= s.t0 else { continue } + let elapsed = t - s.t0 + let yBot = CGFloat(elapsed * s.speed) + let yTop = yBot - CGFloat(s.len) + guard yBot > 0 else { continue } + let streakX = CGFloat(320 - p.iw/2 + s.xNorm * p.iw) + let fadeOut = 1 - gSeg(t, 0.40, 0.55) + let alpha = CGFloat(s.alpha * fx * fadeOut) + ctx.setStrokeColor(CGColor(red: 1, green: 1, blue: 1, alpha: alpha)) + ctx.setLineWidth(CGFloat(s.thick)); ctx.setLineCap(.butt) + ctx.beginPath() + ctx.move(to: CGPoint(x: streakX, y: max(0, yTop))) + ctx.addLine(to: CGPoint(x: streakX, y: min(150, yBot))) + ctx.strokePath() + } + } + + // RING: single white burst at 0.45 s, ~90 dots + let ringT0 = 0.45 + let k = gSeg(t, ringT0, ringT0 + 1.35) + if k > 0 && k < 1 { + let rx = gLerpF(14, 380, CGFloat(GE.out(k))) + let ry = rx * 0.34 + let fade = CGFloat((1-k) * (k < 0.08 ? k/0.08 : 1) * fx * p.card) + for dot in greetParticles.ring { + let r: CGFloat = 1 + CGFloat(dot.j) + ctx.setFillColor(CGColor(red: 1, green: 1, blue: 1, + alpha: CGFloat(dot.al) * fade)) + let dx = CGFloat(GC0.x) + cos(CGFloat(dot.a)) * rx * r + let dy = CGFloat(GC0.y) + sin(CGFloat(dot.a)) * ry * r + ctx.fill(CGRect(x: dx, y: dy, width: CGFloat(dot.s), height: CGFloat(dot.s))) + } + } +} + +private func drawHeader(_ ctx: CGContext, alpha: Double) { + guard alpha > 0 else { return } + ctx.saveGState() + ctx.setAlpha(CGFloat(alpha)) + gRR(ctx, 18, 4, 44, 26, 13) + ctx.setFillColor(gHex("#1D1F23")); ctx.fillPath() + ctx.setFillColor(gHex("#F5F6F8")) + ctx.beginPath() + ctx.move(to: CGPoint(x: 33, y: 20)); ctx.addLine(to: CGPoint(x: 40, y: 13)) + ctx.addLine(to: CGPoint(x: 47, y: 20)); ctx.addLine(to: CGPoint(x: 47, y: 25)) + ctx.addLine(to: CGPoint(x: 33, y: 25)); ctx.closePath(); ctx.fillPath() + ctx.setFillColor(gHex("#8E939C")) + ctx.addEllipse(in: CGRect(x: 75.5, y: 10.5, width: 13, height: 13)); ctx.fillPath() + ctx.addEllipse(in: CGRect(x: 572, y: 11, width: 12, height: 12)); ctx.fillPath() + ctx.setFillColor(gHex("#000000")) + ctx.addEllipse(in: CGRect(x: 575.6, y: 14.6, width: 4.8, height: 4.8)); ctx.fillPath() + ctx.restoreGState() +} + +private let miniColors = ["#E86A6A","#3E86E0","#EFAE5A","#8C73F2"] + +private func drawMinis(_ ctx: CGContext, alpha: Double, compact: IslandRestingLayout) { + guard alpha > 0.01 else { return } + let cx = 320 - compact.width/2 + compact.miniGridCenterX + let cy = compact.botCenterY + let sp: CGFloat = 6 * compact.miniGridScale + let offsets: [(CGFloat, CGFloat)] = [(-sp,-sp),(sp,-sp),(-sp,sp),(sp,sp)] + for (i,(dx,dy)) in offsets.enumerated() { + ctx.saveGState() + ctx.translateBy(x: cx+dx, y: cy+dy) + let scale = CGFloat(alpha) * compact.miniGridScale + ctx.scaleBy(x: scale, y: scale) + ctx.setFillColor(gHex(miniColors[i])) + ctx.addPath(mochiPath(hw: 5.3, hh: 4)); ctx.fillPath() + ctx.restoreGState() + } +} + +// MARK: - Full draw + +private func drawGreeting(_ ctx: CGContext, size: CGSize, t: Double, + tc: Double, compact: IslandRestingLayout) { + let p = pose(t, tc: tc, compact: compact) + + if p.card > 0 { + ctx.saveGState() + ctx.setAlpha(CGFloat(p.card)) + gRR(ctx, GCARD.minX, GCARD.minY, GCARD.width, GCARD.height, GCARD_R) + ctx.setFillColor(gHex("#141518")); ctx.fillPath() + ctx.restoreGState() + + ctx.saveGState() + gRR(ctx, GCARD.minX, GCARD.minY, GCARD.width, GCARD.height, GCARD_R) + ctx.clip() + drawParticles(ctx, t: t, tc: tc, p: p) + ctx.restoreGState() + } else if tc.isFinite && t >= tc { + ctx.saveGState() + drawParticles(ctx, t: t, tc: tc, p: p) + ctx.restoreGState() + } + + drawMinis(ctx, alpha: p.minis, compact: compact) + drawMochi(ctx, p: p) +} + +// MARK: - SwiftUI View + +struct GreetingCanvasView: View { + @ObservedObject var state: AppState + + @State private var startDate = Date() + @State private var tc: Double = .infinity + @State private var greetFired = false + @State private var doneWork: DispatchWorkItem? = nil + + var body: some View { + TimelineView(.animation) { timeline in + let t = timeline.date.timeIntervalSince(startDate) + Canvas { context, size in + context.withCGContext { cgCtx in + drawGreeting(cgCtx, size: size, t: t, tc: tc, + compact: IslandRestingLayout(width: state.notchWidth + 160, + height: state.notchHeight)) + } + } + .onChange(of: !greetFired && t >= GT.end && tc >= GT.autoLeave) { _, trigger in + if trigger { fireGreetComplete() } + } + } + .onAppear { + startDate = Date() + tc = .infinity + greetFired = false + // Original score plays from the start; greet.wav and blip.wav kept for other uses + SoundEngine.shared.play("greeting") + // Safety fallback: fire done if timeline onChange misses it + let item = DispatchWorkItem { fireGreetComplete() } + doneWork = item + DispatchQueue.main.asyncAfter(deadline: .now() + GT.end + 0.05, execute: item) + } + .onDisappear { + doneWork?.cancel(); doneWork = nil + SoundEngine.shared.fadeOut("greeting", duration: 0.2) + } + .onReceive(NotificationCenter.default.publisher(for: .greetingHover)) { _ in + if tc >= GT.autoLeave { tc = .infinity } + } + .onReceive(NotificationCenter.default.publisher(for: .greetingInterrupt)) { _ in + let t = Date().timeIntervalSince(startDate) + if tc.isInfinite || tc > t { tc = t } + doneWork?.cancel(); doneWork = nil + SoundEngine.shared.fadeOut("greeting", duration: 0.25) + } + } + + private func fireGreetComplete() { + guard !greetFired else { return } + greetFired = true + doneWork?.cancel(); doneWork = nil + // Note: greeting music continues for ~0.3 s after greetComplete — intentional queue overlap + NotificationCenter.default.post(name: .greetComplete, object: nil) + } +} diff --git a/NotchBuddy/Sources/App/HookServer.swift b/NotchBuddy/Sources/App/HookServer.swift new file mode 100644 index 0000000..26dde14 --- /dev/null +++ b/NotchBuddy/Sources/App/HookServer.swift @@ -0,0 +1,2418 @@ +import Foundation +import Darwin +import AppKit +import SwiftUI +import CryptoKit + +// MARK: - HookServer +// Listens on a Unix domain socket for events from nb-hook (Claude Code hooks). +// Thread-safe: socket I/O on background threads, state updates dispatched to main queue. + +final class HookServer: @unchecked Sendable { + static let shared = HookServer() + + // Support directory paths + static var supportDir: URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("NotchBuddy") + } + static var socketPath: String { + #if APPSTORE + // Container home root keeps path ≤ 103 bytes (sun_path limit on macOS is 104 incl. NUL) + // /Users/louis/Library/Containers/fr.louisraille.Coucou/Data/nb.sock = 66 bytes ✓ + return NSHomeDirectory() + "/nb.sock" + #else + return supportDir.appendingPathComponent("nb.sock").path + #endif + } + // hookScriptPath is only used by the non-App Store build. + // App Store build derives the command from the panel-selected claudeURL in buildHooksData(claudeURL:). + static var hookScriptPath: String { supportDir.appendingPathComponent("nb-hook").path } + + // No approval blocking state — notch is notification-only, user answers in VS Code + + private static let maxPayload = 1_048_576 // 1 MB — reject oversized messages + private static let receiveTimeoutSeconds: Int = 5 // SO_RCVTIMEO on client sockets + private static let maxConnections = 32 // concurrent connection ceiling + + private var serverFD: Int32 = -1 + private let connectionLock = NSLock() + private var connectionCount = 0 + private var pendingApprovalFD: Int32 = -1 // held open while user decides + private var approvalFDSource: (any DispatchSourceRead)? = nil // monitors pendingApprovalFD + private var pendingQuestionFD: Int32 = -1 // held open while user answers AskUserQuestion + private var questionFDSource: (any DispatchSourceRead)? = nil // monitors pendingQuestionFD + private var questionPillId: String = "" // pill that owns the pending question + private var focusBeforeQuestion: String? = nil // saved focus to restore after question + private var activeSessionId: String? = nil // current Claude Code session + private var focusBeforeApproval: String? = nil // saved focus to restore after approval + + private init() {} + + // MARK: - Approval fd helpers + + @MainActor + private func cancelApprovalFDSource() { + approvalFDSource?.cancel() + approvalFDSource = nil + } + + /// Cancels the approval fd source (which closes the fd via its cancel handler), shows a + /// 3-second note, clears approval state, then collapses the island. + @MainActor + private func dismissApprovalCard(note: String) { + // cancelApprovalFDSource() triggers the cancel handler which closes the fd. + // Never close the fd here directly — Apple requires it to happen in the cancel handler. + cancelApprovalFDSource() + pendingApprovalFD = -1 + let state = AppState.shared + let pillId = state.pendingApproval?.pillId ?? "integration_claude" + state.pendingApproval = nil + state.isPinned = false + state.updateTask(id: pillId, state: .working) + clearPillBadge(id: pillId) + // Restore focus to the pill that was focused before the approval card appeared. + if let prev = focusBeforeApproval { + focusBeforeApproval = nil + if state.focusId == pillId, state.tasks.contains(where: { $0.id == prev }) { + withAnimation(.spring(response: 0.5, dampingFraction: 0.72)) { state.focusId = prev } + } + } + state.noteMessage = note + state.view = .note + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + NotificationCenter.default.post(name: .islandCollapse, object: nil) + } + } + + // MARK: - Question fd helpers + + @MainActor + private func cancelQuestionFDSource() { + questionFDSource?.cancel() + questionFDSource = nil + } + + @MainActor + private func dismissQuestionCard(note: String) { + cancelQuestionFDSource() + pendingQuestionFD = -1 + let state = AppState.shared + let pillId = questionPillId + state.pendingQuestion = nil + state.isPinned = false + state.updateTask(id: pillId, state: .working) + clearPillBadge(id: pillId) + if let prev = focusBeforeQuestion { + focusBeforeQuestion = nil + if state.focusId == pillId, state.tasks.contains(where: { $0.id == prev }) { + withAnimation(.spring(response: 0.5, dampingFraction: 0.72)) { state.focusId = prev } + } + } + if !note.isEmpty { + state.noteMessage = note + state.view = .note + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + NotificationCenter.default.post(name: .islandCollapse, object: nil) + } + } else { + state.view = state.tasks.isEmpty ? .empty : .overview + } + } + + /// Called by QuestionView. Sends answers JSON and cleans up. + @MainActor + func sendQuestionAnswers(_ answers: [String: Any]) { + let fd = pendingQuestionFD + pendingQuestionFD = -1 + let source = questionFDSource + questionFDSource = nil + if fd >= 0, let data = try? JSONSerialization.data(withJSONObject: ["permissionDecision": "answer", "answers": answers], options: .withoutEscapingSlashes), + let json = String(data: data, encoding: .utf8) { + Task.detached { [weak self] in + self?.sendLine(fd: fd, text: json) + DispatchQueue.main.async { source?.cancel() } + } + } else { + source?.cancel() + } + dismissQuestionCard(note: "") + } + + /// Called by QuestionView.onDisappear — card left screen without an explicit answer. + /// Sends "ask" immediately to unblock nb-hook; does NOT navigate (view already changed). + @MainActor + func releaseQuestionFD() { + guard pendingQuestionFD >= 0 else { return } + let fd = pendingQuestionFD + pendingQuestionFD = -1 + let source = questionFDSource + questionFDSource = nil + AppState.shared.pendingQuestion = nil + Task.detached { [weak self] in + self?.sendLine(fd: fd, text: #"{"permissionDecision":"ask"}"#) + DispatchQueue.main.async { source?.cancel() } + } + } + + /// Called by QuestionView "Reply in terminal" button. + @MainActor + func sendQuestionAsk() { + let fd = pendingQuestionFD + pendingQuestionFD = -1 + let source = questionFDSource + questionFDSource = nil + if fd >= 0 { + Task.detached { [weak self] in + self?.sendLine(fd: fd, text: #"{"permissionDecision":"ask"}"#) + DispatchQueue.main.async { source?.cancel() } + } + } else { + source?.cancel() + } + dismissQuestionCard(note: "") + } + + /// Returns the tool_input serialized as sorted-keys JSON, "" if absent or empty. + /// Same computation used in processPermissionRequest and processEvent to match PostToolUse. + private static func approvalInputKey(_ input: [String: Any]) -> String { + guard !input.isEmpty, + let data = try? JSONSerialization.data(withJSONObject: input, options: .sortedKeys), + let str = String(data: data, encoding: .utf8) else { return "" } + return str + } + + // MARK: - Start + + func start() { + // Ensure support directory exists (mode 0700 — not world-readable) + let dir = Self.supportDir + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try? FileManager.default.setAttributes([.posixPermissions: 0o700 as NSNumber], ofItemAtPath: dir.path) + #if !APPSTORE + installHookScript() + #endif + Thread.detachNewThread { self.serverThread() } + } + + // MARK: - Socket server (background thread) + + private func serverThread() { + let path = Self.socketPath + // sun_path on macOS is 104 bytes including the NUL terminator → max 103 usable bytes + let maxSunPathBytes = MemoryLayout.size - MemoryLayout.size - 1 + guard path.utf8.count <= maxSunPathBytes else { + NSLog("HookServer: socket path too long (\(path.utf8.count) bytes, max \(maxSunPathBytes)): \(path)") + return + } + try? FileManager.default.removeItem(atPath: path) + + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { return } + serverFD = fd + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let cpath = Array(path.utf8CString) + withUnsafeMutableBytes(of: &addr.sun_path) { raw in + for (i, c) in cpath.enumerated() where i < raw.count { raw[i] = UInt8(bitPattern: c) } + } + + let bindRC = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { Darwin.bind(fd, $0, socklen_t(MemoryLayout.size)) } + } + guard bindRC == 0 else { close(fd); return } + // Restrict socket to owner only + chmod(path, 0o600) + guard Darwin.listen(fd, 32) == 0 else { close(fd); return } + + while true { + let clientFD = Darwin.accept(fd, nil, nil) + guard clientFD >= 0 else { break } + // Reject connections from other users (same-UID check) + var euid: uid_t = 0 + var egid: gid_t = 0 + guard getpeereid(clientFD, &euid, &egid) == 0, euid == getuid() else { + close(clientFD) + continue + } + // Enforce concurrent connection ceiling + connectionLock.lock() + let count = connectionCount + if count < Self.maxConnections { connectionCount += 1 } + connectionLock.unlock() + guard count < Self.maxConnections else { + close(clientFD) + continue + } + Thread.detachNewThread { self.handleClient(fd: clientFD) } + } + } + + // MARK: - Client handler (background thread) + + private func handleClient(fd: Int32) { + defer { + connectionLock.lock(); connectionCount -= 1; connectionLock.unlock() + } + // 5-second receive timeout — unresponsive clients don't hold threads forever + var tv = timeval(tv_sec: Self.receiveTimeoutSeconds, tv_usec: 0) + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout.size)) + + // Read newline-delimited JSON + var raw = Data() + var buf = [UInt8](repeating: 0, count: 4096) + outer: while true { + let n = recv(fd, &buf, buf.count, 0) + if n <= 0 { break } + for i in 0.. Self.maxPayload { break } + } + + guard !raw.isEmpty, + let payload = try? JSONSerialization.jsonObject(with: raw) as? [String: Any] else { + sendLine(fd: fd, text: #"{"ok":true}"#) + close(fd) + return + } + + let coucouKind = payload["coucou_kind"] as? String ?? "" + + // statusline payloads are handled separately — no session, no reveal, no sound + if coucouKind == "statusline" { + Task { @MainActor in self.processStatusLine(payload: payload) } + sendLine(fd: fd, text: #"{"ok":true}"#) + close(fd) + return + } + + // AskUserQuestion via --ask PreToolUse hook — hold fd open like PermissionRequest + if coucouKind == "ask_user_question" { + let toolInput = payload["tool_input"] as? [String: Any] ?? [:] + if let parsed = AskQuestion.parse(toolInput: toolInput) { + Task { @MainActor in self.processQuestionRequest(fd: fd, parsed: parsed, payload: payload) } + } else { + // Malformed payload — fall back: send ask so Claude Code re-asks in terminal + sendLine(fd: fd, text: #"{"permissionDecision":"ask"}"#) + close(fd) + } + return + } + + let eventName = payload["hook_event_name"] as? String ?? "" + + if eventName == "PermissionRequest" { + // Hold fd open — Claude Code waits for our decision (up to 120s) + Task { @MainActor in self.processPermissionRequest(fd: fd, payload: payload) } + } else { + Task { @MainActor in self.processEvent(name: eventName, payload: payload) } + sendLine(fd: fd, text: #"{"ok":true}"#) + close(fd) + } + } + + + // MARK: - Event → AppState + // Claude Code events route to the permanent "integration_claude" task. + // Events tagged with a valid coucou_agent route to a dynamic "integration_" task. + // View switches only happen if VS Code (or the agent pill) is currently focused. + // When not focused: state updates animate the mini bot in the pill; badge shown for alerts. + + @MainActor + private func processEvent(name: String, payload: [String: Any]) { + let state = AppState.shared + let sessionId = payload["session_id"] as? String + ?? payload["conversation_id"] as? String + ?? "unknown" + let cwd = payload["cwd"] as? String ?? "" + let rawName = URL(fileURLWithPath: cwd).lastPathComponent + let projectName = aliasProjectName(rawName.isEmpty ? "Session" : rawName) + + // Determine which pill this event belongs to. + // coucou_agent must be lowercase, digits and hyphens, ≤ 24 chars. + let rawAgent = payload["coucou_agent"] as? String ?? "" + let validAgent = Self.validateAgent(rawAgent) + + let termProgram = payload["term_program"] as? String ?? "" + let bundleId = payload["bundle_id"] as? String ?? "" + + // Cursor identified solely by its stable Electron bundle ID. + // ToDesktop builds other apps too — do not match on "todesktop" alone. + let isCursorEditor = bundleId.lowercased() == "com.todesktop.230313mzl4w4u92" + let isVSCodeEditor = !isCursorEditor && ( + termProgram.lowercased().contains("vscode") || + bundleId.lowercased().contains("vscode")) + + // Routing: + // • "codex" → agent_codex (GitHub build only: workspace pill, approvals in the notch) + // • other valid coucou_agent → external pill (fire-and-forget, no approval card) + // • Cursor bundle ID → agent_cursor + // • VS Code → integration_claude + #if !APPSTORE + let isCodexEvent = rawAgent == "codex" + #else + let isCodexEvent = false + #endif + let agentId: String + let isExternalAgent: Bool + if isCodexEvent { + agentId = "agent_codex" + isExternalAgent = false + } else if let agent = validAgent { + agentId = "agent_\(agent)" + isExternalAgent = true + } else if isCursorEditor { + agentId = "agent_cursor" + isExternalAgent = false + } else if isVSCodeEditor { + agentId = "integration_claude" + isExternalAgent = false + } else { + nbLog("Ignored \(name) from \(termProgram.isEmpty ? bundleId : termProgram) (\(projectName))") + return + } + + let focused = state.focusId == agentId + + #if PHONE_LINK + // The iPhone's "last turn" (prompt, actions, diffs, answer). + if !isExternalAgent { TurnRecorder.shared.record(event: name, payload: payload, pillId: agentId) } + #endif + + // While a permission request is pending, dismiss when the resolving event arrives, + // then continue normal processing. Only skip normal processing when unresolved. + if let pending = state.pendingApproval, agentId == pending.pillId { + let handledNote: String + switch pending.pillId { + case "agent_cursor": handledNote = "Handled in Cursor." + case "agent_codex": handledNote = "Handled in Codex." + default: handledNote = "Handled in VS Code." + } + var resolved = false + switch name { + case "PostToolUse", "PostToolUseFailure": + // Only dismiss when this exact tool call finished — same session, tool and input. + // Other parallel tools finishing must not close the card. + if sessionId == pending.sessionId, + (payload["tool_name"] as? String ?? "") == pending.tool, + Self.approvalInputKey(payload["tool_input"] as? [String: Any] ?? [:]) == pending.inputKey { + dismissApprovalCard(note: handledNote) + resolved = true + } + case "Stop", "StopFailure", "UserPromptSubmit", "SessionEnd", "Interrupt": + // Turn ended or session interrupted — the permission is moot. + if sessionId == pending.sessionId { + dismissApprovalCard(note: handledNote) + resolved = true + } + default: break + } + if !resolved { return } + // Approval dismissed — fall through so the resolving event updates state normally. + } + + switch name { + + case "SessionStart": + activeSessionId = sessionId + if isExternalAgent { upsertExternalAgent(id: agentId, name: validAgent!) } else { upsertWorkspaceTask(id: agentId, projectName: projectName, cwd: cwd) } + if let idx = state.tasks.firstIndex(where: { $0.id == agentId }) { state.tasks[idx].finalLine = nil } + nbLog("SessionStart \(isExternalAgent ? agentId : projectName) (\(sessionId.prefix(8)))") + if state.isPresent { expandIfNeeded(to: .overview) } + SoundEngine.shared.play("work") + + case "UserPromptSubmit": + activeSessionId = sessionId + if isExternalAgent { upsertExternalAgent(id: agentId, name: validAgent!) } else { upsertWorkspaceTask(id: agentId, projectName: projectName, cwd: cwd) } + if let idx = state.tasks.firstIndex(where: { $0.id == agentId }) { state.tasks[idx].finalLine = nil } + state.updateTask(id: agentId, state: .thinking) + if let prompt = payload["prompt"] as? String, !prompt.isEmpty { + appendStep(id: agentId, step: String(prompt.prefix(60))) + } + if state.isPresent { expandIfNeeded(to: .overview) } + + case "PreToolUse": + activeSessionId = sessionId + let tool = payload["tool_name"] as? String ?? "Tool" + if let idx = state.tasks.firstIndex(where: { $0.id == agentId }) { state.tasks[idx].finalLine = nil } + // AskUserQuestion is handled via the dedicated --ask hook. + // Skip state/step update here to avoid flickering over the question card. + guard tool != "AskUserQuestion" else { break } + if isExternalAgent { upsertExternalAgent(id: agentId, name: validAgent!) } else { upsertWorkspaceTask(id: agentId, projectName: projectName, cwd: cwd) } + state.updateTask(id: agentId, state: .working) + let input = payload["tool_input"] as? [String: Any] ?? [:] + let step = frenchStep(tool: tool, input: input) + appendStep(id: agentId, step: step) + nbLog("PreToolUse \(tool)") + + case "PostToolUse": + state.updateTask(id: agentId, state: .working) + // Live diff for Edit / MultiEdit / Write + let diffTool = payload["tool_name"] as? String ?? "" + let diffInput = payload["tool_input"] as? [String: Any] ?? [:] + if let diff = buildFileDiff(tool: diffTool, input: diffInput, pillId: agentId) { + let idx = state.appendSessionDiff(diff, for: agentId) + let step = String.makeDiffStep(filename: diff.name, added: diff.added, removed: diff.removed, diffId: idx) + appendStep(id: agentId, step: step) + } + + case "PostToolUseFailure": + state.updateTask(id: agentId, state: .working) + appendStep(id: agentId, step: "⚠ failed") + + case "Notification": + let message = payload["message"] as? String ?? "" + let lower = message.lowercased() + if lower.contains("rate limit") || lower.contains("limite d") { + state.updateTask(id: agentId, state: .ratelimit) + SoundEngine.shared.play("rate") + } else if message.hasSuffix("?") { + state.updateTask(id: agentId, state: .question) + appendStep(id: agentId, step: message) + } + + case "Stop": + state.updateTask(id: agentId, state: .finished) + let rawFinal = (payload["last_assistant_message"] as? String) + ?? (payload["message"] as? String) ?? "" + let finalText = DiffEngine.toOneLine(rawFinal) + if !finalText.isEmpty { + appendStep(id: agentId, step: finalText) + if let idx = state.tasks.firstIndex(where: { $0.id == agentId }) { + state.tasks[idx].finalLine = finalText + } + } + SoundEngine.shared.play("finish") + if focused { + expandIfNeeded(to: .finished) + } else { + setPillBadge(id: agentId, badge: .finished) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 5.2) { + if isExternalAgent { + AppState.shared.removeTask(id: agentId) + } else { + AppState.shared.updateTask(id: agentId, state: .idle) + self.clearPillBadge(id: agentId) + } + } + + case "StopFailure": + state.updateTask(id: agentId, state: .error) + SoundEngine.shared.play("error") + if focused { + expandIfNeeded(to: .error) + } else { + setPillBadge(id: agentId, badge: .error) + } + + case "Interrupt": + // Codex: user stopped the turn + activeSessionId = nil + state.updateTask(id: agentId, state: .idle) + clearPillBadge(id: agentId) + + case "SessionEnd": + activeSessionId = nil + if let idx = state.tasks.firstIndex(where: { $0.id == agentId }) { state.tasks[idx].finalLine = nil } + state.clearSessionDiffs(for: agentId) + state.removeTask(id: agentId) + + case "SubagentStart": + appendStep(id: agentId, step: "+ subagent") + + case "SubagentStop": + appendStep(id: agentId, step: "• subagent done") + + default: + break + } + } + + // MARK: - Agent validation + dynamic pill + + /// Validates a coucou_agent name: lowercase, digits and hyphens, 1–24 chars. + /// "claude" is reserved and rejected so it cannot impersonate the Claude Code pill. + /// Returns the name unchanged if valid, nil otherwise. + private static func validateAgent(_ raw: String) -> String? { + guard !raw.isEmpty, raw.count <= 24, raw != "claude" else { return nil } + for scalar in raw.unicodeScalars { + let v = scalar.value + let ok = (v >= 0x61 && v <= 0x7A) // a-z + || (v >= 0x30 && v <= 0x39) // 0-9 + || v == 0x2D // - + guard ok else { return nil } + } + return raw + } + + /// Creates a dynamic pill for a third-party agent on first event, then no-ops. + /// ID format: "agent_" — never collides with "integration_*" pills. + /// Inserted right after integration_claude so it appears in the visible prefix(4). + @MainActor + private func upsertExternalAgent(id: String, name: String) { + let state = AppState.shared + guard state.tasks.firstIndex(where: { $0.id == id }) == nil else { return } + let color = IslandConst.colorForProject(name) + let task = AgentTask(id: id, name: name, color: color, state: .idle, steps: [], source: .agent) + if let claudeIdx = state.tasks.firstIndex(where: { $0.id == "integration_claude" }) { + state.tasks.insert(task, at: claudeIdx + 1) + } else { + state.tasks.append(task) + } + if state.focusId == nil { state.focusId = id } + state.syncMode() + } + + // MARK: - Helpers + + @MainActor + private func expandIfNeeded(to view: IslandView) { + let state = AppState.shared + let isAlert: Bool + switch view { + case .approval, .question, .finished, .error, .confused: isAlert = true + default: isAlert = false + } + if state.mode == .expanded { + // Approval and question always win; other alerts are blocked while a card is showing + if view == .approval || view == .question { + state.view = view + } else if isAlert && state.pendingApproval == nil { + state.view = view + } + } else if isAlert { + // Alerts always force-expand + NotificationCenter.default.post(name: .hookExpand, object: view) + } else if state.mode == .hidden { + // Non-alert work events: reveal compact only, never force-expand + NotificationCenter.default.post(name: .hookReveal, object: nil) + } + // Already compact and non-alert: Mochi state update is enough, no expand + } + + // MARK: - Status line (plan gauge) + + @MainActor + private func processStatusLine(payload: [String: Any]) { + if let usage = ClaudePlanGauge.parse(payload: payload) { + AppState.shared.claudePlanUsage = usage + } + } + + // MARK: - Permission request (blocking — Claude Code waits for decision) + + @MainActor + private func processPermissionRequest(fd: Int32, payload: [String: Any]) { + let state = AppState.shared + let sessionId = payload["session_id"] as? String + ?? payload["conversation_id"] as? String + ?? "unknown" + let cwd = payload["cwd"] as? String ?? "" + let rawName = URL(fileURLWithPath: cwd).lastPathComponent + let projectName = aliasProjectName(rawName.isEmpty ? "Session" : rawName) + + let rawAgent = payload["coucou_agent"] as? String ?? "" + let termProgram = payload["term_program"] as? String ?? "" + let bundleId = payload["bundle_id"] as? String ?? "" + let isCursorEditor = bundleId.lowercased() == "com.todesktop.230313mzl4w4u92" + let isVSCodeEditor = !isCursorEditor && ( + termProgram.lowercased().contains("vscode") || + bundleId.lowercased().contains("vscode")) + + // Codex gets the same approval card as Claude Code / Cursor (GitHub build only). + // Other external agents (any other coucou_agent) answer immediately with "ask" + // so the agent re-asks in its own terminal — they do not get a notch card. + #if !APPSTORE + let isCodexRequest = rawAgent == "codex" + #else + let isCodexRequest = false + #endif + if !isCodexRequest && Self.validateAgent(rawAgent) != nil { + Task.detached { [weak self] in + self?.sendLine(fd: fd, text: #"{"permissionDecision":"ask"}"#) + close(fd) + } + return + } + + // Determine which workspace pill owns the request. + let pillId: String + if isCodexRequest { + pillId = "agent_codex" + } else if isCursorEditor { + pillId = "agent_cursor" + } else { + pillId = "integration_claude" + } + guard isCodexRequest || isCursorEditor || isVSCodeEditor else { + Task.detached { [weak self] in + self?.sendLine(fd: fd, text: #"{"permissionDecision":"ask"}"#) + close(fd) + } + return + } + + let tool = payload["tool_name"] as? String ?? "Tool" + let toolInput = payload["tool_input"] as? [String: Any] ?? [:] + let inputKey = Self.approvalInputKey(toolInput) + nbLog("PermissionRequest \(tool) [\(pillId)]") + + // AskUserQuestion is now handled via the dedicated --ask PreToolUse hook. + // If it still arrives here as a PermissionRequest, reply "ask" so Claude Code + // re-asks in the terminal — never show the question twice. + if tool == "AskUserQuestion" { + Task.detached { [weak self] in + self?.sendLine(fd: fd, text: #"{"permissionDecision":"ask"}"#) + close(fd) + } + return + } + + let command = toolInput["command"] as? String ?? tool + + if pendingApprovalFD >= 0 { + // Displace the previous request: write "ask" then cancel its source. + // The cancel handler closes the old fd — never close it directly. + let old = pendingApprovalFD + let oldSource = approvalFDSource + approvalFDSource = nil + Task.detached { [weak self] in + // "ask" → nb-hook outputs nothing → Claude Code re-asks + self?.sendLine(fd: old, text: #"{"permissionDecision":"ask"}"#) + DispatchQueue.main.async { oldSource?.cancel() } + } + } + pendingApprovalFD = fd + activeSessionId = sessionId + + upsertWorkspaceTask(id: pillId, projectName: projectName, cwd: cwd) + state.updateTask(id: pillId, state: .approval) + state.pendingApproval = ApprovalInfo(sessionId: sessionId, tool: tool, + command: command, inputKey: inputKey, pillId: pillId) + state.isPinned = true + SoundEngine.shared.play("approval") + + // Approval always forces the island open — user must be able to respond. + // Save current focus so we can restore it when the card is dismissed. + if focusBeforeApproval == nil { focusBeforeApproval = state.focusId } + withAnimation(.spring(response: 0.5, dampingFraction: 0.72)) { state.focusId = pillId } + expandIfNeeded(to: .approval) + + // Monitor fd: if the editor closes the connection (handled externally), dismiss the card. + // The cancel handler closes the fd — never close it anywhere else. + let capturedPillId = pillId + let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: .main) + source.setEventHandler { [weak self] in + guard let self, self.pendingApprovalFD == fd else { return } + let note: String + switch capturedPillId { + case "agent_cursor": note = "Handled in Cursor." + case "agent_codex": note = "Handled in Codex." + default: note = "Handled in VS Code." + } + self.dismissApprovalCard(note: note) + } + source.setCancelHandler { close(fd) } + source.resume() + approvalFDSource = source + + // 115s safety timeout — show a note and cancel without sending a decision. + // nb-hook reads EOF from the cancel handler's close and exits; Claude Code / Codex re-asks. + let captured = fd + DispatchQueue.main.asyncAfter(deadline: .now() + 115) { [weak self] in + guard let self, self.pendingApprovalFD == captured else { return } + let note: String + switch capturedPillId { + case "agent_cursor": note = "Still waiting in Cursor." + case "agent_codex": note = "Still waiting in Codex." + default: note = "Still waiting in VS Code." + } + self.dismissApprovalCard(note: note) + } + } + + /// Called by ApprovalView buttons. Writes the decision to the waiting nb-hook and cleans up. + @MainActor + func sendApprovalDecision(_ decision: String) { + let fd = pendingApprovalFD + pendingApprovalFD = -1 + // Capture source before nulling — we send the decision first, then cancel the source. + // The cancel handler closes the fd; never close it directly. + let source = approvalFDSource + approvalFDSource = nil + + let json: String + switch decision { + case "allow": json = #"{"permissionDecision":"allow"}"# + case "always": json = #"{"permissionDecision":"always"}"# + case "ask": json = #"{"permissionDecision":"ask"}"# + default: json = #"{"permissionDecision":"deny"}"# + } + + if fd >= 0 { + Task.detached { [weak self] in + // Write decision while fd is still valid, then cancel source → cancel handler closes fd + self?.sendLine(fd: fd, text: json) + DispatchQueue.main.async { source?.cancel() } + } + } else { + source?.cancel() + } + + let state = AppState.shared + let pillId = state.pendingApproval?.pillId ?? "integration_claude" + state.pendingApproval = nil + state.isPinned = false + state.updateTask(id: pillId, state: .working) + clearPillBadge(id: pillId) + // Restore focus to the pill that was focused before the approval card appeared. + if let prev = focusBeforeApproval { + focusBeforeApproval = nil + if state.focusId == pillId, state.tasks.contains(where: { $0.id == prev }) { + withAnimation(.spring(response: 0.5, dampingFraction: 0.72)) { state.focusId = prev } + } + } + state.view = state.tasks.isEmpty ? .empty : .overview + } + + // MARK: - Question request + + @MainActor + private func processQuestionRequest(fd: Int32, parsed: AskQuestion, payload: [String: Any]) { + let state = AppState.shared + let sessionId = payload["session_id"] as? String + ?? payload["conversation_id"] as? String + ?? "unknown" + let cwd = payload["cwd"] as? String ?? "" + let rawName = URL(fileURLWithPath: cwd).lastPathComponent + let projectName = aliasProjectName(rawName.isEmpty ? "Session" : rawName) + + let rawAgent = payload["coucou_agent"] as? String ?? "" + let termProgram = payload["term_program"] as? String ?? "" + let bundleId = payload["bundle_id"] as? String ?? "" + let isCursorEditor = bundleId.lowercased() == "com.todesktop.230313mzl4w4u92" + let isVSCodeEditor = !isCursorEditor && ( + termProgram.lowercased().contains("vscode") || + bundleId.lowercased().contains("vscode")) + #if !APPSTORE + let isCodexRequest = rawAgent == "codex" + #else + let isCodexRequest = false + #endif + let pillId: String + if isCodexRequest { + pillId = "agent_codex" + } else if isCursorEditor { + pillId = "agent_cursor" + } else { + pillId = "integration_claude" + } + guard isCodexRequest || isCursorEditor || isVSCodeEditor else { + Task.detached { [weak self] in + self?.sendLine(fd: fd, text: #"{"permissionDecision":"ask"}"#) + close(fd) + } + return + } + + // Displace any previous question waiting for an answer. + if pendingQuestionFD >= 0 { + let old = pendingQuestionFD + let oldSrc = questionFDSource + questionFDSource = nil + Task.detached { [weak self] in + self?.sendLine(fd: old, text: #"{"permissionDecision":"ask"}"#) + DispatchQueue.main.async { oldSrc?.cancel() } + } + } + pendingQuestionFD = fd + activeSessionId = sessionId + questionPillId = pillId + + upsertWorkspaceTask(id: pillId, projectName: projectName, cwd: cwd) + state.updateTask(id: pillId, state: .question) + state.pendingQuestion = parsed + state.isPinned = true + SoundEngine.shared.play("approval") + + if focusBeforeQuestion == nil { focusBeforeQuestion = state.focusId } + withAnimation(.spring(response: 0.5, dampingFraction: 0.72)) { state.focusId = pillId } + expandIfNeeded(to: .question) + + let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: .main) + source.setEventHandler { [weak self] in + guard let self, self.pendingQuestionFD == fd else { return } + self.dismissQuestionCard(note: "") + } + source.setCancelHandler { close(fd) } + source.resume() + questionFDSource = source + + let captured = fd + DispatchQueue.main.asyncAfter(deadline: .now() + 120) { [weak self] in + guard let self, self.pendingQuestionFD == captured else { return } + // Send "ask" so nb-hook exits cleanly; Claude Code re-asks in terminal. + let askFD = self.pendingQuestionFD + self.pendingQuestionFD = -1 + let src = self.questionFDSource + self.questionFDSource = nil + Task.detached { [weak self] in + self?.sendLine(fd: askFD, text: #"{"permissionDecision":"ask"}"#) + DispatchQueue.main.async { src?.cancel() } + } + self.dismissQuestionCard(note: "") + } + } + + /// Updates or transiently creates a workspace pill (VS Code or Cursor) task. + /// If the task already exists (persistent), just updates name/cwd. + /// If missing (transient), creates it and inserts after the main pill. + @MainActor + private func upsertWorkspaceTask(id: String, projectName: String, cwd: String = "") { + let state = AppState.shared + if let idx = state.tasks.firstIndex(where: { $0.id == id }) { + state.tasks[idx].name = projectName + if !cwd.isEmpty { state.tasks[idx].sessionCwd = cwd } + return + } + // Transient: create and insert after the main pill + let def = PillCatalog.definition(for: id) + let color = def?.color ?? "#C0C4CC" + let source = def?.source ?? .agent + let task = AgentTask(id: id, name: projectName, color: color, + state: .idle, steps: [], source: source, isIntegration: true) + if let mainIdx = state.tasks.firstIndex(where: { $0.id == state.mainPillId }) { + state.tasks.insert(task, at: mainIdx + 1) + } else { + state.tasks.insert(task, at: 0) + } + if state.focusId == nil { state.focusId = id } + state.syncMode() + } + + // MARK: - Badge helpers + + @MainActor + private func setPillBadge(id: String, badge: PillBadge) { + let state = AppState.shared + guard let idx = state.tasks.firstIndex(where: { $0.id == id }) else { return } + state.tasks[idx].pillBadge = badge + } + + @MainActor + private func clearPillBadge(id: String) { + let state = AppState.shared + guard let idx = state.tasks.firstIndex(where: { $0.id == id }) else { return } + state.tasks[idx].pillBadge = nil + } + + @MainActor + private func appendStep(id: String, step: String) { + let state = AppState.shared + guard let idx = state.tasks.firstIndex(where: { $0.id == id }) else { return } + state.tasks[idx].steps.append(step) + if state.tasks[idx].steps.count > 20 { state.tasks[idx].steps.removeFirst() } + state.tasks[idx].stepIndex = state.tasks[idx].steps.count - 1 + } + + // MARK: - Project name alias mapping + + private func aliasProjectName(_ name: String) -> String { + let aliases: [String: String] = [ + "notch-buddy": "Notch Buddy", + "notchbuddy": "Notch Buddy", + "notch_buddy": "Notch Buddy", + ] + return aliases[name.lowercased()] ?? name + } + + // MARK: - French step labels + + private func frenchStep(tool: String, input: [String: Any]) -> String { + let labels: [String: String] = [ + "Bash": "Exécute", + "Read": "Lit", + "Write": "Écrit", + "Edit": "Modifie", + "Glob": "Cherche", + "Grep": "Recherche", + "WebSearch": "Recherche web", + "WebFetch": "Récupère", + "TodoWrite": "Tâches", + "Task": "Agent", + "LS": "Liste", + "MultiEdit": "Modifie", + "NotebookEdit": "Notebook", + // Codex tools + "apply_patch": "Modifie", + "update_plan": "Tâches", + "spawn_agent": "Agent", + ] + var label = labels[tool] ?? tool + + // Codex MCP tools arrive as mcp__server__tool — show "server · tool" + if tool.hasPrefix("mcp__") { + let rest = String(tool.dropFirst(5)) + let parts = rest.components(separatedBy: "__") + label = parts.count >= 2 ? "\(parts[0]) · \(parts.dropFirst().joined(separator: "__"))" : rest + } + + // Bash: infer a more precise verb from the command + if tool == "Bash", let cmd = input["command"] as? String { + return "\(bashVerb(cmd)) · \(oneLine(cmd))" + } + + // apply_patch: extract the first file name from the patch + if tool == "apply_patch", let patch = input["command"] as? String { + for line in patch.split(separator: "\n") { + for prefix in ["*** Update File: ", "*** Add File: ", "*** Delete File: "] { + if line.hasPrefix(prefix) { + let path = String(line.dropFirst(prefix.count)) + return "\(label) · \(URL(fileURLWithPath: path).lastPathComponent)" + } + } + } + return label + } + + if let cmd = input["command"] as? String { + return "\(label) · \(oneLine(cmd))" + } else if let path = input["path"] as? String { + return "\(label) · \(URL(fileURLWithPath: path).lastPathComponent)" + } else if let file = input["file_path"] as? String { + return "\(label) · \(URL(fileURLWithPath: file).lastPathComponent)" + } else if let query = input["query"] as? String { + return "\(label) · \(oneLine(query))" + } + return label + } + + /// Infers a French verb from a shell command's first word. + private func bashVerb(_ command: String) -> String { + let first = command.split(whereSeparator: { $0.isWhitespace }).first.map(String.init) ?? "" + switch first { + case "cat", "bat", "head", "tail", "less", "more", "nl": return "Lit" + case "rg", "grep", "find", "fd", "ls", "tree", "wc": return "Cherche" + default: break + } + let testRunners = ["pytest", "vitest", "jest", "npm test", "npm run test", + "cargo test", "go test", "swift test", "make test", + "xcodebuild test", "unittest"] + if testRunners.contains(where: { command.contains($0) }) { return "Teste" } + return "Exécute" + } + + // MARK: - Live diff helpers + + @MainActor + private func buildFileDiff(tool: String, input: [String: Any], pillId: String) -> FileDiff? { + switch tool { + case "Edit": + guard let old = input["old_string"] as? String, + let new = input["new_string"] as? String, + let path = input["file_path"] as? String, + !old.isEmpty || !new.isEmpty else { return nil } + let d = DiffEngine.fromEdit(old: old, new: new, path: path) + return (d.added > 0 || d.removed > 0) ? d : nil + + case "MultiEdit": + guard let path = input["file_path"] as? String, + let edits = input["edits"] as? [[String: Any]], !edits.isEmpty else { return nil } + var totalAdded = 0, totalRemoved = 0, allHunks: [DiffHunk] = [], anyLarge = false + for edit in edits { + guard let old = edit["old_string"] as? String, + let new = edit["new_string"] as? String else { continue } + let d = DiffEngine.fromEdit(old: old, new: new, path: path) + totalAdded += d.added; totalRemoved += d.removed + allHunks.append(contentsOf: d.hunks); if d.tooLarge { anyLarge = true } + } + guard totalAdded > 0 || totalRemoved > 0 else { return nil } + return FileDiff(path: path, added: totalAdded, removed: totalRemoved, + hunks: allHunks, tooLarge: anyLarge, isNewFile: false) + + case "Write": + guard let path = input["file_path"] as? String, + let content = input["content"] as? String, !content.isEmpty else { return nil } + let d = DiffEngine.fromNew(content: content, path: path) + return (d.added > 0 || d.removed > 0) ? d : nil + + default: + return nil + } + } + + /// Collapses whitespace so a multi-line command stays one ticker row. + private func oneLine(_ text: String, limit: Int = 60) -> String { + let collapsed = text.split(whereSeparator: { $0.isNewline || $0 == "\t" }) + .joined(separator: " ") + return collapsed.count > limit ? String(collapsed.prefix(limit)) + "…" : collapsed + } + + // MARK: - Logging + + private func nbLog(_ message: String) { + appendAppLog("nb.log", message) + } + + private func sendLine(fd: Int32, text: String) { + let bytes = Array((text + "\n").utf8) + bytes.withUnsafeBytes { buffer in + var sent = 0 + while sent < buffer.count { + let n = Darwin.send(fd, buffer.baseAddress! + sent, buffer.count - sent, 0) + if n <= 0 { break } + sent += n + } + } + } + + // MARK: - nb-hook script installation + + func installHookScript() { + #if APPSTORE + // In App Store mode the script is written during settings hook installation + // (requires NSOpenPanel to ~/.claude chosen by the user) + #else + let dir = Self.supportDir + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try? FileManager.default.setAttributes([.posixPermissions: 0o700 as NSNumber], ofItemAtPath: dir.path) + // nb-hook: shell wrapper (always exits 0, calls nb-hook.py via python3) + let wrapperURL = URL(fileURLWithPath: Self.hookScriptPath) + try? nbHookShellWrapper.write(to: wrapperURL, atomically: true, encoding: .utf8) + _ = try? FileManager.default.setAttributes([.posixPermissions: 0o755 as NSNumber], ofItemAtPath: wrapperURL.path) + // nb-hook.py: Python relay + let pyURL = wrapperURL.deletingLastPathComponent().appendingPathComponent("nb-hook.py") + try? nbHookPythonGitHub.write(to: pyURL, atomically: true, encoding: .utf8) + _ = try? FileManager.default.setAttributes([.posixPermissions: 0o755 as NSNumber], ofItemAtPath: pyURL.path) + #endif + } + + // MARK: - Outdated hook detection + + /// Returns true if settings.json has a Coucou hook that needs updating: + /// either a PermissionRequest hook with timeout < 120s, or the AskUserQuestion + /// PreToolUse matcher is missing (requires Claude Code 2.1.85+). + static func hooksNeedUpdate() -> Bool { + let settingsURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".claude/settings.json") + guard let data = try? Data(contentsOf: settingsURL), + let settings = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let hooks = settings["hooks"] as? [String: Any] else { + return false + } + // Track whether any Coucou hook is installed at all + var hasCoucouHooks = false + + if let permReqHooks = hooks["PermissionRequest"] as? [[String: Any]] { + for matcher in permReqHooks { + if let hookList = matcher["hooks"] as? [[String: Any]] { + for hook in hookList { + if let cmd = hook["command"] as? String, + cmd.contains("NotchBuddy") || cmd.contains("coucou") { + hasCoucouHooks = true + if let timeout = hook["timeout"] as? Int, timeout < 120 { return true } + } + } + } + } + } + + // Check that the AskUserQuestion PreToolUse entry exists + if hasCoucouHooks { + let preToolHooks = hooks["PreToolUse"] as? [[String: Any]] ?? [] + let hasAskEntry = preToolHooks.contains { m in + (m["matcher"] as? String) == "AskUserQuestion" + && (m["hooks"] as? [[String: Any]])?.contains { + let cmd = $0["command"] as? String ?? "" + return cmd.contains("NotchBuddy") || cmd.contains("coucou") + } ?? false + } + if !hasAskEntry { return true } + } + return false + } + + // MARK: - Claude Code settings.json hook installer + + private var _pendingHooksData: Data? + + /// Returns preview JSON without writing — call writeClaudeHooks() to confirm. + func previewClaudeHooks() throws -> String { + let data = try buildHooksData() + _pendingHooksData = data + return String(data: data, encoding: .utf8) ?? "" + } + + /// Writes the hooks to disk (call after user confirms preview). + func writeClaudeHooks() throws { + guard let data = _pendingHooksData else { return } + let settingsURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".claude/settings.json") + // Backup first + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd-HHmm" + let stamp = formatter.string(from: Date()) + let backupURL = settingsURL.deletingLastPathComponent() + .appendingPathComponent("settings.json.bak-\(stamp)") + try? FileManager.default.copyItem(at: settingsURL, to: backupURL) + try? FileManager.default.createDirectory(at: settingsURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + try data.write(to: settingsURL, options: .atomic) + _pendingHooksData = nil + } + + private func buildHooksData() throws -> Data { + let settingsURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".claude/settings.json") + var settings: [String: Any] = [:] + if let data = try? Data(contentsOf: settingsURL), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + settings = parsed + } + let hookPath = Self.hookScriptPath + #if APPSTORE + // Sandboxed apps create quarantined files; /bin/sh bypasses the quarantine flag + let quotedCmd = "/bin/sh \"\(hookPath.replacingOccurrences(of: "\"", with: "\\\""))\"" + #else + let quotedCmd = "\"\(hookPath.replacingOccurrences(of: "\"", with: "\\\""))\"" + #endif + let events: [(String, Int)] = [ + ("SessionStart", 10), ("SessionEnd", 10), + ("UserPromptSubmit", 10), + ("PreToolUse", 10), ("PostToolUse", 10), ("PostToolUseFailure", 10), + ("PermissionRequest", 120), + ("Notification", 10), + ("Stop", 10), ("StopFailure", 10), + ("SubagentStart", 10), ("SubagentStop", 10), + ] + var hooks = settings["hooks"] as? [String: Any] ?? [:] + for (event, timeout) in events { + var existing = hooks[event] as? [[String: Any]] ?? [] + existing.removeAll { ($0["hooks"] as? [[String: Any]])?.contains { ($0["command"] as? String)?.contains("NotchBuddy") == true || ($0["command"] as? String)?.contains("coucou") == true } ?? false } + existing.append(["hooks": [["type": "command", "command": quotedCmd, "timeout": timeout]]]) + hooks[event] = existing + } + // Dedicated AskUserQuestion PreToolUse hook (Claude Code 2.1.85+, timeout 130s) + var preToolUse = hooks["PreToolUse"] as? [[String: Any]] ?? [] + preToolUse.append([ + "matcher": "AskUserQuestion", + "hooks": [["type": "command", "command": "\(quotedCmd) --ask", "timeout": 130]], + ]) + hooks["PreToolUse"] = preToolUse + settings["hooks"] = hooks + return try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys]) + } + + func uninstallClaudeHooks() throws { + let settingsURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".claude/settings.json") + guard let data = try? Data(contentsOf: settingsURL), + var settings = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + var hooks = settings["hooks"] as? [String: Any] else { return } + + for key in hooks.keys { + if var matchers = hooks[key] as? [[String: Any]] { + matchers.removeAll { matcher in + (matcher["hooks"] as? [[String: Any]])?.contains { + ($0["command"] as? String)?.contains("NotchBuddy") == true || + ($0["command"] as? String)?.contains("coucou") == true + } ?? false + } + if matchers.isEmpty { hooks.removeValue(forKey: key) } + else { hooks[key] = matchers } + } + } + settings["hooks"] = hooks + let newData = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys]) + try newData.write(to: settingsURL, options: .atomic) + } + + // MARK: - Claude plan status line installer + + private var statusLinePreviousURL: URL { + Self.supportDir.appendingPathComponent("statusline-previous.json") + } + + /// Returns true if our statusLine command is installed in ~/.claude/settings.json. + static func statusLineInstalled() -> Bool { + let url = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".claude/settings.json") + guard let data = try? Data(contentsOf: url), + let settings = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + let sl = settings["statusLine"] as? [String: Any], + let cmd = sl["command"] as? String else { return false } + return cmd.contains("nb-hook") + } + + private var _pendingStatusLineData: Data? + private var _pendingPreviousData: Data? + private var _pendingDeletePrevious: Bool = false + + /// Returns a diff string (only the statusLine key: before → after) without writing anything. + func previewStatusLine(install: Bool) throws -> String { + let settingsURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".claude/settings.json") + var settings: [String: Any] = [:] + if let d = try? Data(contentsOf: settingsURL), + let parsed = (try? JSONSerialization.jsonObject(with: d)) as? [String: Any] { + settings = parsed + } + let hookPath = Self.hookScriptPath + let quotedPath = hookPath.replacingOccurrences(of: "\"", with: "\\\"") + let quotedCmd = "\"\(quotedPath)\" --statusline" + + // Reset pending side-effects + _pendingPreviousData = nil + _pendingDeletePrevious = false + + let oldSL = settings["statusLine"] as? [String: Any] + let newSL: [String: Any]? + + if install { + // Check that Python 3 is available (requires Command Line Tools) + let clCheck = Process() + clCheck.executableURL = URL(fileURLWithPath: "/usr/bin/xcode-select") + clCheck.arguments = ["-p"] + clCheck.standardOutput = FileHandle.nullDevice + clCheck.standardError = FileHandle.nullDevice + try? clCheck.run() + clCheck.waitUntilExit() + if clCheck.terminationStatus != 0 { + throw NSError(domain: "Coucou", code: 1, + userInfo: [NSLocalizedDescriptionKey: + "Command Line Tools are required but not installed. Run: xcode-select --install"]) + } + + if let existing = oldSL, + let cmd = existing["command"] as? String, !cmd.contains("nb-hook") { + // Keep existing object but swap command; save old for later restoration + var updated = existing + updated["command"] = quotedCmd + newSL = updated + _pendingPreviousData = try? JSONSerialization.data(withJSONObject: existing, + options: [.prettyPrinted, .sortedKeys]) + } else if let existing = oldSL, + let cmd = existing["command"] as? String, cmd.contains("nb-hook") { + // Already installed — rebuild to update path if needed, keep other fields + var updated = existing + updated["command"] = quotedCmd + newSL = updated + } else { + newSL = ["type": "command", "command": quotedCmd] + } + } else { + // Uninstall: only if it's ours + if let cur = oldSL, let cmd = cur["command"] as? String, cmd.contains("nb-hook") { + if let prevData = try? Data(contentsOf: statusLinePreviousURL), + let prevObj = (try? JSONSerialization.jsonObject(with: prevData)) as? [String: Any] { + newSL = prevObj + _pendingDeletePrevious = true + } else { + newSL = nil + } + } else { + newSL = oldSL // not ours — leave unchanged + } + } + + // Build the full settings.json with the new statusLine + var newSettings = settings + if let sl = newSL { + newSettings["statusLine"] = sl + } else { + newSettings.removeValue(forKey: "statusLine") + } + let data = try JSONSerialization.data(withJSONObject: newSettings, + options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) + _pendingStatusLineData = data + + // Build a compact diff: show only the statusLine key before → after + func slJSON(_ val: [String: Any]?) throws -> String { + guard let v = val else { return "(none)" } + let d = try JSONSerialization.data(withJSONObject: v, options: [.prettyPrinted, .sortedKeys]) + return String(data: d, encoding: .utf8) ?? "(none)" + } + let before = try slJSON(oldSL) + let after = try slJSON(newSL) + return "statusLine\nBefore:\n\(before)\n\nAfter:\n\(after)" + } + + /// Writes settings.json and commits side effects (call after user confirms). + func writeStatusLine() throws { + guard let data = _pendingStatusLineData else { return } + let settingsURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".claude/settings.json") + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd-HHmm" + let stamp = formatter.string(from: Date()) + let backupURL = settingsURL.deletingLastPathComponent() + .appendingPathComponent("settings.json.bak-\(stamp)") + try? FileManager.default.copyItem(at: settingsURL, to: backupURL) + try? FileManager.default.createDirectory(at: settingsURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + try data.write(to: settingsURL, options: .atomic) + // Commit side effects only after successful write + if let prevData = _pendingPreviousData { + try? prevData.write(to: statusLinePreviousURL, options: .atomic) + } + if _pendingDeletePrevious { + try? FileManager.default.removeItem(at: statusLinePreviousURL) + } + _pendingStatusLineData = nil + _pendingPreviousData = nil + _pendingDeletePrevious = false + } + + // MARK: - App Store: hooks via security-scoped bookmark + + #if APPSTORE + /// Writes nb-hook script and updates settings.json in one shot. + /// claudeURL must be a URL from NSOpenPanel (sandbox access is granted immediately — no security scope needed). + func installAndWriteClaudeHooksAppStore(claudeURL: URL) throws { + let data = try buildHooksData(claudeURL: claudeURL) + + // Write nb-hook (shell wrapper) + nb-hook.py (Python relay) into ~/.claude/coucou/ + let coucouDir = claudeURL.appendingPathComponent("coucou") + try FileManager.default.createDirectory(at: coucouDir, withIntermediateDirectories: true) + let wrapperURL = coucouDir.appendingPathComponent("nb-hook") + try nbHookShellWrapper.write(to: wrapperURL, atomically: true, encoding: .utf8) + _ = try? FileManager.default.setAttributes([.posixPermissions: 0o755 as NSNumber], ofItemAtPath: wrapperURL.path) + let pyURL = coucouDir.appendingPathComponent("nb-hook.py") + try nbHookPythonAppStore.write(to: pyURL, atomically: true, encoding: .utf8) + _ = try? FileManager.default.setAttributes([.posixPermissions: 0o755 as NSNumber], ofItemAtPath: pyURL.path) + + // Write settings.json (with backup) + let settingsURL = claudeURL.appendingPathComponent("settings.json") + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd-HHmm" + let backupURL = claudeURL.appendingPathComponent("settings.json.bak-\(formatter.string(from: Date()))") + try? FileManager.default.copyItem(at: settingsURL, to: backupURL) + try data.write(to: settingsURL, options: .atomic) + UserDefaults.standard.set(true, forKey: "coucouHooksInstalled") + } + + func uninstallClaudeHooksAppStore(claudeURL: URL) throws { + let settingsURL = claudeURL.appendingPathComponent("settings.json") + guard let data = try? Data(contentsOf: settingsURL), + var settings = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + var hooks = settings["hooks"] as? [String: Any] else { return } + for key in hooks.keys { + if var matchers = hooks[key] as? [[String: Any]] { + matchers.removeAll { matcher in + (matcher["hooks"] as? [[String: Any]])?.contains { + ($0["command"] as? String)?.contains("coucou") == true || + ($0["command"] as? String)?.contains("NotchBuddy") == true + } ?? false + } + if matchers.isEmpty { hooks.removeValue(forKey: key) } + else { hooks[key] = matchers } + } + } + settings["hooks"] = hooks + let newData = try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys]) + try newData.write(to: settingsURL, options: .atomic) + UserDefaults.standard.set(false, forKey: "coucouHooksInstalled") + } + + private func buildHooksData(claudeURL: URL) throws -> Data { + let settingsURL = claudeURL.appendingPathComponent("settings.json") + var settings: [String: Any] = [:] + if let data = try? Data(contentsOf: settingsURL), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + settings = parsed + } + // Derive hook path from the panel-selected claudeURL (real ~/.claude, not container) + let hookPath = claudeURL.appendingPathComponent("coucou/nb-hook").path + let quotedCmd = "/bin/sh \"\(hookPath.replacingOccurrences(of: "\"", with: "\\\""))\"" + let events: [(String, Int)] = [ + ("SessionStart", 10), ("SessionEnd", 10), + ("UserPromptSubmit", 10), + ("PreToolUse", 10), ("PostToolUse", 10), ("PostToolUseFailure", 10), + ("PermissionRequest", 120), + ("Notification", 10), + ("Stop", 10), ("StopFailure", 10), + ("SubagentStart", 10), ("SubagentStop", 10), + ] + var hooks = settings["hooks"] as? [String: Any] ?? [:] + for (event, timeout) in events { + var existing = hooks[event] as? [[String: Any]] ?? [] + existing.removeAll { ($0["hooks"] as? [[String: Any]])?.contains { + ($0["command"] as? String)?.contains("coucou") == true || + ($0["command"] as? String)?.contains("NotchBuddy") == true + } ?? false } + existing.append(["hooks": [["type": "command", "command": quotedCmd, "timeout": timeout]]]) + hooks[event] = existing + } + // Dedicated AskUserQuestion PreToolUse hook (Claude Code 2.1.85+, timeout 130s) + var preToolUse = hooks["PreToolUse"] as? [[String: Any]] ?? [] + preToolUse.append([ + "matcher": "AskUserQuestion", + "hooks": [["type": "command", "command": "\(quotedCmd) --ask", "timeout": 130]], + ]) + hooks["PreToolUse"] = preToolUse + settings["hooks"] = hooks + return try JSONSerialization.data(withJSONObject: settings, options: [.prettyPrinted, .sortedKeys]) + } + #endif + + // MARK: - Gemini CLI and Antigravity hook installers (#if !APPSTORE only) + + #if !APPSTORE + private static var geminiSettingsURL: URL { + FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".gemini/settings.json") + } + private static var agyHooksURL: URL { + FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".gemini/config/hooks.json") + } + + // MARK: Installed-state detection + + static func geminiHooksInstalled() -> Bool { + guard let data = try? Data(contentsOf: geminiSettingsURL), + let settings = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + let hooks = settings["hooks"] as? [String: Any] else { return false } + for value in hooks.values { + guard let groups = value as? [[String: Any]] else { continue } + for group in groups { + if let innerHooks = group["hooks"] as? [[String: Any]] { + for hook in innerHooks { + if let cmd = hook["command"] as? String, + cmd.contains("nb-hook"), cmd.contains("--agent gemini") { return true } + } + } + // Legacy flat entry + if let cmd = group["command"] as? String, + cmd.contains("nb-hook"), cmd.contains("--agent gemini") { return true } + } + } + return false + } + + static func agyHooksInstalled() -> Bool { + guard let data = try? Data(contentsOf: agyHooksURL), + let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + let coucou = root["coucou"] else { return false } + let json = (try? JSONSerialization.data(withJSONObject: coucou)) + .flatMap { String(data: $0, encoding: .utf8) } ?? "" + return json.contains("nb-hook") + } + + // MARK: Gemini CLI – preview / write + + private var _pendingGeminiData: Data? + private var _pendingGeminiFingerprint: String? + + func previewGeminiHooks(install: Bool) throws -> String { + let url = Self.geminiSettingsURL + let exists = FileManager.default.fileExists(atPath: url.path) + if !install && !exists { + throw NSError(domain: "CoucouNoop", code: 0, userInfo: [ + NSLocalizedDescriptionKey: "No Gemini CLI hooks to remove." + ]) + } + let current = exists ? try Data(contentsOf: url) : Data() + _pendingGeminiFingerprint = sha256Hex(current) + let newData = install ? try buildGeminiHooksData() : try withoutGeminiHooks() + _pendingGeminiData = newData + return String(data: newData, encoding: .utf8) ?? "" + } + + func writeGeminiHooks() throws { + guard let data = _pendingGeminiData, let fp = _pendingGeminiFingerprint else { return } + let url = Self.geminiSettingsURL + let current = (try? Data(contentsOf: url)) ?? Data() + guard sha256Hex(current) == fp else { + throw NSError(domain: "Coucou", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "~/.gemini/settings.json changed since preview. Refresh and try again." + ]) + } + try writeJSONFile(data, to: url, suffix: "settings.json") + _pendingGeminiData = nil + _pendingGeminiFingerprint = nil + } + + private func buildGeminiHooksData() throws -> Data { + var settings = try Self.strictReadJSONObject(at: Self.geminiSettingsURL, + label: "~/.gemini/settings.json") + if let raw = settings["hooks"], !(raw is [String: Any]) { + throw NSError(domain: "Coucou", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "~/.gemini/settings.json: \"hooks\" has an unexpected type — Coucou has not touched it." + ]) + } + let base = hookBase() + // (Gemini event key, normalized event name passed via argv, timeout in ms) + let events: [(String, String, Int)] = [ + ("SessionStart", "SessionStart", 10000), + ("SessionEnd", "SessionEnd", 10000), + ("BeforeTool", "PreToolUse", 5000), + ("AfterTool", "PostToolUse", 5000), + ("BeforeAgent", "UserPromptSubmit", 5000), + ("AfterAgent", "Stop", 5000), + ] + var hooks = settings["hooks"] as? [String: Any] ?? [:] + for (geminiEvent, normalizedEvent, timeout) in events { + if let raw = hooks[geminiEvent], !(raw is [[String: Any]]) { + throw NSError(domain: "Coucou", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "~/.gemini/settings.json: \"hooks\"[\"\(geminiEvent)\"] has an unexpected type — Coucou has not touched it." + ]) + } + var groups = hooks[geminiEvent] as? [[String: Any]] ?? [] + // Remove legacy flat entries and groups whose inner hooks contain nb-hook + groups = removeNbHookEntries(from: groups) + let hookEntry: [String: Any] = [ + "type": "command", + "command": "\(base) --agent gemini \(normalizedEvent)", + "timeout": timeout, + ] + groups.append(["matcher": "*", "hooks": [hookEntry]]) + hooks[geminiEvent] = groups + } + settings["hooks"] = hooks + return try JSONSerialization.data(withJSONObject: settings, + options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) + } + + private func withoutGeminiHooks() throws -> Data { + var settings = try Self.strictReadJSONObject(at: Self.geminiSettingsURL, + label: "~/.gemini/settings.json") + if let raw = settings["hooks"], !(raw is [String: Any]) { + throw NSError(domain: "Coucou", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "~/.gemini/settings.json: \"hooks\" has an unexpected type — Coucou has not touched it." + ]) + } + if var hooks = settings["hooks"] as? [String: Any] { + for key in hooks.keys { + if let groups = hooks[key] as? [[String: Any]] { + let cleaned = removeNbHookEntries(from: groups) + if cleaned.isEmpty { hooks.removeValue(forKey: key) } else { hooks[key] = cleaned } + } + } + if hooks.isEmpty { settings.removeValue(forKey: "hooks") } else { settings["hooks"] = hooks } + } + return try JSONSerialization.data(withJSONObject: settings, + options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) + } + + // MARK: Antigravity – preview / write + + private var _pendingAgyData: Data? + private var _pendingAgyFingerprint: String? + + func previewAgyHooks(install: Bool) throws -> String { + let url = Self.agyHooksURL + let exists = FileManager.default.fileExists(atPath: url.path) + if !install && !exists { + throw NSError(domain: "CoucouNoop", code: 0, userInfo: [ + NSLocalizedDescriptionKey: "No Antigravity hooks to remove." + ]) + } + let current = exists ? try Data(contentsOf: url) : Data() + _pendingAgyFingerprint = sha256Hex(current) + let newData = install ? try buildAgyHooksData() : try withoutAgyHooks() + _pendingAgyData = newData + return String(data: newData, encoding: .utf8) ?? "" + } + + func writeAgyHooks() throws { + guard let data = _pendingAgyData, let fp = _pendingAgyFingerprint else { return } + let url = Self.agyHooksURL + let current = (try? Data(contentsOf: url)) ?? Data() + guard sha256Hex(current) == fp else { + throw NSError(domain: "Coucou", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "~/.gemini/config/hooks.json changed since preview. Refresh and try again." + ]) + } + try writeJSONFile(data, to: url, suffix: "hooks.json") + _pendingAgyData = nil + _pendingAgyFingerprint = nil + } + + private func buildAgyHooksData() throws -> Data { + var root = try Self.strictReadJSONObject(at: Self.agyHooksURL, + label: "~/.gemini/config/hooks.json") + let base = hookBase() + // PreToolUse / PostToolUse: tool-level hooks — use matcher group + // PreInvocation / PostInvocation / Stop: lifecycle hooks — direct handler, no matcher + var coucou: [String: Any] = [:] + for event in ["PreToolUse", "PostToolUse"] { + let hook: [String: Any] = ["type": "command", + "command": "\(base) --agent antigravity \(event)", + "timeout": 10] + coucou[event] = [["matcher": "*", "hooks": [hook]]] + } + for event in ["PreInvocation", "PostInvocation", "Stop"] { + let hook: [String: Any] = ["type": "command", + "command": "\(base) --agent antigravity \(event)", + "timeout": 10] + coucou[event] = [hook] + } + root["coucou"] = coucou + return try JSONSerialization.data(withJSONObject: root, + options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) + } + + private func withoutAgyHooks() throws -> Data { + var root = try Self.strictReadJSONObject(at: Self.agyHooksURL, + label: "~/.gemini/config/hooks.json") + root.removeValue(forKey: "coucou") + return try JSONSerialization.data(withJSONObject: root, + options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) + } + + // MARK: Shared helpers + + /// /bin/sh "" — quoted for paths containing spaces (Application Support). + private func hookBase() -> String { + let path = Self.hookScriptPath.replacingOccurrences(of: "\"", with: "\\\"") + return "/bin/sh \"\(path)\"" + } + + /// Reads a JSON object from url. + /// Absent file → empty dict. Present but invalid → throws with a user-facing message. + private static func strictReadJSONObject(at url: URL, label: String) throws -> [String: Any] { + guard FileManager.default.fileExists(atPath: url.path) else { return [:] } + let data: Data + do { data = try Data(contentsOf: url) } + catch { + throw NSError(domain: "Coucou", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "\(label) cannot be read — Coucou has not touched it." + ]) + } + guard let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { + throw NSError(domain: "Coucou", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "\(label) is not valid JSON — Coucou has not touched it." + ]) + } + return obj + } + + /// Backs up the existing file (throws on failure), creates parent dirs, then atomically writes. + private func writeJSONFile(_ data: Data, to url: URL, suffix: String) throws { + let fm = FileManager.default + if fm.fileExists(atPath: url.path) { + let fmt = DateFormatter() + fmt.locale = Locale(identifier: "en_US_POSIX") + fmt.dateFormat = "yyyyMMdd-HHmmss" + let backupURL = url.deletingLastPathComponent() + .appendingPathComponent("\(suffix).bak-\(fmt.string(from: Date()))") + do { try fm.copyItem(at: url, to: backupURL) } + catch { + throw NSError(domain: "Coucou", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "Could not back up \(url.lastPathComponent): \(error.localizedDescription)" + ]) + } + } + try fm.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: url, options: .atomic) + } + + /// Removes entries containing "nb-hook" from a Gemini-format groups array. + /// Handles both new group format (matcher + hooks[]) and legacy flat format (command at top level). + /// Returns the cleaned array; empty groups (after inner-hook removal) are dropped. + private func removeNbHookEntries(from groups: [[String: Any]]) -> [[String: Any]] { + groups.compactMap { group -> [String: Any]? in + // Legacy flat entry — command at group level + if let cmd = group["command"] as? String, cmd.contains("nb-hook") { return nil } + // Group format — filter inner hooks + if var innerHooks = group["hooks"] as? [[String: Any]] { + innerHooks.removeAll { ($0["command"] as? String)?.contains("nb-hook") == true } + if innerHooks.isEmpty { return nil } + var updated = group + updated["hooks"] = innerHooks + return updated + } + return group + } + } + + // MARK: - Codex hook installer (#if !APPSTORE only) + + static var codexHooksURL: URL { + FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".codex/hooks.json") + } + + /// True when ~/.codex/hooks.json already routes Codex events to Coucou's nb-hook. + static func codexHooksInstalled() -> Bool { + guard let data = try? Data(contentsOf: codexHooksURL), + let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + let hooks = root["hooks"] as? [String: Any] else { return false } + for value in hooks.values { + guard let groups = value as? [[String: Any]] else { continue } + for group in groups { + if let innerHooks = group["hooks"] as? [[String: Any]] { + for hook in innerHooks { + if let cmd = hook["command"] as? String, + cmd.contains("nb-hook"), cmd.contains("--agent codex") { return true } + } + } + } + } + return false + } + + private var _pendingCodexData: Data? + private var _pendingCodexFingerprint: String? + + func previewCodexHooks(install: Bool) throws -> String { + let url = Self.codexHooksURL + let exists = FileManager.default.fileExists(atPath: url.path) + if !install && !exists { + throw NSError(domain: "CoucouNoop", code: 0, userInfo: [ + NSLocalizedDescriptionKey: "No Codex hooks to remove." + ]) + } + let current = exists ? try Data(contentsOf: url) : Data() + _pendingCodexFingerprint = sha256Hex(current) + let newData = install ? try buildCodexHooksData() : try withoutCodexHooks() + _pendingCodexData = newData + return String(data: newData, encoding: .utf8) ?? "" + } + + func writeCodexHooks() throws { + guard let data = _pendingCodexData, let fp = _pendingCodexFingerprint else { return } + let url = Self.codexHooksURL + let current = (try? Data(contentsOf: url)) ?? Data() + guard sha256Hex(current) == fp else { + throw NSError(domain: "Coucou", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "~/.codex/hooks.json changed since preview. Refresh and try again." + ]) + } + try writeJSONFile(data, to: url, suffix: "hooks.json") + _pendingCodexData = nil + _pendingCodexFingerprint = nil + } + + private func buildCodexHooksData() throws -> Data { + var root = try Self.strictReadJSONObject(at: Self.codexHooksURL, label: "~/.codex/hooks.json") + if let raw = root["hooks"], !(raw is [String: Any]) { + throw NSError(domain: "Coucou", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "~/.codex/hooks.json: \"hooks\" has an unexpected type — Coucou has not touched it." + ]) + } + let base = hookBase() + // Events, timeouts in seconds (Codex format). + // PermissionRequest uses 120s + a statusMessage shown in the Codex UI while waiting. + let events: [(String, Int, String?)] = [ + ("SessionStart", 10, nil), + ("UserPromptSubmit", 10, nil), + ("PreToolUse", 10, nil), + ("PermissionRequest", 120, "Waiting for your answer in the notch (Coucou)"), + ("PostToolUse", 10, nil), + ("Stop", 10, nil), + ("SubagentStart", 10, nil), + ("SubagentStop", 10, nil), + ("Interrupt", 3, nil), + ("SessionEnd", 3, nil), + ] + var hooks = root["hooks"] as? [String: Any] ?? [:] + for (event, timeout, statusMsg) in events { + if let raw = hooks[event], !(raw is [[String: Any]]) { + throw NSError(domain: "Coucou", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "~/.codex/hooks.json: \"hooks\"[\"\(event)\"] has an unexpected type — Coucou has not touched it." + ]) + } + var groups = hooks[event] as? [[String: Any]] ?? [] + // Remove existing Coucou entries + groups = removeNbHookEntries(from: groups) + var hookEntry: [String: Any] = [ + "type": "command", + "command": "\(base) --agent codex", + "timeout": timeout, + ] + if let msg = statusMsg { hookEntry["statusMessage"] = msg } + groups.append(["hooks": [hookEntry]]) + hooks[event] = groups + } + root["hooks"] = hooks + return try JSONSerialization.data(withJSONObject: root, + options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) + } + + private func withoutCodexHooks() throws -> Data { + var root = try Self.strictReadJSONObject(at: Self.codexHooksURL, label: "~/.codex/hooks.json") + if let raw = root["hooks"], !(raw is [String: Any]) { + throw NSError(domain: "Coucou", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "~/.codex/hooks.json: \"hooks\" has an unexpected type — Coucou has not touched it." + ]) + } + if var hooks = root["hooks"] as? [String: Any] { + for key in hooks.keys { + if let groups = hooks[key] as? [[String: Any]] { + let cleaned = removeNbHookEntries(from: groups) + if cleaned.isEmpty { hooks.removeValue(forKey: key) } else { hooks[key] = cleaned } + } + } + if hooks.isEmpty { root.removeValue(forKey: "hooks") } else { root["hooks"] = hooks } + } + return try JSONSerialization.data(withJSONObject: root, + options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) + } + + // MARK: SHA-256 fingerprint + + private func sha256Hex(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + #endif +} + +// MARK: - Notification names for hook server → controller communication + +extension Notification.Name { + static let hookExpand = Notification.Name("notchBuddy.hookExpand") +} + +// MARK: - nb-hook shell wrapper (same for both GitHub and App Store) +// Invoked by Claude Code via /bin/sh or directly via shebang. +// Always exits 0 — never blocks Claude Code. +// Checks xcode-select before running python3 to avoid triggering the +// "install developer tools" dialog on machines without Xcode CLI tools. + +private let nbHookShellWrapper = """ +#!/bin/sh +# Coucou hook relay — always exits 0, never blocks Claude Code +HOOK_DIR="$(dirname "$0")" +if xcode-select -p >/dev/null 2>&1; then + out=$(/usr/bin/python3 "$HOOK_DIR/nb-hook.py" "$@" 2>/dev/null) + rc=$? + if [ "$rc" -eq 0 ] && [ -n "$out" ]; then + printf '%s\\n' "$out" + fi +fi +exit 0 +""" + +// MARK: - nb-hook Python relay (GitHub / non-sandboxed version) + +private let nbHookPythonGitHub = """ +#!/usr/bin/env python3 +# nb-hook.py — Coucou hook relay for Claude Code and third-party agents (GitHub version) +# Reads JSON from stdin, forwards to Coucou via Unix socket, translates response. +import sys, json, os, socket + +def normalize_event(name): + mapping = { + 'BeforeTool': 'PreToolUse', 'BeforeToolSelection': 'PreToolUse', + 'AfterTool': 'PostToolUse', 'AfterModel': 'PostToolUse', + 'BeforeAgent': 'UserPromptSubmit', 'AfterAgent': 'Stop', + 'startup': 'SessionStart', 'exit': 'SessionEnd', + 'PreInvocation': 'UserPromptSubmit', 'PostInvocation': 'PostToolUse', + } + return mapping.get(name, name) + +def normalize_tool_fields(payload): + if 'tool_name' in payload: + return + tool = payload.get('toolCall') + if not isinstance(tool, dict): + tool = {} + name = tool.get('name') or payload.get('tool', '') + if name: + payload['tool_name'] = name + if 'tool_input' not in payload and isinstance(tool.get('args'), dict): + flat = dict(tool['args']) + for src, dst in [('CommandLine', 'command'), ('FilePath', 'file_path'), + ('Path', 'path'), ('Url', 'url'), ('Query', 'query'), ('Pattern', 'pattern')]: + if src in flat: + flat[dst] = flat[src] + payload['tool_input'] = flat + if 'session_id' not in payload: + for k in ['conversationId', 'conversation_id', 'sessionId', 'GEMINI_SESSION_ID']: + if payload.get(k): + payload['session_id'] = payload[k] + break + if 'session_id' not in payload: + sid = os.environ.get('GEMINI_SESSION_ID', '') + if sid: + payload['session_id'] = sid + +def main(): + raw = b'' + payload = {} + try: + raw = sys.stdin.buffer.read() + if not raw: + if '--statusline' not in sys.argv[1:]: + return + else: + payload = json.loads(raw) + except Exception: + if '--statusline' not in sys.argv[1:]: + return + + socket_path = os.path.expanduser( + '~/Library/Application Support/NotchBuddy/nb.sock' + ) + + # --statusline mode: relay rate_limits to Coucou, then delegate to saved previous + if '--statusline' in sys.argv[1:]: + relay = { + 'coucou_kind': 'statusline', + 'session_id': payload.get('session_id', ''), + 'rate_limits': payload.get('rate_limits', {}), + } + try: + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.settimeout(0.3) + s.connect(socket_path) + s.sendall((json.dumps(relay) + '\\n').encode()) + s.close() + except Exception: + pass + prev_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'statusline-previous.json') + if os.path.exists(prev_file): + try: + import subprocess + with open(prev_file) as f: + prev = json.load(f) + cmd = prev.get('command', '') + if cmd: + result = subprocess.run(['/bin/sh', '-c', cmd], input=raw, + capture_output=True, timeout=10) + if result.stdout: + sys.stdout.buffer.write(result.stdout) + sys.stdout.buffer.flush() + except Exception: + pass + return + + # --ask mode: dedicated hook for AskUserQuestion via PreToolUse (Claude Code 2.1.85+) + if '--ask' in sys.argv[1:]: + tool = payload.get('tool_name', '') + if tool != 'AskUserQuestion': + return # Not an AskUserQuestion invocation — exit cleanly (no output) + payload['coucou_kind'] = 'ask_user_question' + env = os.environ + payload.setdefault('term_program', env.get('TERM_PROGRAM', '')) + payload.setdefault('iterm_session_id', env.get('ITERM_SESSION_ID', '')) + payload.setdefault('term_session_id', env.get('TERM_SESSION_ID', '')) + payload.setdefault('bundle_id', env.get('__CFBundleIdentifier', '')) + if 'cwd' not in payload or not payload['cwd']: + paths = payload.get('workspacePaths') or payload.get('workspace_roots', []) + if isinstance(paths, list) and paths: + payload['cwd'] = paths[0] + else: + payload['cwd'] = os.getcwd() + try: + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.settimeout(125) + s.connect(socket_path) + s.sendall((json.dumps(payload) + '\\n').encode()) + chunks = [] + while True: + chunk = s.recv(4096) + if not chunk: + break + chunks.append(chunk) + if b'\\n' in chunk: + break + s.close() + response = b''.join(chunks).decode().strip() + if response: + try: + resp_obj = json.loads(response) + decision = resp_obj.get('permissionDecision', '') + except Exception: + decision = '' + if decision == 'answer': + answers = resp_obj.get('answers', {}) + questions = payload.get('tool_input', {}).get('questions', []) + out = {'hookSpecificOutput': {'hookEventName': 'PreToolUse', 'permissionDecision': 'allow', 'updatedInput': {'questions': questions, 'answers': answers}}} + sys.stdout.write(json.dumps(out) + '\\n') + sys.stdout.flush() + sys.exit(0) + # 'ask' or unknown: fall through → no output → Claude Code asks in terminal + except Exception: + pass + return + + # Parse --agent and optional positional event from argv. + # --agent tags the payload with coucou_agent so the app routes to the right pill. + # The positional arg is a fallback event name for agents that do not set hook_event_name. + args = sys.argv[1:] + agent = '' + arg_event = '' + i = 0 + while i < len(args): + if args[i] == '--agent' and i + 1 < len(args): + agent = args[i + 1] + i += 2 + else: + if not arg_event: + arg_event = args[i] + i += 1 + if agent: + payload.setdefault('coucou_agent', agent) + + # Enrich with terminal context + env = os.environ + payload.setdefault('term_program', env.get('TERM_PROGRAM', '')) + payload.setdefault('iterm_session_id', env.get('ITERM_SESSION_ID', '')) + payload.setdefault('term_session_id', env.get('TERM_SESSION_ID', '')) + payload.setdefault('bundle_id', env.get('__CFBundleIdentifier', '')) + if 'cwd' not in payload or not payload['cwd']: + paths = payload.get('workspacePaths') or payload.get('workspace_roots', []) + if isinstance(paths, list) and paths: + payload['cwd'] = paths[0] + else: + payload['cwd'] = os.getcwd() + + # Normalize event name and tool fields (Gemini CLI / Antigravity → canonical names) + try: + raw_event = payload.get('hook_event_name', '') or arg_event + if raw_event: + payload['hook_event_name'] = normalize_event(raw_event) + normalize_tool_fields(payload) + except Exception: + pass + + event = payload.get('hook_event_name', '') + # socket_path is already defined above + + if event == 'PermissionRequest': + # Block and wait for Coucou's decision (Claude Code allows up to 120s) + try: + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.settimeout(118) + s.connect(socket_path) + s.sendall((json.dumps(payload) + '\\n').encode()) + chunks = [] + while True: + chunk = s.recv(4096) + if not chunk: + break + chunks.append(chunk) + if b'\\n' in chunk: + break + s.close() + response = b''.join(chunks).decode().strip() + if response: + try: + resp_obj = json.loads(response) + decision = resp_obj.get('permissionDecision', '') + except Exception: + decision = '' + if decision == 'allow': + out = {'hookSpecificOutput': {'hookEventName': 'PermissionRequest', 'decision': {'behavior': 'allow'}}} + sys.stdout.write(json.dumps(out) + '\\n') + sys.stdout.flush() + sys.exit(0) + elif decision == 'always' and agent != 'codex': + # Let Claude Code persist the rule via updatedPermissions + suggestions = payload.get('permission_suggestions', []) + out = {'hookSpecificOutput': {'hookEventName': 'PermissionRequest', 'decision': {'behavior': 'allow', 'updatedPermissions': suggestions}}} + sys.stdout.write(json.dumps(out) + '\\n') + sys.stdout.flush() + sys.exit(0) + elif decision == 'always': + # Codex rejects updatedPermissions — answer a plain allow instead + out = {'hookSpecificOutput': {'hookEventName': 'PermissionRequest', 'decision': {'behavior': 'allow'}}} + sys.stdout.write(json.dumps(out) + '\\n') + sys.stdout.flush() + sys.exit(0) + elif decision == 'deny': + out = {'hookSpecificOutput': {'hookEventName': 'PermissionRequest', 'decision': {'behavior': 'deny', 'message': 'Denied from Coucou'}}} + sys.stdout.write(json.dumps(out) + '\\n') + sys.stdout.flush() + sys.exit(0) + elif decision == 'answer': + # AskUserQuestion answered from the notch + answers = resp_obj.get('answers', {}) + questions = payload.get('tool_input', {}).get('questions', []) + out = {'hookSpecificOutput': {'hookEventName': 'PermissionRequest', 'decision': {'behavior': 'allow', 'updatedInput': {'questions': questions, 'answers': answers}}}} + sys.stdout.write(json.dumps(out) + '\\n') + sys.stdout.flush() + sys.exit(0) + # 'ask' or unknown: fall through → no output → agent re-asks + except Exception: + pass + # App unreachable, timed out, or no explicit decision — print nothing + # Claude Code / Codex will handle the absence of output (re-ask or default behaviour) + sys.exit(0) + + # All other events: fire-and-forget (0.3s timeout, never blocks) + try: + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.settimeout(0.3) + s.connect(socket_path) + s.sendall((json.dumps(payload) + '\\n').encode()) + s.close() + except Exception: + pass # Always exit cleanly — never block the agent + + # Gemini CLI and Antigravity expect a JSON response on stdout (empty = no decision) + if agent in ('gemini', 'antigravity'): + sys.stdout.write('{}\\n') + sys.stdout.flush() + +main() +sys.exit(0) +""" + +// MARK: - nb-hook Python relay (App Store — socket in sandboxed container) + +private let nbHookPythonAppStore = """ +#!/usr/bin/env python3 +# nb-hook.py — Coucou (App Store) hook relay for Claude Code and third-party agents +# Socket lives inside the sandboxed container; script runs outside the sandbox. +import sys, json, os, socket + +def normalize_event(name): + mapping = { + 'BeforeTool': 'PreToolUse', 'BeforeToolSelection': 'PreToolUse', + 'AfterTool': 'PostToolUse', 'AfterModel': 'PostToolUse', + 'BeforeAgent': 'UserPromptSubmit', 'AfterAgent': 'Stop', + 'startup': 'SessionStart', 'exit': 'SessionEnd', + 'PreInvocation': 'UserPromptSubmit', 'PostInvocation': 'PostToolUse', + } + return mapping.get(name, name) + +def normalize_tool_fields(payload): + if 'tool_name' in payload: + return + tool = payload.get('toolCall') + if not isinstance(tool, dict): + tool = {} + name = tool.get('name') or payload.get('tool', '') + if name: + payload['tool_name'] = name + if 'tool_input' not in payload and isinstance(tool.get('args'), dict): + flat = dict(tool['args']) + for src, dst in [('CommandLine', 'command'), ('FilePath', 'file_path'), + ('Path', 'path'), ('Url', 'url'), ('Query', 'query'), ('Pattern', 'pattern')]: + if src in flat: + flat[dst] = flat[src] + payload['tool_input'] = flat + if 'session_id' not in payload: + for k in ['conversationId', 'conversation_id', 'sessionId', 'GEMINI_SESSION_ID']: + if payload.get(k): + payload['session_id'] = payload[k] + break + if 'session_id' not in payload: + sid = os.environ.get('GEMINI_SESSION_ID', '') + if sid: + payload['session_id'] = sid + +def main(): + raw = b'' + payload = {} + try: + raw = sys.stdin.buffer.read() + if not raw: + if '--statusline' not in sys.argv[1:]: + return + else: + payload = json.loads(raw) + except Exception: + if '--statusline' not in sys.argv[1:]: + return + + socket_path = os.path.expanduser( + '~/Library/Containers/fr.louisraille.Coucou/Data/nb.sock' + ) + + # --statusline mode: relay rate_limits to Coucou, then delegate to saved previous + if '--statusline' in sys.argv[1:]: + relay = { + 'coucou_kind': 'statusline', + 'session_id': payload.get('session_id', ''), + 'rate_limits': payload.get('rate_limits', {}), + } + try: + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.settimeout(0.3) + s.connect(socket_path) + s.sendall((json.dumps(relay) + '\\n').encode()) + s.close() + except Exception: + pass + prev_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'statusline-previous.json') + if os.path.exists(prev_file): + try: + import subprocess + with open(prev_file) as f: + prev = json.load(f) + cmd = prev.get('command', '') + if cmd: + result = subprocess.run(['/bin/sh', '-c', cmd], input=raw, + capture_output=True, timeout=10) + if result.stdout: + sys.stdout.buffer.write(result.stdout) + sys.stdout.buffer.flush() + except Exception: + pass + return + + # --ask mode: dedicated hook for AskUserQuestion via PreToolUse (Claude Code 2.1.85+) + if '--ask' in sys.argv[1:]: + tool = payload.get('tool_name', '') + if tool != 'AskUserQuestion': + return # Not an AskUserQuestion invocation — exit cleanly (no output) + payload['coucou_kind'] = 'ask_user_question' + env = os.environ + payload.setdefault('term_program', env.get('TERM_PROGRAM', '')) + payload.setdefault('iterm_session_id', env.get('ITERM_SESSION_ID', '')) + payload.setdefault('term_session_id', env.get('TERM_SESSION_ID', '')) + payload.setdefault('bundle_id', env.get('__CFBundleIdentifier', '')) + if 'cwd' not in payload or not payload['cwd']: + paths = payload.get('workspacePaths') or payload.get('workspace_roots', []) + if isinstance(paths, list) and paths: + payload['cwd'] = paths[0] + else: + payload['cwd'] = os.getcwd() + try: + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.settimeout(125) + s.connect(socket_path) + s.sendall((json.dumps(payload) + '\\n').encode()) + chunks = [] + while True: + chunk = s.recv(4096) + if not chunk: + break + chunks.append(chunk) + if b'\\n' in chunk: + break + s.close() + response = b''.join(chunks).decode().strip() + if response: + try: + resp_obj = json.loads(response) + decision = resp_obj.get('permissionDecision', '') + except Exception: + decision = '' + if decision == 'answer': + answers = resp_obj.get('answers', {}) + questions = payload.get('tool_input', {}).get('questions', []) + out = {'hookSpecificOutput': {'hookEventName': 'PreToolUse', 'permissionDecision': 'allow', 'updatedInput': {'questions': questions, 'answers': answers}}} + sys.stdout.write(json.dumps(out) + '\\n') + sys.stdout.flush() + sys.exit(0) + # 'ask' or unknown: fall through → no output → Claude Code asks in terminal + except Exception: + pass + return + + # Parse --agent and optional positional event from argv. + # --agent tags the payload with coucou_agent so the app routes to the right pill. + # The positional arg is a fallback event name for agents that do not set hook_event_name. + args = sys.argv[1:] + agent = '' + arg_event = '' + i = 0 + while i < len(args): + if args[i] == '--agent' and i + 1 < len(args): + agent = args[i + 1] + i += 2 + else: + if not arg_event: + arg_event = args[i] + i += 1 + if agent: + payload.setdefault('coucou_agent', agent) + + env = os.environ + payload.setdefault('term_program', env.get('TERM_PROGRAM', '')) + payload.setdefault('iterm_session_id', env.get('ITERM_SESSION_ID', '')) + payload.setdefault('term_session_id', env.get('TERM_SESSION_ID', '')) + payload.setdefault('bundle_id', env.get('__CFBundleIdentifier', '')) + if 'cwd' not in payload or not payload['cwd']: + paths = payload.get('workspacePaths') or payload.get('workspace_roots', []) + if isinstance(paths, list) and paths: + payload['cwd'] = paths[0] + else: + payload['cwd'] = os.getcwd() + + # Normalize event name and tool fields (Gemini CLI / Antigravity → canonical names) + try: + raw_event = payload.get('hook_event_name', '') or arg_event + if raw_event: + payload['hook_event_name'] = normalize_event(raw_event) + normalize_tool_fields(payload) + except Exception: + pass + + event = payload.get('hook_event_name', '') + # socket_path is already defined above + + if event == 'PermissionRequest': + # Block and wait for Coucou's decision (Claude Code allows up to 120s) + try: + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.settimeout(118) + s.connect(socket_path) + s.sendall((json.dumps(payload) + '\\n').encode()) + chunks = [] + while True: + chunk = s.recv(4096) + if not chunk: + break + chunks.append(chunk) + if b'\\n' in chunk: + break + s.close() + response = b''.join(chunks).decode().strip() + if response: + try: + resp_obj = json.loads(response) + decision = resp_obj.get('permissionDecision', '') + except Exception: + decision = '' + if decision == 'allow': + out = {'hookSpecificOutput': {'hookEventName': 'PermissionRequest', 'decision': {'behavior': 'allow'}}} + sys.stdout.write(json.dumps(out) + '\\n') + sys.stdout.flush() + sys.exit(0) + elif decision == 'always' and agent != 'codex': + # Let Claude Code persist the rule via updatedPermissions + suggestions = payload.get('permission_suggestions', []) + out = {'hookSpecificOutput': {'hookEventName': 'PermissionRequest', 'decision': {'behavior': 'allow', 'updatedPermissions': suggestions}}} + sys.stdout.write(json.dumps(out) + '\\n') + sys.stdout.flush() + sys.exit(0) + elif decision == 'always': + # Codex rejects updatedPermissions — answer a plain allow instead + out = {'hookSpecificOutput': {'hookEventName': 'PermissionRequest', 'decision': {'behavior': 'allow'}}} + sys.stdout.write(json.dumps(out) + '\\n') + sys.stdout.flush() + sys.exit(0) + elif decision == 'deny': + out = {'hookSpecificOutput': {'hookEventName': 'PermissionRequest', 'decision': {'behavior': 'deny', 'message': 'Denied from Coucou'}}} + sys.stdout.write(json.dumps(out) + '\\n') + sys.stdout.flush() + sys.exit(0) + elif decision == 'answer': + # AskUserQuestion answered from the notch + answers = resp_obj.get('answers', {}) + questions = payload.get('tool_input', {}).get('questions', []) + out = {'hookSpecificOutput': {'hookEventName': 'PermissionRequest', 'decision': {'behavior': 'allow', 'updatedInput': {'questions': questions, 'answers': answers}}}} + sys.stdout.write(json.dumps(out) + '\\n') + sys.stdout.flush() + sys.exit(0) + # 'ask' or unknown: fall through → no output → agent re-asks + except Exception: + pass + # App unreachable, timed out, or no explicit decision — print nothing + sys.exit(0) + + try: + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.settimeout(0.3) + s.connect(socket_path) + s.sendall((json.dumps(payload) + '\\n').encode()) + s.close() + except Exception: + pass # Always exit cleanly — never block the agent + + # Gemini CLI and Antigravity expect a JSON response on stdout (empty = no decision) + if agent in ('gemini', 'antigravity'): + sys.stdout.write('{}\\n') + sys.stdout.flush() + +main() +sys.exit(0) +""" diff --git a/NotchBuddy/Sources/App/HotKeyCenter.swift b/NotchBuddy/Sources/App/HotKeyCenter.swift new file mode 100644 index 0000000..2af7ee9 --- /dev/null +++ b/NotchBuddy/Sources/App/HotKeyCenter.swift @@ -0,0 +1,148 @@ +import AppKit +import Carbon.HIToolbox + +// MARK: - C-level event handler +// +// Carbon's InstallEventHandler requires a top-level C-compatible function. +// We store state in nonisolated(unsafe) globals — safe because all mutations +// happen on the main thread (Carbon dispatches hot-key events on the main thread). + +private nonisolated(unsafe) var gHotKeyHandler: EventHandlerRef? = nil +private nonisolated(unsafe) var gHotKeyTable: [UInt32: ShortcutAction] = [:] +private nonisolated(unsafe) var gOnAction: ((ShortcutAction) -> Void)? = nil + +private func coucouHotKeyEventHandler( + _: EventHandlerCallRef?, + _ event: EventRef?, + _: UnsafeMutableRawPointer? +) -> OSStatus { + guard let event else { return OSStatus(eventNotHandledErr) } + var hkid = EventHotKeyID() + let err = GetEventParameter( + event, + EventParamName(kEventParamDirectObject), + EventParamType(typeEventHotKeyID), + nil, + MemoryLayout.size, + nil, + &hkid + ) + guard err == noErr, let action = gHotKeyTable[hkid.id] else { + return OSStatus(eventNotHandledErr) + } + // Carbon events are dispatched on the main thread. + MainActor.assumeIsolated { gOnAction?(action) } + return noErr +} + +// MARK: - HotKeyCenter + +/// Manages all global keyboard shortcuts via Carbon `RegisterEventHotKey`. +/// +/// - No Accessibility permission required. +/// - Works in the App Store sandbox. +/// - The hot-key event is consumed and never forwarded to the front application. +@MainActor +final class HotKeyCenter { + static let shared = HotKeyCenter() + private init() {} + + // "COUC" in big-endian — unique signature for Coucou's hot-key IDs + private let kSignature = OSType(0x434F5543) + + // Live registered refs (action → EventHotKeyRef) + private var refs: [ShortcutAction: EventHotKeyRef] = [:] + + /// Actions whose `RegisterEventHotKey` call failed (system conflict). + private(set) var conflicts: Set = [] + + // MARK: - Lifecycle + + /// Start the hot-key engine and fire `onAction` whenever the user presses a registered shortcut. + /// Safe to call more than once — the handler is installed only once. + func start(onAction: @escaping @MainActor (ShortcutAction) -> Void) { + gOnAction = onAction + + if gHotKeyHandler == nil { + var spec = EventTypeSpec( + eventClass: OSType(kEventClassKeyboard), + eventKind: UInt32(kEventHotKeyPressed) + ) + InstallEventHandler( + GetApplicationEventTarget(), + coucouHotKeyEventHandler, + 1, &spec, + nil, &gHotKeyHandler + ) + } + + registerAll() + } + + /// Unregister every hot key. Called on app quit. + func unregisterAll() { + for (action, ref) in refs { + UnregisterEventHotKey(ref) + let idx = UInt32(ShortcutAction.allCases.firstIndex(of: action)!) + gHotKeyTable.removeValue(forKey: idx) + } + refs.removeAll() + } + + /// Re-register all shortcuts from the current UserDefaults state. + func registerAll() { + unregisterAll() + conflicts.removeAll() + + for action in ShortcutAction.allCases { + #if APPSTORE + if action.isNonAppStore { continue } + #endif + if !ShortcutLogic.isEnabled(action) { continue } + registerOne(action) + } + } + + /// Re-register a single action (called after a setting change). + func reregister(_ action: ShortcutAction) { + // Remove old registration if present + if let ref = refs[action] { + UnregisterEventHotKey(ref) + let idx = UInt32(ShortcutAction.allCases.firstIndex(of: action)!) + gHotKeyTable.removeValue(forKey: idx) + refs.removeValue(forKey: action) + } + conflicts.remove(action) + + #if APPSTORE + if action.isNonAppStore { return } + #endif + if !ShortcutLogic.isEnabled(action) { return } + registerOne(action) + } + + // MARK: - Private + + private func registerOne(_ action: ShortcutAction) { + let spec = ShortcutLogic.hotKey(for: action) + let carbon = ShortcutLogic.carbonModifiers(fromNS: spec.nsFlags) + let idx = UInt32(ShortcutAction.allCases.firstIndex(of: action)!) + let hkid = EventHotKeyID(signature: kSignature, id: idx) + var ref: EventHotKeyRef? + + let status = RegisterEventHotKey( + UInt32(spec.keyCode), + carbon, + hkid, + GetApplicationEventTarget(), + 0, + &ref + ) + if status == noErr, let ref { + refs[action] = ref + gHotKeyTable[idx] = action + } else { + conflicts.insert(action) + } + } +} diff --git a/NotchBuddy/Sources/App/IslandRootView.swift b/NotchBuddy/Sources/App/IslandRootView.swift new file mode 100644 index 0000000..eb91f23 --- /dev/null +++ b/NotchBuddy/Sources/App/IslandRootView.swift @@ -0,0 +1,631 @@ +import SwiftUI + +/// Top-level SwiftUI view rendered inside the 720×320 transparent panel. +/// The island is drawn at the top-center; everything else is transparent and click-through. +/// Note: drag-drop is handled at the AppKit level in IslandWindowController (FileDropNSView), +/// not in SwiftUI, to avoid interfering with SwiftUI hit-testing. +struct IslandRootView: View { + @EnvironmentObject var state: AppState + + var body: some View { + ZStack(alignment: .top) { + Color.clear + .frame(maxWidth: .infinity, maxHeight: .infinity) + IslandContainer(state: state) + .frame(maxWidth: .infinity, alignment: .center) + } + .ignoresSafeArea() + } +} + +// MARK: - Island container + +struct IslandContainer: View { + @ObservedObject var state: AppState + @State private var islandWidth: CGFloat = IslandConst.notchWidth + @State private var islandHeight: CGFloat = IslandConst.notchHeight + @State private var cornerRadius: CGFloat = IslandConst.roundedCorner + // topRadius > 0 → convex expanded corners; < 0 → concave ear cutouts + @State private var islandTopRadius: CGFloat = 0 + @State private var greetNotif: Bool = false + + private let openSpring = Animation.spring(response: 0.5, dampingFraction: 0.72) + private let closeEase = Animation.timingCurve(0.45, 0, 0.2, 1, duration: 0.34) + + private var chatPromptHeight: CGFloat { + let base: CGFloat = 240 + let perMsg: CGFloat = 40 + return min(300, base + CGFloat(state.chatHistory.count) * perMsg) + } + + /// Pixels the content must be pushed down to clear the concave ear transparent area. + /// = 0 in expanded mode (no ears), = earRadius in compact/notch mode. + private var earOffset: CGFloat { max(0, -islandTopRadius) } + + var body: some View { + // Canvas active during drag-over (.upload), post-drop animation (.uploading), + // AND choose overlay (.choose) — canvas handles the full sequence through user action. + // Engine deactivates when user clicks a canvas choose button or navigates away. + let uploadActive = state.mode == .expanded + && UploadSequenceEngine.shared.isActive + && (state.view == .upload || state.view == .uploading || state.view == .choose) + + let greetingActive = state.mode == .expanded && state.view == .greeting + + return ZStack(alignment: .topLeading) { + // Black island shape + IslandShape(width: islandWidth, height: islandHeight, + cornerRadius: cornerRadius, topRadius: islandTopRadius) + .fill(Color.black) + + // Content + if state.mode == .expanded { + if greetingActive { + // Greeting canvas: fixed 640-wide, centered by offset so x=320 aligns with island center + GreetingCanvasView(state: state) + .frame(width: IslandConst.expandedWidth, height: 150) + .offset(x: (islandWidth - IslandConst.expandedWidth) / 2) + .clipShape(IslandShape(width: islandWidth, height: islandHeight, + cornerRadius: cornerRadius, topRadius: islandTopRadius)) + .transition(.opacity) + } else if uploadActive { + ZStack(alignment: .topLeading) { + UploadCanvasView(state: state) + .frame(width: islandWidth, height: islandHeight) + .clipShape(IslandShape(width: islandWidth, height: islandHeight, + cornerRadius: cornerRadius, topRadius: islandTopRadius)) + // Header overlaid: canvas CARD_Y=42 aligns exactly with header bottom, + // matching normal view proportions (8pt top + 34pt header + card + 10pt bottom). + IslandHeader(state: state) + .frame(width: islandWidth, height: 34) + .offset(y: 8) + } + .transition(.opacity) + } else { + IslandContentView(state: state) + .frame(width: islandWidth, height: islandHeight - earOffset) + .offset(y: earOffset) + .clipShape(IslandShape(width: islandWidth, height: islandHeight, + cornerRadius: cornerRadius, topRadius: islandTopRadius)) + .transition(.opacity) + } + } + + // Single BotPlacement — always alive in the view tree so spring animations + // fire from the current position (e.g. choose at 60,101) when canvas deactivates. + // Hidden during upload canvas or greeting (both draw their own Mochi). + BotPlacement(state: state, islandW: islandWidth, islandH: islandHeight) + // Keep idle animations inside the resting strip. Expanded views + // retain the panel's full height for particles and hands. + .mask(alignment: .topLeading) { + Rectangle().frame(width: islandWidth, + height: state.mode == .expanded ? 320 : islandHeight) + } + .opacity(uploadActive || greetingActive ? 0 : 1) + .animation(.easeInOut(duration: 0.25), value: uploadActive || greetingActive) + + CountdownBar(state: state, islandW: islandWidth) + + Group { + if state.mode == .compact { + CompactMiniGrid(state: state) + .scaleEffect(IslandRestingLayout(width: islandWidth, height: islandHeight).miniGridScale) + .position(x: islandWidth - 40, y: islandHeight / 2) + .transition(.opacity) + } + } + .animation(.easeInOut(duration: 0.25), value: state.mode == .compact) + } + .frame(width: islandWidth, height: islandHeight, alignment: .topLeading) + .onChange(of: state.mode) { oldMode, newMode in + let shrinking = modeOrder(newMode) < modeOrder(oldMode) + let anim = shrinking ? closeEase : openSpring + let (w, h) = islandSize(mode: newMode, view: state.view, + progress: state.uploadProgress, + nw: state.notchWidth, nh: state.notchHeight) + let cr = newMode == .expanded ? IslandConst.expandedCorner : IslandConst.roundedCorner + let tr: CGFloat = 0 + withAnimation(anim) { + islandWidth = w + islandHeight = (newMode == .expanded && state.view == .prompt) ? chatPromptHeight : h + cornerRadius = cr + islandTopRadius = tr + } + } + .onChange(of: state.view) { _, newView in + guard state.mode == .expanded else { return } + // Deactivate engine if user navigates outside the upload flow + let uploadViews: Set = [.upload, .uploading, .choose] + if UploadSequenceEngine.shared.isActive && !uploadViews.contains(newView) { + UploadSequenceEngine.shared.deactivate() + } + let (w, h) = islandSize(mode: .expanded, view: newView, + progress: state.uploadProgress, + nw: state.notchWidth, nh: state.notchHeight) + withAnimation(openSpring) { + islandWidth = w + islandHeight = newView == .prompt ? chatPromptHeight : h + } + } + .onChange(of: state.chatHistory.count) { _, _ in + guard state.mode == .expanded, state.view == .prompt else { return } + withAnimation(openSpring) { islandHeight = chatPromptHeight } + } + .onAppear { + let (w, h) = islandSize(mode: state.mode, view: state.view, + progress: state.uploadProgress, + nw: state.notchWidth, nh: state.notchHeight) + islandWidth = w + islandHeight = state.view == .prompt ? chatPromptHeight : h + cornerRadius = state.mode == .expanded ? IslandConst.expandedCorner : IslandConst.roundedCorner + islandTopRadius = 0 + } + .onReceive(NotificationCenter.default.publisher(for: .botGreet)) { _ in + greetNotif.toggle() + } + } + + private func modeOrder(_ m: IslandMode) -> Int { + switch m { case .hidden: return 0; case .compact: return 1; case .expanded: return 2 } + } +} + +// MARK: - Island shape +// +// topRadius > 0 → convex rounded top corners (expanded mode) +// topRadius < 0 → concave ear cutouts, |topRadius| = ear radius (compact/notch mode) +// topRadius = 0 → sharp top corners (transient during animation) + +struct IslandShape: Shape { + var width: CGFloat + var height: CGFloat + var cornerRadius: CGFloat // bottom corners + var topRadius: CGFloat // see above + + var animatableData: AnimatablePair, CGFloat>, CGFloat> { + get { .init(.init(.init(width, height), cornerRadius), topRadius) } + set { + width = newValue.first.first.first + height = newValue.first.first.second + cornerRadius = newValue.first.second + topRadius = newValue.second + } + } + + func path(in rect: CGRect) -> Path { + let cr = max(0, cornerRadius) + var p = Path() + + if topRadius >= 0 { + // ── Convex rounded top corners (expanded) ────────────────────────── + let tr = min(topRadius, min(width / 2, height / 2)) + p.move(to: CGPoint(x: tr, y: 0)) + p.addLine(to: CGPoint(x: width - tr, y: 0)) + // Top-right convex corner + p.addArc(center: CGPoint(x: width - tr, y: tr), radius: tr, + startAngle: .degrees(270), endAngle: .degrees(0), clockwise: false) + // Right edge + p.addLine(to: CGPoint(x: width, y: height - cr)) + // Bottom-right corner + p.addArc(center: CGPoint(x: width - cr, y: height - cr), radius: cr, + startAngle: .degrees(0), endAngle: .degrees(90), clockwise: false) + // Bottom edge + p.addLine(to: CGPoint(x: cr, y: height)) + // Bottom-left corner + p.addArc(center: CGPoint(x: cr, y: height - cr), radius: cr, + startAngle: .degrees(90), endAngle: .degrees(180), clockwise: false) + // Left edge + p.addLine(to: CGPoint(x: 0, y: tr)) + // Top-left convex corner + p.addArc(center: CGPoint(x: tr, y: tr), radius: tr, + startAngle: .degrees(180), endAngle: .degrees(270), clockwise: false) + } else { + // ── Concave ear cutouts (compact / notch) ───────────────────────── + let er = -topRadius // positive ear radius + p.move(to: CGPoint(x: 0, y: 0)) + // Top-left ear + p.addArc(center: CGPoint(x: 0, y: er), radius: er, + startAngle: .degrees(270), endAngle: .degrees(0), clockwise: false) + // Top edge + p.addLine(to: CGPoint(x: width - er, y: er)) + // Top-right ear + p.addArc(center: CGPoint(x: width, y: er), radius: er, + startAngle: .degrees(180), endAngle: .degrees(270), clockwise: false) + // Right edge + p.addLine(to: CGPoint(x: width, y: height - cr)) + // Bottom-right corner + p.addArc(center: CGPoint(x: width - cr, y: height - cr), radius: cr, + startAngle: .degrees(0), endAngle: .degrees(90), clockwise: false) + // Bottom edge + p.addLine(to: CGPoint(x: cr, y: height)) + // Bottom-left corner + p.addArc(center: CGPoint(x: cr, y: height - cr), radius: cr, + startAngle: .degrees(90), endAngle: .degrees(180), clockwise: false) + // Left edge back to top-left corner + p.addLine(to: CGPoint(x: 0, y: 0)) + } + + p.closeSubpath() + return p + } +} + +// MARK: - Bot placement helper + +struct BotPlacement: View { + @ObservedObject var state: AppState + let islandW: CGFloat + let islandH: CGFloat + + var body: some View { + let (cx, cy, diameter, opacity) = botPosition(mode: state.mode, view: state.view, islandW: islandW, islandH: islandH, uploadProgress: state.uploadProgress, hasNotch: state.hasNotch) + let canvasSize = diameter / 0.6 + let overhang: CGFloat = 40 + let isUploading = state.view == .uploading + + Group { + // No glow in uploading mode — the tiny dot doesn't need it + if state.mode == .expanded && !isUploading { + Circle() + .fill(RadialGradient( + gradient: Gradient(stops: [ + .init(color: botGlowColor(state.effectiveState), location: 0), + .init(color: .clear, location: 0.62) + ]), + center: .center, + startRadius: 0, + endRadius: diameter * 1.1 + )) + .frame(width: diameter * 2.2, height: diameter * 2.2) + .blur(radius: 6) + .opacity(botGlowOpacity(state.effectiveState)) + .position(x: cx, y: cy) + .animation(.easeInOut(duration: 0.4), value: state.effectiveState) + } + + // Uploading: no particle overhang (no hearts during upload), positioned directly at cy. + // BotEngine cy = H/2 + 0 + oy*R + R*0.06 ≈ H/2 (body centered in canvas). + // With .position(x:y:) placing the frame center at (uploadCx, cy), bot is at cy ✓. + // + // Normal: extra 40pt canvas at top for heart particles; position offset up by 20pt; + // BotEngine compensates with cy = H/2 + particleOverhang/2 + oy*R + R*0.06. + if isUploading { + TimelineView(.animation) { tl in + let elapsed: Double = { + guard let start = state.uploadStartTime else { return 0 } + return tl.date.timeIntervalSince(start) + }() + let t = min(1.0, max(0, elapsed / state.uploadDuration)) + // cx = 36 + 526*t: bot center at fill right edge (bar left=36, width=526) + let uploadCx = 36 + CGFloat(t * (2 - t)) * 526 + BotCanvasView(state: state, particleOverhang: 0) + .frame(width: canvasSize, height: canvasSize) + .opacity(state.isDraggingBot || state.mochiOnDesktop ? 0 : opacity) + .position(x: uploadCx, y: cy) + } + .transition(.scale(scale: 0.01, anchor: .center).combined(with: .opacity)) + } else { + BotCanvasView(state: state, particleOverhang: overhang) + .frame(width: canvasSize, height: canvasSize + overhang) + .opacity(state.isDraggingBot || state.mochiOnDesktop ? 0 : opacity) + .position(x: cx, y: cy - overhang / 2) + .animation(.spring(response: 0.5, dampingFraction: 0.72), value: cx) + .animation(.spring(response: 0.5, dampingFraction: 0.72), value: cy) + .animation(.spring(response: 0.5, dampingFraction: 0.72), value: canvasSize) + .transition(.scale(scale: 0.01, anchor: .center).combined(with: .opacity)) + } + } + // Branch switch (uploading ↔ normal) animates with a fast spring: uploading dot + // scales out at bar-end while normal bot scales in at choose position. + .animation(.spring(response: 0.36, dampingFraction: 0.72), value: isUploading) + // Slap, drag, and hover are handled by the AppKit NSEvent monitor in + // IslandWindowController — not SwiftUI gestures — so this is safe. + .allowsHitTesting(false) + } + + private func botGlowColor(_ s: BotState) -> Color { + switch s { + case .working: return Color(hex: "#3B9EFF") + case .thinking: return Color(hex: "#A78BFA") + case .searching: return Color(hex: "#6366F1") + case .approval: return Color(hex: "#F5A524") + case .error: return Color(hex: "#F4505E") + case .finished: return Color(hex: "#34D399") + case .ratelimit: return Color(hex: "#F59E0B") + default: return Color.white + } + } + + private func botGlowOpacity(_ s: BotState) -> Double { + switch s { + case .idle, .sleeping: return 0.15 + case .dizzy: return 0.0 + default: return 0.65 + } + } +} + +func botPosition(mode: IslandMode, view: IslandView, islandW: CGFloat, islandH: CGFloat, uploadProgress: Double, hasNotch: Bool = true) -> (CGFloat, CGFloat, CGFloat, Double) { + let resting = IslandRestingLayout(width: islandW, height: islandH) + switch mode { + case .hidden: + return hasNotch ? (46, 16, 6, 0) + : (islandW / 2, resting.botCenterY, resting.botDiameter, 1) + case .compact: return (40, resting.botCenterY, resting.botDiameter, 1) + case .expanded: + let layout = IslandConst.viewLayouts[view]! + let diameter = layout.botDiameter + // Uploading: Mochi dot rides the leading edge of the progress fill. + // Bar in island coords: left=36, width=526. cx = 36 + progress*526 (dot center at fill right edge). + // cy comes from ViewLayout.botY (bar center in island coords). + if view == .uploading { + let cx = 36 + CGFloat(uploadProgress) * 526 + return (cx, layout.botY ?? 103, diameter, 1) + } + let cx = layout.botX + let cy: CGFloat + if let fixedY = layout.botY { + cy = fixedY + } else { + // Center of the fixed 84pt card (VStack top=8, header=34 → content starts at y=42) + let headerBottom: CGFloat = 42 + let cardH: CGFloat = 84 + cy = headerBottom + (islandH - headerBottom - cardH) / 2 + cardH / 2 + } + return (cx, cy, diameter, 1) + } +} + +// MARK: - Countdown bar + +struct CountdownBar: View { + @ObservedObject var state: AppState + let islandW: CGFloat + @State private var barWidth: CGFloat = 0 + @State private var timer: Timer? = nil + + var body: some View { + GeometryReader { _ in + Rectangle() + .fill(Color.white.opacity(0.35)) + .frame(width: barWidth, height: 2) + .cornerRadius(2) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + } + .onAppear { startTimer() } + .onDisappear { timer?.invalidate() } + } + + private func startTimer() { + timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in + updateBar() + } + } + + private func updateBar() { + guard state.mode == .expanded && !state.isPinned else { + barWidth = 0 + return + } + let autoClose = state.autoCloseInterval + let window = min(10.0, autoClose * 0.6) + let elapsed = Date.now.timeIntervalSince(state.lastActivity) + let remaining = autoClose - elapsed + if remaining < window { + barWidth = max(0, CGFloat(remaining / window) * 160) + } else { + barWidth = 0 + } + } +} + +// MARK: - Island content (header + views, only in expanded mode) + +struct IslandContentView: View { + @ObservedObject var state: AppState + + var body: some View { + VStack(spacing: 0) { + IslandHeader(state: state) + .frame(height: 34) + .opacity(state.view == .confused ? 0 : 1) + .animation(.easeInOut(duration: 0.2), value: state.view == .confused) + + ZStack { + ForEach(IslandView.allCases, id: \.self) { v in + let active = state.view == v + // Views that fill available height instead of the fixed 98pt content frame: + // chat (prompt) is always flexible; mail is flexible only when active so + // it doesn't push the ZStack taller when inactive. + let isTall = v == .prompt || (v == .mail && active) + let anim: Animation = active + ? .spring(response: 0.4, dampingFraction: 0.8).delay(0.16) + : .easeIn(duration: 0.16) + IslandViewContent(view: v, state: state) + .frame(maxWidth: .infinity) + .frame(height: isTall ? nil : 98) + .frame(minHeight: (isTall && !active) ? 0 : nil, maxHeight: isTall ? .infinity : nil) + .opacity(active ? 1 : 0) + .scaleEffect(active ? 1 : 0.97) + .allowsHitTesting(active) + .animation(anim, value: state.view) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 10) + } + .padding(.top, 8) + .padding(.bottom, 10) + .foregroundColor(Color(hex: "#F5F6F8")) + } +} + +// MARK: - Island header (tabs + icons) + +struct IslandHeader: View { + @ObservedObject var state: AppState + + var body: some View { + HStack(spacing: 0) { + // Left: tab capsules + HStack(spacing: 5) { + TabButton(icon: "house.fill", view: .overview, state: state) + TabButton(icon: "bubble.left.fill", view: .prompt, state: state, preAction: { + #if !APPSTORE + if state.promptContext == nil { + state.promptContext = WindowContextCapture.captureActive(from: state.lastExternalApp) + } + #endif + }) + TabButton(icon: "plus", view: .upload, state: state) + } + .padding(.leading, 14) + + Spacer() + + // Right: plan pill (GitHub build, home view only) + action icons + HStack(spacing: 8) { + #if !APPSTORE + if state.view == .overview && state.showPlanInNotch && state.planRelayInstalled { + ClaudePlanHeaderPill(state: state) + } + #endif + HStack(spacing: 14) { + Button(action: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + state.view = .settings + } + }) { + Image(systemName: state.view == .settings ? "gearshape.fill" : "gearshape") + .font(.system(size: 14)) + .foregroundColor(state.view == .settings ? Color(hex: "#F5F6F8") : Color(hex: "#8E939C")) + } + .buttonStyle(.plain) + + Button(action: { state.soundEnabled.toggle() }) { + Image(systemName: state.soundEnabled ? "speaker.wave.2" : "speaker.slash") + .font(.system(size: 14)) + .foregroundColor(Color(hex: "#8E939C")) + } + .buttonStyle(.plain) + } + } + .padding(.trailing, 16) + } + .frame(maxHeight: .infinity) + } +} + +struct TabButton: View { + let icon: String + let view: IslandView + @ObservedObject var state: AppState + var preAction: (() -> Void)? = nil + @State private var isHovered = false + + private var isOn: Bool { + if view == .overview { return state.view == .overview || state.view == .empty } + return state.view == view + } + + var body: some View { + Button(action: { + preAction?() + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + state.view = view + } + }) { + Image(systemName: icon) + .font(.system(size: 13)) + .foregroundColor(isOn ? Color(hex: "#F5F6F8") : (isHovered ? Color(hex: "#B0B5BE") : Color(hex: "#8E939C"))) + .frame(width: 30, height: 22) + .background( + isOn ? Color(hex: "#1D1F23") : + isHovered ? Color.white.opacity(0.07) : Color.clear + ) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + .onHover { isHovered = $0 } + } +} + +// MARK: - Claude Plan header pill (GitHub build only) + +#if !APPSTORE +struct ClaudePlanHeaderPill: View { + @ObservedObject var state: AppState + @State private var isHovered = false + + private var effectiveColor: String { + ClaudePlanGauge.color(for: state.claudePlanUsage.flatMap { ClaudePlanGauge.dominantPct($0) }) + } + + private var label: String { + guard let usage = state.claudePlanUsage, + let pct = ClaudePlanGauge.dominantPct(usage) else { return "Claude —" } + return "Claude \(Int(pct.rounded()))%" + } + + private var isActive: Bool { state.showingPlanDetail || isHovered } + + var body: some View { + Button(action: { + withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) { + state.showingPlanDetail.toggle() + } + }) { + HStack(spacing: 4) { + Circle() + .fill(Color(hex: effectiveColor)) + .frame(width: 6, height: 6) + Text(label) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(isActive + ? Color(hex: effectiveColor).lighter(by: 0.3) + : Color(hex: "#6B7079")) + .lineLimit(1) + .fixedSize() + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + Capsule() + .fill(isActive + ? Color(hex: effectiveColor).opacity(0.18) + : Color(hex: "#0E0F11")) + ) + .overlay( + Capsule() + .stroke(Color(hex: effectiveColor).opacity(isActive ? 0.55 : 0.14), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .onHover { h in + withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { isHovered = h } + } + } +} +#endif + +// MARK: - Compact mini mochi grid (2×2 to the right of the notch) + +struct CompactMiniGrid: View { + @ObservedObject var state: AppState + + private var others: [AgentTask] { + Array(state.tasks.filter { $0.id != state.focusId }.prefix(4)) + } + + var body: some View { + let cols = [GridItem(.fixed(12), spacing: 4), GridItem(.fixed(12), spacing: 4)] + LazyVGrid(columns: cols, spacing: 4) { + ForEach(others) { task in + MiniBotCanvasView(task: task) + .frame(width: 12 / 0.6, height: 12 / 0.6) + .frame(width: 12, height: 12, alignment: .center) + } + } + .frame(width: 28, height: 28) + } +} diff --git a/NotchBuddy/Sources/App/IslandStateMachine.swift b/NotchBuddy/Sources/App/IslandStateMachine.swift new file mode 100644 index 0000000..a8fe331 --- /dev/null +++ b/NotchBuddy/Sources/App/IslandStateMachine.swift @@ -0,0 +1,184 @@ +import Foundation + +/// Pure 4-state FSM for island open/close logic. +/// No AppKit / AppState dependencies — communicates via `onTransition`. +@MainActor +final class IslandStateMachine { + + enum State: Equatable { + case hidden // island invisible (notch size) + case petit // compact island (notch + ears) + case home // expanded, overview + case coucou // expanded, greeting animation + } + + private(set) var state: State = .hidden + + /// Fired on every transition: (from, to) + var onTransition: ((State, State) -> Void)? + + /// When non-nil and returns true, timers and mouse-leave never auto-collapse or hide the island. + var isHeldOpen: (() -> Bool)? + + /// home → petit delay (seconds). Override for debug. + var homeToPetitDelay: TimeInterval = 15 + /// petit → hidden delay (seconds). Override for debug. + var petitToHiddenDelay: TimeInterval = 60 + /// coucou → petit delay after greeting animation ends (no hover). ~0.6s syncs with canvas collapse. + var greetAutoCollapseDelay: TimeInterval = 0.6 + /// coucou → petit delay when mouse is hovering over the greeting. + var greetHoverCollapseDelay: TimeInterval = 10 + + private var petitHideWork: DispatchWorkItem? + private var homeCollapseWork: DispatchWorkItem? + private var greetCollapseWork: DispatchWorkItem? + + // MARK: – Inputs + + /// App launched or debug "launch greeting" + func launch() { + cancelTimers() + transition(to: .coucou) + } + + /// Mouse entered the island notch area + func mouseEntered() { + switch state { + case .hidden: + if isHeldOpen?() == true { + // Island already expanded by an external call — sync FSM state without transition + state = .home + } else { + cancelTimers() + transition(to: .petit) + } + case .petit: + petitHideWork?.cancel() + petitHideWork = nil + case .home: + homeCollapseWork?.cancel() + homeCollapseWork = nil + case .coucou: + // Mouse hovering during greeting — cancel short auto-collapse, extend to hover delay + scheduleGreetCollapse(delay: greetHoverCollapseDelay) + } + } + + /// Mouse left the island notch area + func mouseLeft() { + switch state { + case .hidden: + break + case .petit: + schedulePetitHide() + case .home: + if isHeldOpen?() != true { scheduleHomeCollapse() } + case .coucou: + if isHeldOpen?() != true { + // Interrupt greeting immediately → compact (overrides 10s auto-collapse) + greetCollapseWork?.cancel(); greetCollapseWork = nil + transition(to: .petit) + } + } + } + + /// Compact island clicked. + /// Also accepts `.hidden`: after an alert the island can be on screen while the + /// FSM never saw the mouse enter (it was already there), and the click must still open it. + func click() { + guard state == .petit || state == .hidden else { return } + cancelTimers() + transition(to: .home) + } + + /// The app hid the island on its own (e.g. `AppState.syncMode()` when the last + /// task ends). Mirror it without side effects, so the next hover peeks again + /// instead of being swallowed by a FSM that still thinks the island is `.petit`. + func hiddenExternally() { + guard state == .petit else { return } + cancelTimers() + state = .hidden + } + + /// The app expanded the island externally (hookExpand for an alert). + /// Cancel timers and sync state to `.home` without firing `onTransition`, so the + /// next hover/mouseLeft behave correctly instead of collapsing the island. + func openedExternally() { + cancelTimers() + guard state != .home && state != .coucou else { return } + state = .home + } + + /// The app folded the island itself (Escape, Settings, OK button, auto-close). + /// Move to `.petit` right away so hover and click keep working; waiting for the + /// 15 s home timer left the island compact on screen while the FSM still said `.home`. + func collapse() { + guard state == .home || state == .coucou else { return } + cancelTimers() + transition(to: .petit) + } + + /// Greeting animation finished (called at T.end ≈ 4.60 s). + /// Schedules auto-collapse. Does not override a longer hover timer already running. + func greetComplete() { + guard state == .coucou else { return } + // If mouse entered before this fires (hover timer already running), don't override it + if greetCollapseWork == nil { + scheduleGreetCollapse(delay: greetAutoCollapseDelay) + } + } + + private func scheduleGreetCollapse(delay: TimeInterval) { + greetCollapseWork?.cancel() + let item = DispatchWorkItem { [weak self] in + guard let self, self.state == .coucou else { return } + self.transition(to: .petit) + } + greetCollapseWork = item + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: item) + } + + /// Non-alert work event: show compact from hidden (HookServer reveal) + func reveal() { + guard state == .hidden else { return } + cancelTimers() + transition(to: .petit) + schedulePetitHide() + } + + // MARK: – Timers + + private func schedulePetitHide() { + petitHideWork?.cancel() + let item = DispatchWorkItem { [weak self] in + guard let self, self.state == .petit, !(self.isHeldOpen?() ?? false) else { return } + self.transition(to: .hidden) + } + petitHideWork = item + DispatchQueue.main.asyncAfter(deadline: .now() + petitToHiddenDelay, execute: item) + } + + private func scheduleHomeCollapse() { + homeCollapseWork?.cancel() + let item = DispatchWorkItem { [weak self] in + guard let self, self.state == .home, !(self.isHeldOpen?() ?? false) else { return } + self.transition(to: .petit) + } + homeCollapseWork = item + DispatchQueue.main.asyncAfter(deadline: .now() + homeToPetitDelay, execute: item) + } + + func cancelTimers() { + petitHideWork?.cancel(); petitHideWork = nil + homeCollapseWork?.cancel(); homeCollapseWork = nil + greetCollapseWork?.cancel(); greetCollapseWork = nil + } + + private func transition(to new: State) { + guard new != state else { return } + let old = state + state = new + onTransition?(old, new) + } + +} diff --git a/NotchBuddy/Sources/App/IslandViewContent.swift b/NotchBuddy/Sources/App/IslandViewContent.swift new file mode 100644 index 0000000..3947f0d --- /dev/null +++ b/NotchBuddy/Sources/App/IslandViewContent.swift @@ -0,0 +1,4718 @@ +import SwiftUI + +// MARK: - Dispatch view content by IslandView + +struct IslandViewContent: View { + let view: IslandView + @ObservedObject var state: AppState + + var body: some View { + switch view { + case .overview: OverviewView(state: state) + case .empty: EmptyStateView(state: state) + case .approval: ApprovalView(state: state) + case .question: QuestionView(state: state) + case .error: ErrorView(state: state) + case .finished: FinishedView(state: state) + case .confused: ConfusedView() + case .upload: UploadView(state: state) + case .uploading: UploadingView(state: state) + case .choose: ChooseView(state: state) + case .mail: MailView(state: state) + case .prompt: PromptView(state: state) + case .searching: SearchingView(state: state) + case .result: ResultView(state: state) + case .note: NoteView(state: state) + case .settings: SettingsIslandView(state: state) + case .greeting: EmptyView() // GreetingCanvasView overlaid in IslandRootView + case .wardrobe: WardrobeView(state: state) + } + } +} + +// MARK: - Overview + +struct OverviewView: View { + @ObservedObject var state: AppState + @State private var showingN8nDetail = false + @State private var activeDiffId: Int? = nil + + var agent: AgentTask? { state.focusTask } + + var body: some View { + HStack(spacing: 10) { + // Left card: title row + ticker below + ↗ button overlay + ZStack(alignment: .topLeading) { + CardBackground(wash: nil) + + // Title row + ticker stacked (or integration card) + if let agent = agent { + if agent.isIntegration { + IntegrationCardView(task: agent, showingDetail: $showingN8nDetail, onDiffTap: { diffIdx in + withAnimation(.easeIn(duration: 0.16)) { activeDiffId = diffIdx } + }) + } else { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 6) { + Circle() + .fill(Color(hex: agent.color)) + .frame(width: 7, height: 7) + Text(agent.name) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Color(hex: "#F5F6F8")) + .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(1) + Text({ () -> String in + switch agent.source { + case .claudeCode: return "Claude Code" + case .agent: return "Agent" + case .n8n: return "n8n" + } + }()) + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#8E939C")) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 2) + if agent.steps.count > 1 { + Text("\(min(agent.stepIndex + 1, agent.steps.count))/\(agent.steps.count)") + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#6B7079")) + .fixedSize() + } + } + .padding(.top, 6) + .padding(.leading, 108) + .padding(.trailing, 36) + + TickerView(task: agent, onDiffTap: { diffIdx in + withAnimation(.easeIn(duration: 0.16)) { activeDiffId = diffIdx } + }) + .frame(height: 44) + .padding(.top, 6) + .padding(.leading, 108) + .padding(.trailing, 12) + } + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.top, 4) + } + } + + // Plan detail overlays on top of normal content (GitHub build, home view only) + #if !APPSTORE + if state.showingPlanDetail { + CardBackground(wash: nil) + ClaudePlanCardView(usage: state.claudePlanUsage) + .transition(.opacity) + } + #endif + + // Diff overlay — replaces ticker when a diff step is tapped + if let diffId = activeDiffId, + let task = agent, + let diff = state.sessionDiffs[task.id]?.first(where: { $0.id == diffId }) { + CardBackground(wash: nil) + DiffCardView(diff: diff, onDismiss: { activeDiffId = nil }) + .transition(.opacity) + } + + // ↗ jump button — last in ZStack so it renders on top; hidden while any detail is open + #if !APPSTORE + let hideJumpButton = showingN8nDetail || state.showingPlanDetail || activeDiffId != nil + #else + let hideJumpButton = showingN8nDetail || activeDiffId != nil + #endif + if !hideJumpButton { + Button(action: { openAgentTarget(agent) }) { + Image(systemName: "arrow.up.right") + .font(.system(size: 8, weight: .medium)) + .foregroundColor(Color(hex: "#5F646D")) + .frame(width: 16, height: 16) + .background(Color.white.opacity(0.07)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .padding(.top, 8) + .padding(.trailing, 10) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + .frame(width: 322) + + // Right card: agent pills + CardBackground(wash: nil) { + AgentPillsView(state: state) + } + } + .onChange(of: state.focusId) { _, new in + showingN8nDetail = false + activeDiffId = nil + #if !APPSTORE + withAnimation(.easeIn(duration: 0.16)) { state.showingPlanDetail = false } + #endif + if new == "integration_github" { GithubPoller.shared.refreshIfStale() } + } + #if !APPSTORE + .onChange(of: state.view) { _, v in + if v != .overview { state.showingPlanDetail = false; activeDiffId = nil } + } + #endif + .onChange(of: state.mode) { _, m in + #if !APPSTORE + if m != .expanded { state.showingPlanDetail = false; activeDiffId = nil } + #endif + if m == .expanded && state.focusId == "integration_github" { + GithubPoller.shared.refreshIfStale() + } + } + .onReceive(NotificationCenter.default.publisher(for: .islandToggleDiff)) { _ in + if let id = activeDiffId { + withAnimation(.easeIn(duration: 0.16)) { activeDiffId = nil } + _ = id + } else if let task = state.focusTask, + let last = state.sessionDiffs[task.id]?.last { + withAnimation(.easeIn(duration: 0.16)) { activeDiffId = last.id } + } + } + } + + private func openAgentTarget(_ task: AgentTask?) { + guard let task else { return } + switch task.id { + case "integration_claude": + let vscodeBundleId = "com.microsoft.VSCode" + if let app = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == vscodeBundleId }) { + app.activate(options: .activateIgnoringOtherApps) + } else { + NSWorkspace.shared.open(URL(fileURLWithPath: "/Applications/Visual Studio Code.app")) + } + case "integration_resend": + NSWorkspace.shared.open(URL(string: "https://resend.com/emails")!) + case "integration_vercel": + NSWorkspace.shared.open(URL(string: "https://vercel.com/dashboard")!) + case "integration_github": + NSWorkspace.shared.open(URL(string: "https://github.com/pulls")!) + case "integration_n8n": + if let urlStr = KeychainStore.shared.get("n8n-url"), let url = URL(string: urlStr) { + NSWorkspace.shared.open(url) + } + case "integration_stripe": + NSWorkspace.shared.open(URL(string: "https://dashboard.stripe.com/payments")!) + case "integration_notion": + NSWorkspace.shared.open(URL(string: "https://notion.so")!) + case "integration_calcom": + NSWorkspace.shared.open(URL(string: "https://app.cal.com/bookings")!) + case "agent_cursor": + #if !APPSTORE + if let url = NSWorkspace.shared.urlForApplication( + withBundleIdentifier: "com.todesktop.230313mzl4w4u92") { + NSWorkspace.shared.openApplication(at: url, configuration: .init(), completionHandler: nil) + } + #endif + case "agent_codex": + #if !APPSTORE + if let url = NSWorkspace.shared.urlForApplication( + withBundleIdentifier: "com.openai.codex") { + NSWorkspace.shared.openApplication(at: url, configuration: .init(), completionHandler: nil) + } + #endif + case "agent_gemini", "agent_antigravity": + #if !APPSTORE + let terminalBundleIds = ["com.apple.Terminal", "com.googlecode.iterm2", + "net.kovidgoyal.kitty", "com.mitchellh.ghostty"] + if let hit = terminalBundleIds.compactMap({ id in + NSWorkspace.shared.runningApplications.first { $0.bundleIdentifier == id } + }).first { + hit.activate(options: .activateIgnoringOtherApps) + } + #endif + case "ai_anthropic": + switchChatProvider(.anthropic) + case "ai_google": + switchChatProvider(.google) + case "ai_openai": + switchChatProvider(.openai) + case "ai_ollama": + switchChatProvider(.ollama) + case "ai_lmstudio": + switchChatProvider(.lmstudio) + case "integration_music": + #if !APPSTORE + MusicController.shared.openMusic() + #endif + default: + // Non-integration real tasks + if task.source == .n8n { + if let urlStr = KeychainStore.shared.get("n8n-url"), let url = URL(string: urlStr) { + NSWorkspace.shared.open(url) + } + } else { + #if !APPSTORE + let terminalBundleIds = ["com.apple.Terminal", "com.googlecode.iterm2", + "net.kovidgoyal.kitty", "com.mitchellh.ghostty"] + if let hit = terminalBundleIds.compactMap({ id in + NSWorkspace.shared.runningApplications.first { $0.bundleIdentifier == id } + }).first { + hit.activate(options: .activateIgnoringOtherApps) + } + #endif + } + } + } +} + +// MARK: - Empty + +struct EmptyStateView: View { + @ObservedObject var state: AppState + + var body: some View { + ZStack { + CardBackground(wash: nil) + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 5) { + Text("Nothing running right now.") + .font(.system(size: 15, weight: .semibold)) + Text("Drop a file or window, or ask me anything.") + .font(.system(size: 13)) + .foregroundColor(Color(hex: "#9398A1")) + } + Spacer() + PrimaryButton("Ask Claude") { + state.view = .prompt + } + } + .padding(.leading, 118) + .padding(.trailing, 18) + } + } +} + +// MARK: - Approval + +struct ApprovalView: View { + @ObservedObject var state: AppState + + var approval: ApprovalInfo? { state.pendingApproval } + + var body: some View { + ZStack { + CardBackground(wash: .amber) + VStack(alignment: .leading, spacing: 5) { + AgentWho(task: state.focusTask, label: "needs permission") + CodeBlock(text: approval?.command ?? approval?.tool ?? "…") + HStack(spacing: 8) { + SecondaryButton("Deny") { + HookServer.shared.sendApprovalDecision("deny") + } + PrimaryButton("Allow") { + HookServer.shared.sendApprovalDecision("allow") + } + // Codex rejects updatedPermissions, so "Always" is not offered + if approval?.pillId != "agent_codex" { + SecondaryButton("Always") { + HookServer.shared.sendApprovalDecision("always") + } + } + } + } + .padding(.leading, 116) + .padding(.trailing, 16) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +// MARK: - Question + +struct QuestionView: View { + @ObservedObject var state: AppState + @State private var questionIndex = 0 + // Per-question selected labels (empty = none chosen yet) + @State private var selections: [[String]] = [] + // Per-question custom "Other…" text + @State private var otherTexts: [String] = [] + // Per-question "Other…" mode active + @State private var showOther: [Bool] = [] + @FocusState private var otherFieldFocused: Bool + + var question: AskQuestion? { state.pendingQuestion } + + var body: some View { + ZStack { + CardBackground(wash: .cyan) + if let q = question, !q.questions.isEmpty { + let qi = min(questionIndex, q.questions.count - 1) + let item = q.questions[qi] + let isLast = qi == q.questions.count - 1 + let isMulti = item.multiSelect + let curSel = qi < selections.count ? selections[qi] : [] + let curOther = qi < showOther.count ? showOther[qi] : false + let curOtherText = qi < otherTexts.count ? otherTexts[qi] : "" + let canProceed = !curSel.isEmpty || (curOther && !curOtherText.isEmpty) + + VStack(alignment: .leading, spacing: 4) { + // Header row: agent name + question counter + "Reply in terminal" link + HStack(spacing: 4) { + AgentWho(task: nil, label: "Claude Code is asking") + Spacer(minLength: 4) + if q.questions.count > 1 { + Text("\(qi + 1)/\(q.questions.count)") + .font(.system(size: 10)) + .foregroundColor(Color(hex: "#6B7079")) + } + Button("Reply in terminal") { HookServer.shared.sendQuestionAsk() } + .buttonStyle(.plain) + .font(.system(size: 10)) + .foregroundColor(Color(hex: "#6B7079")) + .underline() + } + // Optional short header label above question text + if !item.header.isEmpty { + Text(item.header) + .font(.system(size: 10)) + .foregroundColor(Color(hex: "#6B7079")) + } + Text(item.question) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(Color(hex: "#F5F6F8")) + .lineLimit(2) + // Options (wrapping) or "Other…" compact inline row + if curOther { + HStack(spacing: 6) { + TextField("Your answer…", text: Binding( + get: { qi < otherTexts.count ? otherTexts[qi] : "" }, + set: { v in if qi < otherTexts.count { otherTexts[qi] = v } } + )) + .textFieldStyle(.plain) + .font(.system(size: 12)) + .foregroundColor(Color(hex: "#F5F6F8")) + .focused($otherFieldFocused) + .onAppear { otherFieldFocused = true } + .onSubmit { commitOtherAndProceed(q: q, qi: qi, isLast: isLast) } + .onExitCommand { if qi < showOther.count { showOther[qi] = false } } + .padding(.horizontal, 8).padding(.vertical, 5) + .background(Color.white.opacity(0.07)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + Button(isLast ? "Send" : "Next") { + commitOtherAndProceed(q: q, qi: qi, isLast: isLast) + } + .buttonStyle(.plain) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(curOtherText.isEmpty ? Color(hex: "#6B7079") : Color(hex: "#F5F6F8")) + .padding(.horizontal, 8).padding(.vertical, 5) + .background(Color.white.opacity(curOtherText.isEmpty ? 0.05 : 0.15)) + .clipShape(Capsule()) + .disabled(curOtherText.isEmpty) + Button { if qi < showOther.count { showOther[qi] = false } } label: { + Text("✕").font(.system(size: 9)) + } + .buttonStyle(.plain) + .foregroundColor(Color(hex: "#6B7079")) + } + } else { + ChipFlowLayout(spacing: 6) { + ForEach(Array(item.options.enumerated()), id: \.offset) { idx, opt in + let isSelected = curSel.contains(opt.label) + if isMulti { + Button { + toggleSelection(qi: qi, label: opt.label) + } label: { + Text(opt.label) + .font(.system(size: 12, weight: .medium)) + .padding(.horizontal, 8).padding(.vertical, 4) + .background(isSelected ? Color(hex: "#22D3EE").opacity(0.22) : Color.white.opacity(0.07)) + .foregroundColor(isSelected ? Color(hex: "#67E8F9") : Color(hex: "#C5C8CD")) + .clipShape(RoundedRectangle(cornerRadius: 7)) + .overlay(RoundedRectangle(cornerRadius: 7).stroke(isSelected ? Color(hex: "#22D3EE").opacity(0.55) : Color.white.opacity(0.1), lineWidth: 1)) + } + .buttonStyle(.plain) + .keyboardShortcut(KeyEquivalent(Character(String(idx + 1))), modifiers: []) + } else { + SecondaryButton(opt.label) { + selectAndProceed(q: q, qi: qi, label: opt.label, isLast: isLast) + } + .keyboardShortcut(KeyEquivalent(Character(String(idx + 1))), modifiers: []) + } + } + // "Other…" implicit free-text option + SecondaryButton("Other…") { + if qi < showOther.count { showOther[qi] = true } + } + } + } + // Send/Next — only for multi-select (and not while "Other…" field is open) + if isMulti && !curOther { + PrimaryButton(isLast ? "Send" : "Next") { + proceedFromQuestion(q: q, qi: qi, isLast: isLast) + } + .disabled(!canProceed) + .opacity(canProceed ? 1 : 0.4) + } + } + .padding(.leading, 116) + .padding(.trailing, 16) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .onAppear { resetQuestionState() } + .onChange(of: state.pendingQuestion) { _, _ in resetQuestionState() } + .onDisappear { HookServer.shared.releaseQuestionFD() } + } + + private func resetQuestionState() { + questionIndex = 0 + let count = state.pendingQuestion?.questions.count ?? 0 + selections = Array(repeating: [], count: count) + otherTexts = Array(repeating: "", count: count) + showOther = Array(repeating: false, count: count) + } + + private func toggleSelection(qi: Int, label: String) { + guard qi < selections.count else { return } + if let i = selections[qi].firstIndex(of: label) { + selections[qi].remove(at: i) + } else { + selections[qi].append(label) + } + } + + // Single-select: pick a label and immediately advance/send + private func selectAndProceed(q: AskQuestion, qi: Int, label: String, isLast: Bool) { + guard qi < selections.count else { return } + selections[qi] = [label] + if isLast { sendAnswers(q: q) } else { withAnimation { questionIndex = qi + 1 } } + } + + // Multi-select Send/Next button + private func proceedFromQuestion(q: AskQuestion, qi: Int, isLast: Bool) { + if isLast { sendAnswers(q: q) } else { withAnimation { questionIndex = qi + 1 } } + } + + // "Other…" confirm + private func commitOtherAndProceed(q: AskQuestion, qi: Int, isLast: Bool) { + let text = qi < otherTexts.count ? otherTexts[qi] : "" + guard !text.isEmpty else { return } + if qi < selections.count { selections[qi] = [text] } + if isLast { + sendAnswers(q: q) + } else { + if qi < showOther.count { showOther[qi] = false } + withAnimation { questionIndex = qi + 1 } + } + } + + private func sendAnswers(q: AskQuestion) { + let answers = AskQuestion.buildAnswers(questions: q.questions, selections: selections) + HookServer.shared.sendQuestionAnswers(answers) + } +} + +// MARK: - Error + +struct ErrorView: View { + @ObservedObject var state: AppState + + var body: some View { + ZStack { + CardBackground(wash: .red) + VStack(alignment: .leading, spacing: 5) { + AgentWho(task: state.focusTask, label: "n8n") + Text("Workflow stopped.") + .font(.system(size: 15, weight: .semibold)) + Text("Gmail node timed out after 30s. Retry or open n8n.") + .font(.system(size: 12)) + .foregroundColor(Color(hex: "#FF8D97")) + HStack(spacing: 8) { + PrimaryButton("Retry") { /* retry */ } + SecondaryButton("Open in n8n") { /* open */ } + } + } + .padding(.leading, 116) + .padding(.trailing, 16) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +// MARK: - Finished + +struct FinishedView: View { + @ObservedObject var state: AppState + + var body: some View { + ZStack { + CardBackground(wash: .green) + VStack(alignment: .leading, spacing: 5) { + AgentWho(task: state.focusTask, label: "Claude Code finished") + Text({ + if let fl = state.focusTask?.finalLine { return fl } + if let s = state.focusTask?.steps.last(where: { !$0.isDiffStep }) { return s } + return "Session finished" + }()) + .font(.system(size: 15, weight: .semibold)) + .lineLimit(1) + .truncationMode(.tail) + HStack(spacing: 8) { + #if !APPSTORE + PrimaryButton("Open terminal") { + let terminalBundleIds = ["com.apple.Terminal", "com.googlecode.iterm2", "net.kovidgoyal.kitty", "com.mitchellh.ghostty"] + let activated = terminalBundleIds.compactMap { id in + NSWorkspace.shared.runningApplications.first { $0.bundleIdentifier == id } + }.first.map { $0.activate(options: .activateIgnoringOtherApps) } + if activated == nil { + NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/Utilities/Terminal.app")) + } + NotificationCenter.default.post(name: .islandCollapse, object: nil) + } + #endif + SecondaryButton("OK") { + NotificationCenter.default.post(name: .islandCollapse, object: nil) + } + } + } + .padding(.leading, 116) + .padding(.trailing, 16) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +// MARK: - Diff Card + +struct DiffCardView: View { + let diff: FileDiff + let onDismiss: () -> Void + + private var allLines: [DiffLine] { diff.hunks.flatMap { $0.lines } } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header + HStack(spacing: 6) { + Button(action: onDismiss) { + HStack(spacing: 3) { + Image(systemName: "chevron.left") + .font(.system(size: 8, weight: .medium)) + Text(diff.name) + .font(.system(size: 12, weight: .semibold)) + .lineLimit(1) + } + .foregroundColor(Color(hex: "#F5F6F8")) + } + .buttonStyle(.plain) + Spacer(minLength: 2) + if diff.added > 0 { + Text("+\(diff.added)") + .font(.system(size: 10, weight: .medium).monospaced()) + .foregroundColor(Color(hex: "#22C55E")) + } + if diff.removed > 0 { + Text("−\(diff.removed)") + .font(.system(size: 10, weight: .medium).monospaced()) + .foregroundColor(Color(hex: "#F4505E")) + } + Button(action: { openInEditor(diff) }) { + Image(systemName: "arrow.up.right") + .font(.system(size: 8, weight: .medium)) + .foregroundColor(Color(hex: "#5F646D")) + .frame(width: 14, height: 14) + .background(Color.white.opacity(0.07)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + .padding(.top, 6) + .padding(.bottom, 3) + + // Content + if diff.tooLarge { + Text("Diff too large") + .font(.system(size: 10.5)) + .foregroundColor(Color(hex: "#6B7079")) + } else if allLines.isEmpty { + Text("No changes") + .font(.system(size: 10.5)) + .foregroundColor(Color(hex: "#6B7079")) + } else { + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(allLines.enumerated()), id: \.offset) { _, line in + DiffLineRowView(line: line) + } + } + } + } + } + .padding(.leading, 108) + .padding(.trailing, 10) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .onExitCommand { onDismiss() } + } + + private func openInEditor(_ diff: FileDiff) { + let path = diff.path + #if !APPSTORE + let codePaths = ["/opt/homebrew/bin/code", "/usr/local/bin/code", "/usr/bin/code", + "\(NSHomeDirectory())/.nvm/current/bin/code"] + if let codePath = codePaths.first(where: { FileManager.default.fileExists(atPath: $0) }) { + let p = Process() + p.executableURL = URL(fileURLWithPath: codePath) + // Write: open at line 1; Edit/MultiEdit: open file without line number + p.arguments = diff.isNewFile ? ["-g", "\(path):1"] : [path] + try? p.run() + return + } + #endif + NSWorkspace.shared.open(URL(fileURLWithPath: path)) + } +} + +struct DiffLineRowView: View { + let line: DiffLine + + private var bgColor: Color { + switch line.kind { + case .added: return Color(hex: "#22C55E").opacity(0.12) + case .removed: return Color(hex: "#F4505E").opacity(0.12) + case .context: return Color.clear + } + } + private var fgColor: Color { + switch line.kind { + case .added: return Color(hex: "#86EFAC") + case .removed: return Color(hex: "#FCA5A5") + case .context: return Color(hex: "#6B7079") + } + } + private var symbol: String { + switch line.kind { + case .added: return "+" + case .removed: return "−" + case .context: return " " + } + } + + var body: some View { + HStack(spacing: 0) { + Text(symbol) + .font(.system(size: 10.5).monospaced()) + .foregroundColor(line.kind == .added ? Color(hex: "#22C55E") : + line.kind == .removed ? Color(hex: "#F4505E") : + Color(hex: "#454850")) + .frame(width: 12, alignment: .leading) + Text(line.text) + .font(.system(size: 10.5).monospaced()) + .foregroundColor(fgColor) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(bgColor) + .frame(maxWidth: .infinity) + } +} + +// MARK: - Confused + +struct ConfusedView: View { + var body: some View { + ZStack { + CardBackground(wash: .pink) + VStack(alignment: .leading, spacing: 5) { + Text("Too many hits at once.").font(.system(size: 15, weight: .semibold)) + Text("Give me a sec — back to work in three seconds.") + .font(.system(size: 13)).foregroundColor(Color(hex: "#9398A1")) + } + .padding(.leading, 128) + .padding(.trailing, 18) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +// MARK: - Upload (drop zone) + +struct UploadView: View { + @ObservedObject var state: AppState + @State private var dashPhase: CGFloat = 0 + @State private var breathAngle: Double = 0 + // Timer only runs while this is the active tab — killed on deactivation + @State private var animTimer: Timer? = nil + + private var borderOpacity: Double { + let breathe = 0.11 + 0.04 * (sin(breathAngle) * 0.5 + 0.5) + return state.fileDragOver ? 0.65 : breathe + } + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 20) + .fill(Color(hex: "#0E0F11")) + RoundedRectangle(cornerRadius: 20) + .stroke( + state.fileDragOver + ? Color(hex: "#22C55E").opacity(borderOpacity) + : Color.white.opacity(borderOpacity), + style: StrokeStyle(lineWidth: 1.5, dash: [6, 5], dashPhase: dashPhase) + ) + RoundedRectangle(cornerRadius: 20) + .fill(RadialGradient( + colors: [Color(hex: "#22C55E").opacity(state.fileDragOver ? 0.13 : 0), Color.clear], + center: .bottom, startRadius: 0, endRadius: 200 + )) + VStack(alignment: .leading, spacing: 8) { + Text("Drop your files here") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(state.fileDragOver ? Color(hex: "#34D399") : Color(hex: "#D5D7DB")) + HStack(spacing: 6) { + ForEach(["PDF", "Images", "Code", "Docs"], id: \.self) { label in + Text(label) + .font(.system(size: 11)) + .padding(.horizontal, 8).padding(.vertical, 3) + .background(Color.white.opacity(0.07)) + .foregroundColor(Color(hex: "#B9BDC4")) + .clipShape(Capsule()) + } + } + } + .padding(.leading, 196) + .frame(maxWidth: .infinity, alignment: .leading) + } + .onChange(of: state.view) { _, newView in + newView == .upload ? startTimer() : stopTimer() + } + .onAppear { + if state.view == .upload { startTimer() } + } + .onDisappear { stopTimer() } + } + + private func startTimer() { + guard animTimer == nil else { return } + // 20 fps — smooth enough for slow dash, 3× lighter than 60fps + animTimer = Timer.scheduledTimer(withTimeInterval: 1.0/20.0, repeats: true) { _ in + dashPhase += 1.0 // 20 pt/s march + breathAngle += 0.9 / 20.0 // advance sin phase at 0.9 rad/s + } + } + + private func stopTimer() { + animTimer?.invalidate() + animTimer = nil + } +} + +// MARK: - Uploading + +struct UploadingView: View { + @ObservedObject var state: AppState + + // Bar geometry in content coords (content has 10pt H padding each side). + // Island bar: left=36, right=562 (640-78), width=526. + // Content bar: left=26, width=526. + // barTop=58 → island y = content_start(42)+58 = 100; bot cy=103 (center = barTop+3). + private let barLeft: CGFloat = 26 + private let barWidth: CGFloat = 526 + private let barTop: CGFloat = 58 + + var body: some View { + // TimelineView fires at display refresh rate — progress derived from elapsed wall time, + // not from @Published uploadProgress (which only flips to 1.0 at completion). + TimelineView(.animation) { tl in + let elapsed: Double = { + guard let start = state.uploadStartTime else { return 0 } + return tl.date.timeIntervalSince(start) + }() + let t = min(1.0, max(0, elapsed / state.uploadDuration)) + let progress = CGFloat(t * (2 - t)) // ease-out quad + let fillWidth = max(0, barWidth * progress) + let isDone = state.uploadProgress >= 0.999 // only true after handle() sets it + + ZStack(alignment: .topLeading) { + // Background: dark base + RoundedRectangle(cornerRadius: 20) + .fill(Color(hex: "#141518")) + + // Permanent green radial wash — brighter at completion + RoundedRectangle(cornerRadius: 20) + .fill(RadialGradient( + colors: [Color(hex: "#34D399").opacity(isDone ? 0.28 : 0.14), Color.clear], + center: UnitPoint(x: 0.5, y: 1.4), + startRadius: 0, + endRadius: 260 + )) + + // Bar track + RoundedRectangle(cornerRadius: 3) + .fill(Color.white.opacity(0.09)) + .frame(width: barWidth, height: 6) + .offset(x: barLeft, y: barTop) + + // Bar fill + RoundedRectangle(cornerRadius: 3) + .fill(LinearGradient( + colors: [Color(hex: "#1FA87A"), Color(hex: "#34D399")], + startPoint: .leading, endPoint: .trailing + )) + .frame(width: fillWidth, height: 6) + .offset(x: barLeft, y: barTop) + + // Glow trail behind dot leading edge + if progress > 0.01 { + Ellipse() + .fill(Color(hex: "#6EE7B7").opacity(0.45)) + .frame(width: 28, height: 12) + .blur(radius: 5) + .offset(x: barLeft + fillWidth - 14, y: barTop - 3) + } + + // Text row — filename + % (above bar) + HStack(spacing: 0) { + if isDone { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 12)) + .foregroundColor(Color(hex: "#34D399")) + Text(" \(state.droppedFile?.name ?? "File")") + .font(.system(size: 12.5, weight: .semibold)) + .foregroundColor(Color(hex: "#34D399")) + .lineLimit(1).truncationMode(.middle) + } else { + Text("Uploading \(state.droppedFile?.name ?? "file")") + .font(.system(size: 12.5)) + .foregroundColor(Color(hex: "#A9ADB5")) + .lineLimit(1).truncationMode(.middle) + Spacer(minLength: 8) + Text("\(Int(progress * 100)) %") + .font(.system(size: 12.5, weight: .medium)) + .foregroundColor(Color(hex: "#A9ADB5")) + .monospacedDigit() + } + } + .frame(width: barWidth) + .offset(x: barLeft, y: barTop - 22) + + // Subtle top border (same as CardBackground) + RoundedRectangle(cornerRadius: 20) + .stroke(Color.white.opacity(0.035), lineWidth: 1) + } + } + } +} + +// MARK: - Choose + +struct ChooseView: View { + @ObservedObject var state: AppState + + var body: some View { + ZStack(alignment: .leading) { + CardBackground(wash: nil) + VStack(alignment: .leading, spacing: 8) { + let fileName = state.droppedFile?.name ?? "file" + (Text(fileName).font(.system(size: 14, weight: .semibold)) + Text(" is ready.").font(.system(size: 14, weight: .semibold))) + Text("What do you want to do with it?").font(.system(size: 12.5)).foregroundColor(Color(hex: "#9398A1")) + HStack(spacing: 8) { + PrimaryButton("Ask a question") { state.view = .prompt } + SecondaryButton("Send by email") { state.view = .mail } + } + } + .padding(.leading, 98) + .padding(.trailing, 18) + } + } +} + +// MARK: - Mail + +struct MailView: View { + @ObservedObject var state: AppState + @State private var to: String = "" + @State private var subject: String = "" + @State private var bodyText: String = "" + @State private var statusMsg: String = "" + @State private var isSending = false + + var body: some View { + ZStack(alignment: .leading) { + CardBackground(wash: nil) + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Text("New email").font(.system(size: 12, weight: .semibold)) + if let name = state.droppedFile?.name { + Text("with").font(.system(size: 12)).foregroundColor(Color(hex: "#8E939C")) + Text(name).font(.system(size: 12)).foregroundColor(Color(hex: "#8E939C")) + .lineLimit(1).truncationMode(.middle) + } + } + + MailField(label: "To", placeholder: "address@example.com", text: $to) + MailField(label: "Subject", placeholder: state.droppedFile?.name ?? "Subject", text: $subject) + + // Body — TextEditor scrolls internally when text overflows + TextEditor(text: $bodyText) + .scrollContentBackground(.hidden) + .font(.system(size: 12.5)) + .foregroundColor(Color(hex: "#F5F6F8")) + .frame(height: 44) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.white.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + if !statusMsg.isEmpty { + Text(statusMsg).font(.system(size: 11)).foregroundColor(Color(hex: "#FF8D97")) + } + + HStack(spacing: 8) { + PrimaryButton(isSending ? "Sending…" : "Send") { + guard !isSending else { return } + sendMail() + } + SecondaryButton("Cancel") { state.view = .choose } + } + } + .padding(.leading, 92) + .padding(.trailing, 18) + .padding(.vertical, 8) + } + .onAppear { subject = state.droppedFile?.name ?? "" } + } + + private func sendMail() { + guard !to.isEmpty else { statusMsg = "Missing recipient."; return } + let subj = subject.isEmpty ? (state.droppedFile?.name ?? "File") : subject + + // Prefer Resend if API key + sender address are configured + let apiKey = KeychainStore.shared.get("resend-api-key") + let fromAddr = KeychainStore.shared.get("resend-from") + + if let apiKey, let fromAddr { + isSending = true + statusMsg = "" + let recipient = to + let msgBody = bodyText + let fileURL = state.droppedFile?.url + Task { + let ok = await sendViaResend(apiKey: apiKey, from: fromAddr, + to: recipient, subject: subj, + body: msgBody, fileURL: fileURL) + await MainActor.run { + isSending = false + if ok { onSuccess(recipient: recipient) } + else { statusMsg = "Resend error — check API key & sender." } + } + } + } else if apiKey != nil && fromAddr == nil { + // API key set but no sender — guide user instead of silent fallback + statusMsg = "Set sender address in Settings." + } else { + // No Resend — fallback to Mail + sendViaAppleMail(to: to, subject: subj) + } + } + + private func sendViaResend(apiKey: String, from: String, to: String, + subject: String, body: String, fileURL: URL?) async -> Bool { + guard let url = URL(string: "https://api.resend.com/emails") else { return false } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + var payload: [String: Any] = [ + "from": from, + "to": [to], + "subject": subject, + "text": body.isEmpty ? " " : body + ] + if let fileURL, let data = try? Data(contentsOf: fileURL) { + payload["attachments"] = [[ + "filename": fileURL.lastPathComponent, + "content": data.base64EncodedString() + ]] + } + guard let httpBody = try? JSONSerialization.data(withJSONObject: payload) else { return false } + request.httpBody = httpBody + guard let (_, response) = try? await URLSession.shared.data(for: request) else { return false } + let code = (response as? HTTPURLResponse)?.statusCode ?? 0 + if code == 200 || code == 201 { return true } + print("[Resend] HTTP \(code)") + return false + } + + private func sendViaAppleMail(to: String, subject: String) { + #if APPSTORE + // App Store: no AppleScript — use NSSharingService to compose (user sends manually) + guard let service = NSSharingService(named: .composeEmail) else { + statusMsg = "Mail not available." + return + } + var items: [Any] = [bodyText.isEmpty ? " " : bodyText] + if let url = state.droppedFile?.url, + FileManager.default.fileExists(atPath: url.path) { + items.append(url) + } + service.recipients = [to] + service.subject = subject + service.perform(withItems: items) + onSuccess(recipient: to) + #else + func asEscape(_ s: String) -> String { + s.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + } + + let bodyLines = bodyText.isEmpty ? [""] : bodyText.components(separatedBy: "\n") + let bodyExpr = bodyLines.map { "\"\(asEscape($0))\"" }.joined(separator: " & linefeed & ") + + " & return & return" + + let attachBlock: String + if let url = state.droppedFile?.url, + FileManager.default.fileExists(atPath: url.path) { + let escapedPath = asEscape(url.path) + attachBlock = "make new attachment with properties {file name:(POSIX file \"\(escapedPath)\")} at after the last paragraph of content" + } else { + attachBlock = "" + } + + let script = """ + tell application "Mail" + set m to make new outgoing message with properties {subject:"\(asEscape(subject))", visible:false} + set content of m to \(bodyExpr) + tell m + make new to recipient at end of to recipients with properties {address:"\(asEscape(to))"} + \(attachBlock) + end tell + delay 1 + send m + end tell + """ + var err: NSDictionary? + NSAppleScript(source: script)?.executeAndReturnError(&err) + if err == nil { onSuccess(recipient: to) } + else { statusMsg = "Mail error: \(err?["NSAppleScriptErrorMessage"] as? String ?? "unknown")" } + #endif + } + + private func onSuccess(recipient: String) { + SoundEngine.shared.play("send") + NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.wink) + state.noteMessage = "Email sent to \(recipient)." + state.view = .note + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + NotificationCenter.default.post(name: .islandCollapse, object: nil) + } + } +} + +// MARK: - Prompt (chat) + +struct PromptView: View { + @ObservedObject var state: AppState + @State private var text: String = "" + @FocusState private var focused: Bool + @State private var showModelPicker = false + + var body: some View { + ZStack(alignment: .leading) { + CardBackground(wash: .indigo) + + VStack(alignment: .leading, spacing: 6) { + if let ctx = state.promptContext { + ContextChip(context: ctx).padding(.top, 4) + } + + if !state.chatHistory.isEmpty { + ScrollViewReader { proxy in + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 6) { + ForEach(state.chatHistory) { msg in + ChatBubble(message: msg).id(msg.id) + } + if state.stateOverride != nil { + HStack { TypingDotsView(); Spacer(minLength: 32) } + .id("typing") + } + } + .padding(.vertical, 2) + } + .onChange(of: state.chatHistory) { _, _ in + if let last = state.chatHistory.last(where: { !$0.content.isEmpty }) { + proxy.scrollTo(last.id, anchor: .bottom) + } + } + .onChange(of: state.stateOverride) { _, v in + if v != nil { + withAnimation { proxy.scrollTo("typing", anchor: .bottom) } + } else if let last = state.chatHistory.last(where: { !$0.content.isEmpty }) { + withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } + } + } + .onAppear { + if let last = state.chatHistory.last { + proxy.scrollTo(last.id, anchor: .bottom) + } + } + } + .frame(maxHeight: .infinity) + } else { + Spacer() + } + + HStack(spacing: 0) { + Spacer() + Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) { + showModelPicker.toggle() + } + } label: { + HStack(spacing: 5) { + Circle() + .fill(Color(hex: state.chatProvider.accentHex)) + .frame(width: 6, height: 6) + Text(state.activeChatModel) + .font(.system(size: 10.5, weight: .medium)) + .foregroundColor(Color(hex: "#7B8089")) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 8)) + .foregroundColor(Color(hex: "#5C6370")) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.white.opacity(0.06)) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + .popover(isPresented: $showModelPicker, arrowEdge: .bottom) { + ModelPickerView(state: state, isPresented: $showModelPicker) + .frame(width: 300) + } + } + .padding(.horizontal, 10) + + HStack(spacing: 8) { + TextField(state.chatHistory.isEmpty ? "Ask me anything…" : "Continue…", text: $text) + .textFieldStyle(.plain) + .font(.system(size: 13)) + .focused($focused) + .onSubmit { sendMessage() } + + Button(action: sendMessage) { + Image(systemName: "arrow.up") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(Color(hex: "#0B0C0E")) + } + .buttonStyle(SendButtonStyle()) + .disabled(text.isEmpty) + } + .padding(.horizontal, 10).padding(.vertical, 6) + .background(Color.white.opacity(0.07)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .simultaneousGesture(TapGesture().onEnded { focused = true }) + } + .padding(.leading, 84) + .padding(.trailing, 16) + .padding(.top, 12) + .padding(.bottom, 14) + } + .padding(.bottom, 10) + .onAppear { focused = true } + .onChange(of: state.view) { _, view in + if view == .prompt { + state.fetchModelsIfNeeded(for: state.chatProvider) + } + } + .onChange(of: state.chatProvider) { _, provider in + if state.view == .prompt { + state.fetchModelsIfNeeded(for: provider) + } + } + .onReceive(NotificationCenter.default.publisher(for: .islandSendMessage)) { _ in + guard state.view == .prompt else { return } + sendMessage() + } + .onReceive(NotificationCenter.default.publisher(for: .islandNewConversation)) { _ in + guard state.view == .prompt else { return } + text = "" + state.chatHistory = [] + ClaudeService.shared.clearConversation() + focused = true + } + } + + private func sendMessage() { + let query = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return } + text = "" + focused = false + state.chatHistory.append(ChatMessage(role: .user, content: query)) + state.stateOverride = .thinking + Task { + await ClaudeService.shared.chat(query: query, context: state.promptContext, state: state) + await MainActor.run { focused = true } + } + } +} + + +// MARK: - Model / provider picker + +/// Wrapping horizontal flow layout — used by ModelPickerView and QuestionView. +struct ChipFlowLayout: Layout { + var spacing: CGFloat = 6 + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize { + let r = rows(maxW: proposal.replacingUnspecifiedDimensions().width, subviews: subviews) + return r.size + } + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) { + let r = rows(maxW: bounds.width, subviews: subviews) + for (idx, pt) in r.placements.enumerated() { + subviews[idx].place(at: CGPoint(x: bounds.minX + pt.x, y: bounds.minY + pt.y), proposal: .unspecified) + } + } + private func rows(maxW: CGFloat, subviews: Subviews) -> (size: CGSize, placements: [(x: CGFloat, y: CGFloat)]) { + var x: CGFloat = 0, y: CGFloat = 0, lineH: CGFloat = 0, maxX: CGFloat = 0 + var pts: [(x: CGFloat, y: CGFloat)] = [] + for sv in subviews { + let sz = sv.sizeThatFits(.unspecified) + if x + sz.width > maxW, x > 0 { x = 0; y += lineH + spacing; lineH = 0 } + pts.append((x: x, y: y)) + x += sz.width + spacing + lineH = max(lineH, sz.height) + maxX = max(maxX, x - spacing) + } + return (CGSize(width: max(maxX, 0), height: y + lineH), pts) + } +} + +struct ModelPickerView: View { + @ObservedObject var state: AppState + @Binding var isPresented: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Provider chips — wrap; hide local providers when not connected and not already active + let visibleProviders = ChatProvider.allCases.filter { p in + if p == .ollama { return !AppState.shared.ollamaServerURL.isEmpty || state.chatProvider == .ollama } + if p == .lmstudio { return !AppState.shared.lmstudioServerURL.isEmpty || state.chatProvider == .lmstudio } + return true + } + ChipFlowLayout(spacing: 6) { + ForEach(visibleProviders, id: \.self) { provider in + Button { + guard provider != state.chatProvider else { return } + withAnimation(.spring(response: 0.25, dampingFraction: 0.7)) { + state.chatProvider = provider + } + NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.surprised) + SoundEngine.shared.play("pop") + } label: { + HStack(spacing: 5) { + Circle() + .fill(Color(hex: provider.accentHex)) + .frame(width: 7, height: 7) + Text(provider.displayName) + .font(.system(size: 12, weight: state.chatProvider == provider ? .semibold : .regular)) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(state.chatProvider == provider + ? Color(hex: provider.accentHex).opacity(0.18) + : Color.white.opacity(0.06)) + .overlay(Capsule().stroke( + state.chatProvider == provider + ? Color(hex: provider.accentHex).opacity(0.5) + : Color.white.opacity(0.1), + lineWidth: 1)) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + } + + Divider().opacity(0.2) + + // Model list for current provider — fetched dynamically + modelListView + .frame(height: 260, alignment: .top) + } + .padding(14) + .background(Color(hex: "#16171B")) + .onAppear { + // Force-refresh local providers every time the picker opens + if state.chatProvider.isLocal { + state.fetchedProviderModels[state.chatProvider] = nil + state.providerModelFetchError[state.chatProvider] = nil + } + state.fetchModelsIfNeeded(for: state.chatProvider) + } + .onChange(of: state.chatProvider) { _, provider in + if provider.isLocal { + state.fetchedProviderModels[provider] = nil + state.providerModelFetchError[provider] = nil + } + state.fetchModelsIfNeeded(for: provider) + } + } + + @ViewBuilder + private var modelListView: some View { + if state.loadingProviderModels.contains(state.chatProvider) { + HStack(spacing: 8) { + ProgressView().scaleEffect(0.7) + Text("Loading models…") + .font(.system(size: 12)) + .foregroundColor(Color(hex: "#8A8F98")) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 4) + } else if let error = state.providerModelFetchError[state.chatProvider] { + Text(error) + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#8A8F98")) + .padding(.vertical, 4) + } else if let models = state.fetchedProviderModels[state.chatProvider] { + ScrollView { + VStack(alignment: .leading, spacing: 2) { + ForEach(models, id: \.id) { model in + Button { + switch state.chatProvider { + case .anthropic: state.claudeModel = model.id + case .google: state.googleChatModel = model.id + case .openai: state.openAIChatModel = model.id + case .ollama: state.ollamaChatModel = model.id + case .lmstudio: state.lmstudioChatModel = model.id + } + isPresented = false + SoundEngine.shared.play("blip") + } label: { + HStack { + Text(model.label) + .font(.system(size: 12)) + .foregroundColor(state.activeChatModel == model.id + ? Color(hex: state.chatProvider.accentHex) + : Color(hex: "#C8CDD4")) + Spacer() + if state.activeChatModel == model.id { + Image(systemName: "checkmark") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(Color(hex: state.chatProvider.accentHex)) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(state.activeChatModel == model.id + ? Color(hex: state.chatProvider.accentHex).opacity(0.1) + : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + .buttonStyle(.plain) + } + } + } + } + } +} + +struct ChatBubble: View { + let message: ChatMessage + + var body: some View { + if !message.content.isEmpty { + HStack(alignment: .top) { + if message.role == .user { + Spacer(minLength: 32) + Text(message.content) + .font(.system(size: 12.5)) + .foregroundColor(Color(hex: "#F1F2F4")) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + .padding(.horizontal, 10).padding(.vertical, 6) + .background(Color.white.opacity(0.13)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } else { + ChatMarkdownView(markdown: message.content) + Spacer(minLength: 8) + } + } + } + } +} + +struct TypingDotsView: View { + @State private var phase = false + + var body: some View { + HStack(spacing: 4) { + ForEach(0..<3, id: \.self) { i in + Circle() + .fill(Color(hex: "#6B7079")) + .frame(width: 5, height: 5) + .scaleEffect(phase ? 1.2 : 0.6) + .animation( + .easeInOut(duration: 0.45).repeatForever().delay(Double(i) * 0.14), + value: phase + ) + } + } + .padding(.horizontal, 2).padding(.vertical, 4) + .onAppear { phase = true } + } +} + +// MARK: - Searching + +struct SearchingView: View { + @ObservedObject var state: AppState + + var label: String { + switch state.promptContext { + case .window(_, let title, _): return "Claude is reading \(title)…" + case .file(let name, _): return "Claude is reading \(name)…" + case nil: return "Claude is searching…" + } + } + + var body: some View { + ZStack(alignment: .leading) { + CardBackground(wash: .indigo) + + VStack(alignment: .leading, spacing: 8) { + if let ctx = state.promptContext { + ContextChip(context: ctx) + } + ShimmeringText(label) + .font(.system(size: 13.5)) + } + .padding(.leading, 84) + .padding(.trailing, 16) + } + } +} + +// MARK: - Result + +struct ResultView: View { + @ObservedObject var state: AppState + + var body: some View { + ZStack(alignment: .leading) { + CardBackground(wash: .green) + + if let result = state.searchResult { + VStack(alignment: .leading, spacing: 7) { + Text(result.title) + .font(.system(size: 15, weight: .semibold)) + + VStack(spacing: 4) { + ForEach(result.items.prefix(3), id: \.label) { item in + HStack { + Text(item.label).font(.system(size: 12.5, weight: .semibold)) + Spacer() + Text(item.detail).font(.system(size: 12.5)).foregroundColor(Color(hex: "#9398A1")) + } + .padding(.horizontal, 10).padding(.vertical, 6) + .background(Color.white.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + } + + if let note = result.note { + Text(note).font(.system(size: 11)).foregroundColor(Color(hex: "#6E737C")) + } + + HStack(spacing: 8) { + // The URL comes from the model, which may have read attacker-controlled + // files or pages: only plain web links may leave the app. + let openURL = safeWebURL(result.items.first?.url) + PrimaryButton("Open") { + if let openURL { NSWorkspace.shared.open(openURL) } + } + .disabled(openURL == nil) + .help(openURL?.absoluteString ?? "") + SecondaryButton("Copy") { + let text = result.items.map { "\($0.label): \($0.detail)" }.joined(separator: "\n") + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + SecondaryButton("Close") { state.view = state.tasks.isEmpty ? .empty : .overview } + } + } + .padding(.leading, 84) + .padding(.trailing, 16) + } + } + } +} + +// MARK: - Note (short message, auto-closes) + +struct NoteView: View { + @ObservedObject var state: AppState + + var body: some View { + ZStack(alignment: .leading) { + CardBackground(wash: nil) + VStack(alignment: .leading, spacing: 4) { + Text(state.noteMessage ?? "") + .font(.system(size: 15, weight: .semibold)) + } + .padding(.leading, 98) + } + } +} + +// MARK: - Integration card (overview left card when an integration pill is focused) + +struct IntegrationCardView: View { + let task: AgentTask + @Binding var showingDetail: Bool + var onDiffTap: ((Int) -> Void)? = nil + @ObservedObject private var appState = AppState.shared + @State private var githubDetailSection: GitHubDetailSection = .myPRs + + private var isConfigured: Bool { + switch task.id { + case "integration_claude": + #if APPSTORE + // Sandboxed: can't read ~/.claude directly — check install flag set by HookServer + return UserDefaults.standard.bool(forKey: "coucouHooksInstalled") + #else + let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".claude/settings.json") + guard let data = try? Data(contentsOf: url), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let hooks = json["hooks"] as? [String: Any], + let ss = hooks["SessionStart"] as? [[String: Any]] else { return false } + return ss.contains { ($0["hooks"] as? [[String: Any]])?.contains { + let cmd = $0["command"] as? String + return cmd?.contains("NotchBuddy") == true || cmd?.contains("coucou") == true + } ?? false } + #endif + case "agent_gemini": + #if !APPSTORE + return HookServer.geminiHooksInstalled() + #else + return false + #endif + case "agent_antigravity": + #if !APPSTORE + return HookServer.agyHooksInstalled() + #else + return false + #endif + case "agent_cursor", "agent_codex": + return false // coming soon + case "integration_music": + #if !APPSTORE + return true // Apple Music is always installed on macOS + #else + return false + #endif + case "ai_anthropic": return KeychainStore.shared.get("anthropic-api-key") != nil + case "ai_google": return KeychainStore.shared.get("google-api-key") != nil + case "ai_openai": return KeychainStore.shared.get("openai-api-key") != nil + case "ai_ollama": return !AppState.shared.ollamaServerURL.isEmpty + case "ai_lmstudio": return !AppState.shared.lmstudioServerURL.isEmpty + case "integration_resend": return KeychainStore.shared.get("resend-api-key") != nil + case "integration_n8n": return KeychainStore.shared.get("n8n-api-key") != nil + case "integration_vercel": return KeychainStore.shared.get("vercel-token") != nil + case "integration_github": return KeychainStore.shared.get("github-token") != nil + case "integration_stripe": return KeychainStore.shared.get("stripe-api-key") != nil + case "integration_notion": return KeychainStore.shared.get("notion-api-key") != nil + case "integration_calcom": return KeychainStore.shared.get("calcom-api-key") != nil + default: return false + } + } + + private var openURL: URL? { + switch task.id { + case "integration_claude": return nil // uses terminal button below + case "integration_resend": return URL(string: "https://resend.com/emails") + case "integration_n8n": + if let s = KeychainStore.shared.get("n8n-url") { return URL(string: s) } + return nil + case "integration_vercel": return URL(string: "https://vercel.com/dashboard") + case "integration_github": return URL(string: "https://github.com") + case "integration_stripe": return URL(string: "https://dashboard.stripe.com/payments") + case "integration_notion": return URL(string: "https://notion.so") + case "integration_calcom": return URL(string: "https://app.cal.com/bookings") + default: return nil + } + } + + // Workspace/agent pill with active session: show ticker layout + private var agentSessionActive: Bool { + guard let def = PillCatalog.definition(for: task.id) else { return false } + guard def.category == .workspace || def.category == .agent else { return false } + return task.state != .idle || !task.steps.isEmpty + } + + // n8n with a finished execution: show result row instead of "Open n8n" button + private var n8nHasActivity: Bool { + task.id == "integration_n8n" && !task.steps.isEmpty && + (task.state == .finished || task.state == .error) + } + + // Vercel with recent deployments + private var vercelHasActivity: Bool { + task.id == "integration_vercel" && !appState.vercelDeployments.isEmpty + } + + // Resend with recent emails + private var resendHasData: Bool { + task.id == "integration_resend" && !appState.resendEmails.isEmpty + } + + // GitHub with stats or pulse loaded + private var githubHasData: Bool { + task.id == "integration_github" && (appState.githubPulse != nil || appState.githubStats != nil) + } + + // GitHub with pulse loaded (richer card) + private var githubHasPulse: Bool { + task.id == "integration_github" && appState.githubPulse != nil + } + + // Stripe: show card as soon as first poll completes (balance OR payments) + private var stripeHasData: Bool { + task.id == "integration_stripe" && appState.stripeLoaded + } + + // Cal.com: show calendar as soon as first poll completes + private var calcomHasData: Bool { + task.id == "integration_calcom" && appState.calcomLoaded + } + + // Notion: show pages as soon as first poll completes + private var notionHasData: Bool { + task.id == "integration_notion" && appState.notionLoaded + } + + // Apple Music: show card when a track is loaded (playing or paused) or automation is denied + private var musicIsActive: Bool { + #if !APPSTORE + guard task.id == "integration_music" else { return false } + if appState.musicAutomationDenied { return true } + return MusicController.shared.trackTitle != nil + #else + return false + #endif + } + + private var statusDot: Color { + #if !APPSTORE + if task.id == "integration_music" { + if appState.musicAutomationDenied { return Color(hex: "#F4505E") } + return appState.musicPlaying ? Color(hex: "#FA2D48") : Color(hex: "#22C55E") + } + #endif + if PillCatalog.definition(for: task.id)?.comingSoon == true { return Color(hex: "#6B7079") } + let svcErr = task.id == "integration_stripe" ? appState.stripeError + : task.id == "integration_calcom" ? appState.calcomError + : nil + if svcErr != nil { return Color(hex: "#F4505E") } + return isConfigured ? Color(hex: "#22C55E") : Color(hex: "#F4505E") + } + + private var statusLabel: String { + #if !APPSTORE + if task.id == "integration_music" { + if appState.musicAutomationDenied { return "Automation not allowed" } + if appState.musicPlaying { return "Playing · \(MusicController.shared.trackTitle ?? "Unknown")" } + return "Not playing" + } + #endif + if PillCatalog.definition(for: task.id)?.comingSoon == true { return "Coming soon" } + let svcErr = task.id == "integration_stripe" ? appState.stripeError + : task.id == "integration_calcom" ? appState.calcomError + : nil + if let err = svcErr { return err } + let isHooks = task.id == "agent_gemini" || task.id == "agent_antigravity" + let isAI = ChatProvider(pillID: task.id) != nil + if isConfigured { + if isHooks { return "Hooks installed" } + if isAI { + let provider = ChatProvider(pillID: task.id)! + if provider.isLocal { + let model = provider == .ollama ? appState.ollamaChatModel : appState.lmstudioChatModel + return "Connected · \(model)" + } + let model: String + switch task.id { + case "ai_anthropic": model = appState.claudeModel + case "ai_google": model = appState.googleChatModel + case "ai_openai": model = appState.openAIChatModel + default: model = "" + } + return "Key configured · \(model)" + } + return "Connected · loading…" + } else { + if isHooks { return "Hooks not installed" } + if isAI { + let provider = ChatProvider(pillID: task.id)! + return provider.isLocal ? "Not connected" : "Key not configured" + } + return "Key not configured" + } + } + + var body: some View { + if showingDetail && n8nHasActivity { + N8nDetailView(task: task) { + withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) { showingDetail = false } + } + .transition(.opacity) + } else if showingDetail && vercelHasActivity { + VercelDetailView(deployment: appState.vercelDeployments[0]) { + withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) { showingDetail = false } + } + .transition(.opacity) + } else if vercelHasActivity { + VercelDeploymentListView(deployments: appState.vercelDeployments, onOpenDetail: { + withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) { showingDetail = true } + }) + .transition(.opacity) + } else if resendHasData { + ResendCardView(emails: appState.resendEmails, total: appState.resendTotal) + .transition(.opacity) + } else if showingDetail && githubHasPulse { + GitHubDetailView( + section: githubDetailSection, + pulse: appState.githubPulse!, + activity: appState.githubActivity, + stats: appState.githubStats, + onBack: { + withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) { showingDetail = false } + } + ) + .transition(.opacity) + } else if githubHasPulse { + GitHubPulseCardView( + pulse: appState.githubPulse!, + stats: appState.githubStats, + activity: appState.githubActivity, + onTapSection: { section in + githubDetailSection = section + withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) { showingDetail = true } + } + ) + .transition(.opacity) + } else if githubHasData { + GitHubStatsCardView(stats: appState.githubStats!) + .transition(.opacity) + } else if stripeHasData { + StripeCardView() + .transition(.opacity) + } else if calcomHasData { + CalcomCardView() + .transition(.opacity) + } else if notionHasData { + NotionCardView() + .transition(.opacity) + } else if musicIsActive { + #if !APPSTORE + MusicCardView() + .transition(.opacity) + #endif + } else if agentSessionActive { + // Active session view — reuse overview layout + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 6) { + Circle() + .fill(Color(hex: task.color)) + .frame(width: 7, height: 7) + Text(task.name) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Color(hex: "#F5F6F8")) + .lineLimit(1).truncationMode(.tail) + .layoutPriority(1) + Text(PillCatalog.definition(for: task.id)?.sessionSubtitle ?? "Agent") + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#8E939C")) + .lineLimit(1).truncationMode(.tail) + Spacer(minLength: 2) + if task.steps.count > 1 { + Text("\(min(task.stepIndex + 1, task.steps.count))/\(task.steps.count)") + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#6B7079")) + .fixedSize() + } + } + .padding(.top, 6) + .padding(.leading, 108) + .padding(.trailing, 36) + + TickerView(task: task, onDiffTap: onDiffTap) + .frame(height: 44) + .padding(.top, 6) + .padding(.leading, 108) + .padding(.trailing, 12) + } + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.top, 4) + } else { + // Idle / not connected view — slides in from left when returning from detail + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Circle() + .fill(Color(hex: task.color)) + .frame(width: 7, height: 7) + Text(PillCatalog.definition(for: task.id)?.name ?? task.name) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Color(hex: "#F5F6F8")) + Text(PillCatalog.definition(for: task.id)?.subtitle ?? "Integration") + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#8E939C")) + Spacer(minLength: 2) + } + .padding(.top, 6) + .padding(.leading, 108) + .padding(.trailing, 36) + + HStack(spacing: 5) { + Circle().fill(statusDot).frame(width: 5, height: 5) + Text(statusLabel) + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#6B7079")) + } + .padding(.leading, 108) + .padding(.top, 2) + + HStack(spacing: 8) { + if task.id == "integration_claude" { + Button("Open Visual Studio Code") { openVSCode() } + .font(.system(size: 11, weight: .medium)) + .foregroundColor(Color(hex: task.color).opacity(0.7)) + .buttonStyle(.plain) + } else if task.id == "agent_cursor" { + #if !APPSTORE + if let url = NSWorkspace.shared.urlForApplication( + withBundleIdentifier: "com.todesktop.230313mzl4w4u92") { + Button("Open Cursor") { + NSWorkspace.shared.openApplication(at: url, configuration: .init(), + completionHandler: nil) + } + .font(.system(size: 11, weight: .medium)) + .foregroundColor(Color(hex: task.color).opacity(0.85)) + .buttonStyle(.plain) + } + #endif + } else if task.id == "agent_codex" { + #if !APPSTORE + if let url = NSWorkspace.shared.urlForApplication( + withBundleIdentifier: "com.openai.codex") { + Button("Open Codex") { + NSWorkspace.shared.openApplication(at: url, configuration: .init(), + completionHandler: nil) + } + .font(.system(size: 11, weight: .medium)) + .foregroundColor(Color(hex: task.color).opacity(0.85)) + .buttonStyle(.plain) + } + #endif + } else if let provider = ChatProvider(pillID: task.id) { + if isConfigured { + Button("Chat with \(task.name)") { + switchChatProvider(provider) + } + .font(.system(size: 11, weight: .medium)) + .foregroundColor(Color(hex: task.color).opacity(0.85)) + .buttonStyle(.plain) + } + } else if task.id == "integration_music" { + #if !APPSTORE + Button("Open Music") { MusicController.shared.openMusic() } + .font(.system(size: 11, weight: .medium)) + .foregroundColor(Color(hex: task.color).opacity(0.85)) + .buttonStyle(.plain) + if appState.musicAutomationDenied { + Button("Open Settings…") { MusicController.shared.openAutomationSettings() } + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#8E939C")) + .buttonStyle(.plain) + } + #endif + } else if n8nHasActivity { + // Clickable pill — tap to open execution detail + let success = task.state == .finished + let accent = success ? Color(hex: "#22C55E") : Color(hex: "#F4505E") + Button(action: { + withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) { showingDetail = true } + }) { + HStack(spacing: 5) { + Circle().fill(accent).frame(width: 5, height: 5) + Text(task.steps.first ?? "Workflow") + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#C5C8CD")) + .lineLimit(1).truncationMode(.tail) + Image(systemName: "ellipsis") + .font(.system(size: 8, weight: .medium)) + .foregroundColor(Color(hex: "#6B7079")) + } + .padding(.horizontal, 8).padding(.vertical, 3) + .background(accent.opacity(0.1)) + .clipShape(Capsule()) + .overlay(Capsule().stroke(accent.opacity(0.22), lineWidth: 1)) + } + .buttonStyle(.plain) + } else if let url = openURL { + Button("Open \(task.name)") { NSWorkspace.shared.open(url) } + .font(.system(size: 11, weight: .medium)) + .foregroundColor(Color(hex: task.color).opacity(0.85)) + .buttonStyle(.plain) + } + if task.id == "integration_stripe" && isConfigured { + Button("Refresh") { Task { @MainActor in StripePoller.shared.pollNow() } } + .font(.system(size: 11, weight: .medium)) + .foregroundColor(Color(hex: "#0570DE").opacity(0.85)) + .buttonStyle(.plain) + } + if task.id == "integration_calcom" && isConfigured { + Button("Refresh") { Task { @MainActor in CalcomPoller.shared.pollNow() } } + .font(.system(size: 11, weight: .medium)) + .foregroundColor(Color(hex: "#C9956A").opacity(0.85)) + .buttonStyle(.plain) + } + // Settings button: shown when not configured, except cursor/codex and music + if !isConfigured + && task.id != "agent_cursor" + && task.id != "agent_codex" + && task.id != "integration_music" { + Button("Settings…") { + let section: String + switch PillCatalog.definition(for: task.id)?.category { + case .workspace, .agent: section = "agents" + case .ai: section = "chat" + default: section = "integrations" + } + NotificationCenter.default.post(name: .openFullSettings, object: section) + } + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#8E939C")) + .buttonStyle(.plain) + } + } + .padding(.leading, 108) + .padding(.top, 2) + } + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.top, 4) + .transition(.opacity) + } + } + + private func openVSCode() { + let ids = ["com.microsoft.VSCode", "com.microsoft.VSCodeInsiders", "com.vscodium.codium"] + let appURL = ids.compactMap { NSWorkspace.shared.urlForApplication(withBundleIdentifier: $0) }.first + + // If we have a project folder, open it directly in VS Code + if let cwd = task.sessionCwd, !cwd.isEmpty, let appURL = appURL { + NSWorkspace.shared.open( + [URL(fileURLWithPath: cwd)], + withApplicationAt: appURL, + configuration: .init(), + completionHandler: nil + ) + return + } + + // No cwd: activate running instance or launch fresh + if let running = ids.compactMap({ id in + NSWorkspace.shared.runningApplications.first { $0.bundleIdentifier == id } + }).first { + running.activate(options: .activateIgnoringOtherApps) + return + } + if let appURL = appURL { + NSWorkspace.shared.openApplication(at: appURL, configuration: .init(), completionHandler: nil) + } + } + +} + +// MARK: - Vercel Deployment List View + +struct VercelDeploymentListView: View { + let deployments: [VercelDeployment] + let onOpenDetail: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header + HStack(spacing: 6) { + Circle() + .fill(Color(hex: "#7C5CFF")) + .frame(width: 7, height: 7) + Text("Vercel") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Color(hex: "#F5F6F8")) + Text("Deployments") + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#8E939C")) + } + .padding(.top, 6) + .padding(.leading, 108) + .padding(.trailing, 36) + + // Deployment rows + VStack(alignment: .leading, spacing: 3) { + // First deployment — highlighted, with detail button + if let first = deployments.first { + let accent = Color(hex: first.isSuccess ? "#22C55E" : "#F4505E") + HStack(spacing: 5) { + Circle().fill(accent).frame(width: 5, height: 5) + Text(first.projectName) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(Color(hex: "#C5C8CD")) + .lineLimit(1).truncationMode(.tail) + .layoutPriority(1) + Text(first.timeAgo) + .font(.system(size: 10)) + .foregroundColor(Color(hex: "#6B7079")) + Button(action: onOpenDetail) { + Image(systemName: "ellipsis") + .font(.system(size: 8, weight: .medium)) + .foregroundColor(Color(hex: "#6B7079")) + .frame(width: 18, height: 18) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 8).padding(.vertical, 3) + .frame(maxWidth: .infinity, alignment: .leading) + .background(accent.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 5)) + } + + // Remaining deployments — plain rows, identical structure → perfect alignment + ForEach(Array(deployments.dropFirst().prefix(2))) { dep in + let accent = Color(hex: dep.isSuccess ? "#22C55E" : "#F4505E") + HStack(spacing: 5) { + Circle().fill(accent).frame(width: 5, height: 5) + Text(dep.projectName) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(Color(hex: "#9398A1")) + .lineLimit(1).truncationMode(.tail) + .layoutPriority(1) + Text(dep.timeAgo) + .font(.system(size: 10)) + .foregroundColor(Color(hex: "#6B7079")) + } + .padding(.horizontal, 8).padding(.vertical, 3) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.top, 5) + .padding(.leading, 108) + .padding(.trailing, 12) + } + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.top, 4) + } +} + +// MARK: - Vercel Deployment Detail View + +struct VercelDetailView: View { + let deployment: VercelDeployment + let onClose: () -> Void + + private var accent: Color { Color(hex: deployment.isSuccess ? "#22C55E" : "#F4505E") } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // Header + HStack(spacing: 7) { + Button(action: onClose) { + Image(systemName: "chevron.left") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(Color(hex: "#6B7079")) + .frame(width: 28, height: 28) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + Circle().fill(accent).frame(width: 6, height: 6) + Text(deployment.projectName) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(Color(hex: "#F5F6F8")) + .lineLimit(1).truncationMode(.middle) + .layoutPriority(1) + Spacer(minLength: 2) + Text(deployment.statusLabel) + .font(.system(size: 10, weight: .medium)) + .foregroundColor(accent) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(accent.opacity(0.14)) + .clipShape(Capsule()) + } + + // Details + VStack(alignment: .leading, spacing: 4) { + if let commit = deployment.commitMessage { + Text(commit) + .font(.system(size: 10.5)) + .foregroundColor(Color(hex: "#C5C8CD")) + .lineLimit(2) + } + HStack(spacing: 8) { + if let branch = deployment.branch { + Label(branch, systemImage: "arrow.branch") + .font(.system(size: 10)) + .foregroundColor(Color(hex: "#6B7079")) + } + Text(deployment.timeAgo + " ago") + .font(.system(size: 10)) + .foregroundColor(Color(hex: "#6B7079")) + } + Button(action: { + if let url = URL(string: "https://\(deployment.url)") { + NSWorkspace.shared.open(url) + } + }) { + Text(deployment.url) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(Color(hex: "#7C5CFF").opacity(0.85)) + .lineLimit(1).truncationMode(.middle) + } + .buttonStyle(.plain) + } + } + .padding(.top, 8) + .padding(.leading, 108) + .padding(.trailing, 12) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .contentShape(Rectangle()) + } +} + +// MARK: - Resend Card View + +struct ResendPulseDot: View { + @State private var on = false + var body: some View { + Circle() + .fill(Color(hex: "#22C55E")) + .frame(width: 4, height: 4) + .opacity(on ? 1 : 0.2) + .onAppear { + withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { on = true } + } + } +} + +struct ResendCardView: View { + let emails: [ResendEmail] + let total: Int? + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header + HStack(spacing: 6) { + Circle() + .fill(Color(hex: "#22C55E")) + .frame(width: 7, height: 7) + Text("Resend") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Color(hex: "#F5F6F8")) + Text("Emails") + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#8E939C")) + if let total { + ResendPulseDot() + Text("\(total)") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(Color(hex: "#C5C8CD")) + .monospacedDigit() + } + } + .padding(.top, 6) + .padding(.leading, 108) + .padding(.trailing, 36) + + // Email rows — first is highlighted, rest plain (same structure as Vercel list) + VStack(alignment: .leading, spacing: 3) { + if let first = emails.first { + let accent = Color(hex: first.isDelivered ? "#22C55E" : "#F4505E") + HStack(spacing: 5) { + Circle().fill(accent).frame(width: 5, height: 5) + Text(first.recipientShort) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(Color(hex: "#C5C8CD")) + .lineLimit(1).truncationMode(.tail) + .layoutPriority(1) + Text(first.timeAgo) + .font(.system(size: 10)) + .foregroundColor(Color(hex: "#6B7079")) + if !first.subject.isEmpty { + Text(first.subject) + .font(.system(size: 10)) + .foregroundColor(Color(hex: "#4D5159")) + .lineLimit(1).truncationMode(.tail) + } + } + .padding(.horizontal, 8).padding(.vertical, 3) + .frame(maxWidth: .infinity, alignment: .leading) + .background(accent.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 5)) + } + + ForEach(Array(emails.dropFirst().prefix(2))) { email in + let accent = Color(hex: email.isDelivered ? "#22C55E" : "#F4505E") + HStack(spacing: 5) { + Circle().fill(accent).frame(width: 5, height: 5) + Text(email.recipientShort) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(Color(hex: "#9398A1")) + .lineLimit(1).truncationMode(.tail) + .layoutPriority(1) + Text(email.timeAgo) + .font(.system(size: 10)) + .foregroundColor(Color(hex: "#6B7079")) + } + .padding(.horizontal, 8).padding(.vertical, 3) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.top, 5) + .padding(.leading, 108) + .padding(.trailing, 12) + } + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.top, 4) + } +} + +// MARK: - GitHub Pulse Card View + +private func ciWorstState(_ prs: [GitHubPR]) -> CIState { + if prs.contains(where: { $0.ci == .failure }) { return .failure } + if prs.contains(where: { $0.ci == .pending }) { return .pending } + if prs.contains(where: { $0.ci == .success }) { return .success } + return .unknown +} + +private func ciColor(_ state: CIState) -> String { + switch state { + case .failure: return "#F4505E" + case .pending: return "#F5A524" + case .success: return "#22C55E" + case .unknown: return "#6B7079" + } +} + +private func mainCIWorst(_ repos: [GitHubRepoCI]) -> CIState { + if repos.contains(where: { $0.ci == .failure }) { return .failure } + if repos.contains(where: { $0.ci == .pending }) { return .pending } + if repos.contains(where: { $0.ci == .success }) { return .success } + return .unknown +} + +struct GitHubPulseCardView: View { + let pulse: GitHubPulse + let stats: GitHubStats? + let activity: GitHubActivity? + let onTapSection: (GitHubDetailSection) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header + HStack(spacing: 6) { + Circle() + .fill(Color(hex: "#F4505E")) + .frame(width: 7, height: 7) + Text("GitHub") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Color(hex: "#F5F6F8")) + if let s = stats { + // Stars + 7-day mini-row → opens Activity detail + Button(action: { onTapSection(.activity) }) { + HStack(spacing: 4) { + Text("★ \(formatCount(s.totalStars))") + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#8E939C")) + .lineLimit(1) + if let act = activity { + HStack(spacing: 2) { + ForEach(act.lastDays(7), id: \.date) { day in + RoundedRectangle(cornerRadius: 1.5) + .fill(contributionColor(day.level)) + .frame(width: 7, height: 7) + } + } + } + } + } + .buttonStyle(.plain) + } else if let act = activity { + // No stats yet but activity loaded — show mini-row only + Button(action: { onTapSection(.activity) }) { + HStack(spacing: 2) { + ForEach(act.lastDays(7), id: \.date) { day in + RoundedRectangle(cornerRadius: 1.5) + .fill(contributionColor(day.level)) + .frame(width: 7, height: 7) + } + } + } + .buttonStyle(.plain) + } else { + Text("Overview") + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#8E939C")) + } + } + .padding(.top, 6) + .padding(.leading, 108) + .padding(.trailing, 36) + + // Stat rows + VStack(alignment: .leading, spacing: 4) { + // My PRs + let prWorst = ciWorstState(pulse.myPRs) + let prValue: String = { + let n = pulse.myPRs.count + if n == 0 { return "0" } + let failing = pulse.myPRs.filter { $0.ci == .failure }.count + let pending = pulse.myPRs.filter { $0.ci == .pending }.count + if failing > 0 { return "\(n) · \(failing) failing" } + if pending > 0 { return "\(n) · running" } + return "\(n)" + }() + GitHubStatRow( + icon: "arrow.triangle.pull", iconColor: ciColor(prWorst), + label: "My PRs", value: prValue + ) { onTapSection(.myPRs) } + + // To review + let reviewCount = pulse.toReview.count + GitHubStatRow( + icon: "eye", + iconColor: reviewCount > 0 ? "#8AB4F8" : "#6B7079", + label: "To review", + value: "\(reviewCount)" + ) { onTapSection(.toReview) } + + // Default branch CI + let mainWorst = mainCIWorst(pulse.mainCI) + let (ciIcon, ciIconColor, ciValue): (String, String, String) = { + switch mainWorst { + case .failure: + let n = pulse.mainCI.filter { $0.ci == .failure }.count + return ("xmark.octagon.fill", "#F4505E", "\(n) failing") + case .pending: + return ("checkmark.seal.fill", "#F5A524", "running") + case .success: + return ("checkmark.seal.fill", "#22C55E", "all green") + case .unknown: + return ("checkmark.seal.fill", "#6B7079", pulse.mainCI.isEmpty ? "no repos" : "unknown") + } + }() + GitHubStatRow( + icon: ciIcon, iconColor: ciIconColor, + label: "Default branch CI", value: ciValue + ) { onTapSection(.mainCI) } + } + .padding(.top, 6) + .padding(.leading, 108) + .padding(.trailing, 12) + } + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.top, 4) + .clipped() + } + + private func formatCount(_ n: Int) -> String { + if n >= 1000 { return String(format: "%.1fk", Double(n) / 1000) } + return "\(n)" + } +} + +private struct GitHubStatRow: View { + let icon: String + let iconColor: String + let label: String + let value: String + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 7) { + Image(systemName: icon) + .font(.system(size: 10)) + .foregroundColor(Color(hex: iconColor)) + .frame(width: 14) + Text(label) + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#6B7079")) + Spacer() + Text(value) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Color(hex: "#C5C8CD")) + .monospacedDigit() + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + } +} + +// MARK: - GitHub Detail View + +struct GitHubDetailView: View { + let section: GitHubDetailSection + let pulse: GitHubPulse + let activity: GitHubActivity? + let stats: GitHubStats? + let onBack: () -> Void + @ObservedObject private var appState = AppState.shared + + private var title: String { + switch section { + case .myPRs: return "My PRs" + case .toReview: return "To review" + case .mainCI: return "Default branch CI" + case .activity: return "Activity" + } + } + + private var items: [GitHubPR] { + switch section { + case .myPRs: return pulse.myPRs + case .toReview: return pulse.toReview + case .mainCI, .activity: return [] + } + } + + private var repoItems: [GitHubRepoCI] { + section == .mainCI ? pulse.mainCI : [] + } + + private var totalItems: Int { items.count + repoItems.count } + + var body: some View { + if section == .activity { + GitHubActivityDetailContent( + activity: activity, + stats: stats, + login: pulse.login, + onBack: onBack + ) + } else { + VStack(alignment: .leading, spacing: 0) { + // Header + HStack(spacing: 6) { + Button(action: onBack) { + HStack(spacing: 3) { + Image(systemName: "chevron.left") + .font(.system(size: 8, weight: .medium)) + Text(title) + .font(.system(size: 12, weight: .semibold)) + } + .foregroundColor(Color(hex: "#F5F6F8")) + } + .buttonStyle(.plain) + Spacer(minLength: 2) + } + .padding(.top, 6) + .padding(.leading, 108) + .padding(.trailing, 12) + + // List + if items.isEmpty && repoItems.isEmpty { + Text("Nothing here") + .font(.system(size: 10.5)) + .foregroundColor(Color(hex: "#6B7079")) + .padding(.top, 8) + .padding(.leading, 108) + } else { + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(items.enumerated()), id: \.element.id) { idx, pr in + GitHubPRRowView(pr: pr, showCI: section == .myPRs, + selected: appState.cardSelection == idx) + } + ForEach(Array(repoItems.enumerated()), id: \.element.repo) { idx, repo in + GitHubRepoCIRowView(repo: repo, + selected: appState.cardSelection == items.count + idx) + } + } + } + .frame(maxHeight: 60) // 3 rows × 20 pt; rest scrolls + .mask( + Group { + if totalItems > 3 { + LinearGradient( + stops: [ + .init(color: .black, location: 0), + .init(color: .black, location: 0.8), + .init(color: .clear, location: 1.0) + ], + startPoint: .top, endPoint: .bottom + ) + } else { + Color.black + } + } + ) + .padding(.top, 4) + .padding(.leading, 108) + .padding(.trailing, 8) + } + } + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.top, 4) + .clipped() + .onAppear { + GithubPoller.shared.refreshIfStale() + appState.cardItemCount = totalItems + } + .onDisappear { appState.cardItemCount = 0 } + .onReceive(NotificationCenter.default.publisher(for: .islandActivateCardSelection)) { _ in + guard let sel = appState.cardSelection else { return } + if sel < items.count { + let pr = items[sel] + if let url = safeWebURL(pr.url), url.host == "github.com" { + NSWorkspace.shared.open(url) + } + } else { + let repoIdx = sel - items.count + guard repoIdx < repoItems.count else { return } + let repo = repoItems[repoIdx] + let actionsURL = repo.url.hasSuffix("/") ? repo.url + "actions" : repo.url + "/actions" + if let url = safeWebURL(actionsURL), url.host == "github.com" { + NSWorkspace.shared.open(url) + } + } + } + .onExitCommand { onBack() } + } + } +} + +// MARK: - GitHub Activity Detail + +private struct GitHubActivityDetailContent: View { + let activity: GitHubActivity? + let stats: GitHubStats? + let login: String + let onBack: () -> Void + + @State private var hoveredDay: ContributionDay? = nil + + // Dynamic grid: s=7pt, spacing=1.5pt; numWeeks = floor((202 + 1.5) / (7 + 1.5)) = 23 + private let squareSize: CGFloat = 7 + private let spacing: CGFloat = 1.5 + private var numWeeks: Int { Int((202 + spacing) / (squareSize + spacing)) } + + private var headerRight: String { + if let day = hoveredDay { + let label: String + switch day.count { + case 0: label = "No contributions" + case 1: label = "1 contribution" + default: label = "\(day.count) contributions" + } + return "\(activityDateLabel(day.date)) · \(label)" + } + guard let act = activity else { return "" } + let total = activityTotalLabel(act.total) + if let s = stats { return "\(total) past year · \(s.totalRepos) repos" } + return "\(total) past year" + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header + HStack(spacing: 6) { + Button(action: onBack) { + HStack(spacing: 3) { + Image(systemName: "chevron.left") + .font(.system(size: 8, weight: .medium)) + Text("Activity") + .font(.system(size: 12, weight: .semibold)) + } + .foregroundColor(Color(hex: "#F5F6F8")) + } + .buttonStyle(.plain) + Spacer(minLength: 2) + if activity != nil { + Button(action: { + let urlStr = "https://github.com/\(login)" + if let url = safeWebURL(urlStr), url.host == "github.com" { + NSWorkspace.shared.open(url) + } + }) { + Text(headerRight) + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#8E939C")) + .lineLimit(1) + } + .buttonStyle(.plain) + } + } + .padding(.top, 6) + .padding(.leading, 108) + .padding(.trailing, 12) + + // Grid + if let act = activity { + let weeks = act.lastWeeks(numWeeks) + HStack(alignment: .top, spacing: spacing) { + ForEach(weeks.indices, id: \.self) { wi in + VStack(spacing: spacing) { + ForEach(0..<7, id: \.self) { dow in + if let day = weeks[wi].first(where: { $0.weekday == dow }) { + RoundedRectangle(cornerRadius: 1.5) + .fill(contributionColor(day.level)) + .frame(width: squareSize, height: squareSize) + .onHover { hovering in hoveredDay = hovering ? day : nil } + .onTapGesture { + hoveredDay = (hoveredDay?.date == day.date) ? nil : day + } + } else { + Color.clear.frame(width: squareSize, height: squareSize) + } + } + } + } + } + .padding(.top, 5) + .padding(.leading, 108) + .padding(.trailing, 12) + } else { + Text("Loading…") + .font(.system(size: 10.5)) + .foregroundColor(Color(hex: "#6B7079")) + .padding(.top, 8) + .padding(.leading, 108) + } + } + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.top, 4) + .clipped() + .onAppear { GithubPoller.shared.refreshActivityIfStale() } + .onExitCommand { onBack() } + } + + private func activityDateLabel(_ dateStr: String) -> String { + let parts = dateStr.split(separator: "-") + guard parts.count == 3, + let month = Int(parts[1]), month >= 1 && month <= 12, + let day = Int(parts[2]) else { return dateStr } + let months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] + return "\(months[month - 1]) \(day)" + } + + private func activityTotalLabel(_ n: Int) -> String { + let nf = NumberFormatter() + nf.numberStyle = .decimal + nf.locale = Locale(identifier: "en_US") + return nf.string(from: NSNumber(value: n)) ?? "\(n)" + } +} + +private func contributionColor(_ level: Int) -> Color { + switch level { + case 1: return Color(hex: "#0E4429") + case 2: return Color(hex: "#006D32") + case 3: return Color(hex: "#26A641") + case 4: return Color(hex: "#39D353") + default: return Color.white.opacity(0.06) + } +} + +private func ghCIDot(_ ci: CIState) -> Color { + switch ci { + case .failure: return Color(hex: "#F4505E") + case .pending: return Color(hex: "#F5A524") + case .success: return Color(hex: "#22C55E") + case .unknown: return Color.clear + } +} + +private struct GitHubPRRowView: View { + let pr: GitHubPR + let showCI: Bool + var selected: Bool = false + + var body: some View { + Button(action: { + if let url = safeWebURL(pr.url), url.host == "github.com" { + NSWorkspace.shared.open(url) + } + }) { + HStack(spacing: 5) { + if showCI { + Circle() + .fill(ghCIDot(pr.ci)) + .frame(width: 5, height: 5) + .opacity(pr.ci == .unknown ? 0 : 1) + } else { + Spacer().frame(width: 5) + } + Text("\(pr.repo.components(separatedBy: "/").last ?? pr.repo)#\(pr.number)") + .font(.system(size: 10.5)) + .foregroundColor(Color(hex: "#9398A1")) + .lineLimit(1) + Text(pr.title) + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#C5C8CD")) + .lineLimit(1) + .truncationMode(.tail) + if pr.isDraft { + Text("Draft") + .font(.system(size: 9.5)) + .foregroundColor(Color(hex: "#6B7079")) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: 20) + .overlay( + RoundedRectangle(cornerRadius: 4) + .fill(Color.white.opacity(0.12)) + .opacity(selected ? 1 : 0) + ) + } + .buttonStyle(.plain) + } +} + +private struct GitHubRepoCIRowView: View { + let repo: GitHubRepoCI + var selected: Bool = false + + private var ciStateWord: String? { + switch repo.ci { + case .failure: return "failing" + case .pending: return "running" + case .success: return "passing" + case .unknown: return nil + } + } + + var body: some View { + Button(action: { + let actionsURL = repo.url.hasSuffix("/") ? repo.url + "actions" : repo.url + "/actions" + if let url = safeWebURL(actionsURL), url.host == "github.com" { + NSWorkspace.shared.open(url) + } + }) { + HStack(spacing: 5) { + Circle() + .fill(ghCIDot(repo.ci)) + .frame(width: 5, height: 5) + .opacity(repo.ci == .unknown ? 0 : 1) + Text(repo.repo.components(separatedBy: "/").last ?? repo.repo) + .font(.system(size: 10.5)) + .foregroundColor(Color(hex: "#9398A1")) + .lineLimit(1) + Text(repo.branch) + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#C5C8CD")) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 4) + if let word = ciStateWord { + Text(word) + .font(.system(size: 10)) + .foregroundColor(ghCIDot(repo.ci)) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: 20) + .overlay( + RoundedRectangle(cornerRadius: 4) + .fill(Color.white.opacity(0.12)) + .opacity(selected ? 1 : 0) + ) + } + .buttonStyle(.plain) + } +} + +// MARK: - GitHub Stats Card View + +struct GitHubStatsCardView: View { + let stats: GitHubStats + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header + HStack(spacing: 6) { + Circle() + .fill(Color(hex: "#F4505E")) + .frame(width: 7, height: 7) + Text("GitHub") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Color(hex: "#F5F6F8")) + Text("Overview") + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#8E939C")) + } + .padding(.top, 6) + .padding(.leading, 108) + .padding(.trailing, 36) + + // Stats rows + VStack(alignment: .leading, spacing: 5) { + StatRow(icon: "star.fill", color: "#F5A524", + label: "Total stars", value: formatCount(stats.totalStars)) + StatRow(icon: "square.stack.fill", color: "#6B7079", + label: "Repositories", value: "\(stats.totalRepos)") + } + .padding(.top, 8) + .padding(.leading, 108) + .padding(.trailing, 12) + } + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.top, 4) + } + + private func formatCount(_ n: Int) -> String { + if n >= 1000 { return String(format: "%.1fk", Double(n) / 1000) } + return "\(n)" + } +} + +private struct StatRow: View { + let icon: String + let color: String + let label: String + let value: String + + var body: some View { + HStack(spacing: 7) { + Image(systemName: icon) + .font(.system(size: 10)) + .foregroundColor(Color(hex: color)) + .frame(width: 14) + Text(label) + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#6B7079")) + Spacer() + Text(value) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Color(hex: "#C5C8CD")) + .monospacedDigit() + } + .frame(maxWidth: .infinity) + } +} + +// MARK: - Stripe Card View + +struct StripeCardView: View { + @ObservedObject private var appState = AppState.shared + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header + HStack(spacing: 6) { + Circle() + .fill(Color(hex: "#0570DE")) + .frame(width: 7, height: 7) + Text("Stripe") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Color(hex: "#F5F6F8")) + Text("Payments") + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#8E939C")) + } + .padding(.top, 6) + .padding(.leading, 108) + .padding(.trailing, 36) + + // Balance + HStack(alignment: .firstTextBaseline, spacing: 3) { + Text(balanceFormatted) + .font(.system(size: 20, weight: .bold, design: .monospaced)) + .foregroundColor(Color(hex: "#F5F6F8")) + .contentTransition(.numericText(countsDown: false)) + .animation(.easeOut(duration: 1.2), value: appState.stripeDisplayBalance) + Text(appState.stripeCurrency.uppercased()) + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(Color(hex: "#6B7079")) + .padding(.bottom, 1) + } + .padding(.leading, 108) + .padding(.top, 4) + + // Payment rows (animated list) + VStack(alignment: .leading, spacing: 2) { + ForEach(appState.stripePayments) { payment in + StripePaymentRow(payment: payment) + .transition(.asymmetric( + insertion: .move(edge: .top).combined(with: .opacity), + removal: .move(edge: .bottom).combined(with: .opacity) + )) + } + } + .animation(.spring(response: 0.38, dampingFraction: 0.82), + value: appState.stripePayments.map(\.id)) + .padding(.leading, 108) + .padding(.trailing, 12) + .padding(.top, 4) + } + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.top, 4) + } + + private var balanceFormatted: String { + String(format: "%.2f", Double(appState.stripeDisplayBalance) / 100.0) + } +} + +private struct StripePaymentRow: View { + let payment: StripePayment + + var body: some View { + let accent = payment.isSuccess ? Color(hex: "#22C55E") : Color(hex: "#F4505E") + HStack(spacing: 5) { + Circle().fill(accent).frame(width: 5, height: 5) + Text(payment.description ?? "Payment") + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#C5C8CD")) + .lineLimit(1).truncationMode(.tail) + .layoutPriority(1) + Spacer(minLength: 4) + Text("+\(payment.amountFormatted)") + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundColor(Color(hex: "#22C55E")) + .fixedSize() + Text(payment.timeAgo) + .font(.system(size: 10)) + .foregroundColor(Color(hex: "#6B7079")) + .fixedSize() + } + .frame(maxWidth: .infinity) + } +} + +// MARK: - Cal.com Card View + +struct CalcomCardView: View { + @ObservedObject private var appState = AppState.shared + @State private var selectedDate: Date? = nil + @State private var selectedBooking: CalcomBooking? = nil + @State private var displayMonth: Date = Date() + @State private var displayHalf: Int = 1 // 1 = first half, 2 = second half + + var body: some View { + Group { + if let booking = selectedBooking { + CalcomBookingDetailView(booking: booking) { + withAnimation(.easeOut(duration: 0.2)) { selectedBooking = nil } + } + } else if let date = selectedDate { + CalcomDayView( + date: date, + bookings: bookingsFor(date), + onSelect: { b in withAnimation(.easeOut(duration: 0.2)) { selectedBooking = b } }, + onBack: { withAnimation(.easeOut(duration: 0.2)) { selectedDate = nil } } + ) + } else { + CalcomCalendarView( + displayMonth: $displayMonth, + displayHalf: $displayHalf, + bookings: appState.calcomBookings, + onSelect: { d in withAnimation(.easeOut(duration: 0.2)) { selectedDate = d } } + ) + } + } + .onChange(of: appState.focusId) { _, _ in + selectedDate = nil; selectedBooking = nil; displayHalf = 1 + } + } + + private func bookingsFor(_ date: Date) -> [CalcomBooking] { + let c = Calendar.current.dateComponents([.year, .month, .day], from: date) + let key = "\(c.year!)-\(String(format: "%02d", c.month!))-\(String(format: "%02d", c.day!))" + return appState.calcomBookings.filter { $0.dayKey == key } + .sorted { $0.startTime < $1.startTime } + } +} + +struct CalcomCalendarView: View { + @Binding var displayMonth: Date + @Binding var displayHalf: Int + let bookings: [CalcomBooking] + let onSelect: (Date) -> Void + + private let cal = Calendar.current + + private var navLabel: String { + let f = DateFormatter(); f.dateFormat = "MMMM yyyy" + return "\(f.string(from: displayMonth)) Q\(displayHalf)" + } + + // 7 consecutive days per row, day 1 always at far left — no weekday alignment + private var allWeeks: [[Date?]] { + let comps = cal.dateComponents([.year, .month], from: displayMonth) + let monthStart = cal.date(from: comps)! + let daysInMonth = cal.range(of: .day, in: .month, for: displayMonth)!.count + var result: [[Date?]] = [] + var chunk: [Date?] = [] + for i in 0.. Bool { + let c = cal.dateComponents([.year, .month, .day], from: d) + let key = "\(c.year!)-\(String(format: "%02d", c.month!))-\(String(format: "%02d", c.day!))" + return bookings.contains { $0.dayKey == key } + } + + private func goBack() { + if displayHalf == 1 { + displayMonth = cal.date(byAdding: .month, value: -1, to: displayMonth) ?? displayMonth + displayHalf = 2 + } else { + displayHalf = 1 + } + } + + private func goForward() { + if displayHalf == 1 { + displayHalf = 2 + } else { + displayMonth = cal.date(byAdding: .month, value: 1, to: displayMonth) ?? displayMonth + displayHalf = 1 + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 6) { + Circle().fill(Color(hex: "#C9956A")).frame(width: 7, height: 7) + Text("Cal.com").font(.system(size: 12, weight: .semibold)).foregroundColor(Color(hex: "#F5F6F8")) + Text("Schedule").font(.system(size: 11)).foregroundColor(Color(hex: "#8E939C")) + } + .padding(.top, 6).padding(.leading, 108).padding(.trailing, 36) + + HStack(spacing: 0) { + Button { goBack() } label: { + Image(systemName: "chevron.left").font(.system(size: 8, weight: .semibold)) + .foregroundColor(Color(hex: "#6B7079")).frame(width: 18, height: 16) + }.buttonStyle(.plain) + Text(navLabel).font(.system(size: 10, weight: .semibold)) + .foregroundColor(Color(hex: "#C5C8CD")).frame(maxWidth: .infinity) + Button { goForward() } label: { + Image(systemName: "chevron.right").font(.system(size: 8, weight: .semibold)) + .foregroundColor(Color(hex: "#6B7079")).frame(width: 18, height: 16) + }.buttonStyle(.plain) + } + .padding(.leading, 108).padding(.trailing, 12).padding(.top, 2) + + VStack(spacing: 1) { + ForEach(visibleWeeks.indices, id: \.self) { i in + CalcomWeekRow(week: visibleWeeks[i], hasBookings: hasBookings, + isToday: cal.isDateInToday, onSelect: onSelect) + } + } + .padding(.leading, 108).padding(.trailing, 12).padding(.top, 2) + } + .frame(maxWidth: .infinity, alignment: .topLeading).padding(.top, 4) + .transition(.opacity) + } +} + +private struct CalcomWeekRow: View { + let week: [Date?] + let hasBookings: (Date) -> Bool + let isToday: (Date) -> Bool + let onSelect: (Date) -> Void + + private var weekLabel: String { + guard let first = week.compactMap({ $0 }).first else { return "" } + let f = DateFormatter(); f.dateFormat = "dd/MM" + return f.string(from: first) + } + + var body: some View { + HStack(spacing: 0) { + Text(weekLabel).font(.system(size: 7)).foregroundColor(Color(hex: "#4B5563")) + .frame(width: 26, alignment: .leading) + ForEach(0..<7, id: \.self) { i in + if let day = week[i] { + CalcomDayCell(day: day, hasEvents: hasBookings(day), isToday: isToday(day)) + .contentShape(Rectangle()).onTapGesture { onSelect(day) }.frame(maxWidth: .infinity) + } else { + Color.clear.frame(maxWidth: .infinity).frame(height: 18) + } + } + } + } +} + +private struct CalcomDayCell: View { + let day: Date + let hasEvents: Bool + let isToday: Bool + var body: some View { + VStack(spacing: 1) { + Text("\(Calendar.current.component(.day, from: day))") + .font(.system(size: 9, weight: isToday ? .bold : .regular)) + .foregroundColor(isToday ? .white : Color(hex: "#9398A1")) + .frame(width: 13, height: 13) + .background(isToday ? Color(hex: "#C9956A").opacity(0.55) : Color.clear) + .clipShape(Circle()) + Circle().fill(hasEvents ? Color(hex: "#C9956A") : Color.clear).frame(width: 3, height: 3) + } + .frame(height: 18) + } +} + +struct CalcomDayView: View { + let date: Date + let bookings: [CalcomBooking] + let onSelect: (CalcomBooking) -> Void + let onBack: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 4) { + Button(action: onBack) { + Image(systemName: "chevron.left").font(.system(size: 9, weight: .semibold)) + .foregroundColor(Color(hex: "#6B7079")).frame(width: 22, height: 22).contentShape(Rectangle()) + }.buttonStyle(.plain).padding(.leading, 108) + Text(dayLabel).font(.system(size: 11, weight: .semibold)).foregroundColor(Color(hex: "#C5C8CD")) + Spacer() + } + .padding(.top, 6).padding(.trailing, 12) + + if bookings.isEmpty { + Text("No calls scheduled").font(.system(size: 11)).foregroundColor(Color(hex: "#6B7079")) + .padding(.leading, 116).padding(.top, 8) + } else { + VStack(alignment: .leading, spacing: 3) { + ForEach(bookings) { b in + Button { onSelect(b) } label: { + HStack(spacing: 6) { + Circle().fill(Color(hex: "#C9956A")).frame(width: 4, height: 4) + Text(b.timeLabel) + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundColor(Color(hex: "#C9956A")).fixedSize() + Text(b.title).font(.system(size: 11)).foregroundColor(Color(hex: "#C5C8CD")) + .lineLimit(1).truncationMode(.tail).layoutPriority(1) + Spacer(minLength: 2) + Image(systemName: "chevron.right").font(.system(size: 8)) + .foregroundColor(Color(hex: "#4B5563")) + } + .padding(.horizontal, 6).padding(.vertical, 3) + .background(Color(hex: "#C9956A").opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + }.buttonStyle(.plain) + } + } + .padding(.leading, 108).padding(.trailing, 12).padding(.top, 5) + } + } + .frame(maxWidth: .infinity, alignment: .topLeading).padding(.top, 4) + .transition(.opacity) + } + private var dayLabel: String { + let f = DateFormatter(); f.dateFormat = "EEEE d MMMM"; return f.string(from: date) + } +} + +struct CalcomBookingDetailView: View { + let booking: CalcomBooking + let onBack: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 4) { + Button(action: onBack) { + Image(systemName: "chevron.left").font(.system(size: 9, weight: .semibold)) + .foregroundColor(Color(hex: "#6B7079")).frame(width: 22, height: 22).contentShape(Rectangle()) + }.buttonStyle(.plain).padding(.leading, 108) + Text(booking.timeLabel) + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundColor(Color(hex: "#C9956A")) + Spacer() + } + .padding(.top, 6).padding(.trailing, 12) + + VStack(alignment: .leading, spacing: 4) { + Text(booking.title).font(.system(size: 12, weight: .semibold)) + .foregroundColor(Color(hex: "#F5F6F8")).lineLimit(1) + if let name = booking.attendeeName, !name.isEmpty { + CalcomDetailRow(icon: "person.fill", text: name, size: 11) + } + if let email = booking.attendeeEmail, !email.isEmpty { + CalcomDetailRow(icon: "envelope.fill", text: email, size: 10, truncate: true) + } + if let notes = booking.attendeeNotes, !notes.isEmpty { + CalcomDetailRow(icon: "note.text", text: notes, size: 10, lines: 2) + } + } + .padding(.leading, 114).padding(.trailing, 12).padding(.top, 5) + } + .frame(maxWidth: .infinity, alignment: .topLeading).padding(.top, 4) + .transition(.opacity) + } +} + +private struct CalcomDetailRow: View { + let icon: String + let text: String + var size: CGFloat = 11 + var truncate: Bool = false + var lines: Int = 1 + var body: some View { + HStack(alignment: .top, spacing: 4) { + Image(systemName: icon).font(.system(size: 9)).foregroundColor(Color(hex: "#6B7079")).frame(width: 10) + Text(text).font(.system(size: size)).foregroundColor(Color(hex: "#9398A1")) + .lineLimit(lines).truncationMode(truncate ? .middle : .tail) + } + } +} + +// MARK: - Notion Card View + +struct NotionCardView: View { + @ObservedObject private var appState = AppState.shared + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 6) { + Circle().fill(Color(hex: "#E8E8E8")).frame(width: 7, height: 7) + Text("Notion").font(.system(size: 12, weight: .semibold)).foregroundColor(Color(hex: "#F5F6F8")) + Text("Recent").font(.system(size: 11)).foregroundColor(Color(hex: "#8E939C")) + } + .padding(.top, 6).padding(.leading, 108).padding(.trailing, 36) + + VStack(alignment: .leading, spacing: 2) { + ForEach(appState.notionPages.prefix(3)) { page in + Button { + if let url = safeWebURL(page.url) { NSWorkspace.shared.open(url) } + } label: { + HStack(spacing: 6) { + if let emoji = page.emoji { + Text(emoji).font(.system(size: 10)).frame(width: 14) + } else { + Image(systemName: "doc.text").font(.system(size: 9)) + .foregroundColor(Color(hex: "#6B7079")).frame(width: 14) + } + Text(page.title).font(.system(size: 11)) + .foregroundColor(Color(hex: "#C5C8CD")) + .lineLimit(1).truncationMode(.tail).layoutPriority(1) + Spacer(minLength: 4) + Text(page.timeAgo).font(.system(size: 9)) + .foregroundColor(Color(hex: "#4B5563")) + } + .padding(.horizontal, 6).padding(.vertical, 4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + .padding(.leading, 102).padding(.trailing, 12).padding(.top, 5) + } + .frame(maxWidth: .infinity, alignment: .topLeading).padding(.top, 4) + .transition(.opacity) + } +} + +// MARK: - n8n Execution Detail View + +struct N8nDetailView: View { + let task: AgentTask + let onClose: () -> Void + + private var success: Bool { task.state == .finished } + private var accent: Color { success ? Color(hex: "#22C55E") : Color(hex: "#F4505E") } + private var statusLabel: String { success ? "Success" : "Failed" } + private var detail: String? { task.steps.dropFirst().first } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + + // Header: back button + workflow name + status badge + HStack(spacing: 7) { + Button(action: onClose) { + Image(systemName: "chevron.left") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(Color(hex: "#6B7079")) + .frame(width: 28, height: 28) // large hit area + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + Circle().fill(accent).frame(width: 6, height: 6) + + Text(task.steps.first ?? "Workflow") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(Color(hex: "#F5F6F8")) + .lineLimit(1).truncationMode(.middle) + .layoutPriority(1) + + Spacer(minLength: 2) + + Text(statusLabel) + .font(.system(size: 10, weight: .medium)) + .foregroundColor(accent) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(accent.opacity(0.14)) + .clipShape(Capsule()) + } + + // Detail body — monospaced, selectable + if let detail { + ScrollView(.vertical, showsIndicators: false) { + Text(detail) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundColor(Color(hex: "#9398A1")) + .frame(maxWidth: .infinity, alignment: .leading) + .lineSpacing(2) + .textSelection(.enabled) + } + .frame(maxHeight: 88) + } else { + Text(success ? "Completed successfully." : "No error details available.") + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#6B7079")) + } + } + .padding(.top, 8) + .padding(.leading, 108) + .padding(.trailing, 12) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .contentShape(Rectangle()) // prevent taps falling through transparent areas + } +} + +// MARK: - Ticker (overview scrolling task steps) V2 + +struct TickerView: View { + let task: AgentTask? + var onDiffTap: ((Int) -> Void)? = nil + + @State private var rowA: String = "…" // completed (above, left-shifted) + @State private var rowB: String = "…" // current (below) → animates diagonally up-left + @State private var rowC: String = "" // incoming current — slides in from below + + @State private var rowAOffset: CGFloat = 0 + @State private var rowAOpacity: Double = 1 + @State private var rowBOffset: CGFloat = 22 + @State private var rowBPhase: Double = 0 // 0=current, 1=completed (drives X+scale) + @State private var rowCOffset: CGFloat = 44 + @State private var rowCOpacity: Double = 0 + + @State private var displayIndex: Int = -1 + @State private var isTransitioning = false + + private let completedScale: CGFloat = 11.5 / 13 // 0.885 — matches completed font size + + var steps: [String] { + let raw = task?.steps ?? [] + return raw.isEmpty ? ["…"] : raw + } + + var body: some View { + let isActive = task?.state == .thinking || task?.state == .working + let rowADiffTap: (() -> Void)? = rowA.parseDiffStep().map { dp in { onDiffTap?(dp.diffId) } } + let rowBDiffTap: (() -> Void)? = rowB.parseDiffStep().map { dp in { onDiffTap?(dp.diffId) } } + + ZStack(alignment: .topLeading) { + Color.clear + + // Row A: completed row — always rendered at phase=1 + completedScale + TickerRowView(text: rowA, phase: 1.0, isActive: isActive, onDiffTap: rowADiffTap) + .scaleEffect(completedScale, anchor: .leading) + .offset(x: -10, y: rowAOffset) + .opacity(rowAOpacity) + + // Row B: current step → animates diagonally up-left, phase 0→1, scale 1→completedScale + TickerRowView(text: rowB, phase: rowBPhase, isActive: isActive, onDiffTap: rowBDiffTap) + .scaleEffect(1 - rowBPhase * (1 - completedScale), anchor: .leading) + .offset(x: -rowBPhase * 10, y: rowBOffset) + + // Row C: incoming new step — slides in from below at phase=0 + TickerRowView(text: rowC, phase: 0.0, isActive: isActive) + .offset(y: rowCOffset) + .opacity(rowCOpacity) + } + .frame(height: 44) + .clipped() + .mask(LinearGradient( + stops: [ + .init(color: .clear, location: 0), + .init(color: .black, location: 0.12), + .init(color: .black, location: 0.85), + .init(color: .clear, location: 1) + ], + startPoint: .top, endPoint: .bottom + )) + .onAppear { + let idx = task?.stepIndex ?? -1 + displayIndex = idx + if idx >= 0, !steps.isEmpty { + rowA = idx > 0 ? steps[max(0, idx - 1)] : "…" + rowB = steps[min(idx, steps.count - 1)] + } + } + .onChange(of: task?.steps.count) { _, _ in + guard let task, !task.steps.isEmpty, !isTransitioning else { return } + let newIdx = task.stepIndex + if displayIndex < 0 { + displayIndex = newIdx + rowA = newIdx > 0 ? steps[max(0, newIdx - 1)] : "…" + rowB = steps[min(newIdx, steps.count - 1)] + return + } + guard newIdx != displayIndex else { return } + tickerAnimate(to: newIdx) + } + } + + private func tickerAnimate(to newIdx: Int) { + isTransitioning = true + rowC = steps[min(newIdx, steps.count - 1)] + rowCOffset = 44 + rowCOpacity = 0 + + // Old completed (rowA): fades + slides further up + withAnimation(.easeOut(duration: 0.28)) { + rowAOffset = -22 + rowAOpacity = 0 + } + + // Current (rowB): moves diagonally up-left + shrinks to completed size + withAnimation(.timingCurve(0.4, 0, 0.2, 1, duration: 0.38)) { + rowBOffset = 0 + rowBPhase = 1 + } + + // New current (rowC): slides in from below + withAnimation(.timingCurve(0.4, 0, 0.2, 1, duration: 0.38)) { + rowCOffset = 22 + rowCOpacity = 1 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.50) { + self.displayIndex = newIdx + self.rowA = self.rowB + self.rowAOffset = 0 + self.rowAOpacity = 1 + self.rowB = self.rowC + self.rowBOffset = 22 + self.rowBPhase = 0 + self.rowCOffset = 44 + self.rowCOpacity = 0 + self.isTransitioning = false + } + } +} + +struct TickerRowView: View { + let text: String + let phase: Double // 0 = current (shimmer, large), 1 = completed (dim, scaled down by caller) + var isActive: Bool = true + var onDiffTap: (() -> Void)? = nil + + var body: some View { + let chevronOpacity: Double = isActive ? max(0, 1 - phase * 2) : 0 + let checkmarkOpacity: Double = isActive ? max(0, phase * 2 - 1) : 1 + let shimmerOpacity: Double = isActive ? max(0, 1 - phase * 1.6) : 0 + let staticOpacity: Double = isActive ? min(1, max(0, phase * 2 - 0.4)) : 1 + let staticColor = (!isActive && phase < 0.5) ? Color(hex: "#C9CDD4") : Color(hex: "#6B7079") + + if let dp = text.parseDiffStep() { + HStack(spacing: 6) { + ZStack { + Image(systemName: "chevron.right") + .font(.system(size: 9, weight: .medium)) + .foregroundColor(Color(hex: "#8E939C")) + .opacity(chevronOpacity) + Image(systemName: "checkmark") + .font(.system(size: 8, weight: .regular)) + .foregroundColor(Color(hex: "#454850")) + .opacity(checkmarkOpacity) + } + .frame(width: 12, alignment: .center) + // Filename + counts + HStack(spacing: 0) { + ZStack(alignment: .leading) { + TickerShimmerText(text: dp.filename) + .opacity(shimmerOpacity) + Text(dp.filename) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(staticColor) + .lineLimit(1).truncationMode(.tail) + .opacity(staticOpacity) + } + if dp.added > 0 { + Text(" +\(dp.added)") + .font(.system(size: 10, weight: .medium).monospaced()) + .foregroundColor(Color(hex: "#22C55E")) + .fixedSize() + } + if dp.removed > 0 { + Text(" −\(dp.removed)") + .font(.system(size: 10, weight: .medium).monospaced()) + .foregroundColor(Color(hex: "#F4505E")) + .fixedSize() + } + } + } + .frame(height: 22, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture { onDiffTap?() } + } else { + HStack(spacing: 6) { + // Icon: chevron fades out first half, checkmark fades in second half + ZStack { + Image(systemName: "chevron.right") + .font(.system(size: 9, weight: .medium)) + .foregroundColor(Color(hex: "#8E939C")) + .opacity(chevronOpacity) + Image(systemName: "checkmark") + .font(.system(size: 8, weight: .regular)) + .foregroundColor(Color(hex: "#454850")) + .opacity(checkmarkOpacity) + } + .frame(width: 12, alignment: .center) + + // Text: shimmer fades out, dim completed text fades in (overlapping cross-fade) + ZStack(alignment: .leading) { + TickerShimmerText(text: text) + .opacity(shimmerOpacity) + Text(text) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(staticColor) + .lineLimit(1).truncationMode(.tail) + .opacity(staticOpacity) + } + } + .frame(height: 22, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +struct TickerShimmerText: View { + let text: String + + var body: some View { + TimelineView(.animation) { tl in + let t = tl.date.timeIntervalSinceReferenceDate + let p = CGFloat(t.truncatingRemainder(dividingBy: 2.2) / 2.2) + // phase sweeps -0.1 → 1.1 so white peak enters from left and exits right + let phase = p * 1.2 - 0.1 + Text(text) + .font(.system(size: 13, weight: .medium)) + .lineLimit(1) + .truncationMode(.tail) + .foregroundStyle(LinearGradient(stops: [ + .init(color: Color(hex: "#7c818a"), location: max(0, phase - 0.3)), + .init(color: Color(hex: "#F2F3F5"), location: max(0, min(1, phase))), + .init(color: Color(hex: "#7c818a"), location: min(1, phase + 0.3)), + ], startPoint: .leading, endPoint: .trailing)) + } + } +} + +// MARK: - Agent pills (overview right card) + +struct AgentPillsView: View { + @ObservedObject var state: AppState + @State private var swapping = false + + private var others: [AgentTask] { + state.tasks.filter { $0.id != state.focusId } + } + + private var displayTasks: [AgentTask] { + Array(others.prefix(4)) + } + + private let columns = [ + GridItem(.flexible(), spacing: 4), + GridItem(.flexible(), spacing: 4) + ] + + var body: some View { + VStack(spacing: 0) { + Spacer(minLength: 0) + LazyVGrid(columns: columns, spacing: 4) { + ForEach(displayTasks) { task in + #if !APPSTORE + if task.id == "integration_music" { + MusicPill(task: task, state: state, swapping: $swapping) { + swapping = true + state.setFocus(task.id) + SoundEngine.shared.play("blip") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { swapping = false } + } + } else { + AgentPill(task: task, state: state, swapping: $swapping) { + swapping = true + state.setFocus(task.id) + SoundEngine.shared.play("blip") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { swapping = false } + } + } + #else + AgentPill(task: task, state: state, swapping: $swapping) { + swapping = true + state.setFocus(task.id) + SoundEngine.shared.play("blip") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { swapping = false } + } + #endif + } + } + .padding(.horizontal, 8) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +struct AgentPill: View { + let task: AgentTask + @ObservedObject var state: AppState + @Binding var swapping: Bool + let onTap: () -> Void + @State private var isHovered = false + + private var effectiveColor: String { task.color } + + // VS Code pill always shows "VS Code" label regardless of active project name + private var displayName: String { + task.id == "integration_claude" ? "VS Code" : task.name + } + + var body: some View { + Button(action: { onTap() }) { + ZStack(alignment: .topTrailing) { + ZStack { + Capsule() + .fill(isHovered + ? Color(hex: effectiveColor).opacity(0.18) + : Color(hex: "#0E0F11")) + Capsule() + .stroke(Color(hex: effectiveColor).opacity(isHovered ? 0.55 : 0.14), lineWidth: 1) + HStack(spacing: 0) { + MiniBotCanvasView(task: task) + .frame(width: 22 / 0.6, height: 22 / 0.6) + .frame(width: 22, height: 22, alignment: .center) + .padding(.leading, 8) + Spacer() + } + Text(displayName) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(isHovered + ? Color(hex: effectiveColor).lighter(by: 0.3) + : Color(hex: "#6B7079")) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .center) + } + .frame(maxWidth: .infinity) + .frame(height: 28) + .shadow(color: Color(hex: effectiveColor).opacity(isHovered ? 0.35 : 0), radius: 10, x: 0, y: 2) + + // Alert badge (approval / finished / error) + if let badge = task.pillBadge { + PillBadgeView(badge: badge, taskColor: effectiveColor) + .offset(x: 3, y: -3) + } + } + } + .buttonStyle(.plain) + .scaleEffect(isHovered ? 1.04 : 1.0) + .brightness(isHovered ? 0.06 : 0) + .onHover { newHover in + guard !swapping else { return } + withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { isHovered = newHover } + } + } +} + +// MARK: - Music Pill (GitHub build only) + +#if !APPSTORE +struct MusicPill: View { + let task: AgentTask + @ObservedObject var state: AppState + @Binding var swapping: Bool + let onTap: () -> Void + @State private var isHovered = false + + private var isPlaying: Bool { AppState.shared.musicPlaying } + private var showControls: Bool { isHovered && MusicController.shared.trackTitle != nil } + + var body: some View { + ZStack { + // Selection target — full pill area, receives taps where controls don't + Capsule() + .fill(Color.clear) + .contentShape(Capsule()) + .onTapGesture { onTap() } + + // Visual fills + Capsule() + .fill(isHovered ? Color(hex: task.color).opacity(0.18) : Color(hex: "#0E0F11")) + .allowsHitTesting(false) + Capsule() + .stroke(Color(hex: task.color).opacity(isHovered ? 0.55 : 0.14), lineWidth: 1) + .allowsHitTesting(false) + + // Mini Mochi at leading edge + HStack(spacing: 0) { + MiniBotCanvasView(task: task, isDancing: isPlaying) + .frame(width: 22 / 0.6, height: 22 / 0.6) + .frame(width: 22, height: 22, alignment: .center) + .padding(.leading, 8) + Spacer() + } + .allowsHitTesting(false) + + // Title — trailing padding grows on hover to make room for buttons + Text(task.name) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(isHovered ? Color(hex: task.color).lighter(by: 0.3) : Color(hex: "#6B7079")) + .lineLimit(1) + .truncationMode(.tail) + .padding(.leading, 34) + .padding(.trailing, showControls ? 52 : 10) + .frame(maxWidth: .infinity, alignment: .center) + .animation(.spring(response: 0.2, dampingFraction: 0.7), value: showControls) + .allowsHitTesting(false) + + // Playback controls — appear on hover when a track is loaded + if showControls { + HStack(spacing: 0) { + Spacer() + HStack(spacing: 2) { + MusicControlButton(icon: isPlaying ? "pause.fill" : "play.fill", color: task.color) { + MusicController.shared.playPause() + } + MusicControlButton(icon: "forward.fill", color: task.color) { + MusicController.shared.nextTrack() + } + } + .padding(.trailing, 4) + } + .transition(.opacity.combined(with: .scale(scale: 0.85, anchor: .trailing))) + } + } + .frame(maxWidth: .infinity) + .frame(height: 28) + .shadow(color: Color(hex: task.color).opacity(isHovered ? 0.35 : 0), radius: 10, x: 0, y: 2) + .scaleEffect(isHovered ? 1.04 : 1.0) + .brightness(isHovered ? 0.06 : 0) + .onHover { newHover in + guard !swapping else { return } + withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { isHovered = newHover } + } + } +} + +struct MusicControlButton: View { + let icon: String + let color: String + let action: () -> Void + @State private var isHovered = false + + var body: some View { + Button(action: action) { + ZStack { + Circle() + .fill(isHovered ? Color(hex: color).opacity(0.18) : Color(hex: "#0E0F11")) + Circle() + .stroke(Color(hex: color).opacity(isHovered ? 0.55 : 0.14), lineWidth: 1) + Image(systemName: icon) + .font(.system(size: 8, weight: .semibold)) + .foregroundColor(isHovered ? Color(hex: color).lighter(by: 0.3) : Color(hex: "#6B7079")) + } + .frame(width: 20, height: 20) + .shadow(color: Color(hex: color).opacity(isHovered ? 0.35 : 0), radius: 6) + } + .buttonStyle(.plain) + .scaleEffect(isHovered ? 1.1 : 1.0) + .onHover { newHover in + withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { isHovered = newHover } + } + } +} +#endif + +// MARK: - Music Card View (GitHub build only) + +#if !APPSTORE +struct MusicCardView: View { + @ObservedObject private var controller = MusicController.shared + @ObservedObject private var appState = AppState.shared + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + if appState.musicAutomationDenied { + // Automation denied — prompt user to fix + HStack(spacing: 6) { + Circle() + .fill(Color(hex: "#F4505E")) + .frame(width: 7, height: 7) + Text("Apple Music") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Color(hex: "#F5F6F8")) + Spacer(minLength: 2) + } + .padding(.top, 6) + .padding(.leading, 108) + .padding(.trailing, 36) + + Text("Allow Coucou to control Music") + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#8E939C")) + .padding(.leading, 108) + .padding(.trailing, 12) + + Button("Open Settings…") { MusicController.shared.openAutomationSettings() } + .font(.system(size: 11, weight: .medium)) + .foregroundColor(Color(hex: "#FA2D48").opacity(0.85)) + .buttonStyle(.plain) + .padding(.leading, 108) + .padding(.top, 2) + } else { + // Line 1: dot + title + HStack(spacing: 6) { + Circle() + .fill(Color(hex: "#FA2D48")) + .frame(width: 7, height: 7) + if let title = controller.trackTitle { + Text(title) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Color(hex: "#F5F6F8")) + .lineLimit(1).truncationMode(.tail) + .frame(maxWidth: 150, alignment: .leading) + } + } + .padding(.top, 6) + .padding(.leading, 108) + + // Line 2: artist + if let artist = controller.artist { + Text(artist) + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#8E939C")) + .lineLimit(1).truncationMode(.tail) + .frame(maxWidth: 150, alignment: .leading) + .padding(.leading, 108) + } + + // Line 3: controls + HStack(spacing: 8) { + Button(action: { MusicController.shared.previousTrack() }) { + Image(systemName: "backward.fill") + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#8E939C")) + } + .buttonStyle(.plain) + Button(action: { MusicController.shared.playPause() }) { + Image(systemName: appState.musicPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#FA2D48")) + } + .buttonStyle(.plain) + Button(action: { MusicController.shared.nextTrack() }) { + Image(systemName: "forward.fill") + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#8E939C")) + } + .buttonStyle(.plain) + } + .padding(.leading, 108) + .padding(.top, 6) + } + } + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.top, 4) + } +} +#endif + +struct PillBadgeView: View { + let badge: PillBadge + let taskColor: String + + private var badgeColor: Color { + switch badge { + case .approval: return Color(hex: "#F5A524") + case .finished: return Color(hex: "#22C55E") + case .error: return Color(hex: "#F4505E") + } + } + + private var icon: String { + switch badge { + case .approval: return "exclamationmark" + case .finished: return "checkmark" + case .error: return "xmark" + } + } + + var body: some View { + ZStack { + Circle() + .fill(Color(hex: "#0B0C0E")) + .frame(width: 14, height: 14) + Circle() + .fill(badgeColor) + .frame(width: 12, height: 12) + Image(systemName: icon) + .font(.system(size: 6, weight: .bold)) + .foregroundColor(.black) + } + .shadow(color: badgeColor.opacity(0.6), radius: 4, x: 0, y: 0) + } +} + +// MARK: - Column agents (right side of non-overview views) + +struct ColumnAgentsView: View { + @ObservedObject var state: AppState + + var others: [AgentTask] { + state.tasks.filter { $0.id != state.focusId } + } + + var body: some View { + VStack(spacing: 0) { + ForEach(Array(others.prefix(4).enumerated()), id: \.1.id) { idx, task in + MiniBotCanvasView(task: task) + .frame(width: 16 / 0.6, height: 16 / 0.6) + .frame(width: 16, height: 16) + .position(x: 0, y: CGFloat(50 + idx * 24)) + .animation(.spring(response: 0.5, dampingFraction: 0.72).delay(Double(idx) * 0.035), value: idx) + } + } + } +} + +// MARK: - Wardrobe + +struct WardrobeView: View { + @ObservedObject var state: AppState + @State private var hoveredOutfit: Outfit? = nil + + private let columns = Array(repeating: GridItem(.fixed(30), spacing: 5), count: 14) + + private var headerRight: String { + // Hover takes priority: show hovered outfit name + if let h = hoveredOutfit { + if h == .auto { + let seasonal = Outfit.seasonal(for: Date(), calendar: .current) + let name = seasonal == .none ? "None" : seasonal.displayName + return "Auto · follows the seasons (now: \(name))" + } + return h.displayName + } + // Fall back to current selection + let sel = state.mochiOutfitSelection + if sel == .auto { + let seasonal = Outfit.seasonal(for: Date(), calendar: .current) + let name = seasonal == .none ? "None" : seasonal.displayName + return "Auto · \(name)" + } + return sel.displayName + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header + HStack(spacing: 6) { + Text("Wardrobe") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Color(hex: "#F5F6F8")) + Spacer(minLength: 4) + Text(headerRight) + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#8E939C")) + .lineLimit(1) + } + .padding(.top, 6) + .padding(.horizontal, 10) + + // Grid + let allOutfits = Outfit.allCases.filter { $0 != .auto } + let withAuto = [Outfit.auto] + allOutfits + ScrollView(.vertical, showsIndicators: false) { + LazyVGrid(columns: columns, spacing: 5) { + ForEach(withAuto, id: \.rawValue) { outfit in + OutfitPillView( + outfit: outfit, + isSelected: state.mochiOutfitSelection == outfit, + isHovered: hoveredOutfit == outfit, + onHover: { h in + hoveredOutfit = h ? outfit : nil + if h { + // Preview on main Mochi + let preview: Outfit = outfit == .auto + ? Outfit.seasonal(for: Date(), calendar: .current) + : outfit + state.wardrobePreviewOutfit = preview + } else if hoveredOutfit == nil { + state.wardrobePreviewOutfit = nil + } + }, + onTap: { + guard state.mochiOutfitSelection != outfit else { return } + state.mochiOutfitSelection = outfit + SoundEngine.shared.play("pop") + NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.proud) + }, + seasonalOutfit: outfit == .auto ? state.resolvedOutfit : .none + ) + } + } + .padding(.vertical, 6) + .padding(.horizontal, 8) + } + .frame(maxHeight: .infinity) + .mask( + LinearGradient( + stops: [ + .init(color: .white, location: 0), + .init(color: .white, location: 0.85), + .init(color: .clear, location: 1) + ], + startPoint: .top, endPoint: .bottom + ) + ) + } + .padding(.leading, 108) + .padding(.trailing, 12) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .onDisappear { + state.wardrobePreviewOutfit = nil + hoveredOutfit = nil + } + .onExitCommand { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + state.view = .overview + } + } + } +} + +struct OutfitPillView: View { + let outfit: Outfit + let isSelected: Bool + let isHovered: Bool + let onHover: (Bool) -> Void + let onTap: () -> Void + var seasonalOutfit: Outfit = .none + + var body: some View { + Canvas { context, size in + drawOutfitIcon(context: context, size: size, outfit: outfit, seasonal: seasonalOutfit) + } + .frame(width: 30, height: 30) + .background( + RoundedRectangle(cornerRadius: 7) + .fill(isHovered ? Color.white.opacity(0.10) : Color.white.opacity(0.06)) + ) + .overlay( + RoundedRectangle(cornerRadius: 7) + .stroke(isSelected ? Color.white.opacity(0.40) : Color.white.opacity(0.08), lineWidth: 1) + ) + .onHover { onHover($0) } + .onTapGesture { onTap() } + } +} + +private func drawOutfitIcon(context: GraphicsContext, size: CGSize, outfit: Outfit, seasonal: Outfit = .none) { + let W = size.width, H = size.height + let cx = W / 2, cy = H / 2 + let R: CGFloat = 6.5 // small scale for icon + + switch outfit { + case .auto: + let iconR: CGFloat = 10.0 + let rx = iconR * 1.14, ry = iconR * 0.88 + let mH = MochiH(R: iconR, yaw: 0, pitch: 0) + let bodyPath = mochiOutfitPath(rx, ry) + let iconCY = cy + iconR * 0.62 + + // Draw the seasonal outfit behind body + if seasonal != .none && seasonal != .auto { + drawOutfitBehindStatic(context: context, outfit: seasonal, H: mH, + cx: cx, cy: iconCY, tilt: 0, sx: 1, sy: 1, + roll: 0, morph: 0, isMini: false) + } + // Body + var ctx = context + ctx.translateBy(x: cx, y: iconCY) + ctx.fill(bodyPath, with: .linearGradient( + Gradient(colors: [Color(red: 0.929, green: 0.929, blue: 0.937), + Color(red: 0.769, green: 0.773, blue: 0.792)]), + startPoint: CGPoint(x: rx*0.7, y: -ry*0.85), + endPoint: CGPoint(x: -rx*0.8, y: ry*0.9) + )) + ctx.fill(bodyPath, with: .radialGradient( + Gradient(stops: [.init(color: .clear, location: 0.6), + .init(color: Color.black.opacity(0.2), location: 1)]), + center: .zero, startRadius: iconR*0.15, endRadius: iconR*1.25 + )) + // Eyes + var eyeCtx = ctx; eyeCtx.clip(to: bodyPath) + let ink = Color(red: 0.102, green: 0.082, blue: 0.071) + for f in mEyeFrames(mH) { + guard f.visible else { continue } + var ec = eyeCtx; ec.translateBy(x: f.x, y: f.y); ec.scaleBy(x: f.fx, y: f.fy) + let hh = max(f.h, f.w*0.3) + var pill = Path() + pill.addRoundedRect(in: CGRect(x: -f.w/2, y: -hh/2, width: f.w, height: hh), + cornerSize: CGSize(width: min(f.w/2,hh/2), height: min(f.w/2,hh/2))) + ec.fill(pill, with: .color(ink)) + } + // Front outfit + if seasonal != .none && seasonal != .auto { + drawOutfitFrontStatic(context: context, outfit: seasonal, H: mH, + cx: cx, cy: iconCY, tilt: 0, sx: 1, sy: 1, + roll: 0, morph: 0, isMini: false) + } + // AUTO badge at bottom + var badgeCtx = context + let badgeCY = iconCY + ry * 0.72 + badgeCtx.translateBy(x: cx, y: badgeCY) + let bw: CGFloat = 14, bh: CGFloat = 6.5 + var badge = Path() + badge.addRoundedRect(in: CGRect(x: -bw/2, y: -bh/2, width: bw, height: bh), + cornerSize: CGSize(width: bh/2, height: bh/2)) + badgeCtx.fill(badge, with: .color(Color.black.opacity(0.60))) + badgeCtx.draw(Text("AUTO").font(.system(size: 4.2, weight: .semibold)).foregroundColor(.white), + at: .zero) + + case .none: + var ctx = context + ctx.translateBy(x: cx, y: cy) + // Circle with diagonal slash (⊘) + var circle = Path() + circle.addEllipse(in: CGRect(x: -R * 0.82, y: -R * 0.82, width: R * 1.64, height: R * 1.64)) + ctx.stroke(circle, with: .color(Color(hex: "#454850")), style: StrokeStyle(lineWidth: 1.4, lineCap: .round)) + var slash = Path() + slash.move(to: CGPoint(x: -R * 0.56, y: R * 0.56)) + slash.addLine(to: CGPoint(x: R * 0.56, y: -R * 0.56)) + ctx.stroke(slash, with: .color(Color(hex: "#454850")), style: StrokeStyle(lineWidth: 1.4, lineCap: .round)) + + default: + // Small Mochi wearing the outfit + let iconR: CGFloat = 10.0 + let rx = iconR * 1.14, ry = iconR * 0.88 + let mH = MochiH(R: iconR, yaw: 0, pitch: 0) + let bodyPath = mochiOutfitPath(rx, ry) + let iconCY = cy + iconR * 0.62 + + // Draw outfit behind + drawOutfitBehindStatic(context: context, outfit: outfit, H: mH, + cx: cx, cy: iconCY, tilt: 0, sx: 1, sy: 1, + roll: 0, morph: 0, isMini: false) + + // Draw body + var ctx = context + ctx.translateBy(x: cx, y: iconCY) + let pumpkin = outfit == .pumpkin + let top = pumpkin ? Color(hex: "#FFA94D") : Color(red: 0.929, green: 0.929, blue: 0.937) + let bot = pumpkin ? Color(hex: "#E8590C") : Color(red: 0.769, green: 0.773, blue: 0.792) + ctx.fill(bodyPath, with: .linearGradient( + Gradient(colors: [top, bot]), + startPoint: CGPoint(x: rx * 0.7, y: -ry * 0.85), + endPoint: CGPoint(x: -rx * 0.8, y: ry * 0.9) + )) + ctx.fill(bodyPath, with: .radialGradient( + Gradient(stops: [ + .init(color: .clear, location: 0), + .init(color: .clear, location: 0.6), + .init(color: Color.black.opacity(0.2), location: 1) + ]), + center: .zero, startRadius: iconR * 0.15, endRadius: iconR * 1.25 + )) + ctx.fill(bodyPath, with: .radialGradient( + Gradient(stops: [ + .init(color: Color.white.opacity(0.55), location: 0), + .init(color: .clear, location: 1) + ]), + center: CGPoint(x: rx * 0.34, y: -ry * 0.46), + startRadius: 0, endRadius: iconR * 0.42 + )) + + // Draw eyes + var eyeCtx = ctx + eyeCtx.clip(to: bodyPath) + let ink = Color(red: 0.102, green: 0.082, blue: 0.071) + for f in mEyeFrames(mH) { + guard f.visible else { continue } + var ec = eyeCtx + ec.translateBy(x: f.x, y: f.y) + ec.scaleBy(x: f.fx, y: f.fy) + let hh = max(f.h, f.w * 0.3) + var pill = Path() + pill.addRoundedRect( + in: CGRect(x: -f.w / 2, y: -hh / 2, width: f.w, height: hh), + cornerSize: CGSize(width: min(f.w / 2, hh / 2), height: min(f.w / 2, hh / 2)) + ) + ec.fill(pill, with: .color(ink)) + } + + // Draw outfit front + drawOutfitFrontStatic(context: context, outfit: outfit, H: mH, + cx: cx, cy: iconCY, tilt: 0, sx: 1, sy: 1, + roll: 0, morph: 0, isMini: false) + } +} +// MARK: - Card background + +struct CardBackground: View { + enum Wash { case red, green, pink, amber, cyan, indigo, soft } + + let wash: Wash? + let content: (() -> Content)? + + init(wash: Wash?, @ViewBuilder content: @escaping () -> Content) { + self.wash = wash + self.content = content + } + + var washColor: Color { + switch wash { + case .red: return Color(hex: "#F4505E").opacity(0.55) + case .green: return Color(hex: "#34D399").opacity(0.5) + case .pink: return Color(hex: "#F472B6").opacity(0.55) + case .amber: return Color(hex: "#F5A524").opacity(0.42) + case .cyan: return Color(hex: "#22D3EE").opacity(0.38) + case .indigo: return Color(hex: "#6366F1").opacity(0.5) + case .soft: return Color.white.opacity(0.08) + case nil: return Color.clear + } + } + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 20) + .fill(Color(hex: "#141518")) + .overlay( + RadialGradient( + gradient: Gradient(stops: [ + .init(color: washColor, location: 0), + .init(color: .clear, location: 0.7) + ]), + center: UnitPoint(x: 0.5, y: 1.3), + startRadius: 0, + endRadius: 280 + ) + .clipShape(RoundedRectangle(cornerRadius: 20)) + ) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(Color.white.opacity(0.035), lineWidth: 1) + ) + + if let content = content { + content() + } + } + } +} + +extension CardBackground where Content == EmptyView { + init(wash: Wash?) { + self.wash = wash + self.content = nil + } + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 20) + .fill(Color(hex: "#141518")) + .overlay( + RadialGradient( + gradient: Gradient(stops: [ + .init(color: washColor, location: 0), + .init(color: .clear, location: 0.7) + ]), + center: UnitPoint(x: 0.5, y: 1.3), + startRadius: 0, + endRadius: 280 + ) + .clipShape(RoundedRectangle(cornerRadius: 20)) + ) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(Color.white.opacity(0.035), lineWidth: 1) + ) + } + } +} + +// MARK: - Shared sub-components + +struct AgentWho: View { + let task: AgentTask? + let label: String + + var body: some View { + HStack(spacing: 7) { + if let task = task { + Circle().fill(Color(hex: task.color)).frame(width: 8, height: 8) + Text(task.name).font(.system(size: 12, weight: .semibold)).foregroundColor(Color(hex: "#F5F6F8")) + } + Text(label).font(.system(size: 12)).foregroundColor(Color(hex: "#8E939C")) + } + } +} + +struct CodeBlock: View { + let text: String + + var body: some View { + Text(text) + .font(.system(size: 12, design: .monospaced)) + .padding(.horizontal, 10).padding(.vertical, 5) + .background(Color.white.opacity(0.07)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.white.opacity(0.06))) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .foregroundColor(Color(hex: "#E8E9EC")) + } +} + +struct ContextChip: View { + let context: PromptContext + @State private var glowing = false + + var label: String { + switch context { + case .window(let app, _, let url): + if let url = url, let host = URL(string: url)?.host { return "\(app) · \(host)" } + return app + case .file(let name, _): return name + } + } + + var body: some View { + HStack(spacing: 6) { + Circle() + .fill(LinearGradient(colors: [Color(hex: "#FF6B5B"), Color(hex: "#F7B32B"), Color(hex: "#2DD4A7"), Color(hex: "#38BDF8"), Color(hex: "#A78BFA")], startPoint: .topLeading, endPoint: .bottomTrailing)) + .frame(width: 7, height: 7) + Text(label) + .font(.system(size: 11.5)) + .foregroundColor(Color(hex: "#F1F2F4")) + } + .padding(.horizontal, 10).padding(.vertical, 4) + .background(Color.white.opacity(0.1)) + .clipShape(Capsule()) + .overlay(Capsule().stroke(Color.white.opacity(glowing ? 0.75 : 0), lineWidth: 1.5)) + .scaleEffect(glowing ? 1.06 : 1.0) + .onAppear { + withAnimation(.spring(response: 0.25, dampingFraction: 0.55)) { glowing = true } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { + withAnimation(.easeOut(duration: 0.3)) { glowing = false } + } + } + } +} + +struct MailField: View { + let label: String + let placeholder: String + @Binding var text: String + + var body: some View { + HStack(spacing: 8) { + Text(label) + .font(.system(size: 12.5)) + .foregroundColor(Color(hex: "#80858E")) + .frame(width: 44, alignment: .leading) + TextField(placeholder, text: $text) + .textFieldStyle(.plain) + .font(.system(size: 12.5)) + .foregroundColor(Color(hex: "#F5F6F8")) + } + .padding(.horizontal, 10).padding(.vertical, 6) + .background(Color.white.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } +} + +struct ShimmeringText: View { + let text: String + init(_ text: String) { self.text = text } + + var body: some View { + Text(text) + .foregroundStyle( + LinearGradient( + stops: [ + .init(color: Color(hex: "#7c818a"), location: 0), + .init(color: .white, location: 0.4), + .init(color: Color(hex: "#7c818a"), location: 0.7) + ], + startPoint: .leading, + endPoint: .trailing + ) + ) + } +} + +struct ShimmerOverlay: View { + @State private var phase: CGFloat = 0.0 + + var body: some View { + LinearGradient( + stops: [ + // Clamp all locations to [0,1] and keep them ordered + .init(color: .clear, location: max(0, phase - 0.3)), + .init(color: Color.white.opacity(0.6), location: max(0, min(1, phase))), + .init(color: .clear, location: min(1, phase + 0.3)) + ], + startPoint: .leading, endPoint: .trailing + ) + .blendMode(.overlay) + .onAppear { + withAnimation(.linear(duration: 2.2).repeatForever(autoreverses: false)) { + phase = 1.3 // travels left→right, exits right edge cleanly + } + } + } +} + +// MARK: - Button styles + +struct PrimaryButton: View { + let title: String + let kbd: String? + let action: () -> Void + + init(_ title: String, kbd: String? = nil, action: @escaping () -> Void) { + self.title = title; self.kbd = kbd; self.action = action + } + + var body: some View { + Button(action: action) { + HStack(spacing: 7) { + Text(title).font(.system(size: 12.5, weight: .medium)) + if let k = kbd { + Text(k).font(.system(size: 10.5)) + .padding(.horizontal, 4) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.black.opacity(0.4))) + .opacity(0.55) + } + } + .padding(.horizontal, 13).padding(.vertical, 7) + .background(Color(hex: "#F5F6F8")) + .foregroundColor(Color(hex: "#0B0C0E")) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } +} + +struct SecondaryButton: View { + let title: String + let kbd: String? + let action: () -> Void + + init(_ title: String, kbd: String? = nil, action: @escaping () -> Void) { + self.title = title; self.kbd = kbd; self.action = action + } + + var body: some View { + Button(action: action) { + HStack(spacing: 7) { + Text(title).font(.system(size: 12.5, weight: .medium)) + if let k = kbd { + Text(k).font(.system(size: 10.5)) + .padding(.horizontal, 4) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.white.opacity(0.4))) + .opacity(0.55) + } + } + .padding(.horizontal, 13).padding(.vertical, 7) + .background(Color.white.opacity(0.09)) + .foregroundColor(Color(hex: "#F1F2F4")) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } +} + +struct IconButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .frame(width: 28, height: 28) + .background(Color.white.opacity(0.08)) + .clipShape(Circle()) + .scaleEffect(configuration.isPressed ? 0.94 : 1) + } +} + +struct SendButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .frame(width: 28, height: 28) + .background(Color(hex: "#F5F6F8")) + .clipShape(Circle()) + .scaleEffect(configuration.isPressed ? 0.94 : 1) + } +} + +// MARK: - Settings island view (Point 7) + +struct SettingsIslandView: View { + @ObservedObject var state: AppState + + private var claudeConnected: Bool { + let url = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".claude/settings.json") + guard let data = try? Data(contentsOf: url), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let hooks = json["hooks"] as? [String: Any], + let ss = hooks["SessionStart"] as? [[String: Any]] else { return false } + return ss.contains { matcher in + (matcher["hooks"] as? [[String: Any]])?.contains { + ($0["command"] as? String)?.contains("NotchBuddy") == true + } ?? false + } + } + + private var apiConnected: Bool { + KeychainStore.shared.get("anthropic-api-key") != nil + } + + var body: some View { + ZStack(alignment: .leading) { + CardBackground(wash: nil) + VStack(alignment: .leading, spacing: 10) { + // Sound row + HStack(spacing: 10) { + Toggle("", isOn: $state.soundEnabled) + .toggleStyle(.switch) + .labelsHidden() + .scaleEffect(0.75) + .frame(width: 44) + Text("Sound") + .font(.system(size: 12.5)) + .foregroundColor(Color(hex: "#C5C8CD")) + Slider(value: $state.soundVolume, in: 0...0.2) + .frame(width: 72) + .opacity(state.soundEnabled ? 1 : 0.4) + } + + // Auto-close row + HStack(spacing: 10) { + Image(systemName: "timer") + .font(.system(size: 12)) + .foregroundColor(Color(hex: "#8E939C")) + .frame(width: 16) + Text("Auto-close · \(Int(state.autoCloseInterval))s") + .font(.system(size: 12)) + .foregroundColor(Color(hex: "#C5C8CD")) + Spacer() + HStack(spacing: 6) { + ForEach([10, 15, 30], id: \.self) { s in + Button("\(s)s") { + state.autoCloseInterval = Double(s) + } + .font(.system(size: 11)) + .padding(.horizontal, 7).padding(.vertical, 3) + .background(state.autoCloseInterval == Double(s) ? Color(hex: "#252830") : Color.clear) + .foregroundColor(state.autoCloseInterval == Double(s) ? Color(hex: "#F5F6F8") : Color(hex: "#6B7079")) + .clipShape(Capsule()) + .buttonStyle(.plain) + } + } + } + + // Connection status + HStack(spacing: 14) { + StatusBadge(label: "Claude Code", ok: claudeConnected) + StatusBadge(label: "API", ok: apiConnected) + Spacer() + Button("Settings…") { + NotificationCenter.default.post(name: .openFullSettings, object: nil) + } + .font(.system(size: 11.5)) + .foregroundColor(Color(hex: "#8E939C")) + .buttonStyle(.plain) + } + } + .padding(.leading, 84) + .padding(.trailing, 16) + .padding(.vertical, 14) + } + } +} + +struct StatusBadge: View { + let label: String + let ok: Bool + + var body: some View { + HStack(spacing: 4) { + Circle() + .fill(ok ? Color(hex: "#22C55E") : Color(hex: "#F4505E")) + .frame(width: 6, height: 6) + Text(label) + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#8E939C")) + } + } +} + +// MARK: - Color extension (lighten) + +// MARK: - Chat-provider switch (used by AI pill buttons and ↗ action) + +/// Mirrors ModelPickerView provider-chip tap: animates, fires surprised emote + "pop" sound, +/// then opens the chat view. No-op if provider is already selected (just opens chat). +@MainActor +func switchChatProvider(_ provider: ChatProvider) { + let state = AppState.shared + if provider != state.chatProvider { + withAnimation(.spring(response: 0.25, dampingFraction: 0.7)) { + state.chatProvider = provider + } + NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.surprised) + SoundEngine.shared.play("pop") + } + state.view = .prompt +} + +extension Color { + func lighter(by amount: Double) -> Color { + guard let components = NSColor(self).usingColorSpace(.sRGB) else { return self } + return Color( + red: min(1, Double(components.redComponent) + amount), + green: min(1, Double(components.greenComponent) + amount), + blue: min(1, Double(components.blueComponent) + amount) + ) + } +} diff --git a/NotchBuddy/Sources/App/IslandWindowController.swift b/NotchBuddy/Sources/App/IslandWindowController.swift new file mode 100644 index 0000000..c49e37f --- /dev/null +++ b/NotchBuddy/Sources/App/IslandWindowController.swift @@ -0,0 +1,1154 @@ +import AppKit +import Combine +import SwiftUI + +@MainActor +final class IslandWindowController: NSWindowController { + + private var islandPanel: IslandPanel! + private var state: AppState { AppState.shared } + + // State machine (replaces all hover/absence/auto-close timers) + let fsm = IslandStateMachine() + + private var wasInIsland = false + private var frameTimer: Timer? + private var keyMonitor: Any? + private var viewSubscription: AnyCancellable? + + // Confused recovery timer (set by handleDizzy) + private var confusedRecoveryTimer: DispatchWorkItem? + + // Suppress peek sound on next reveal (e.g. musicReveal) + var silentNextReveal = false + + // Finished-pin timer + private var finishedPinTimer: DispatchWorkItem? + + // Bot-head hover (love emote — mirrors prototype botHover()) + private var hoverTimer: DispatchWorkItem? + private var botHoverTimer: DispatchWorkItem? + private var botHovering: Bool = false + private var lastLoveTime: Double = 0 + private var botHoverStartPos: CGPoint = .zero + + // Window attach drag (M8) + private var attachDragStart: NSPoint? = nil + private var pendingIslandClick = false // any island click → expand on mouseUp + private var inAttachDrag = false + private var dragGhostPanel: NSPanel? = nil + private var dragGhostSize: CGFloat = 0 + private var ghostCurrentOrigin: NSPoint = .zero + private var highlightPanel: NSPanel? = nil + private var highlightWindowPid: pid_t = 0 + + // Notch real dimensions (set on init) + private var notchW: CGFloat = IslandConst.notchWidth + private var notchH: CGFloat = IslandConst.notchHeight + private var hasNotch = true + + // Island-local key monitor (active only when island is key window) + private var localKeyMonitor: Any? + + convenience init() { + let screen = Self.notchScreen() ?? NSScreen.main! + let geometry = Self.screenGeometry(for: screen) + let nW = geometry.width + let nH = geometry.height + + let panelW: CGFloat = 720 + let panelH: CGFloat = 320 + let sf = screen.frame + let panel = IslandPanel( + contentRect: NSRect(x: sf.midX - panelW/2, y: sf.maxY - panelH, + width: panelW, height: panelH), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, defer: false + ) + panel.notchWidth = nW + panel.notchHeight = nH + + self.init(window: panel) + self.islandPanel = panel + self.notchW = nW + self.notchH = nH + self.hasNotch = geometry.hasNotch + setupPanel(screen: screen) + } + + private func setupPanel(screen: NSScreen) { + guard let panel = window as? IslandPanel else { return } + panel.backgroundColor = .clear + panel.isOpaque = false + panel.hasShadow = false + panel.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.mainMenuWindow)) + 3) + panel.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary, .ignoresCycle] + panel.ignoresMouseEvents = true + + // Propagate real notch dimensions to AppState + AppState.shared.notchWidth = notchW + AppState.shared.notchHeight = notchH + AppState.shared.hasNotch = hasNotch + + let contentSize = panel.contentRect(forFrameRect: panel.frame).size + + // Apple-recommended pattern: put NSHostingView and drag destination as siblings + // inside a common superview, rather than embedding one inside the other. + let container = NSView(frame: NSRect(origin: .zero, size: contentSize)) + container.autoresizingMask = [.width, .height] + + let hosting = NSHostingView(rootView: IslandRootView().environmentObject(AppState.shared)) + hosting.frame = NSRect(origin: .zero, size: contentSize) + hosting.autoresizingMask = [.width, .height] + + // FileDropNSView sits below the hosting view (hitTest returns nil → no mouse interference). + // AppKit routes NSDraggingDestination events to registered views independently of hitTest. + let dropView = FileDropNSView(frame: NSRect(origin: .zero, size: contentSize)) + dropView.autoresizingMask = [.width, .height] + dropView.onDragEntered = { [weak self] loc in + Task { @MainActor in + let iLoc = self?.windowToIsland(loc) ?? CGPoint(x: 320, y: 88) + AppState.shared.fileDragOver = true + // enterZone sets isActive=true BEFORE hookExpand triggers re-render, + // so IslandContainer sees isActive=true when state.view becomes .upload. + UploadSequenceEngine.shared.enterZone(x: iLoc.x, y: iLoc.y) + NotificationCenter.default.post(name: .hookExpand, object: IslandView.upload) + NotificationCenter.default.post(name: .botMorphTo, object: CGFloat(1)) + } + } + dropView.onDragUpdated = { [weak self] loc in + Task { @MainActor in + let iLoc = self?.windowToIsland(loc) ?? CGPoint(x: 320, y: 88) + UploadSequenceEngine.shared.updateCursor(x: iLoc.x, y: iLoc.y) + } + } + dropView.onDragExited = { + Task { @MainActor in + AppState.shared.fileDragOver = false + // Do NOT collapse — drag session still active; island stays open. + NotificationCenter.default.post(name: .botMorphTo, object: CGFloat(0)) + UploadSequenceEngine.shared.exitZone() + } + } + dropView.onFilesDropped = { urls in + Task { @MainActor in + await FileDropHandler.handle(urls: urls, state: AppState.shared) + } + } + + container.addSubview(hosting) // z-bottom: SwiftUI + mouse events + container.addSubview(dropView) // z-top: drag only (hitTest→nil, transparent to mouse) + panel.contentView = container + + startPolling() + startKeyMonitor() + startLocalKeyMonitor() + startHotKeys() + wireFSM() + + // Make panel key whenever the prompt/chat view becomes active + // (nonactivatingPanel never auto-becomes key, but TextField needs it) + viewSubscription = state.$view + .receive(on: DispatchQueue.main) + .sink { [weak self] newView in + guard let self else { return } + if newView == .prompt { + self.islandPanel.makeKey() + } + } + } + + // MARK: - FSM wiring + + private func wireFSM() { + fsm.onTransition = { [weak self] from, to in + guard let self else { return } + switch to { + case .hidden: + self.setMode(.hidden) + + case .petit: + if from == .coucou { + // Fire interrupt first so canvas collapse starts before mode change + NotificationCenter.default.post(name: .greetingInterrupt, object: nil) + } else if from == .hidden { + if self.silentNextReveal { + self.silentNextReveal = false + } else { + SoundEngine.shared.play("peek") + } + } + // setMode BEFORE changing view: onChange(of: state.view) guards on .expanded, + // so setting view while already compact won't trigger a spurious open animation. + self.setMode(.compact) + if from == .coucou { self.state.view = self.defaultView() } + // Start 60s hide timer if mouse is not currently over the island + if !self.wasInIsland { self.fsm.mouseLeft() } + + case .home: + self.expand(to: self.defaultView()) + // Start collapse timer if mouse not currently hovering + if !self.wasInIsland { + self.fsm.mouseLeft() + } + + case .coucou: + self.expand(to: .greeting) + } + } + + // FSM observes greetComplete notification + NotificationCenter.default.addObserver( + forName: .greetComplete, object: nil, queue: .main + ) { [weak self] _ in + self?.fsm.greetComplete() + } + + fsm.isHeldOpen = { AppState.shared.pendingApproval != nil } + } + + // MARK: - 60 Hz polling loop + + private func startPolling() { + frameTimer = Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] _ in + guard let self else { return } + Task { @MainActor in self.pollFrame() } + } + RunLoop.main.add(frameTimer!, forMode: .common) + } + + private func pollFrame() { + guard let panel = window as? IslandPanel else { return } + + let mouse = NSEvent.mouseLocation + + // Convert mouse to panel-local coords (macOS: origin bottom-left) + let pf = panel.frame + let local = CGPoint(x: mouse.x - pf.minX, y: mouse.y - pf.minY) + + // Island rect in panel coords + let islandRect = panel.currentIslandFrame(nw: notchW, nh: notchH) + // On a screen without a notch, the resting bar must not intercept clicks + // in the app window immediately below the menu bar. + let hoverRect = !hasNotch && state.mode != .expanded + ? islandRect : islandRect.insetBy(dx: -6, dy: -6) + let inIsland = hoverRect.contains(local) + + // Toggle click-through + let shouldAcceptMouse = inIsland || inAttachDrag || attachDragStart != nil + if panel.ignoresMouseEvents == shouldAcceptMouse { + panel.ignoresMouseEvents = !shouldAcceptMouse + if shouldAcceptMouse, let cv = panel.contentView { + panel.invalidateCursorRects(for: cv) + } + } + + // Mouse in screen coords (Y flipped, origin top-left) for Bot look-at + let screenH = panel.screen?.frame.height ?? NSScreen.main!.frame.height + let newPos = CGPoint(x: mouse.x - (panel.screen?.frame.minX ?? 0), y: screenH - mouse.y) + let cur = AppState.shared.mousePosition + if abs(newPos.x - cur.x) > 1 || abs(newPos.y - cur.y) > 1 { + AppState.shared.mousePosition = newPos + } + + // AppState can hide the island by itself (last task ended): keep the FSM in step. + if state.mode == .hidden && fsm.state == .petit { fsm.hiddenExternally() } + + // Feed FSM hover enter/leave + if inIsland && !wasInIsland { + guard !inAttachDrag else { wasInIsland = inIsland; return } + // If in coucou: tell greeting to stay open (tc → infinity) + if fsm.state == .coucou { + NotificationCenter.default.post(name: .greetingHover, object: nil) + } + fsm.mouseEntered() + } + if !inIsland && wasInIsland { + fsm.mouseLeft() + } + wasInIsland = inIsland + + // Bot-head hover (love emote) + let overBot = state.mode == .expanded && state.stateOverride == nil && isBotHit(local) + if overBot && !botHovering { botHoverIn(mousePos: NSEvent.mouseLocation) } + if !overBot && botHovering { botHoverOut() } + botHovering = overBot + if botHovering { + let m = NSEvent.mouseLocation + let dist = hypot(m.x - botHoverStartPos.x, m.y - botHoverStartPos.y) + if dist > 40 { + botHoverStartPos = m + botHoverTimer?.cancel() + scheduleLoveTimer() + } + } + + // Ghost Mochi follows cursor + window highlight during drag (60 Hz, no throttle) + if inAttachDrag { + updateDragGhost() + updateWindowHighlight() + } + } + + private var lastMouse: CGPoint = .zero + + // MARK: - Bot-head hover (love emote — mirrors prototype botHover()) + + private func botHoverIn(mousePos: CGPoint) { + guard state.mode == .expanded, state.stateOverride == nil else { return } + guard CACurrentMediaTime() - lastLoveTime > 6 else { return } + botHoverStartPos = mousePos + NotificationCenter.default.post(name: .botBlink, object: nil) + NotificationCenter.default.post(name: .botSetTgEs, object: CGFloat(1.08)) + SoundEngine.shared.play("hover") + scheduleLoveTimer() + } + + private func botHoverOut() { + botHoverTimer?.cancel() + NotificationCenter.default.post(name: .botSetTgEs, object: CGFloat(1)) + } + + private func scheduleLoveTimer() { + botHoverTimer?.cancel() + let item = DispatchWorkItem { [weak self] in + guard let self, self.botHovering, self.state.stateOverride == nil else { return } + guard CACurrentMediaTime() - self.lastLoveTime > 6 else { return } + self.lastLoveTime = CACurrentMediaTime() + NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.love) + SoundEngine.shared.play("love") + } + botHoverTimer = item + DispatchQueue.main.asyncAfter(deadline: .now() + 1.9, execute: item) + } + + private func scheduleHover(after delay: TimeInterval, action: @escaping () -> Void) { + hoverTimer?.cancel() + let item = DispatchWorkItem(block: action) + hoverTimer = item + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: item) + } + + // MARK: - Mode transitions + + private func modeLevel(_ m: IslandMode) -> Int { + switch m { case .hidden: return 0; case .compact: return 1; case .expanded: return 2 } + } + + func setMode(_ mode: IslandMode) { + let prev = state.mode + guard mode != prev else { return } + let shrinking = modeLevel(mode) < modeLevel(prev) + let anim: Animation = shrinking + ? .timingCurve(0.45, 0, 0.2, 1, duration: 0.34) + : .spring(response: 0.5, dampingFraction: 0.72) + withAnimation(anim) { state.mode = mode } + if mode == .expanded { SoundEngine.shared.play("open") } + if prev == .expanded { + SoundEngine.shared.play("close") + if fsm.isHeldOpen?() != true { state.isPinned = false } + } + } + + func expand(to view: IslandView) { + state.view = view + if state.mode == .expanded { + // Already expanded — just switch view + } else { + setMode(.expanded) + } + state.lastActivity = .now + } + + func collapse() { + guard fsm.isHeldOpen?() != true else { return } + state.isPinned = false + finishedPinTimer?.cancel() + // Keep the FSM in step with what is on screen (home/coucou → petit now). + fsm.collapse() + setMode(.compact) + window?.resignKey() + } + + // MARK: - Global hot keys (Carbon) + + private func startHotKeys() { + HotKeyCenter.shared.start { [weak self] action in + self?.handleHotKey(action) + } + } + + func handleHotKey(_ action: ShortcutAction) { + switch action { + case .toggleIsland: + if state.mode == .expanded { + collapse() + } else { + islandPanel.makeKey() + expand(to: defaultView()) + } + + case .openChat: + islandPanel.makeKey() + expand(to: .prompt) + + case .goToAlert: + if state.pendingApproval != nil { + islandPanel.makeKey() + expand(to: .approval) + } else if state.pendingQuestion != nil { + islandPanel.makeKey() + expand(to: .question) + } else { + NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.annoyed) + SoundEngine.shared.play("error") + } + + case .jumpToTerminal: + #if !APPSTORE + performJumpToTerminal() + #endif + + case .attachFrontWindow: + #if !APPSTORE + performAttachFrontWindow() + #endif + + case .nextPill: + cyclePill(by: +1) + + case .prevPill: + cyclePill(by: -1) + + case .muteToggle: + state.soundEnabled.toggle() + if state.soundEnabled { SoundEngine.shared.play("tick") } + NotificationCenter.default.post( + name: .triggerEmote, + object: state.soundEnabled ? BotEmote.happy : BotEmote.annoyed) + + case .desktopToggle: + DesktopMochiController.shared.flyOutOrHome() + + case .wardrobeToggle: + if state.mode == .expanded && state.view == .wardrobe { + collapse() + } else { + islandPanel.makeKey() + expand(to: .wardrobe) + } + } + } + + // MARK: - Island-local shortcuts + + private func startLocalKeyMonitor() { + localKeyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self, self.islandPanel.isKeyWindow else { return event } + return self.handleIslandKey(event) ? nil : event + } + } + + @discardableResult + private func handleIslandKey(_ event: NSEvent) -> Bool { + let raw = event.modifierFlags.intersection([.command, .control, .option, .shift]) + let cmd = raw == .command + + // ⌘→ — next pill + if cmd && event.keyCode == 124 { cyclePill(by: +1); return true } + // ⌘← — previous pill + if cmd && event.keyCode == 123 { cyclePill(by: -1); return true } + // ⌘↓ — navigate list down + if cmd && event.keyCode == 125 { navigateCard(by: +1); return true } + // ⌘↑ — navigate list up + if cmd && event.keyCode == 126 { navigateCard(by: -1); return true } + // ⌘O — open selected card item + if cmd && event.keyCode == 31 { openCardSelection(); return true } + // ⌘E — toggle diff + if cmd && event.keyCode == 14 && state.view == .overview { + NotificationCenter.default.post(name: .islandToggleDiff, object: nil) + return true + } + // ⌘↩ — send chat message + if cmd && event.keyCode == 36 && state.view == .prompt { + NotificationCenter.default.post(name: .islandSendMessage, object: nil) + return true + } + // ⌘K — new conversation + if cmd && event.keyCode == 40 && state.view == .prompt { + NotificationCenter.default.post(name: .islandNewConversation, object: nil) + return true + } + // ⌘, — open Settings + if cmd && event.keyCode == 43 { + NotificationCenter.default.post(name: .openFullSettings, object: nil) + return true + } + // ⌘P — pin / unpin + if cmd && event.keyCode == 35 { + state.isPinned.toggle() + return true + } + // ⌘1–⌘9 — switch to pill by number + let digitCodes: [UInt16: Int] = [18:1,19:2,20:3,21:4,23:5,22:6,26:7,28:8,25:9] + if cmd, let n = digitCodes[event.keyCode] { + switchToPill(number: n); return true + } + // ⎋ Escape — focused views (.onExitCommand) have first crack; fall back to collapse + if event.keyCode == 53 && raw.isEmpty { + let consumed = NSApp.sendAction(Selector(("cancelOperation:")), to: nil, from: nil) + if !consumed && state.mode == .expanded && !state.isPinned { + collapse() + } + return true + } + return false + } + + // MARK: - Pill cycling helpers + + private func cyclePill(by delta: Int) { + guard !state.tasks.isEmpty else { return } + let ids = state.tasks.map { $0.id } + let cur = ids.firstIndex(of: state.focusId ?? "") ?? 0 + state.setFocus(ids[(cur + delta + ids.count) % ids.count]) + state.cardSelection = nil + expand(to: .overview) + } + + private func switchToPill(number: Int) { + guard number >= 1, number <= state.tasks.count else { return } + state.setFocus(state.tasks[number - 1].id) + state.cardSelection = nil + expand(to: .overview) + } + + private func navigateCard(by delta: Int) { + guard state.cardItemCount > 0 else { return } + state.cardSelection = ShortcutLogic.navigate( + selection: state.cardSelection, delta: delta, itemCount: state.cardItemCount) + } + + private func openCardSelection() { + guard state.cardSelection != nil else { return } + NotificationCenter.default.post(name: .islandActivateCardSelection, object: nil) + } + + // MARK: - Terminal jump + + #if !APPSTORE + private func performJumpToTerminal() { + guard state.focusTask != nil else { + SoundEngine.shared.play("error") + NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.annoyed) + return + } + let terminalBundleIds = ["com.apple.Terminal", "com.googlecode.iterm2", + "net.kovidgoyal.kitty", "com.mitchellh.ghostty"] + let activated = terminalBundleIds.compactMap { id in + NSWorkspace.shared.runningApplications.first { $0.bundleIdentifier == id } + }.first.map { $0.activate(options: .activateIgnoringOtherApps) } + if activated == nil { + NSWorkspace.shared.open( + URL(fileURLWithPath: "/System/Applications/Utilities/Terminal.app")) + } + collapse() + } + + private func performAttachFrontWindow() { + guard let app = state.lastExternalApp else { + SoundEngine.shared.play("error"); return + } + guard let ctx = WindowContextCapture.captureActive(from: app) else { + SoundEngine.shared.play("error"); return + } + state.promptContext = ctx + SoundEngine.shared.play("approve") + NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy) + islandPanel.makeKey() + expand(to: .prompt) + } + #endif + + // MARK: - Keyboard (Escape closes) + + private func startKeyMonitor() { + keyMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in + Task { @MainActor in + guard let self = self else { return } + if event.keyCode == 53 { // Escape + if self.state.mode == .expanded && !self.state.isPinned { + self.collapse() + } + } + } + } + + // Hook server expand requests (alerts only) + NotificationCenter.default.addObserver(forName: .hookExpand, object: nil, queue: .main) { [weak self] note in + guard let self, let view = note.object as? IslandView else { return } + self.fsm.openedExternally() + self.expand(to: view) + } + + // Hook server compact reveal (non-alert work events: session start, tool use, etc.) + NotificationCenter.default.addObserver(forName: .hookReveal, object: nil, queue: .main) { [weak self] _ in + guard let self else { return } + self.fsm.reveal() + } + + // Music started playing: reveal silently (no peek sound) + NotificationCenter.default.addObserver(forName: .musicReveal, object: nil, queue: .main) { [weak self] _ in + guard let self else { return } + self.silentNextReveal = true + self.fsm.reveal() + self.silentNextReveal = false + } + + // Collapse requests from views (OK button, etc.) + NotificationCenter.default.addObserver(forName: .islandCollapse, object: nil, queue: .main) { [weak self] _ in + self?.collapse() + } + + // Wardrobe open/close from desktop Mochi right-click (does NOT post .hookExpand) + NotificationCenter.default.addObserver(forName: .openWardrobeFromDesktop, object: nil, queue: .main) { [weak self] _ in + guard let self else { return } + if self.state.mode == .expanded && self.state.view == .wardrobe { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + self.state.view = .overview + } + } else { + self.expand(to: .wardrobe) + } + } + + // .botDizzy — posted by BotEngine.slap() on 3rd hit; show confused view + recover after 3.3s + NotificationCenter.default.addObserver(forName: .botDizzy, object: nil, queue: .main) { [weak self] _ in + self?.handleDizzy() + } + + // Window attach drag. + // Uses MainActor.assumeIsolated (synchronous) to avoid race with pollFrame(). + // Global mouseUp is the reliable fallback when cursor is outside our panel frame. + NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in + guard let self else { return event } + MainActor.assumeIsolated { + guard self.wasInIsland else { return } + self.pendingIslandClick = true + self.hoverTimer?.cancel() + self.botHoverTimer?.cancel() + self.botHovering = false + // Drag only starts when clicking directly on the bot head + guard self.isBotHit(event.locationInWindow) else { return } + // Notch Mochi is invisible when on desktop — no drag, no slap + guard !self.state.mochiOnDesktop else { return } + self.attachDragStart = NSEvent.mouseLocation + // Post slap only when expanded + guard self.state.mode == .expanded else { return } + NotificationCenter.default.post(name: .triggerSlap, object: nil) + } + return event + } + NSEvent.addLocalMonitorForEvents(matching: .leftMouseDragged) { [weak self] event in + guard let self else { return event } + MainActor.assumeIsolated { + guard let start = self.attachDragStart, !self.inAttachDrag else { return } + let m = NSEvent.mouseLocation + guard hypot(m.x - start.x, m.y - start.y) > 3 else { return } + self.inAttachDrag = true + NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.love) + self.showDragGhost() + } + return event + } + + // mouseUp — local (cursor still in panel) + global (cursor moved outside panel frame) + let finishDrag: @Sendable () -> Void = { [weak self] in + Task { @MainActor in + guard let self, self.inAttachDrag else { return } + let mouse = NSEvent.mouseLocation + self.inAttachDrag = false + self.attachDragStart = nil + self.state.stateOverride = nil + + #if !APPSTORE + let windowCtx = self.windowContextAtPoint(mouse) + let inNotchZone = self.window?.frame.contains(mouse) == true + + if let ctx = windowCtx { + // Drop on a window → attach context as before + self.hideDragGhost() + self.state.promptContext = ctx + SoundEngine.shared.play("approve") + NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy) + self.expand(to: .prompt) + } else if !inNotchZone { + // Drop outside notch zone → install Mochi on the desktop. + // Prevent hideDragGhost from closing the ghost panel so we can promote it. + let ghost = self.dragGhostPanel + self.dragGhostPanel = nil // nil first so hideDragGhost skips close + self.hideDragGhost() // resets isDraggingBot, closes highlight panel + DesktopMochiController.shared.install(ghostPanel: ghost, at: mouse) + } else { + // Drop back in notch zone → Mochi returns to notch + self.hideDragGhost() + } + #else + let inNotchZoneAS = self.window?.frame.contains(mouse) == true + if !inNotchZoneAS { + let ghost = self.dragGhostPanel + self.dragGhostPanel = nil + self.hideDragGhost() + DesktopMochiController.shared.install(ghostPanel: ghost, at: mouse) + } else { + self.hideDragGhost() + } + #endif + } + } + NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { [weak self] event in + guard let self else { return event } + MainActor.assumeIsolated { + let hadPendingClick = self.pendingIslandClick + let wasDragging = self.inAttachDrag + self.pendingIslandClick = false + if wasDragging { + finishDrag() + } else { + self.attachDragStart = nil + if hadPendingClick && self.state.mode != .expanded { + if self.fsm.state == .home { + // FSM already thinks it's open (e.g. the view folded it): just reopen. + self.expand(to: self.defaultView()) + } else { + self.fsm.click() // FSM petit/hidden→home; onTransition calls expand(to:) + } + } + } + } + return event + } + NSEvent.addGlobalMonitorForEvents(matching: .leftMouseUp) { _ in + finishDrag() + } + + NSEvent.addLocalMonitorForEvents(matching: .rightMouseDown) { [weak self] event in + guard let self else { return event } + MainActor.assumeIsolated { + guard self.wasInIsland, self.isBotHit(event.locationInWindow) else { return } + guard !self.state.mochiOnDesktop else { return } + if self.state.mode == .expanded && self.state.view == .wardrobe { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + self.state.view = .overview + } + } else { + self.expand(to: .wardrobe) + } + } + return event + } + + // Track last external app for window context capture + let ourBundle = Bundle.main.bundleIdentifier ?? "" + NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didActivateApplicationNotification, + object: nil, queue: .main + ) { [weak self] note in + guard let self else { return } + if let app = note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + app.bundleIdentifier != ourBundle { + self.state.lastExternalApp = app + } + } + } + + // MARK: - Drag ghost window (Mochi follows cursor during drag) + + private func showDragGhost() { + guard dragGhostPanel == nil else { return } + // Same size as compact bot: diameter=20 → canvasSize≈33, scale 2× for grab comfort + let canvasSize: CGFloat = 40 / 0.6 // ~67 + dragGhostSize = canvasSize + + let mouse = NSEvent.mouseLocation + let s = dragGhostSize + ghostCurrentOrigin = NSPoint(x: mouse.x - s/2, y: mouse.y - s/2) + + let panel = NSPanel( + contentRect: NSRect(x: ghostCurrentOrigin.x, y: ghostCurrentOrigin.y, width: s, height: s), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.backgroundColor = .clear + panel.isOpaque = false + panel.hasShadow = false + panel.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.mainMenuWindow)) + 4) + panel.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle] + panel.ignoresMouseEvents = true + + let hosting = NSHostingView( + rootView: GhostBotView(canvasSize: canvasSize) + ) + hosting.frame = NSRect(x: 0, y: 0, width: s, height: s) + panel.contentView = hosting + panel.alphaValue = 0 + panel.orderFront(nil) + dragGhostPanel = panel + AppState.shared.isDraggingBot = true + + // Fade + scale-in handled by GhostBotView SwiftUI animation; + // also fade in the window itself for extra smoothness + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.18 + ctx.timingFunction = CAMediaTimingFunction(name: .easeOut) + panel.animator().alphaValue = 1 + } + } + + private func hideDragGhost() { + dragGhostPanel?.close() + dragGhostPanel = nil + highlightPanel?.close() + highlightPanel = nil + highlightWindowPid = 0 + AppState.shared.isDraggingBot = false + } + + private func updateDragGhost() { + guard let panel = dragGhostPanel else { return } + let s = dragGhostSize + let mouse = NSEvent.mouseLocation + // Direct follow — bot is "held", no trailing lag + ghostCurrentOrigin = NSPoint(x: mouse.x - s/2, y: mouse.y - s/2) + panel.setFrameOrigin(ghostCurrentOrigin) + } + + // MARK: - Window highlight overlay (white border on target window during drag) + + private func updateWindowHighlight() { + let mouse = NSEvent.mouseLocation + guard let (appKitBounds, pid) = windowBoundsAtScreenPoint(mouse) else { + // Fade out + close if no window under cursor + if let old = highlightPanel { + let captured = old + highlightPanel = nil + highlightWindowPid = 0 + NSAnimationContext.runAnimationGroup({ ctx in + ctx.duration = 0.12 + ctx.timingFunction = CAMediaTimingFunction(name: .easeIn) + captured.animator().alphaValue = 0 + }, completionHandler: { captured.close() }) + } + return + } + + if pid == highlightWindowPid, let existing = highlightPanel { + // Same window — just track position (windows rarely move, instant is fine) + existing.setFrame(appKitBounds, display: false) + } else { + // New window — close old immediately, fade-in new + highlightPanel?.close() + highlightPanel = nil + highlightWindowPid = pid + + let panel = NSPanel( + contentRect: appKitBounds, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, defer: false + ) + panel.backgroundColor = .clear + panel.isOpaque = false + panel.hasShadow = false + panel.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.mainMenuWindow)) + 2) + panel.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle] + panel.ignoresMouseEvents = true + + let hosting = NSHostingView(rootView: + RoundedRectangle(cornerRadius: 12) + .stroke(Color.white.opacity(0.75), lineWidth: 3) + .shadow(color: Color.white.opacity(0.5), radius: 16) + .padding(2) + .ignoresSafeArea() + ) + hosting.frame = CGRect(origin: .zero, size: appKitBounds.size) + hosting.autoresizingMask = [.width, .height] + panel.contentView = hosting + panel.alphaValue = 0 + panel.orderFront(nil) + highlightPanel = panel + + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.14 + ctx.timingFunction = CAMediaTimingFunction(name: .easeOut) + panel.animator().alphaValue = 1 + } + } + } + + private func windowBoundsAtScreenPoint(_ screenPoint: NSPoint) -> (CGRect, pid_t)? { + guard let screen = window?.screen ?? NSScreen.main else { return nil } + let screenMaxY = screen.frame.maxY + let cgPoint = CGPoint(x: screenPoint.x, y: screenMaxY - screenPoint.y) + + guard let list = CGWindowListCopyWindowInfo( + [.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID + ) as? [[String: Any]] else { return nil } + + let ourBundle = Bundle.main.bundleIdentifier ?? "" + for info in list { + guard let b = info[kCGWindowBounds as String] as? [String: Any], + let x = b["X"] as? CGFloat, let y = b["Y"] as? CGFloat, + let w = b["Width"] as? CGFloat, let h = b["Height"] as? CGFloat else { continue } + guard CGRect(x: x, y: y, width: w, height: h).contains(cgPoint) else { continue } + let pid = info[kCGWindowOwnerPID as String] as? pid_t ?? 0 + guard let app = NSRunningApplication(processIdentifier: pid), + app.bundleIdentifier != ourBundle, + app.activationPolicy == .regular else { continue } + // CG → AppKit: flip Y + return (CGRect(x: x, y: screenMaxY - y - h, width: w, height: h), pid) + } + return nil + } + + // MARK: - Window context at screen point (for drag-attach) + + func windowContextAtPoint(_ screenPoint: NSPoint) -> PromptContext? { + let screen = window?.screen ?? NSScreen.main + // CGWindowList uses top-left origin; NSEvent.mouseLocation uses bottom-left + let screenMaxY = screen?.frame.maxY ?? NSScreen.main!.frame.maxY + let cgPoint = CGPoint(x: screenPoint.x, y: screenMaxY - screenPoint.y) + + guard let windowList = CGWindowListCopyWindowInfo( + [.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID + ) as? [[String: Any]] else { return nil } + + let ourBundle = Bundle.main.bundleIdentifier ?? "" + + for info in windowList { + guard let b = info[kCGWindowBounds as String] as? [String: Any], + let x = b["X"] as? CGFloat, let y = b["Y"] as? CGFloat, + let w = b["Width"] as? CGFloat, let h = b["Height"] as? CGFloat else { continue } + guard CGRect(x: x, y: y, width: w, height: h).contains(cgPoint) else { continue } + + let pid = info[kCGWindowOwnerPID as String] as? pid_t ?? 0 + guard let app = NSRunningApplication(processIdentifier: pid), + app.bundleIdentifier != ourBundle, + app.activationPolicy == .regular else { continue } + + return WindowContextCapture.captureActive(from: app) + } + return nil + } + + // MARK: - Coordinate conversion: window (AppKit, y-up) → island coords (y-down, 0,0 = island top-left) + + func windowToIsland(_ loc: CGPoint) -> CGPoint { + let panelH = window?.frame.height ?? 320 + let panelW = window?.frame.width ?? 720 + let islandLeft = (panelW - IslandConst.expandedWidth) / 2 + // Island is glued to panel top; its bottom in AppKit = panelH - 176 + return CGPoint( + x: loc.x - islandLeft, + y: panelH - loc.y // AppKit y is from bottom; island y from top + ) + } + + // MARK: - Helpers + + func defaultView() -> IslandView { + if state.pendingApproval != nil { return .approval } + return state.tasks.isEmpty ? .empty : .overview + } + + func baseMode() -> IslandMode { + guard state.isPresent else { return .hidden } + return state.tasks.isEmpty ? .hidden : .compact + } + + // MARK: - Activity reset (call on any user interaction in island) + + func resetActivity() { + state.lastActivity = .now + } + + // MARK: - Finished task pin (5.2s) + + func pinForFinished(taskId: String) { + state.isPinned = true + finishedPinTimer?.cancel() + let item = DispatchWorkItem { [weak self] in + guard let self else { return } + self.state.removeTask(id: taskId) + self.state.isPinned = false + self.collapse() + } + finishedPinTimer = item + DispatchQueue.main.asyncAfter(deadline: .now() + 5.2, execute: item) + } + + // MARK: - Dizzy recovery (triggered by BotEngine.slap via .botDizzy) + + private func handleDizzy() { + let prevView = state.view + state.stateOverride = .dizzy + expand(to: .confused) + confusedRecoveryTimer?.cancel() + let recovery = DispatchWorkItem { [weak self] in + guard let self else { return } + self.state.stateOverride = nil + if self.state.view == .confused { + let fallback = self.state.tasks.isEmpty ? IslandView.empty : .overview + self.state.view = (prevView == .confused) ? fallback : prevView + } + NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy) + } + confusedRecoveryTimer = recovery + DispatchQueue.main.asyncAfter(deadline: .now() + 3.3, execute: recovery) + } + + // MARK: - Bot hit test (for slap trigger) + + private func isBotHit(_ windowPoint: CGPoint) -> Bool { + let s = AppState.shared + let panelH = window?.frame.height ?? 320 + let panelW = window?.frame.width ?? 720 + let (islandW, fixedH) = islandSize(mode: s.mode, view: s.view, + progress: s.uploadProgress, nw: notchW, nh: notchH) + // Chat view resizes dynamically — must match IslandContainer.chatPromptHeight + let islandH: CGFloat + if s.mode == .expanded && s.view == .prompt { + let base: CGFloat = 240 + let perMsg: CGFloat = 40 + islandH = min(300, base + CGFloat(s.chatHistory.count) * perMsg) + } else { + islandH = fixedH + } + let islandMinX = (panelW - islandW) / 2 + let (cx, cy, diameter, _) = botPosition(mode: s.mode, view: s.view, + islandW: islandW, islandH: islandH, + uploadProgress: s.uploadProgress, hasNotch: s.hasNotch) + let radius = (diameter / 0.6) / 2 + // botPosition cy is from island TOP; panel AppKit coords have y=0 at bottom + // island top in AppKit coords = panelH (island glued to top of panel/screen) + let botX = islandMinX + cx + let botY = panelH - cy + let dx = windowPoint.x - botX + let dy = windowPoint.y - botY + return dx*dx + dy*dy <= radius * radius + } + + // MARK: - Notch detection (static) + + static func notchScreen() -> NSScreen? { + NSScreen.screens.first { $0.safeAreaInsets.top > 0 } + } + + static func screenGeometry(for screen: NSScreen) -> IslandScreenGeometry { + let visibleMenuBarHeight = screen.frame.maxY - screen.visibleFrame.maxY + // visibleFrame includes the menu bar only while it is visible. Keep a + // small resting bar when menus auto-hide or the app is in full screen. + let menuBarHeight = visibleMenuBarHeight > 0 + ? visibleMenuBarHeight : NSStatusBar.system.thickness + return IslandScreenGeometry( + screenWidth: screen.frame.width, safeAreaTop: screen.safeAreaInsets.top, + auxiliaryLeftWidth: screen.auxiliaryTopLeftArea?.width, + auxiliaryRightWidth: screen.auxiliaryTopRightArea?.width, + menuBarHeight: menuBarHeight + ) + } + + nonisolated func cleanup() { + // Called explicitly before release if needed + } +} + +// MARK: - IslandPanel + +final class IslandPanel: NSPanel { + var notchWidth: CGFloat = IslandConst.notchWidth + var notchHeight: CGFloat = IslandConst.notchHeight + + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } + + /// Allow panel to sit in the menu bar / notch area — don't let macOS push it down. + override func constrainFrameRect(_ frameRect: NSRect, to screen: NSScreen?) -> NSRect { + return frameRect + } + + func currentIslandFrame(nw: CGFloat, nh: CGFloat) -> CGRect { + let s = AppState.shared + let (w, fixedH) = islandSize(mode: s.mode, view: s.view, + progress: s.uploadProgress, nw: nw, nh: nh) + let h: CGFloat + if s.mode == .expanded && s.view == .prompt { + let base: CGFloat = 240 + let perMsg: CGFloat = 40 + h = min(300, base + CGFloat(s.chatHistory.count) * perMsg) + } else { + h = fixedH + } + return CGRect(x: (frame.width - w) / 2, y: frame.height - h, width: w, height: h) + } +} + +// MARK: - Ghost bot view (animated scale-in on appear) + +struct GhostBotView: View { + let canvasSize: CGFloat + @State private var scale: CGFloat = 0.35 + + var body: some View { + BotCanvasView(state: AppState.shared) + .frame(width: canvasSize, height: canvasSize) + .scaleEffect(scale) + .onAppear { + withAnimation(.spring(response: 0.28, dampingFraction: 0.55)) { + scale = 1.0 + } + } + } +} + +// MARK: - Notification names + +extension Notification.Name { + static let triggerEmote = Notification.Name("notchBuddy.triggerEmote") + static let triggerSlap = Notification.Name("notchBuddy.triggerSlap") + static let botDizzy = Notification.Name("notchBuddy.botDizzy") + static let botGreet = Notification.Name("notchBuddy.botGreet") + static let botBlink = Notification.Name("notchBuddy.botBlink") + static let botSetTgEs = Notification.Name("notchBuddy.botSetTgEs") + static let botGulp = Notification.Name("notchBuddy.botGulp") + static let botMorphTo = Notification.Name("notchBuddy.botMorphTo") + static let islandAction = Notification.Name("notchBuddy.islandAction") + static let islandCollapse = Notification.Name("notchBuddy.islandCollapse") + static let islandSendMessage = Notification.Name("notchBuddy.islandSendMessage") + static let islandNewConversation = Notification.Name("notchBuddy.islandNewConversation") + static let islandToggleDiff = Notification.Name("notchBuddy.islandToggleDiff") + static let islandActivateCardSelection = Notification.Name("notchBuddy.islandActivateCardSelection") + static let openFullSettings = Notification.Name("notchBuddy.openFullSettings") + static let hookReveal = Notification.Name("notchBuddy.hookReveal") + static let musicReveal = Notification.Name("notchBuddy.musicReveal") + // Greeting ↔ IslandWindowController + static let greetComplete = Notification.Name("notchBuddy.greetComplete") + static let greetingHover = Notification.Name("notchBuddy.greetingHover") + static let greetingInterrupt = Notification.Name("notchBuddy.greetingInterrupt") + static let openWardrobeFromDesktop = Notification.Name("notchBuddy.openWardrobeFromDesktop") +} + +// MARK: - islandSize (takes real notch dimensions) + +func islandSize(mode: IslandMode, view: IslandView, + progress: Double = 0, + nw: CGFloat = IslandConst.notchWidth, + nh: CGFloat = IslandConst.notchHeight) -> (CGFloat, CGFloat) { + switch mode { + case .hidden: return (nw, nh) + case .compact: return (nw + 160, nh) + case .expanded: + let layout = IslandConst.viewLayouts[view]! + return (IslandConst.expandedWidth, layout.height) + } +} diff --git a/NotchBuddy/Sources/App/LocalChat.swift b/NotchBuddy/Sources/App/LocalChat.swift new file mode 100644 index 0000000..0bb3e0a --- /dev/null +++ b/NotchBuddy/Sources/App/LocalChat.swift @@ -0,0 +1,208 @@ +import Foundation + +// MARK: - Typed error for local model calls + +enum LocalChatError: Error { + /// The server is not reachable (wrong URL, not running, network error). + case serverUnreachable(String) + /// The model was requested but is not installed on the server. + case modelNotFound(String) + /// The server replied with an error message. + case serverError(String) + + var localizedDescription: String { + switch self { + case .serverUnreachable(let url): return "Cannot reach \(url). Is the server running?" + case .modelNotFound(let model): return "Model '\(model)' is not installed." + case .serverError(let msg): return msg + } + } +} + +// MARK: - Local model helpers (Foundation only) + +enum LocalChat { + + // MARK: URL normalisation + + /// Removes trailing slashes and common documentation sub-paths (/api, /v1). + static func normaliseURL(_ raw: String) -> String { + var s = raw.trimmingCharacters(in: .whitespacesAndNewlines) + while s.hasSuffix("/") { s = String(s.dropLast()) } + for suffix in ["/api", "/v1"] { + if s.hasSuffix(suffix) { s = String(s.dropLast(suffix.count)) } + } + return s + } + + // MARK: SSE parsing + + /// Parses `delta.content` from one OpenAI-SSE event line. + /// Returns nil for non-data lines, `[DONE]`, missing or null content. + static func parseSSEDelta(_ line: String) -> String? { + guard line.hasPrefix("data: ") else { return nil } + let payload = String(line.dropFirst(6)) + guard payload != "[DONE]" else { return nil } + guard let data = payload.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let choices = json["choices"] as? [[String: Any]], + let delta = choices.first?["delta"] as? [String: Any], + let content = delta["content"] as? String else { return nil } + return content + } + + // MARK: Think-block filtering + + /// Removes completed `…` blocks (reasoning models like DeepSeek-R1). + static func filterThinkingBlocks(_ text: String) -> String { + let pattern = "[\\s\\S]*?" + guard let regex = try? NSRegularExpression(pattern: pattern) else { return text } + let range = NSRange(text.startIndex..., in: text) + return regex.stringByReplacingMatches(in: text, options: [], range: range, withTemplate: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// For streaming: removes closed think blocks AND hides content inside an + /// open (unclosed) `` block that hasn't received its closing tag yet. + static func progressiveFilter(_ text: String) -> String { + let cleaned = filterThinkingBlocks(text) + if let range = cleaned.range(of: "") { + return String(cleaned[.. Result<[(id: String, label: String)], LocalChatError> { + guard let url = URL(string: "\(baseURL)/v1/models") else { + return .failure(.serverUnreachable(baseURL)) + } + var req = URLRequest(url: url, timeoutInterval: 5) + req.setValue("Bearer ollama", forHTTPHeaderField: "Authorization") + do { + let (data, response) = try await URLSession.shared.data(for: req) + guard let http = response as? HTTPURLResponse, http.statusCode == 200, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["data"] as? [[String: Any]] else { + return .failure(.serverUnreachable(baseURL)) + } + let excluded = ["embed", "bge-", "all-minilm", "clip", "rerank"] + let models = items.compactMap { item -> (id: String, label: String)? in + guard let id = item["id"] as? String else { return nil } + let lower = id.lowercased() + guard !excluded.contains(where: { lower.contains($0) }) else { return nil } + return (id: id, label: id) + } + return .success(models) + } catch { + return .failure(.serverUnreachable(baseURL)) + } + } + + /// Fetches models from a local OpenAI-compatible server (`GET /v1/models`). + /// Filters out embedding and non-chat models (nomic-embed, bge, clip, rerank…). + static func fetchModels(baseURL: String) async -> [(id: String, label: String)] { + if case .success(let models) = await fetchModelsResult(baseURL: baseURL) { return models } + return [] + } + + // MARK: Streaming chat + + /// Sends a conversation to a local OpenAI-compatible server and streams the reply. + /// + /// - Parameters: + /// - baseURL: Base URL of the server (e.g. `http://localhost:11434`). + /// - model: Model identifier. + /// - messages: OpenAI-format message array (`[{"role":…,"content":…}]`). + /// - onToken: Called on the **main actor** with the current visible text after + /// each new token. Think-block content is hidden while the block is open. + /// - Returns: The complete final response with thinking blocks removed. + /// - Throws: `LocalChatError` + /// Convenience overload that accepts messages as `[[String: Any]]`. + /// Serialises the body on the caller's actor before crossing the isolation boundary. + static func streamChat( + baseURL: String, + model: String, + messages: [[String: Any]], + onToken: @MainActor @escaping (String) -> Void + ) async throws -> String { + let body: [String: Any] = [ + "model": model, + "messages": messages, + "stream": true, + "max_tokens": 4096, + ] + let bodyData = (try? JSONSerialization.data(withJSONObject: body)) ?? Data() + return try await streamChat(baseURL: baseURL, encodedBody: bodyData, model: model, onToken: onToken) + } + + static func streamChat( + baseURL: String, + encodedBody: Data, + model: String, + onToken: @MainActor @escaping (String) -> Void + ) async throws -> String { + guard let url = URL(string: "\(baseURL)/v1/chat/completions") else { + throw LocalChatError.serverUnreachable(baseURL) + } + + var req = URLRequest(url: url, timeoutInterval: 300) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue("Bearer ollama", forHTTPHeaderField: "Authorization") + req.httpBody = encodedBody + + let bytes: URLSession.AsyncBytes + let response: URLResponse + do { + (bytes, response) = try await URLSession.shared.bytes(for: req) + } catch { + throw LocalChatError.serverUnreachable(baseURL) + } + + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + + if status == 404 { + throw LocalChatError.modelNotFound(model) + } + + if status != 200 { + var raw = "" + do { for try await byte in bytes { raw.append(Character(UnicodeScalar(byte))); if raw.count > 4096 { break } } } catch {} + if let data = raw.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let msg = ((json["error"] as? [String: Any])?["message"] as? String) { + throw LocalChatError.serverError(msg) + } + throw LocalChatError.serverError("HTTP \(status)") + } + + var accumulated = "" + var lastUpdate = Date.distantPast + let minInterval: TimeInterval = 1.0 / 15.0 + do { + for try await line in bytes.lines { + guard let delta = parseSSEDelta(line) else { continue } + accumulated += delta + let now = Date() + if now.timeIntervalSince(lastUpdate) >= minInterval { + lastUpdate = now + let visible = progressiveFilter(accumulated) + await MainActor.run { onToken(visible) } + } + } + } catch { + throw LocalChatError.serverUnreachable(baseURL) + } + // Always send the final filtered state + let visible = progressiveFilter(accumulated) + await MainActor.run { onToken(visible) } + + return filterThinkingBlocks(accumulated) + .trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/NotchBuddy/Sources/App/MusicController.swift b/NotchBuddy/Sources/App/MusicController.swift new file mode 100644 index 0000000..3578e38 --- /dev/null +++ b/NotchBuddy/Sources/App/MusicController.swift @@ -0,0 +1,279 @@ +#if !APPSTORE +import Foundation +import AppKit +import Combine + +// MARK: - Music Controller + +/// Observes Apple Music state via distributed notifications and provides playback controls. +/// Singleton, @MainActor, GitHub build only. +@MainActor +final class MusicController: ObservableObject { + static let shared = MusicController() + + @Published var trackTitle: String? + @Published var artist: String? + @Published var album: String? + + private var notifTokens: [Any] = [] + private var cancellables = Set() + private let queue = DispatchQueue(label: "fr.louisraille.coucou.music") + + private var isPillActive: Bool { + AppState.shared.activeIntegrations.contains("integration_music") + } + + private init() { + // playerInfo fires whenever Music state changes (play/pause/track change). + // Extract Sendable String? values before crossing into @MainActor. + let tok1 = DistributedNotificationCenter.default().addObserver( + forName: NSNotification.Name("com.apple.Music.playerInfo"), + object: nil, + queue: .main + ) { [weak self] notif in + let info = notif.userInfo + let playerState = info?["Player State"] as? String + let name = info?["Name"] as? String + let artist = info?["Artist"] as? String + let album = info?["Album"] as? String + Task { @MainActor [weak self] in + self?.handlePlayerInfo(playerState: playerState, name: name, artist: artist, album: album) + } + } + notifTokens.append(tok1) + + // Track Music launch — read current state only if granted and pill active + let tok2 = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didLaunchApplicationNotification, + object: nil, + queue: .main + ) { [weak self] notif in + let bundleId = (notif.userInfo?[NSWorkspace.applicationUserInfoKey] + as? NSRunningApplication)?.bundleIdentifier + guard bundleId == "com.apple.Music" else { return } + Task { @MainActor [weak self] in + guard let self else { return } + guard self.isPillActive, + UserDefaults.standard.bool(forKey: "coucou.musicAutomationGranted") else { return } + self.fetchAndApply() + } + } + notifTokens.append(tok2) + + // Clear state when Music quits + let tok3 = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didTerminateApplicationNotification, + object: nil, + queue: .main + ) { [weak self] notif in + let bundleId = (notif.userInfo?[NSWorkspace.applicationUserInfoKey] + as? NSRunningApplication)?.bundleIdentifier + guard bundleId == "com.apple.Music" else { return } + Task { @MainActor [weak self] in self?.clearState() } + } + notifTokens.append(tok3) + + // Observe activeIntegrations — pill activated → initial read; deactivated → clear + AppState.shared.$activeIntegrations + .sink { [weak self] integrations in + guard let self else { return } + if integrations.contains("integration_music") { + if self.isMusicRunning(), + UserDefaults.standard.bool(forKey: "coucou.musicAutomationGranted") { + self.fetchAndApply() + } + } else { + self.clearState() + } + } + .store(in: &cancellables) + } + + // MARK: - Private helpers + + private func isMusicRunning() -> Bool { + NSWorkspace.shared.runningApplications.contains { $0.bundleIdentifier == "com.apple.Music" } + } + + // MARK: - Metadata cleaners + + private static func shortTitle(_ raw: String) -> String { + guard !raw.isEmpty else { return raw } + var s = raw + // Cut at first " - " + if let r = s.range(of: " - ") { + s = String(s[.. String { + guard !raw.isEmpty else { return raw } + let lower = raw.lowercased() + for tag in [" feat.", " ft."] { + if let r = lower.range(of: tag) { + let result = String(raw[..= 4 else { return } + let playing = values[0] == "playing" + let wasPlaying = AppState.shared.musicPlaying + trackTitle = values[1].isEmpty ? nil : Self.shortTitle(values[1]) + artist = values[2].isEmpty ? nil : Self.shortArtist(values[2]) + album = values[3].isEmpty ? nil : values[3] + AppState.shared.musicPlaying = playing + syncTaskName() + if playing && !wasPlaying { + NotificationCenter.default.post(name: .musicReveal, object: nil) + } + } + } + + private func clearState() { + trackTitle = nil; artist = nil; album = nil + AppState.shared.musicPlaying = false + syncTaskName() + } + + private func syncTaskName() { + guard let idx = AppState.shared.tasks.firstIndex(where: { $0.id == "integration_music" }) else { return } + let title = trackTitle ?? "" + AppState.shared.tasks[idx].name = title.isEmpty + ? (PillCatalog.definition(for: "integration_music")?.name ?? "Apple Music") + : title + } + + // MARK: - Playback controls + + func playPause() { + guard isMusicRunning() else { return } + Task { await runAppleScript(#"tell application id "com.apple.Music" to playpause"#) } + } + + func nextTrack() { + guard isMusicRunning() else { return } + Task { await runAppleScript(#"tell application id "com.apple.Music" to next track"#) } + } + + func previousTrack() { + guard isMusicRunning() else { return } + Task { await runAppleScript(#"tell application id "com.apple.Music" to back track"#) } + } + + func openMusic() { + if let app = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == "com.apple.Music" }) { + app.activate(options: .activateIgnoringOtherApps) + } else { + NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/Music.app")) + } + } + + func openAutomationSettings() { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation") { + NSWorkspace.shared.open(url) + } + } + + // MARK: - AppleScript runner + + enum ScriptResult { case success([String]), denied, error } + + @discardableResult + private func runAppleScript(_ source: String) async -> ScriptResult { + await withCheckedContinuation { cont in + queue.async { + let script = NSAppleScript(source: source)! + var errDict: NSDictionary? + let desc = script.executeAndReturnError(&errDict) + if let errDict { + let code = (errDict[NSAppleScript.errorNumber] as? Int) ?? 0 + if code == -1743 { + Task { @MainActor in + AppState.shared.musicAutomationDenied = true + UserDefaults.standard.set(false, forKey: "coucou.musicAutomationGranted") + } + cont.resume(returning: .denied) + } else { + cont.resume(returning: .error) + } + return + } + Task { @MainActor in + UserDefaults.standard.set(true, forKey: "coucou.musicAutomationGranted") + AppState.shared.musicAutomationDenied = false + } + // Extract values on this queue before resuming (avoids NSAppleEventDescriptor Sendable issues) + var values: [String] = [] + let count = desc.numberOfItems + if count > 0 { + for i in 1...count { + values.append(desc.atIndex(i)?.stringValue ?? "") + } + } else { + values = [desc.stringValue ?? ""] + } + cont.resume(returning: .success(values)) + } + } + } +} +#endif diff --git a/NotchBuddy/Sources/App/N8nPoller.swift b/NotchBuddy/Sources/App/N8nPoller.swift new file mode 100644 index 0000000..3ce76e4 --- /dev/null +++ b/NotchBuddy/Sources/App/N8nPoller.swift @@ -0,0 +1,259 @@ +import Foundation + +// MARK: - N8nPoller +// Polls n8n for the latest workflow execution every 15s. +// Tries /api/v1/executions first, falls back to /rest/executions. +// On a new terminal execution: fetches full details, stores [name, detail] in steps. + +final class N8nPoller: @unchecked Sendable { + static let shared = N8nPoller() + private var timer: DispatchSourceTimer? + private var lastExecutionId: String = "" + + private init() {} + + func start() { + guard timer == nil else { return } + let t = DispatchSource.makeTimerSource(queue: .global(qos: .background)) + t.schedule(deadline: .now() + 3, repeating: 15) + t.setEventHandler { [weak self] in self?.poll() } + t.resume() + timer = t + } + + // MARK: - Poll list endpoint + + private func poll() { + guard let apiKey = KeychainStore.shared.get("n8n-api-key"), + let rawBase = KeychainStore.shared.get("n8n-url") else { + n8nLog("No API key or URL configured") + return + } + let base = rawBase.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let endpoints = [ + "\(base)/api/v1/executions?limit=1&includeData=false", + "\(base)/rest/executions?limit=1&includeData=false", + ] + tryList(endpoints, apiKey: apiKey, base: base, idx: 0) + } + + private func tryList(_ urls: [String], apiKey: String, base: String, idx: Int) { + guard idx < urls.count, let url = URL(string: urls[idx]) else { + n8nLog("All list endpoints failed") + return + } + var req = URLRequest(url: url, timeoutInterval: 10) + req.setValue(apiKey, forHTTPHeaderField: "X-N8N-API-KEY") + req.setValue("application/json", forHTTPHeaderField: "Accept") + n8nLog("Polling \(url.host ?? "?")\(url.path)") + + URLSession.shared.dataTask(with: req) { [weak self] data, response, error in + guard let self else { return } + let code = (response as? HTTPURLResponse)?.statusCode ?? 0 + if let error { + self.n8nLog("Network error: \(error.localizedDescription)") + self.tryList(urls, apiKey: apiKey, base: base, idx: idx + 1) + return + } + guard let data else { + self.tryList(urls, apiKey: apiKey, base: base, idx: idx + 1) + return + } + self.n8nLog("HTTP \(code) · \(data.count) bytes") + guard code == 200 else { + self.tryList(urls, apiKey: apiKey, base: base, idx: idx + 1) + return + } + guard let json = try? JSONSerialization.jsonObject(with: data) else { return } + + // Response is either { "data": [...] } or [...] + let items: [[String: Any]] + if let obj = json as? [String: Any], let arr = obj["data"] as? [[String: Any]] { + items = arr + } else if let arr = json as? [[String: Any]] { + items = arr + } else { + self.n8nLog("Unexpected response shape") + return + } + + guard let first = items.first else { self.n8nLog("No executions found"); return } + + let id: String + if let s = first["id"] as? String { id = s } + else if let n = first["id"] as? Int { id = "\(n)" } + else { self.n8nLog("No id in execution"); return } + + guard id != self.lastExecutionId else { + self.n8nLog("Same id=\(id) — no change") + return + } + + // Terminal check: use status field — more reliable than the `finished` bool + // (published workflows often have finished=false on error) + let status = first["status"] as? String ?? "" + let isTerminal = ["success", "error", "crashed", "canceled", "failed"].contains(status) + guard isTerminal else { + self.n8nLog("id=\(id) status=\(status.isEmpty ? "?" : status) — not terminal") + return + } + + self.lastExecutionId = id + let success = status == "success" + self.n8nLog("New execution id=\(id) status=\(status)") + + // Fetch full detail (includeData=true required in some n8n versions) + let detailUrls = [ + "\(base)/api/v1/executions/\(id)?includeData=true", + "\(base)/api/v1/executions/\(id)", + "\(base)/rest/executions/\(id)?includeData=true", + "\(base)/rest/executions/\(id)", + ] + self.fetchDetail(detailUrls, apiKey: apiKey, success: success, idx: 0) + }.resume() + } + + // MARK: - Fetch full execution detail + + private func fetchDetail(_ urls: [String], apiKey: String, success: Bool, idx: Int) { + guard idx < urls.count, let url = URL(string: urls[idx]) else { + dispatch(success: success, name: "Workflow", detail: nil) + return + } + var req = URLRequest(url: url, timeoutInterval: 10) + req.setValue(apiKey, forHTTPHeaderField: "X-N8N-API-KEY") + req.setValue("application/json", forHTTPHeaderField: "Accept") + + URLSession.shared.dataTask(with: req) { [weak self] data, response, _ in + guard let self else { return } + let code = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard let data, code == 200 else { + self.n8nLog("Detail HTTP \(code) \(url.host ?? "?")\(url.path)") + self.fetchDetail(urls, apiKey: apiKey, success: success, idx: idx + 1) + return + } + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + self.fetchDetail(urls, apiKey: apiKey, success: success, idx: idx + 1) + return + } + let name = self.extractWorkflowName(from: json) + let detail = self.parseDetail(from: json, success: success) + self.n8nLog("Parsed: \(name)") + self.dispatch(success: success, name: name, detail: detail) + }.resume() + } + + private func extractWorkflowName(from json: [String: Any]) -> String { + if let wd = json["workflowData"] as? [String: Any], let name = wd["name"] as? String { return name } + if let name = json["name"] as? String { return name } + return "Workflow" + } + + // MARK: - Parse execution output / error message + + private func parseDetail(from json: [String: Any], success: Bool) -> String? { + guard let execData = json["data"] as? [String: Any], + let resultData = execData["resultData"] as? [String: Any] else { return nil } + + if success { + return parseSuccessDetail(resultData: resultData) + } else { + return parseErrorDetail(resultData: resultData) + } + } + + private func parseErrorDetail(resultData: [String: Any]) -> String? { + // Top-level error + if let error = resultData["error"] as? [String: Any] { + let msg = error["message"] as? String ?? "" + if let node = (error["node"] as? [String: Any])?["name"] as? String, !node.isEmpty { + return "\(node)\n\(msg)" + } + return msg + } + // Scan runData for first node error + if let runData = resultData["runData"] as? [String: Any] { + for (nodeName, runs) in runData { + if let runs = runs as? [[String: Any]], + let run = runs.first, + let err = run["error"] as? [String: Any], + let msg = err["message"] as? String { + return "\(nodeName)\n\(msg)" + } + } + } + return nil + } + + private func parseSuccessDetail(resultData: [String: Any]) -> String? { + guard let lastNode = resultData["lastNodeExecuted"] as? String, + let runData = resultData["runData"] as? [String: Any], + let nodeRuns = runData[lastNode] as? [[String: Any]], + let run = nodeRuns.first, + let data = run["data"] as? [String: Any], + let main = data["main"] as? [[[String: Any]]], + let items = main.first else { return nil } + + let count = items.count + let header = "→ \(lastNode) · \(count) item\(count == 1 ? "" : "s")" + + // Preview first item's JSON keys (up to 4) + if let firstItem = items.first, + let jsonObj = firstItem["json"] as? [String: Any], !jsonObj.isEmpty { + let lines = jsonObj.prefix(4).map { "\($0.key): \(fmtValue($0.value))" } + return "\(header)\n\(lines.joined(separator: "\n"))" + } + return header + } + + private func fmtValue(_ v: Any) -> String { + if let s = v as? String { return String(s.prefix(50)) } + if let n = v as? NSNumber { return n.stringValue } + if let a = v as? [Any] { return "[\(a.count)]" } + if v is [String: Any] { return "{…}" } + return "\(v)" + } + + // MARK: - Dispatch to main + + private func dispatch(success: Bool, name: String, detail: String?) { + DispatchQueue.main.async { self.handleExecution(success: success, name: name, detail: detail) } + } + + @MainActor + private func handleExecution(success: Bool, name: String, detail: String?) { + let state = AppState.shared + + // Apply workflow filter (empty = all workflows) + if !state.n8nWorkflowFilter.isEmpty && !state.n8nWorkflowFilter.contains(name) { return } + + state.n8nRuns = Array(([N8nRun(workflow: name, detail: detail, success: success, date: Date())] + + state.n8nRuns).prefix(10)) + + guard let idx = state.tasks.firstIndex(where: { $0.id == "integration_n8n" }) else { return } + let focused = state.focusId == "integration_n8n" + + state.tasks[idx].state = success ? .finished : .error + state.tasks[idx].steps = detail != nil ? [name, detail!] : [name] + + if !focused { + state.tasks[idx].pillBadge = success ? .finished : .error + } + SoundEngine.shared.play(success ? "finish" : "error") + + // Auto-clear after 60s (user needs time to read detail) + DispatchQueue.main.asyncAfter(deadline: .now() + 60) { + guard let i = state.tasks.firstIndex(where: { $0.id == "integration_n8n" }) else { return } + guard state.tasks[i].state == .finished || state.tasks[i].state == .error else { return } + state.tasks[i].state = .idle + state.tasks[i].steps = [] + state.tasks[i].pillBadge = nil + } + } + + // MARK: - Logging + + private func n8nLog(_ message: String) { + appendAppLog("n8n.log", message, timestampFormat: "HH:mm:ss") + } +} diff --git a/NotchBuddy/Sources/App/NotchBuddyApp.swift b/NotchBuddy/Sources/App/NotchBuddyApp.swift new file mode 100644 index 0000000..2adffe9 --- /dev/null +++ b/NotchBuddy/Sources/App/NotchBuddyApp.swift @@ -0,0 +1,13 @@ +import SwiftUI +import AppKit + +@main +struct NotchBuddyApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) var delegate + + var body: some Scene { + Settings { + SettingsView() + } + } +} diff --git a/NotchBuddy/Sources/App/NotionPoller.swift b/NotchBuddy/Sources/App/NotionPoller.swift new file mode 100644 index 0000000..1e6b7c3 --- /dev/null +++ b/NotchBuddy/Sources/App/NotionPoller.swift @@ -0,0 +1,99 @@ +import Foundation + +final class NotionPoller: @unchecked Sendable { + static let shared = NotionPoller() + private var timer: DispatchSourceTimer? + private init() {} + + func start() { + guard timer == nil else { return } + let t = DispatchSource.makeTimerSource(queue: .global(qos: .background)) + t.schedule(deadline: .now() + 9, repeating: 300) + t.setEventHandler { [weak self] in self?.poll() } + t.resume() + timer = t + } + + func pollNow() { poll() } + + private func poll() { + guard let token = KeychainStore.shared.get("notion-api-key") else { return } + guard let url = URL(string: "https://api.notion.com/v1/search") else { return } + var req = URLRequest(url: url, timeoutInterval: 10) + req.httpMethod = "POST" + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + req.setValue("2022-06-28", forHTTPHeaderField: "Notion-Version") + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + let body: [String: Any] = [ + "sort": ["direction": "descending", "timestamp": "last_edited_time"], + "page_size": 3 + ] + req.httpBody = try? JSONSerialization.data(withJSONObject: body) + + URLSession.shared.dataTask(with: req) { [weak self] data, response, error in + guard let self else { return } + let code = (response as? HTTPURLResponse)?.statusCode ?? 0 + if code != 200 { + let msg: String + if code == 401 { msg = "Invalid API key (401)" } + else if code == 0 { msg = error?.localizedDescription ?? "No connection" } + else { msg = "API error \(code)" } + DispatchQueue.main.async { AppState.shared.notionError = msg } + return + } + guard let data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let results = json["results"] as? [[String: Any]] else { return } + + let pages = results.compactMap { self.parsePage($0) } + DispatchQueue.main.async { + AppState.shared.notionError = nil + AppState.shared.notionPages = pages + AppState.shared.notionLoaded = true + } + }.resume() + } + + private func parsePage(_ obj: [String: Any]) -> NotionPage? { + guard let id = obj["id"] as? String else { return nil } + let objType = obj["object"] as? String ?? "page" + + var title = "Untitled" + if objType == "database" { + if let arr = obj["title"] as? [[String: Any]], + let text = arr.first?["plain_text"] as? String, !text.isEmpty { + title = text + } + } else { + if let props = obj["properties"] as? [String: Any] { + for (_, val) in props { + guard let prop = val as? [String: Any], + (prop["type"] as? String) == "title", + let arr = prop["title"] as? [[String: Any]], + let text = arr.first?["plain_text"] as? String, !text.isEmpty else { continue } + title = text; break + } + } + } + + var emoji: String? = nil + if let icon = obj["icon"] as? [String: Any], + (icon["type"] as? String) == "emoji", + let em = icon["emoji"] as? String { + emoji = em + } + + guard let editedStr = obj["last_edited_time"] as? String else { return nil } + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + var lastEdited = iso.date(from: editedStr) + if lastEdited == nil { + let iso2 = ISO8601DateFormatter(); iso2.formatOptions = [.withInternetDateTime] + lastEdited = iso2.date(from: editedStr) + } + guard let lastEdited else { return nil } + + let pageURL = (obj["url"] as? String) ?? "https://notion.so" + return NotionPage(id: id, title: title, emoji: emoji, lastEditedAt: lastEdited, url: pageURL) + } +} diff --git a/NotchBuddy/Sources/App/PhoneLink/ApprovalRelay.swift b/NotchBuddy/Sources/App/PhoneLink/ApprovalRelay.swift new file mode 100644 index 0000000..0f52846 --- /dev/null +++ b/NotchBuddy/Sources/App/PhoneLink/ApprovalRelay.swift @@ -0,0 +1,163 @@ +#if PHONE_LINK +import AppKit +import CloudKit +import Combine +import CryptoKit + +// MARK: - iPhone plan, step 7: approve or deny from the iPhone +// +// While Claude Code (or Cursor, Codex) waits for a permission: +// 1. an `ApprovalRequest` record goes to iCloud; the iPhone's query subscription +// turns it into a notification; +// 2. the Mac looks for a `Decision` record every 2 s — only while a request is +// pending, so nothing runs at rest; +// 3. a decision is applied only if its fingerprint matches the request still +// pending (same session, tool, command and input). A late decision, or one +// meant for another command, is ignored. Only "allow" and "deny" exist: no +// "always" from the phone. +// The request record is deleted as soon as the approval is resolved, on either side. + +@MainActor +final class ApprovalRelay { + static let shared = ApprovalRelay() + + private let container = CKContainer(identifier: "iCloud.fr.louisraille.Coucou") + private var database: CKDatabase { container.privateCloudDatabase } + private var zoneID: CKRecordZone.ID { SessionSnapshot.zoneID } + + private var cancellable: AnyCancellable? + private var current: (fingerprint: String, since: Date)? + private var pollTask: Task? + private var changeToken: CKServerChangeToken? + + /// Stable identifier of one pending request. + nonisolated static func fingerprint(_ approval: ApprovalInfo) -> String { + let raw = [approval.pillId, approval.sessionId, approval.tool, approval.command, approval.inputKey] + .joined(separator: "\u{1F}") + return SHA256.hash(data: Data(raw.utf8)).map { String(format: "%02x", $0) }.joined() + } + + func start() { + guard cancellable == nil else { return } + // @Published sends the new value before the property changes, so the + // value is passed along rather than read back from AppState. + cancellable = AppState.shared.$pendingApproval + .removeDuplicates { $0.map(Self.fingerprint) == $1.map(Self.fingerprint) } + .sink { [weak self] approval in + MainActor.assumeIsolated { self?.pendingChanged(to: approval) } + } + log("relay on") + } + + func stop() { + cancellable = nil + pendingChanged(to: nil) + } + + // MARK: Request lifecycle + + private func pendingChanged(to approval: ApprovalInfo?) { + let fingerprint = approval.map(Self.fingerprint) + if let old = current, old.fingerprint != fingerprint { + pollTask?.cancel() + pollTask = nil + current = nil + let id = requestID(old.fingerprint) + Task { _ = try? await database.modifyRecords(saving: [], deleting: [id]) } + } + guard let fingerprint, let approval, current?.fingerprint != fingerprint else { return } + current = (fingerprint, Date()) + Task { await publishRequest(approval, fingerprint: fingerprint) } + pollTask = Task { [weak self] in + // The Mac dismisses the request after 115 s anyway. + for _ in 0..<60 { + try? await Task.sleep(for: .seconds(2)) + guard !Task.isCancelled, let self else { return } + if await self.checkDecisions(for: fingerprint) { return } + } + } + } + + private func requestID(_ fingerprint: String) -> CKRecord.ID { + CKRecord.ID(recordName: "approval-\(fingerprint.prefix(32))", zoneID: zoneID) + } + + private func publishRequest(_ approval: ApprovalInfo, fingerprint: String) async { + let record = CKRecord(recordType: "ApprovalRequest", recordID: requestID(fingerprint)) + record["pillId"] = approval.pillId + record["fingerprint"] = fingerprint + record["createdAt"] = Date() + record.encryptedValues["tool"] = approval.tool + record.encryptedValues["command"] = approval.command + do { + _ = try await database.modifyRecords(saving: [record], deleting: [], savePolicy: .allKeys) + log("approval request sent to the iPhone (\(approval.tool))") + } catch { + log("approval request failed: \(error.localizedDescription)") + } + } + + // MARK: Decisions + + /// Returns true once a matching decision was applied. + private func checkDecisions(for fingerprint: String) async -> Bool { + guard current?.fingerprint == fingerprint, let since = current?.since else { return true } + var found: [(CKRecord.ID, String, String)] = [] // id, fingerprint, decision + do { + var more = true + while more { + let changes = try await database.recordZoneChanges(inZoneWith: zoneID, since: changeToken) + for (id, result) in changes.modificationResultsByID { + guard case .success(let mod) = result, mod.record.recordType == "Decision" else { continue } + let record = mod.record + let decidedAt = record["decidedAt"] as? Date ?? .distantPast + guard decidedAt >= since.addingTimeInterval(-5) else { continue } + found.append((id, record["fingerprint"] as? String ?? "", record["decision"] as? String ?? "")) + } + changeToken = changes.changeToken + more = changes.moreComing + } + } catch let error as CKError where error.code == .changeTokenExpired { + changeToken = nil + return false + } catch { + log("decision check failed: \(error.localizedDescription)") + return false + } + guard !found.isEmpty else { return false } + // Decisions are single use. + let ids = found.map(\.0) + Task { _ = try? await database.modifyRecords(saving: [], deleting: ids) } + + guard let match = found.first(where: { $0.1 == fingerprint }) else { + log("ignored \(found.count) decision(s) for another request") + return false + } + apply(match.2, fingerprint: fingerprint) + return true + } + + private func apply(_ decision: String, fingerprint: String) { + // Check again on the main actor, right before answering the hook. + guard let pending = AppState.shared.pendingApproval, + Self.fingerprint(pending) == fingerprint else { + log("decision arrived after the request was resolved, ignored") + return + } + switch decision { + case "allow": + log("allowed from the iPhone: \(pending.tool)") + HookServer.shared.sendApprovalDecision("allow") + case "deny": + log("denied from the iPhone: \(pending.tool)") + HookServer.shared.sendApprovalDecision("deny") + default: + log("unknown decision '\(decision)', ignored") + } + } + + private func log(_ message: String) { + CloudProbe.shared.log("[approval] \(message)") + } +} +#endif diff --git a/NotchBuddy/Sources/App/PhoneLink/CloudProbe.swift b/NotchBuddy/Sources/App/PhoneLink/CloudProbe.swift new file mode 100644 index 0000000..24a6507 --- /dev/null +++ b/NotchBuddy/Sources/App/PhoneLink/CloudProbe.swift @@ -0,0 +1,277 @@ +#if PHONE_LINK +import AppKit +import CloudKit + +// MARK: - iPhone link +// +// Starts and stops the iPhone sync (Settings → General → iPhone, off by +// default): push registration and SessionPublisher. Nothing runs and nothing +// is sent to iCloud while it is off. +// +// Step 1 spike, kept for testing: when the phoneLinkPing default is on, proves +// that the Mac and the iPhone app share a private CloudKit database: +// writes a `Ping` every 60 s, waits for the iPhone's `Pong`, and logs the +// round trip to ~/Library/Logs/NotchBuddy/nb.log. Pongs arrive both through a +// silent push (CKDatabaseSubscription) and a 5 s poll, so the log shows which +// path is faster. Compiled only with PHONE_LINK; normal builds never see it. + +@MainActor +final class CloudProbe { + static let shared = CloudProbe() + + static let containerID = "iCloud.fr.louisraille.Coucou" + static let zoneID = CKRecordZone.ID(zoneName: "Coucou", ownerName: CKCurrentUserDefaultName) + private static let subscriptionID = "coucou-zone-mac" + + private let container = CKContainer(identifier: CloudProbe.containerID) + private var database: CKDatabase { container.privateCloudDatabase } + + private let launchDate = Date() + private var ready = false + private var pingCount = 0 + private var changeToken: CKServerChangeToken? + private var seenPongs = Set() + private var fetching = false + private var lastPushAt: Date? + private var started = false + private var pingTask: Task? + private var pollTask: Task? + + private var appLabel: String { + #if APPSTORE + "CoucouAppStore" + #else + "NotchBuddy" + #endif + } + + private var macName: String { + Host.current().localizedName ?? ProcessInfo.processInfo.hostName + } + + static let enabledKey = "iPhoneSyncEnabled" + + static var isEnabled: Bool { UserDefaults.standard.bool(forKey: enabledKey) } + + /// Called at launch: starts only if the user turned the iPhone sync on. + func startIfEnabled() { + if Self.isEnabled { start() } + } + + func setEnabled(_ on: Bool) { + UserDefaults.standard.set(on, forKey: Self.enabledKey) + if on { start() } else { stop() } + } + + private func stop() { + guard started else { return } + started = false + pingTask?.cancel(); pingTask = nil + pollTask?.cancel(); pollTask = nil + NSApplication.shared.unregisterForRemoteNotifications() + SessionPublisher.shared.stop() + ApprovalRelay.shared.stop() + QuestionRelay.shared.stop() + ServiceDetailRunner.shared.stop() + ServicePublisher.shared.stop() + TurnRecorder.shared.stop() + #if !APPSTORE + InstructionRunner.shared.stop() + #endif + LiveActivityRelay.shared.stop() + log("iPhone sync off") + } + + private func start() { + guard !started else { return } + started = true + #if DEBUG + let build = "debug" + #else + let build = "release" + #endif + log("starting (\(appLabel), \(build) build, container \(Self.containerID))") + NSApplication.shared.registerForRemoteNotifications() + SessionPublisher.shared.start() + ApprovalRelay.shared.start() + QuestionRelay.shared.start() + ServiceDetailRunner.shared.start() + ServicePublisher.shared.start() + TurnRecorder.shared.start() + #if !APPSTORE + InstructionRunner.shared.startIfEnabled() + #endif + LiveActivityRelay.shared.startIfEnabled() + // The silent database subscription, so the iPhone's requests (services) wake this Mac. + Task { _ = await prepare() } + + // Step 1 Ping/Pong test: off unless asked for, so the Mac stays idle at rest + // (defaults write fr.louisraille.NotchBuddy phoneLinkPing -bool YES). + guard UserDefaults.standard.bool(forKey: "phoneLinkPing") else { + log("ping test off (phoneLinkPing)") + return + } + pingTask = Task { [weak self] in + while !Task.isCancelled { + await self?.pingTick() + try? await Task.sleep(for: .seconds(60)) + } + } + pollTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(5)) + await self?.fetchChanges(source: "poll") + } + } + } + + // MARK: Setup + + /// Checks the iCloud account, creates the zone and the subscription. + /// Returns false (and logs why) when iCloud isn't usable yet; retried on the next tick. + private func prepare() async -> Bool { + if ready { return true } + do { + let status = try await container.accountStatus() + guard status == .available else { + log("iCloud account not available (status \(describe(status))), will retry in 60 s") + return false + } + _ = try await database.modifyRecordZones(saving: [CKRecordZone(zoneID: Self.zoneID)], deleting: []) + log("zone Coucou ready") + + let sub = CKDatabaseSubscription(subscriptionID: Self.subscriptionID) + let info = CKSubscription.NotificationInfo() + info.shouldSendContentAvailable = true // silent push + sub.notificationInfo = info + _ = try await database.modifySubscriptions(saving: [sub], deleting: []) + log("database subscription saved") + let subs = try await database.allSubscriptions() + log("subscriptions on this iCloud account: \(subs.map(\.subscriptionID).sorted().joined(separator: ", "))") + ready = true + return true + } catch { + log("setup failed: \(error.localizedDescription)") + return false + } + } + + // MARK: Ping + + private func pingTick() async { + guard await prepare() else { return } + pingCount += 1 + let record = CKRecord(recordType: "Ping", + recordID: CKRecord.ID(recordName: UUID().uuidString, zoneID: Self.zoneID)) + let sentAt = Date() + record["macName"] = macName + record["app"] = appLabel + record["sentAt"] = sentAt + record.encryptedValues["message"] = "Ping #\(pingCount) from \(appLabel)" + do { + _ = try await database.save(record) + log("Ping #\(pingCount) saved in \(String(format: "%.1f", Date().timeIntervalSince(sentAt))) s") + } catch { + log("Ping #\(pingCount) failed: \(error.localizedDescription)") + } + } + + // MARK: Pong + + func handleRemoteNotification(_ userInfo: [String: Any]) { + guard CKNotification(fromRemoteNotificationDictionary: userInfo) != nil else { + log("remote notification received, not from CloudKit (keys: \(userInfo.keys.sorted().joined(separator: ", ")))") + return + } + lastPushAt = Date() + // A request from the iPhone (a service to read, an action) may be waiting. + Task { await ServiceDetailRunner.shared.checkNow() } + guard pingTask != nil else { return } // the Pong fetch is only for the ping test + log("push received") + Task { await fetchChanges(source: "push") } + } + + private func fetchChanges(source: String) async { + guard ready, !fetching else { return } + fetching = true + defer { fetching = false } + do { + var more = true + while more { + let changes = try await database.recordZoneChanges(inZoneWith: Self.zoneID, since: changeToken) + for (_, result) in changes.modificationResultsByID { + if case .success(let mod) = result { handle(mod.record, source: source) } + } + changeToken = changes.changeToken + more = changes.moreComing + } + } catch let error as CKError where error.code == .changeTokenExpired { + changeToken = nil + } catch let error as CKError where error.code == .zoneNotFound { + log("zone Coucou missing, recreating") + ready = false + } catch { + log("fetch (\(source)) failed: \(error.localizedDescription)") + } + } + + private func handle(_ record: CKRecord, source: String) { + guard record.recordType == "Pong" else { return } + let id = record.recordID.recordName + guard !seenPongs.contains(id) else { return } + seenPongs.insert(id) + // Older pongs from previous runs: remember them, don't log them. + guard let created = record.creationDate, created >= launchDate else { return } + + let message = record.encryptedValues["message"] as? String ?? "?" + var line = "Pong from iPhone: \(message)" + if let pingSentAt = record["pingSentAt"] as? Date { + line += String(format: ", round trip %.1f s", Date().timeIntervalSince(pingSentAt)) + } + if let repliedAt = record["repliedAt"] as? Date { + line += String(format: ", %.1f s after the tap", Date().timeIntervalSince(repliedAt)) + } + line += " (via \(source)" + if source == "poll", let push = lastPushAt, Date().timeIntervalSince(push) < 10 { + line += ", push came \(String(format: "%.1f", Date().timeIntervalSince(push))) s ago" + } + line += ")" + log(line) + } + + // MARK: Helpers + + private func describe(_ status: CKAccountStatus) -> String { + switch status { + case .available: "available" + case .noAccount: "no account" + case .restricted: "restricted" + case .couldNotDetermine: "could not determine" + case .temporarilyUnavailable: "temporarily unavailable" + @unknown default: "unknown" + } + } + + /// Writes to nb.log and to the Xcode console (the sandboxed build's nb.log + /// sits in its container, which Terminal can't read). + func log(_ message: String) { + appendAppLog("nb.log", "[PhoneLink] \(message)") + print("[PhoneLink] \(message)") + } +} + +extension AppDelegate { + @objc func application(_ application: NSApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + let token = deviceToken.prefix(4).map { String(format: "%02x", $0) }.joined() + CloudProbe.shared.log("registered for remote notifications (token \(token)…)") + } + + @objc func application(_ application: NSApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + CloudProbe.shared.log("remote notification registration failed: \(error.localizedDescription)") + } + + @objc func application(_ application: NSApplication, didReceiveRemoteNotification userInfo: [String: Any]) { + CloudProbe.shared.handleRemoteNotification(userInfo) + } +} +#endif diff --git a/NotchBuddy/Sources/App/PhoneLink/InstructionRunner.swift b/NotchBuddy/Sources/App/PhoneLink/InstructionRunner.swift new file mode 100644 index 0000000..b7aa07d --- /dev/null +++ b/NotchBuddy/Sources/App/PhoneLink/InstructionRunner.swift @@ -0,0 +1,182 @@ +#if PHONE_LINK && !APPSTORE +import AppKit +import CloudKit + +// MARK: - Instructions from the iPhone +// +// The iPhone writes an `Instruction` record (text encrypted) for a session. +// When the user turned it on (Settings → General → iPhone, off by default), +// this Mac checks every 15 s, takes each instruction once (the record is +// deleted on read), and continues that same Claude Code conversation in the +// background: `claude -p --resume `, in the session's own +// folder. The folder and the session come from what this Mac saw in the +// hooks, never from the iPhone. Hooks keep working, so the notch, the iPhone +// and the permission requests (approved by hand, with Face ID on the phone) +// follow the run like any other turn. +// GitHub build only: the App Store build is sandboxed and can't start `claude`. + +@MainActor +final class InstructionRunner { + static let shared = InstructionRunner() + + nonisolated static let enabledKey = "iPhoneInstructionsEnabled" + nonisolated static var isEnabled: Bool { UserDefaults.standard.bool(forKey: enabledKey) } + + private var database: CKDatabase { CKContainer(identifier: CloudProbe.containerID).privateCloudDatabase } + private var pollTask: Task? + private var changeToken: CKServerChangeToken? + private var running: [String: Process] = [:] // by session id + + /// Instructions older than this are dropped instead of run. + private let maxAge: TimeInterval = 10 * 60 + + func setEnabled(_ on: Bool) { + UserDefaults.standard.set(on, forKey: Self.enabledKey) + if on && CloudProbe.isEnabled { start() } else { stop() } + } + + func startIfEnabled() { + if Self.isEnabled { start() } + } + + func start() { + guard pollTask == nil else { return } + log("on: checking for instructions every 15 s") + pollTask = Task { [weak self] in + while !Task.isCancelled { + await self?.check() + try? await Task.sleep(for: .seconds(15)) + } + } + } + + func stop() { + guard pollTask != nil else { return } + pollTask?.cancel() + pollTask = nil + log("off") + } + + // MARK: Reading + + private func check() async { + var found: [CKRecord] = [] + do { + var more = true + while more { + let changes = try await database.recordZoneChanges(inZoneWith: SessionSnapshot.zoneID, since: changeToken) + for (_, result) in changes.modificationResultsByID { + if case .success(let mod) = result, mod.record.recordType == "Instruction" { found.append(mod.record) } + } + changeToken = changes.changeToken + more = changes.moreComing + } + } catch let error as CKError where error.code == .changeTokenExpired { + changeToken = nil + return + } catch { + return + } + guard !found.isEmpty else { return } + // Single use: gone from iCloud before anything runs. + _ = try? await database.modifyRecords(saving: [], deleting: found.map(\.recordID)) + for record in found.sorted(by: { ($0["createdAt"] as? Date ?? .distantPast) < ($1["createdAt"] as? Date ?? .distantPast) }) { + handle(record) + } + } + + private func handle(_ record: CKRecord) { + let pillId = record["pillId"] as? String ?? "" + let createdAt = record["createdAt"] as? Date ?? .distantPast + let text = (record.encryptedValues["text"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard Self.isEnabled else { log("ignored: instructions are off"); return } + guard Date().timeIntervalSince(createdAt) < maxAge else { log("ignored: older than 10 min"); return } + guard !text.isEmpty, text.count <= 8000 else { log("ignored: empty or too long"); return } + guard pillId == "integration_claude" || pillId == "agent_cursor" else { log("ignored: \(pillId) can't take instructions"); return } + guard let session = TurnRecorder.shared.lastSession(for: pillId) else { + log("ignored: no Claude Code session seen for \(pillId) yet") + return + } + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: session.cwd, isDirectory: &isDirectory), isDirectory.boolValue else { + log("ignored: the session's folder is gone") + return + } + guard running[session.sessionId] == nil else { + log("ignored: an instruction is already running for this session") + return + } + guard let claude = Self.claudeExecutable() else { + log("can't find the claude command (looked in ~/.claude/local, Homebrew, /usr/local/bin, ~/.npm-global/bin)") + return + } + run(claude: claude, text: text, sessionId: session.sessionId, cwd: session.cwd, pillId: pillId) + } + + // MARK: Running + + private func run(claude: String, text: String, sessionId: String, cwd: String, pillId: String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: claude) + process.arguments = ["-p", text, "--resume", sessionId] + process.currentDirectoryURL = URL(fileURLWithPath: cwd) + var env = ProcessInfo.processInfo.environment + let home = FileManager.default.homeDirectoryForCurrentUser.path + env["PATH"] = [URL(fileURLWithPath: claude).deletingLastPathComponent().path, "/opt/homebrew/bin", "/usr/local/bin", + "/usr/bin", "/bin", "/usr/sbin", "/sbin", "\(home)/.local/bin", env["PATH"] ?? ""].joined(separator: ":") + // The hooks route events by editor: keep them on the same pill. + if pillId == "agent_cursor" { + env["__CFBundleIdentifier"] = "com.todesktop.230313mzl4w4u92" + } else { + env["TERM_PROGRAM"] = "vscode" + } + process.environment = env + process.standardInput = FileHandle.nullDevice + // Output goes to a file (a pipe could fill up and stall claude); its + // end is logged if the run fails. + let logURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Logs/NotchBuddy/instruction-last.log") + try? FileManager.default.createDirectory(at: logURL.deletingLastPathComponent(), withIntermediateDirectories: true) + FileManager.default.createFile(atPath: logURL.path, contents: nil) + let output = try? FileHandle(forWritingTo: logURL) + process.standardOutput = output ?? FileHandle.nullDevice + process.standardError = output ?? FileHandle.nullDevice + process.terminationHandler = { [weak self] finished in + try? output?.close() + let data = (try? Data(contentsOf: logURL)) ?? Data() + let tail = String(decoding: data.suffix(400), as: UTF8.self) + .replacingOccurrences(of: "\n", with: " ") + let status = finished.terminationStatus + Task { @MainActor in + self?.running[sessionId] = nil + self?.log(status == 0 ? "finished (\(sessionId.prefix(8)))" : "ended with \(status): \(tail)") + } + } + do { + try process.run() + running[sessionId] = process + log("running in \(URL(fileURLWithPath: cwd).lastPathComponent) (\(sessionId.prefix(8))): \(text.count) chars") + } catch { + log("couldn't start claude: \(error.localizedDescription)") + } + } + + /// Where `claude` usually lives; the app doesn't get the shell's PATH. + static func claudeExecutable() -> String? { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let candidates = [ + "\(home)/.claude/local/claude", + "\(home)/.local/bin/claude", + "/opt/homebrew/bin/claude", + "/usr/local/bin/claude", + "\(home)/.npm-global/bin/claude", + "\(home)/.bun/bin/claude", + ] + return candidates.first { FileManager.default.isExecutableFile(atPath: $0) } + } + + private func log(_ message: String) { + CloudProbe.shared.log("[instruction] \(message)") + } +} +#endif diff --git a/NotchBuddy/Sources/App/PhoneLink/LiveActivityRelay.swift b/NotchBuddy/Sources/App/PhoneLink/LiveActivityRelay.swift new file mode 100644 index 0000000..80e3dcf --- /dev/null +++ b/NotchBuddy/Sources/App/PhoneLink/LiveActivityRelay.swift @@ -0,0 +1,413 @@ +#if PHONE_LINK +import AppKit +import CloudKit +import Combine + +// MARK: - iPhone plan, step 8: Mochi leaves for the iPhone +// +// When the Mac locks while an agent is working, Mochi moves to the iPhone's +// Dynamic Island and Lock Screen (a Live Activity), and comes back when the +// Mac unlocks. Live Activity pushes need the APNs key, which can't ship in the +// app, so they go through the user's relay (relay/ in this repo, a stateless +// Cloudflare Worker). The iPhone's push tokens come from iCloud (`PhoneToken` +// records, written by LiveActivityLink on the iPhone). +// +// Sent to the relay: the iPhone's token and MochiActivityState (agent, state, +// counts). Never a project name, a step, a command or a path. +// Runs only while the Mac is locked with an agent going: lock notifications +// and AppState changes, no timer at rest. + +@MainActor +final class LiveActivityRelay { + static let shared = LiveActivityRelay() + + static let enabledKey = "iPhoneLiveActivityEnabled" + /// Overrides the relay address (defaults write fr.louisraille.NotchBuddy phoneRelayURL ). + static let relayURLKey = "phoneRelayURL" + /// The deployed relay (relay/README.md). + static let defaultRelayURL = "https://coucou-relay.raillelouis.workers.dev" + + static var isEnabled: Bool { UserDefaults.standard.bool(forKey: enabledKey) } + + private struct Phone { + var env: String + var startToken: String + var updateToken: String + var updatedAt: Date + } + + private var database: CKDatabase { CKContainer(identifier: CloudProbe.containerID).privateCloudDatabase } + private var observers: [NSObjectProtocol] = [] + private var cancellable: AnyCancellable? + private var phones: [String: Phone] = [:] + private var changeToken: CKServerChangeToken? + + private var locked = false + private var latest: MochiActivityState? + /// Set while a Live Activity runs on the iPhone(s). + private var startedAt: Date? + /// Sent with every push of an activity, so the iPhone shows the time since. + private var activitySince: Int? + /// The startedAt whose start push actually went out. + private var startSent: Date? + private var sent: MochiActivityState? + private var sending = false + private var retryTask: Task? + private var retries = 0 + /// The phase a start was sent again for, so it is only tried once each. + private var restartedFor: String? + /// A start waiting to see if the Mac stays locked, and an end waiting to + /// see if it stays unlocked: iOS allows only so many starts an hour, so a + /// quick lock and unlock doesn't spend one. + private var lockTask: Task? + private var unlockTask: Task? + /// Ends the activity 10 minutes after the agents are done, if nothing restarts. + private var doneTask: Task? + + private var relayURL: URL? { + let value = UserDefaults.standard.string(forKey: Self.relayURLKey) ?? Self.defaultRelayURL + return value.isEmpty ? nil : URL(string: value)?.appendingPathComponent("v1/live-activity") + } + + // MARK: Lifecycle + + func setEnabled(_ on: Bool) { + UserDefaults.standard.set(on, forKey: Self.enabledKey) + if on && CloudProbe.isEnabled { start() } else { stop() } + } + + func startIfEnabled() { + if Self.isEnabled { start() } + } + + func start() { + guard cancellable == nil else { return } + let center = DistributedNotificationCenter.default() + observers = [ + center.addObserver(forName: NSNotification.Name("com.apple.screenIsLocked"), object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.lockChanged(true) } + }, + center.addObserver(forName: NSNotification.Name("com.apple.screenIsUnlocked"), object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.lockChanged(false) } + }, + ] + let state = AppState.shared + cancellable = state.$tasks + .combineLatest(state.$pendingApproval) + .map { tasks, approval in Self.leadState(tasks: tasks, approval: approval) } + .removeDuplicates() + .debounce(for: .seconds(1), scheduler: RunLoop.main) + .sink { [weak self] lead in + MainActor.assumeIsolated { self?.stateChanged(lead) } + } + log(relayURL == nil ? "on, but no relay address yet (relay/README.md)" : "on") + } + + func stop() { + guard cancellable != nil else { return } + observers.forEach { DistributedNotificationCenter.default().removeObserver($0) } + observers = [] + cancellable = nil + lockTask?.cancel(); lockTask = nil + unlockTask?.cancel(); unlockTask = nil + if startedAt != nil { finish(dismissAfter: 0) } + log("off") + } + + // MARK: Events + + private func lockChanged(_ isLocked: Bool) { + locked = isLocked + if isLocked { + log("Mac locked") + unlockTask?.cancel(); unlockTask = nil + if startedAt != nil { + // Locked again before the activity left: it carries on. + flush() + return + } + if let latest, latest.isActive { beginSoon(latest) } + } else { + log("Mac unlocked") + lockTask?.cancel(); lockTask = nil + guard startedAt != nil else { return } + // Mochi comes back to the notch, unless the Mac locks again within 30 s. + unlockTask = Task { + try? await Task.sleep(for: .seconds(30)) + guard !Task.isCancelled, !locked else { return } + unlockTask = nil + finish(dismissAfter: 0) + } + } + } + + /// Starts right away when an agent needs you; otherwise once the Mac has + /// stayed locked for 20 s. + private func beginSoon(_ state: MochiActivityState) { + if state.tone == "waiting" || state.tone == "question" { + lockTask?.cancel(); lockTask = nil + begin(state) + return + } + guard lockTask == nil else { return } + lockTask = Task { + try? await Task.sleep(for: .seconds(20)) + lockTask = nil + guard !Task.isCancelled, locked, startedAt == nil, let latest, latest.isActive else { return } + begin(latest) + } + } + + private func stateChanged(_ lead: MochiActivityState?) { + if lead?.tone != latest?.tone, let lead { log("phase: \(lead.agent) \(lead.statusText)\(locked ? "" : " (Mac unlocked, stays on the Mac)")") } + latest = lead + guard locked else { return } + if startedAt == nil { + if let lead, lead.isActive { beginSoon(lead) } + return + } + if let lead, lead.isActive { + // Back to work: the same activity carries on. + doneTask?.cancel(); doneTask = nil + flush() + } else if doneTask == nil { + // Nothing going any more: show "done" for 10 minutes, then leave, + // unless an agent starts again meanwhile. + if lead != nil { flush() } + doneTask = Task { + try? await Task.sleep(for: .seconds(10 * 60)) + guard !Task.isCancelled else { return } + doneTask = nil + finish(dismissAfter: 0) + } + } + } + + // MARK: Sending + + private func begin(_ state: MochiActivityState) { + let start = Date() + activitySince = Int(start.timeIntervalSince1970) + startedAt = start + sent = state + retries = 0 + Task { + await refreshPhones() + // The Mac unlocked meanwhile: don't leave. + guard startedAt == start, locked else { return } + startSent = start + let targets = phones.filter { !$0.value.startToken.isEmpty } + if targets.isEmpty { + log("no iPhone token yet: open Coucou on the iPhone once, with Live Activities allowed") + return + } + var reached = 0 + for (id, phone) in targets { + if await post(event: "start", token: phone.startToken, env: phone.env, state: state, phoneID: id) { + reached += 1 + } + } + if reached > 0 { + log("Mochi left for the iPhone (\(state.agent), \(state.statusText))") + } else { + log("Mochi couldn't reach the iPhone (see the relay line above)") + } + } + } + + /// Sends the latest state if it changed, one request at a time. + private func flush() { + guard !sending, let startedAt, let state = latest, state != sent else { return } + sending = true + Task { + let targets = await updateTargets(since: startedAt) + guard !targets.isEmpty else { + sending = false + // No update token: iOS didn't bring the activity up (it can hold + // back starts after many in a row). When an agent needs you, start + // it again with that state, once per request. + let key = state.approval ?? "\(state.tone)|\(state.statusText)" + if state.tone == "waiting" || state.tone == "question", restartedFor != key, + let sentAt = startSent, Date().timeIntervalSince(sentAt) > 15 { + restartedFor = key + log("the Live Activity isn't on the iPhone: starting it again for \(state.statusText)") + begin(state) + return + } + log("update \(state.tone) waits: no update token from the iPhone yet") + scheduleRetry() + return + } + retries = 0 + let urgent = state.tone == "waiting" || state.tone == "question" + for (id, phone) in targets { + let ok = await post(event: "update", token: phone.updateToken, env: phone.env, state: state, + urgent: urgent, phoneID: id) + // Every change of phase is logged; step counts only when they fail. + if ok && (urgent || sent?.tone != state.tone) { + log("update sent: \(state.agent) \(state.statusText)\(state.approval == nil ? "" : " (with Allow / Deny)")") + } + } + sent = state + sending = false + // Something changed while this one was on its way. + if latest != sent { flush() } + } + } + + private func finish(dismissAfter: Int) { + guard let startedAt else { return } + self.startedAt = nil + retryTask?.cancel(); retryTask = nil + doneTask?.cancel(); doneTask = nil + let last = latest ?? sent ?? .placeholder + sent = nil + guard startSent == startedAt else { + // The start never went out: nothing to end on the iPhone. + log("Mochi is back on the Mac") + return + } + Task { + // A start sent a moment ago: its update token is still on its way. + var targets = await updateTargets(since: startedAt) + for _ in 0..<6 where targets.isEmpty { + try? await Task.sleep(for: .seconds(3)) + targets = await updateTargets(since: startedAt) + } + if targets.isEmpty { log("can't end the Live Activity: no update token from the iPhone") } + for (id, phone) in targets { + await post(event: "end", token: phone.updateToken, env: phone.env, state: last, + dismissAfter: dismissAfter, phoneID: id) + } + log(dismissAfter == 0 ? "Mochi is back on the Mac" : "agents done, the iPhone shows it for \(dismissAfter / 60) min") + } + } + + /// The update token arrives a moment after the start (the iPhone gets it + /// from iOS, then writes it to iCloud): retry a few times. + private func scheduleRetry() { + guard retryTask == nil, retries < 10 else { return } + retries += 1 + retryTask = Task { + try? await Task.sleep(for: .seconds(3)) + retryTask = nil + guard !Task.isCancelled else { return } + flush() + } + } + + private func updateTargets(since date: Date) async -> [String: Phone] { + func fresh() -> [String: Phone] { + phones.filter { !$0.value.updateToken.isEmpty && $0.value.updatedAt >= date.addingTimeInterval(-2) } + } + if fresh().isEmpty { await refreshPhones() } + return fresh() + } + + /// Sends one push through the relay. Returns true when Apple accepted it. + @discardableResult + private func post(event: String, token: String, env: String, state: MochiActivityState, + urgent: Bool = false, dismissAfter: Int? = nil, phoneID: String) async -> Bool { + guard let url = relayURL else { return false } + var state = state + if state.since == nil { state.since = activitySince } + var body: [String: Any] = ["token": token, "env": env, "event": event, "urgent": urgent] + if let dismissAfter { body["dismissAfter"] = dismissAfter } + guard let stateData = try? JSONEncoder().encode(state), + let stateObject = try? JSONSerialization.jsonObject(with: stateData) else { return false } + body["state"] = stateObject + var request = URLRequest(url: url, timeoutInterval: 10) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try? JSONSerialization.data(withJSONObject: body) + do { + let (data, response) = try await URLSession.shared.data(for: request) + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + if status == 200 { return true } + let reason = String(data: data, encoding: .utf8) ?? "" + if reason.contains("BadDeviceToken") { + // A token only works on one of Apple's two push servers (sandbox for + // builds run from Xcode, production for TestFlight and the App Store). + // Try the other one once and keep it if it works. + let other = env == "production" ? "development" : "production" + if phones[phoneID]?.env == env { + phones[phoneID]?.env = other + log("relay \(event): token not valid on \(env), trying \(other)") + return await post(event: event, token: token, env: other, state: state, + urgent: urgent, dismissAfter: dismissAfter, phoneID: phoneID) + } + } + if status == 410 { + // That token is gone (activity ended on the iPhone, app deleted…). + if event == "start" { phones[phoneID]?.startToken = "" } else { phones[phoneID]?.updateToken = "" } + } + log("relay \(event) on \(env) → \(status) \(reason.prefix(120))") + } catch { + log("relay \(event) failed: \(error.localizedDescription)") + } + return false + } + + // MARK: Tokens + + private func refreshPhones() async { + do { + var more = true + while more { + let changes = try await database.recordZoneChanges(inZoneWith: CloudProbe.zoneID, since: changeToken) + for (id, result) in changes.modificationResultsByID { + guard case .success(let mod) = result, mod.record.recordType == "PhoneToken" else { continue } + let record = mod.record + phones[id.recordName] = Phone( + env: record["env"] as? String ?? "production", + startToken: record.encryptedValues["startToken"] as? String ?? "", + updateToken: record.encryptedValues["updateToken"] as? String ?? "", + updatedAt: record["updatedAt"] as? Date ?? .distantPast) + } + for deletion in changes.deletions where deletion.recordType == "PhoneToken" { + phones[deletion.recordID.recordName] = nil + } + changeToken = changes.changeToken + more = changes.moreComing + } + } catch let error as CKError where error.code == .changeTokenExpired { + changeToken = nil + } catch { + log("token fetch failed: \(error.localizedDescription)") + } + } + + // MARK: State + + /// The most urgent session, as the iPhone's Live Activity shows it. + nonisolated static func leadState(tasks: [AgentTask], approval: ApprovalInfo?) -> MochiActivityState? { + let ranked = tasks + .filter { $0.source != .n8n && PillCatalog.isSession($0.id) } // same sessions as SessionPublisher + .map { task -> (AgentTask, Int) in + let urgency = MochiActivityState.urgency(state: task.state, + waitingForOK: approval?.pillId == task.id, + hasQuestion: task.state == .question) + return (task, urgency) + } + guard let lead = ranked.min(by: { $0.1 < $1.1 }) else { return nil } + let (task, urgency) = lead + let others = ranked.filter { $0.0.id != task.id && $0.1 <= 3 }.count + return MochiActivityState( + pillId: task.id, + agent: PillCatalog.definition(for: task.id)?.name ?? "Agent", + color: task.color, + state: (urgency == 0 ? BotState.approval : task.state).rawValue, + statusText: MochiActivityState.statusText(state: task.state, urgency: urgency, + stepIndex: task.stepIndex, stepCount: task.steps.count), + tone: MochiActivityState.tone(urgency: urgency), + stepIndex: max(0, task.stepIndex), + stepCount: task.steps.count, + others: others, + approval: approval?.pillId == task.id ? approval.map(ApprovalRelay.fingerprint) : nil) + } + + private func log(_ message: String) { + CloudProbe.shared.log("[live] \(message)") + } +} +#endif diff --git a/NotchBuddy/Sources/App/PhoneLink/QuestionRelay.swift b/NotchBuddy/Sources/App/PhoneLink/QuestionRelay.swift new file mode 100644 index 0000000..51e0238 --- /dev/null +++ b/NotchBuddy/Sources/App/PhoneLink/QuestionRelay.swift @@ -0,0 +1,132 @@ +#if PHONE_LINK +import AppKit +import CloudKit +import Combine + +// MARK: - Answer Claude's questions from the iPhone +// +// While Claude Code waits on an AskUserQuestion, the session record carries +// the question and its choices (SessionPublisher). The iPhone answers with an +// `Answer` record: the question's fingerprint and the labels picked. This Mac +// looks for answers every 2 s, only while a question waits, and applies one +// only if it matches the question still waiting and every label is one of its +// choices. Answers are single use: deleted as soon as they are read. + +@MainActor +final class QuestionRelay { + static let shared = QuestionRelay() + + private let container = CKContainer(identifier: "iCloud.fr.louisraille.Coucou") + private var database: CKDatabase { container.privateCloudDatabase } + private var zoneID: CKRecordZone.ID { SessionSnapshot.zoneID } + + private var cancellable: AnyCancellable? + private var current: (fingerprint: String, since: Date)? + private var pollTask: Task? + private var changeToken: CKServerChangeToken? + + func start() { + guard cancellable == nil else { return } + // @Published sends the new value before the property changes: use the value passed along. + cancellable = AppState.shared.$pendingQuestion + .map { $0.map(QuestionPayload.init(ask:)) } + .removeDuplicates() + .sink { [weak self] payload in + MainActor.assumeIsolated { self?.pendingChanged(to: payload) } + } + } + + func stop() { + cancellable = nil + pendingChanged(to: nil) + } + + private func pendingChanged(to payload: QuestionPayload?) { + let fingerprint = payload?.fingerprint + guard current?.fingerprint != fingerprint else { return } + pollTask?.cancel() + pollTask = nil + current = nil + guard let fingerprint else { return } + current = (fingerprint, Date()) + pollTask = Task { [weak self] in + // The Mac gives the question back to the terminal after 120 s. + for _ in 0..<62 { + try? await Task.sleep(for: .seconds(2)) + guard !Task.isCancelled, let self else { return } + if await self.checkAnswers(for: fingerprint) { return } + } + } + } + + /// Returns true once a matching answer was applied. + private func checkAnswers(for fingerprint: String) async -> Bool { + guard current?.fingerprint == fingerprint, let since = current?.since else { return true } + var found: [(id: CKRecord.ID, fingerprint: String, selections: String)] = [] + do { + var more = true + while more { + let changes = try await database.recordZoneChanges(inZoneWith: zoneID, since: changeToken) + for (id, result) in changes.modificationResultsByID { + guard case .success(let mod) = result, mod.record.recordType == "Answer" else { continue } + let record = mod.record + let answeredAt = record["answeredAt"] as? Date ?? .distantPast + guard answeredAt >= since.addingTimeInterval(-5) else { continue } + found.append((id, record["fingerprint"] as? String ?? "", + record.encryptedValues["selections"] as? String ?? "")) + } + changeToken = changes.changeToken + more = changes.moreComing + } + } catch let error as CKError where error.code == .changeTokenExpired { + changeToken = nil + return false + } catch { + log("answer check failed: \(error.localizedDescription)") + return false + } + guard !found.isEmpty else { return false } + let ids = found.map { $0.id } + Task { _ = try? await database.modifyRecords(saving: [], deleting: ids) } + guard let match = found.first(where: { $0.fingerprint == fingerprint }) else { + log("ignored \(found.count) answer(s) for another question") + return false + } + apply(match.selections, fingerprint: fingerprint) + return true + } + + private func apply(_ json: String, fingerprint: String) { + // Check again right before answering the hook. + guard let pending = AppState.shared.pendingQuestion else { + log("answer arrived after the question was resolved, ignored") + return + } + let payload = QuestionPayload(ask: pending) + guard payload.fingerprint == fingerprint else { + log("answer for an older question, ignored") + return + } + guard let selections = QuestionPayload.decodeSelections(json), payload.accepts(selections) else { + log("answer with unknown choices, ignored") + return + } + log("answered from the iPhone") + HookServer.shared.sendQuestionAnswers(AskQuestion.buildAnswers(questions: pending.questions, selections: selections)) + } + + private func log(_ message: String) { + CloudProbe.shared.log("[question] \(message)") + } +} + +extension QuestionPayload { + init(ask: AskQuestion) { + self.init(items: ask.questions.map { item in + Item(question: item.question, header: item.header, + options: item.options.map { Option(label: $0.label, description: $0.description) }, + multiSelect: item.multiSelect) + }) + } +} +#endif diff --git a/NotchBuddy/Sources/App/PhoneLink/ServiceDetailRunner.swift b/NotchBuddy/Sources/App/PhoneLink/ServiceDetailRunner.swift new file mode 100644 index 0000000..154450d --- /dev/null +++ b/NotchBuddy/Sources/App/PhoneLink/ServiceDetailRunner.swift @@ -0,0 +1,802 @@ +#if PHONE_LINK +import AppKit +import CloudKit + +// MARK: - Services up close, for the iPhone +// +// When a service's screen opens on the iPhone, it writes a `ServiceAction` +// record of kind "refresh"; to act (redeploy, re-run, merge…) it writes one +// with that action's kind and target. The CloudKit push wakes this Mac (with +// a check every minute in case a push is missed); it takes each request once +// (deleted on read, and only those it could delete), reads the service's API +// with the key in its Keychain and writes a `ServiceDetail` record, +// encrypted. An action runs only if it was offered on an item of the last +// detail sent for that service, and was asked for in the last 5 minutes. +// Nothing that moves money or sends an email is ever offered. + +@MainActor +final class ServiceDetailRunner { + static let shared = ServiceDetailRunner() + + private var database: CKDatabase { CKContainer(identifier: CloudProbe.containerID).privateCloudDatabase } + private var pollTask: Task? + private var changeToken: CKServerChangeToken? + /// The last detail sent per service: actions are checked against it. + private var lastDetails: [String: ServiceDetail] = [:] + private let maxAge: TimeInterval = 5 * 60 + private var checking = false + private var again = false + + func start() { + guard pollTask == nil else { return } + pollTask = Task { [weak self] in + while !Task.isCancelled { + await self?.checkNow() + // In case a push is missed. + try? await Task.sleep(for: .seconds(60)) + } + } + } + + /// Stops and deletes the details this Mac wrote to iCloud. + func stop() { + pollTask?.cancel() + pollTask = nil + lastDetails = [:] + let ids = PillCatalog.phoneServices.map { + CKRecord.ID(recordName: ServiceDetail.recordName(for: $0), zoneID: SessionSnapshot.zoneID) + } + Task { _ = try? await database.modifyRecords(saving: [], deleting: ids, savePolicy: .changedKeys, atomically: false) } + } + + /// From the CloudKit push and the fallback check. One check at a time: two + /// overlapping checks would share the change token and could run an action twice. + func checkNow() async { + guard pollTask != nil else { return } + guard !checking else { again = true; return } + checking = true + repeat { + again = false + await check() + } while again + checking = false + } + + // MARK: Requests + + private func check() async { + var found: [CKRecord] = [] + do { + var more = true + while more { + let changes = try await database.recordZoneChanges(inZoneWith: SessionSnapshot.zoneID, since: changeToken, + desiredKeys: ["pillId", "kind", "requestedAt", "target"]) + for (_, result) in changes.modificationResultsByID { + if case .success(let mod) = result, mod.record.recordType == ServiceDetail.requestType { + found.append(mod.record) + } + } + changeToken = changes.changeToken + more = changes.moreComing + } + } catch let error as CKError where error.code == .changeTokenExpired { + changeToken = nil + return + } catch { + return + } + guard !found.isEmpty else { return } + // Single use: act only on the requests this Mac actually removed from iCloud. + guard let removed = try? await database.modifyRecords(saving: [], deleting: found.map(\.recordID), + savePolicy: .changedKeys, atomically: false) else { + log("couldn't take \(found.count) request(s) off iCloud; skipped") + return + } + let taken = found.filter { + guard case .success? = removed.deleteResults[$0.recordID] else { return false } + return true + } + + // Several refreshes for one service count once. + var refreshed: Set = [] + for record in taken.sorted(by: { ($0["requestedAt"] as? Date ?? .distantPast) < ($1["requestedAt"] as? Date ?? .distantPast) }) { + let pillId = record["pillId"] as? String ?? "" + let kind = record["kind"] as? String ?? "" + let target = record.encryptedValues["target"] as? String ?? "" + let requestedAt = record["requestedAt"] as? Date ?? .distantPast + guard Date().timeIntervalSince(requestedAt) < maxAge, PillCatalog.phoneServices.contains(pillId) else { continue } + if kind == ServiceDetail.refreshKind { + guard refreshed.insert(pillId).inserted else { continue } + await publish(pillId: pillId, lastAction: lastDetails[pillId]?.lastAction) + } else { + await run(kind: kind, target: target, pillId: pillId) + } + } + } + + private func run(kind: String, target: String, pillId: String) async { + guard let action = lastDetails[pillId]?.offered(kind: kind, target: target), + ServiceAPI.allowedKinds.contains(kind), kind.hasPrefix(ServiceAPI.prefix(of: pillId)) else { + log("ignored an action that wasn't offered: \(kind)") + await publish(pillId: pillId, lastAction: ServiceActionResult( + title: "Not done", ok: false, + message: "This action isn't offered any more. The list was refreshed.", date: Date())) + return + } + log("\(action.title) (\(pillId)) asked from the iPhone") + let result: ServiceActionResult + do { + let message = try await ServiceAPI.perform(kind: kind, target: target) + result = ServiceActionResult(title: action.title, ok: true, message: message, date: Date()) + } catch { + result = ServiceActionResult(title: action.title, ok: false, message: ServiceAPI.describe(error), date: Date()) + } + log("\(action.title): \(result.ok ? "done" : "failed, \(result.message)")") + // Give the service a moment, then show where things are. + try? await Task.sleep(for: .seconds(2)) + await publish(pillId: pillId, lastAction: result) + } + + private func publish(pillId: String, lastAction: ServiceActionResult?) async { + var detail = await ServiceAPI.detail(for: pillId) + // The iPhone sync was turned off while the API was read: write nothing. + guard !Task.isCancelled, pollTask != nil else { return } + detail.lastAction = lastAction + lastDetails[pillId] = detail + guard let json = try? JSONEncoder().encode(detail), let payload = String(data: json, encoding: .utf8) else { return } + let record = CKRecord(recordType: ServiceDetail.recordType, + recordID: CKRecord.ID(recordName: ServiceDetail.recordName(for: pillId), zoneID: SessionSnapshot.zoneID)) + record["pillId"] = pillId + record["fetchedAt"] = detail.fetchedAt + record.encryptedValues["payload"] = payload + do { + _ = try await database.modifyRecords(saving: [record], deleting: [], savePolicy: .allKeys) + } catch { + log("detail for \(pillId) not saved: \(error.localizedDescription)") + } + } + + private func log(_ message: String) { + CloudProbe.shared.log("[services] \(message)") + } +} + +// MARK: - The services' APIs + +enum ServiceAPI { + struct Failure: Error { + let status: Int + let message: String + } + + /// Every action the iPhone can ask for. Nothing moves money or sends an email. + static let allowedKinds: Set = [ + "vercel.redeploy", "vercel.promote", "vercel.cancel", + "github.rerun", "github.approve", "github.merge", + "n8n.activate", "n8n.deactivate", "n8n.retry", + ] + + static func prefix(of pillId: String) -> String { + pillId.replacingOccurrences(of: "integration_", with: "") + "." + } + + static func describe(_ error: Error) -> String { + if let failure = error as? Failure { + switch failure.status { + case 401: + if failure.message.localizedCaseInsensitiveContains("restricted") { + // Resend `restricted_api_key`: a Sending-access key can't read emails or domains. + return "This key can only send emails. Use a Full access key in Coucou's Settings on your Mac to see them here." + } + return "The key was refused (401). Check it in Coucou's Settings on your Mac." + case 403: return "Not allowed with this key (403). \(failure.message)" + case 404: return "Not found (404)." + default: return failure.message.isEmpty ? "Error \(failure.status)" : String(failure.message.prefix(160)) + } + } + return error.localizedDescription + } + + // MARK: HTTP + + private static func request(_ urlString: String, method: String = "GET", headers: [String: String], + body: [String: Any]? = nil) async throws -> Any { + guard let url = URL(string: urlString) else { throw Failure(status: 0, message: "Bad address") } + var request = URLRequest(url: url, timeoutInterval: 15) + request.httpMethod = method + for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) } + if let body { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + } + let (data, response) = try await URLSession.shared.data(for: request) + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard (200..<300).contains(status) else { + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + let message = (json?["message"] as? String) + ?? ((json?["error"] as? [String: Any])?["message"] as? String) + ?? (json?["error"] as? String) + ?? String(decoding: data.prefix(200), as: UTF8.self) + throw Failure(status: status, message: message) + } + if data.isEmpty { return [String: Any]() } + return try JSONSerialization.jsonObject(with: data) + } + + private static func date(_ value: Any?) -> Date? { + if let ms = value as? Double { return Date(timeIntervalSince1970: ms > 1e11 ? ms / 1000 : ms) } + if let ms = value as? Int { return Date(timeIntervalSince1970: ms > 100_000_000_000 ? Double(ms) / 1000 : Double(ms)) } + guard let text = value as? String else { return nil } + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = fractional.date(from: text) { return date } + let plain = ISO8601DateFormatter() + if let date = plain.date(from: text) { return date } + // Resend: "2024-05-01 10:00:00.123456+00" + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + for format in ["yyyy-MM-dd HH:mm:ss.SSSSSSZ", "yyyy-MM-dd HH:mm:ss.SSSZ", "yyyy-MM-dd HH:mm:ssZ"] { + formatter.dateFormat = format + if let date = formatter.date(from: text.count > 3 && text.hasSuffix("+00") ? text + "00" : text) { return date } + } + return nil + } + + private static func money(_ cents: Int, _ currency: String) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = currency.uppercased() + // Stripe zero-decimal currencies (docs.stripe.com/currencies#zero-decimal). UGX and ISK stay ×100 + // for backward compatibility; HUF and TWD charges are two-decimal. + let zeroDecimal: Set = ["bif", "clp", "djf", "gnf", "jpy", "kmf", "krw", "mga", "pyg", "rwf", + "vnd", "vuv", "xaf", "xof", "xpf"] + let amount = zeroDecimal.contains(currency.lowercased()) ? Double(cents) : Double(cents) / 100 + return formatter.string(from: NSNumber(value: amount)) ?? "\(amount) \(currency.uppercased())" + } + + // MARK: Reading + + static func detail(for pillId: String) async -> ServiceDetail { + do { + switch pillId { + case "integration_vercel": return try await vercel() + case "integration_github": return try await github() + case "integration_stripe": return try await stripe() + case "integration_resend": return try await resend() + case "integration_calcom": return try await calcom() + case "integration_n8n": return try await n8n() + case "integration_notion": return try await notion() + default: return ServiceDetail(pillId: pillId, fetchedAt: Date(), error: "Not available from the iPhone yet.") + } + } catch { + return ServiceDetail(pillId: pillId, fetchedAt: Date(), error: describe(error)) + } + } + + private static func secret(_ name: String, _ service: String) throws -> String { + guard let value = KeychainStore.shared.get(name), !value.isEmpty else { + throw Failure(status: 0, message: "No \(service) key on your Mac. Add it in Coucou's Settings.") + } + return value + } + + // Vercel: deployments (redeploy, promote to production, cancel) and projects. + private static func vercel() async throws -> ServiceDetail { + let token = try secret("vercel-token", "Vercel") + let headers = ["Authorization": "Bearer \(token)", "Accept": "application/json"] + let deploymentsJSON = try await request("https://api.vercel.com/v6/deployments?limit=20", headers: headers) + let deployments = (deploymentsJSON as? [String: Any])?["deployments"] as? [[String: Any]] ?? [] + let projectsJSON = try? await request("https://api.vercel.com/v9/projects?limit=20", headers: headers) + let projects = (projectsJSON as? [String: Any])?["projects"] as? [[String: Any]] ?? [] + + var items: [DetailItem] = [] + var ready = 0, failed = 0, building = 0 + for d in deployments { + guard let uid = d["uid"] as? String else { continue } + let name = d["name"] as? String ?? "Deployment" + let state = (d["state"] as? String ?? d["readyState"] as? String ?? "").uppercased() + let target = d["target"] as? String + let meta = d["meta"] as? [String: Any] ?? [:] + let message = meta["githubCommitMessage"] as? String ?? "" + let branch = meta["githubCommitRef"] as? String ?? "" + let creator = (d["creator"] as? [String: Any])?["username"] as? String ?? "" + var tone: ServiceTone = .idle + var actions: [ServiceActionDef] = [] + switch state { + case "READY": + ready += 1 + tone = .ok + actions.append(ServiceActionDef(kind: "vercel.redeploy", title: "Redeploy", symbol: "arrow.clockwise", + target: "\(uid)|\(name)|\(target ?? "")")) + if target != "production" { + actions.append(ServiceActionDef(kind: "vercel.promote", title: "Promote to production", + symbol: "arrow.up.circle", target: "\(uid)|\(name)", + confirm: "A new production build starts from this deployment, using your production environment variables.")) + } + case "ERROR": + failed += 1 + tone = .error + actions.append(ServiceActionDef(kind: "vercel.redeploy", title: "Redeploy", symbol: "arrow.clockwise", + target: "\(uid)|\(name)|\(target ?? "")")) + case "BUILDING", "QUEUED", "INITIALIZING": + building += 1 + tone = .warning + actions.append(ServiceActionDef(kind: "vercel.cancel", title: "Cancel build", symbol: "xmark.circle", + target: uid, destructive: true, confirm: "Stop this build?")) + case "CANCELED": tone = .idle + default: break + } + let subtitle = [branch, message.split(separator: "\n").first.map(String.init) ?? "", creator] + .filter { !$0.isEmpty }.joined(separator: " · ") + items.append(DetailItem(title: name, subtitle: subtitle.isEmpty ? state.capitalized : subtitle, tone: tone, + date: date(d["created"] ?? d["createdAt"]), + url: (d["url"] as? String).map { "https://\($0)" }, + badge: target == "production" ? "Production" : (state == "READY" ? "Preview" : state.capitalized), + actions: actions)) + } + let projectItems = projects.prefix(20).map { p in + DetailItem(title: p["name"] as? String ?? "Project", + subtitle: (p["framework"] as? String).map { $0.capitalized } ?? "", + date: date(p["updatedAt"])) + } + var sections = [DetailSection(title: "Deployments", items: items)] + if !projectItems.isEmpty { sections.append(DetailSection(title: "Projects · \(projectItems.count)", items: Array(projectItems))) } + return ServiceDetail(pillId: "integration_vercel", + stats: [DetailStat(label: "Ready", value: "\(ready)", tone: .ok), + DetailStat(label: "Building", value: "\(building)", tone: building > 0 ? .warning : .idle), + DetailStat(label: "Failed", value: "\(failed)", tone: failed > 0 ? .error : .idle), + DetailStat(label: "Projects", value: "\(projects.count)")], + sections: sections, fetchedAt: Date()) + } + + // GitHub: your pull requests (merge), reviews asked of you (approve), CI runs (re-run failed jobs). + private static func github() async throws -> ServiceDetail { + let token = try secret("github-token", "GitHub") + let headers = ["Authorization": "Bearer \(token)", "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28"] + let user = try await request("https://api.github.com/user", headers: headers) as? [String: Any] ?? [:] + let login = user["login"] as? String ?? "" + + func search(_ query: String) async -> [[String: Any]] { + var components = URLComponents(string: "https://api.github.com/search/issues")! + components.queryItems = [URLQueryItem(name: "q", value: query), URLQueryItem(name: "per_page", value: "10"), + URLQueryItem(name: "sort", value: "updated")] + let json = try? await request(components.url!.absoluteString, headers: headers) + return (json as? [String: Any])?["items"] as? [[String: Any]] ?? [] + } + /// "owner/repo#12" from a search result. + func pullRef(_ item: [String: Any]) -> String? { + guard let repoURL = item["repository_url"] as? String, let number = item["number"] as? Int else { return nil } + let parts = repoURL.split(separator: "/").suffix(2).joined(separator: "/") + return "\(parts)#\(number)" + } + + let mine = await search("is:pr is:open author:\(login) archived:false") + let asked = await search("is:pr is:open review-requested:\(login) archived:false") + + let mineItems = mine.compactMap { item -> DetailItem? in + guard let ref = pullRef(item) else { return nil } + let draft = item["draft"] as? Bool ?? false + return DetailItem(title: item["title"] as? String ?? "Pull request", subtitle: ref, + tone: draft ? .idle : .info, date: date(item["updated_at"]), + url: item["html_url"] as? String, badge: draft ? "Draft" : nil, + actions: draft ? [] : [ServiceActionDef(kind: "github.merge", title: "Squash and merge", + symbol: "arrow.triangle.merge", target: ref, + confirm: "Merge \(ref) into its base branch?")]) + } + let askedItems = asked.compactMap { item -> DetailItem? in + guard let ref = pullRef(item) else { return nil } + let author = (item["user"] as? [String: Any])?["login"] as? String ?? "" + return DetailItem(title: item["title"] as? String ?? "Pull request", + subtitle: author.isEmpty ? ref : "\(ref) · \(author)", tone: .warning, + date: date(item["updated_at"]), url: item["html_url"] as? String, + actions: [ServiceActionDef(kind: "github.approve", title: "Approve", symbol: "checkmark.seal", + target: ref)]) + } + + // CI on the repos you pushed to last. + let reposJSON = try? await request("https://api.github.com/user/repos?sort=pushed&per_page=5&affiliation=owner,collaborator", + headers: headers) + let repos = (reposJSON as? [[String: Any]] ?? []).compactMap { $0["full_name"] as? String } + var runItems: [DetailItem] = [] + var failing = 0 + for repo in repos { + let runsJSON = try? await request("https://api.github.com/repos/\(repo)/actions/runs?per_page=3", headers: headers) + let runs = (runsJSON as? [String: Any])?["workflow_runs"] as? [[String: Any]] ?? [] + for run in runs { + guard let id = run["id"] as? Int else { continue } + let status = run["status"] as? String ?? "" + let conclusion = run["conclusion"] as? String ?? "" + var tone: ServiceTone = .idle + var actions: [ServiceActionDef] = [] + if status != "completed" { + tone = .warning + } else if conclusion == "success" { + tone = .ok + } else if conclusion == "failure" || conclusion == "timed_out" { + tone = .error + failing += 1 + actions.append(ServiceActionDef(kind: "github.rerun", title: "Re-run failed jobs", + symbol: "arrow.clockwise", target: "\(repo)/\(id)")) + } + let label = status == "completed" ? conclusion.replacingOccurrences(of: "_", with: " ") : status.replacingOccurrences(of: "_", with: " ") + runItems.append(DetailItem(title: run["name"] as? String ?? "Workflow", + subtitle: "\(repo) · \(run["head_branch"] as? String ?? "")", + tone: tone, date: date(run["created_at"]), + url: run["html_url"] as? String, badge: label.capitalized, actions: actions)) + } + } + + var sections: [DetailSection] = [] + if !askedItems.isEmpty { sections.append(DetailSection(title: "Reviews asked of you", items: askedItems)) } + sections.append(DetailSection(title: "Your pull requests", items: mineItems, + footer: mineItems.isEmpty ? "No open pull request." : nil)) + if !runItems.isEmpty { sections.append(DetailSection(title: "CI on your latest repos", items: runItems)) } + return ServiceDetail(pillId: "integration_github", + stats: [DetailStat(label: "Your PRs", value: "\(mineItems.count)", tone: .info), + DetailStat(label: "Reviews", value: "\(askedItems.count)", tone: askedItems.isEmpty ? .idle : .warning), + DetailStat(label: "CI failing", value: "\(failing)", tone: failing > 0 ? .error : .ok)], + sections: sections, fetchedAt: Date()) + } + + // Stripe: balance, payments, payouts, subscriptions. Read only. + private static func stripe() async throws -> ServiceDetail { + let key = try secret("stripe-api-key", "Stripe") + let headers = ["Authorization": "Bearer \(key)"] + let balance = try await request("https://api.stripe.com/v1/balance", headers: headers) as? [String: Any] ?? [:] + // One entry per currency in each list, in no set order: pair them by currency. + let availableList = balance["available"] as? [[String: Any]] ?? [] + let pendingList = balance["pending"] as? [[String: Any]] ?? [] + let currency = ((availableList.first ?? pendingList.first)?["currency"] as? String ?? "eur").lowercased() + func amount(_ list: [[String: Any]]) -> Int { + list.first { ($0["currency"] as? String)?.lowercased() == currency }?["amount"] as? Int ?? 0 + } + let available = amount(availableList) + let pending = amount(pendingList) + + /// A list, or why it couldn't be read (shown instead of a made-up zero). + func list(_ path: String) async -> ([[String: Any]], [String: Any], String?) { + do { + let json = try await request("https://api.stripe.com/v1/\(path)", headers: headers) as? [String: Any] ?? [:] + return (json["data"] as? [[String: Any]] ?? [], json, nil) + } catch { + return ([], [:], describe(error)) + } + } + + let (charges, _, chargesError) = await list("charges?limit=15") + let chargeItems = charges.map { c -> DetailItem in + let status = c["status"] as? String ?? "" + let refunded = c["refunded"] as? Bool ?? false + let partlyRefunded = !refunded && (c["amount_refunded"] as? Int ?? 0) > 0 + let uncaptured = !refunded && status == "succeeded" && (c["captured"] as? Bool) == false + let who = ((c["billing_details"] as? [String: Any])?["name"] as? String) + ?? (c["receipt_email"] as? String) ?? (c["description"] as? String) ?? "" + let tone: ServiceTone = refunded ? .idle + : (uncaptured || partlyRefunded) ? .warning + : status == "succeeded" ? .ok : status == "failed" ? .error : .warning + let badge = refunded ? "Refunded" : uncaptured ? "Uncaptured" + : partlyRefunded ? "Partly refunded" : status.capitalized + return DetailItem(title: money(c["amount"] as? Int ?? 0, c["currency"] as? String ?? currency), + subtitle: who, tone: tone, date: date(c["created"]), badge: badge) + } + let (payouts, _, payoutsError) = await list("payouts?limit=5") + let payoutItems = payouts.map { p in + DetailItem(title: money(p["amount"] as? Int ?? 0, p["currency"] as? String ?? currency), + subtitle: "Arrives", tone: (p["status"] as? String) == "failed" ? .error : .info, + date: date(p["arrival_date"]), badge: (p["status"] as? String)?.replacingOccurrences(of: "_", with: " ").capitalized) + } + + // Active subscriptions, every page (up to 1 000). + var subs: [[String: Any]] = [] + var moreSubs = false + var subsError: String? + var cursor: String? + for _ in 0..<10 { + let (page, json, error) = await list("subscriptions?status=active&limit=100" + (cursor.map { "&starting_after=\($0)" } ?? "")) + if let error { subsError = error; break } + subs += page + moreSubs = json["has_more"] as? Bool ?? false + guard moreSubs, let last = subs.last?["id"] as? String else { break } + cursor = last + } + // Estimated monthly revenue, one total per currency. Metered and tiered + // prices have no fixed amount per period and discounts aren't applied. + var monthlyByCurrency: [String: Double] = [:] + for sub in subs { + let subCurrency = (sub["currency"] as? String)?.lowercased() + let items = (sub["items"] as? [String: Any])?["data"] as? [[String: Any]] ?? [] + for item in items { + let price = item["price"] as? [String: Any] ?? [:] + let recurring = price["recurring"] as? [String: Any] ?? [:] + if recurring["usage_type"] as? String == "metered" || price["billing_scheme"] as? String == "tiered" { continue } + let perUnit = (price["unit_amount"] as? Int).map { Double($0) } + ?? Double(price["unit_amount_decimal"] as? String ?? "") ?? 0 + let unit = perUnit * Double(item["quantity"] as? Int ?? 1) + let count = Double(recurring["interval_count"] as? Int ?? 1) + let perMonth: Double + switch recurring["interval"] as? String { + case "year": perMonth = unit / (12 * count) + case "week": perMonth = unit * 4.345 / count + case "day": perMonth = unit * 30.4 / count + default: perMonth = unit / count + } + let itemCurrency = subCurrency ?? (price["currency"] as? String)?.lowercased() ?? currency + monthlyByCurrency[itemCurrency, default: 0] += perMonth + } + } + let largest = monthlyByCurrency.max { $0.value < $1.value } + let mrrCurrency = monthlyByCurrency[currency] != nil ? currency : (largest?.key ?? currency) + let monthly = monthlyByCurrency[mrrCurrency] ?? 0 + + var sections = [DetailSection(title: "Payments", items: chargeItems, footer: chargesError)] + if !payoutItems.isEmpty || payoutsError != nil { + sections.append(DetailSection(title: "Payouts", items: payoutItems, footer: payoutsError)) + } + if let subsError { sections.append(DetailSection(title: "Subscriptions", items: [], footer: subsError)) } + return ServiceDetail(pillId: "integration_stripe", + stats: [DetailStat(label: "Available", value: money(available, currency), tone: .ok), + DetailStat(label: "Pending", value: money(pending, currency)), + DetailStat(label: "Subscriptions", + value: subsError != nil ? "—" : (moreSubs ? "\(subs.count)+" : "\(subs.count)"), + tone: .info), + DetailStat(label: "Est. MRR", + value: subsError != nil ? "—" : money(Int(monthly.rounded()), mrrCurrency), + tone: .info)], + sections: sections, fetchedAt: Date()) + } + + // Resend: the latest emails and how they went, and the domains. Read only. + private static func resend() async throws -> ServiceDetail { + let key = try secret("resend-api-key", "Resend") + let headers = ["Authorization": "Bearer \(key)"] + let emailsJSON = try await request("https://api.resend.com/emails?limit=20", headers: headers) + let emails = (emailsJSON as? [String: Any])?["data"] as? [[String: Any]] ?? [] + var bounced = 0, delivered = 0 + let emailItems = emails.map { e -> DetailItem in + let event = e["last_event"] as? String ?? "sent" + let tone: ServiceTone + switch event { + case "delivered", "opened", "clicked": tone = .ok; delivered += 1 + case "bounced", "complained", "failed", "suppressed": tone = .error; bounced += 1 + case "delivery_delayed": tone = .warning + default: tone = .info + } + let to = (e["to"] as? [String])?.joined(separator: ", ") ?? "" + return DetailItem(title: e["subject"] as? String ?? "(no subject)", subtitle: to, tone: tone, + date: date(e["created_at"]), badge: event.replacingOccurrences(of: "_", with: " ").capitalized) + } + let domainsJSON = try? await request("https://api.resend.com/domains", headers: headers) + let domains = (domainsJSON as? [String: Any])?["data"] as? [[String: Any]] ?? [] + let domainItems = domains.map { d in + let status = d["status"] as? String ?? "" + return DetailItem(title: d["name"] as? String ?? "Domain", subtitle: d["region"] as? String ?? "", + tone: status == "verified" ? .ok : (status == "failed" ? .error : .warning), + badge: status.replacingOccurrences(of: "_", with: " ").capitalized) + } + var sections = [DetailSection(title: "Latest emails", items: emailItems)] + if !domainItems.isEmpty { sections.append(DetailSection(title: "Domains", items: domainItems)) } + return ServiceDetail(pillId: "integration_resend", + stats: [DetailStat(label: "Delivered", value: "\(delivered)", tone: .ok), + DetailStat(label: "Bounced", value: "\(bounced)", tone: bounced > 0 ? .error : .idle), + DetailStat(label: "Domains", value: "\(domains.count)")], + sections: sections, fetchedAt: Date()) + } + + // Cal.com: upcoming bookings. Read only: cancelling emails the guest and can refund or charge them. + private static func calcom() async throws -> ServiceDetail { + let key = try secret("calcom-api-key", "Cal.com") + let headers = ["Authorization": "Bearer \(key)", "cal-api-version": "2024-08-13"] + let iso = ISO8601DateFormatter() + let now = Date() + var components = URLComponents(string: "https://api.cal.com/v2/bookings")! + components.queryItems = [URLQueryItem(name: "status", value: "upcoming"), + URLQueryItem(name: "afterStart", value: iso.string(from: now)), + URLQueryItem(name: "take", value: "30")] + let json = try await request(components.url!.absoluteString, headers: headers) + let bookings = (json as? [String: Any])?["data"] as? [[String: Any]] ?? [] + let calendar = Calendar.current + var today = 0, week = 0 + let items = bookings.compactMap { b -> DetailItem? in + guard let start = date(b["start"] ?? b["startTime"]) else { return nil } + if calendar.isDateInToday(start) { today += 1 } + if start.timeIntervalSince(now) < 7 * 24 * 3600 { week += 1 } + let attendee = (b["attendees"] as? [[String: Any]])?.first + let who = attendee?["name"] as? String ?? attendee?["email"] as? String ?? "" + let when = start.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute()) + return DetailItem(title: b["title"] as? String ?? "Meeting", + subtitle: who.isEmpty ? when : "\(when) · \(who)", + tone: calendar.isDateInToday(start) ? .warning : .info, + date: start, url: (b["meetingUrl"] as? String) ?? (b["location"] as? String).flatMap { $0.hasPrefix("https://") ? $0 : nil }, + badge: (b["status"] as? String)?.capitalized) + } + return ServiceDetail(pillId: "integration_calcom", + stats: [DetailStat(label: "Today", value: "\(today)", tone: today > 0 ? .warning : .idle), + DetailStat(label: "Next 7 days", value: "\(week)", tone: .info), + DetailStat(label: "Upcoming", value: "\(items.count)")], + sections: [DetailSection(title: "Upcoming bookings", items: items, + footer: items.isEmpty ? "Nothing booked." : nil)], + fetchedAt: Date()) + } + + // n8n: workflows (activate, deactivate) and executions (retry a failed one). + private static func n8n() async throws -> ServiceDetail { + let key = try secret("n8n-api-key", "n8n") + let base = try secret("n8n-url", "n8n").trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let headers = ["X-N8N-API-KEY": key, "Accept": "application/json"] + // The list comes in pages (at most 250, then `cursor` = the previous `nextCursor`): read + // them all so the counts are right. + var workflows: [[String: Any]] = [] + var cursor: String? + repeat { + var url = "\(base)/api/v1/workflows?limit=250" + if let cursor, let encoded = cursor.addingPercentEncoding(withAllowedCharacters: .alphanumerics) { + url += "&cursor=\(encoded)" + } + let page = try await request(url, headers: headers) as? [String: Any] + workflows += page?["data"] as? [[String: Any]] ?? [] + cursor = page?["nextCursor"] as? String + } while !(cursor ?? "").isEmpty && workflows.count < 5_000 + var names: [String: String] = [:] + var active = 0 + let workflowItems = workflows.compactMap { w -> DetailItem? in + guard let id = (w["id"] as? String) ?? (w["id"] as? Int).map(String.init) else { return nil } + let name = w["name"] as? String ?? "Workflow" + names[id] = name + // Archived workflows are listed but can't be turned on (n8n answers 400). + if w["isArchived"] as? Bool ?? false { return nil } + let isActive = w["active"] as? Bool ?? false + if isActive { active += 1 } + return DetailItem(title: name, tone: isActive ? .ok : .idle, date: date(w["updatedAt"]), + url: "\(base)/workflow/\(id)", badge: isActive ? "Active" : "Off", + actions: [isActive + ? ServiceActionDef(kind: "n8n.deactivate", title: "Turn off", symbol: "pause.circle", + target: id, destructive: true, confirm: "Stop \(name) from running?") + : ServiceActionDef(kind: "n8n.activate", title: "Turn on", symbol: "play.circle", target: id)]) + } + let executionsJSON = try? await request("\(base)/api/v1/executions?limit=15", headers: headers) + let executions = (executionsJSON as? [String: Any])?["data"] as? [[String: Any]] ?? [] + var failed = 0 + let executionItems = executions.compactMap { e -> DetailItem? in + guard let id = (e["id"] as? String) ?? (e["id"] as? Int).map(String.init) else { return nil } + let workflowId = (e["workflowId"] as? String) ?? (e["workflowId"] as? Int).map(String.init) ?? "" + let status = e["status"] as? String ?? ((e["finished"] as? Bool ?? false) ? "success" : "error") + let isFailure = status == "error" || status == "crashed" || status == "failed" + if isFailure { failed += 1 } + return DetailItem(title: names[workflowId] ?? "Workflow \(workflowId)", subtitle: "Execution \(id)", + tone: isFailure ? .error : (status == "success" ? .ok : .warning), + date: date(e["startedAt"]), url: "\(base)/workflow/\(workflowId)/executions/\(id)", + badge: status.capitalized, + actions: isFailure ? [ServiceActionDef(kind: "n8n.retry", title: "Retry", symbol: "arrow.clockwise", target: id)] : []) + } + return ServiceDetail(pillId: "integration_n8n", + stats: [DetailStat(label: "Workflows", value: "\(workflowItems.count)"), + DetailStat(label: "Active", value: "\(active)", tone: .ok), + DetailStat(label: "Failed runs", value: "\(failed)", tone: failed > 0 ? .error : .idle)], + sections: [DetailSection(title: "Latest runs", items: executionItems), + DetailSection(title: "Workflows", + items: Array(workflowItems.sorted { ($0.date ?? .distantPast) > ($1.date ?? .distantPast) }.prefix(50)))], + fetchedAt: Date()) + } + + // Notion: the pages and databases edited last. Read only. + private static func notion() async throws -> ServiceDetail { + let key = try secret("notion-api-key", "Notion") + let headers = ["Authorization": "Bearer \(key)", "Notion-Version": "2022-06-28"] + let json = try await request("https://api.notion.com/v1/search", method: "POST", headers: headers, + body: ["sort": ["direction": "descending", "timestamp": "last_edited_time"], "page_size": 25]) + let results = (json as? [String: Any])?["results"] as? [[String: Any]] ?? [] + var pages: [DetailItem] = [] + var databases: [DetailItem] = [] + for object in results { + let kind = object["object"] as? String ?? "page" + var title = "" + if kind == "database" { + title = ((object["title"] as? [[String: Any]])?.compactMap { $0["plain_text"] as? String }.joined()) ?? "" + } else if let properties = object["properties"] as? [String: Any] { + for case let property as [String: Any] in properties.values where property["type"] as? String == "title" { + title = ((property["title"] as? [[String: Any]])?.compactMap { $0["plain_text"] as? String }.joined()) ?? "" + } + } + var emoji = "" + if let icon = object["icon"] as? [String: Any], icon["type"] as? String == "emoji" { emoji = (icon["emoji"] as? String ?? "") + " " } + let item = DetailItem(title: emoji + (title.isEmpty ? "Untitled" : title), + date: date(object["last_edited_time"]), url: object["url"] as? String, + badge: (object["archived"] as? Bool ?? false) ? "Archived" : nil) + if kind == "database" { databases.append(item) } else { pages.append(item) } + } + var sections = [DetailSection(title: "Edited lately", items: pages)] + if !databases.isEmpty { sections.append(DetailSection(title: "Databases", items: databases)) } + let editedToday = results.filter { date($0["last_edited_time"]).map(Calendar.current.isDateInToday) ?? false }.count + return ServiceDetail(pillId: "integration_notion", + stats: [DetailStat(label: "Edited today", value: "\(editedToday)", tone: editedToday > 0 ? .info : .idle), + DetailStat(label: "Pages", value: "\(pages.count)"), + DetailStat(label: "Databases", value: "\(databases.count)")], + sections: sections, fetchedAt: Date()) + } + + // MARK: Acting + + /// Runs one action. Returns a short line for the iPhone. + static func perform(kind: String, target: String) async throws -> String { + switch kind { + case "vercel.redeploy": + let parts = target.split(separator: "|", omittingEmptySubsequences: false).map(String.init) + guard parts.count == 3 else { throw Failure(status: 0, message: "Unknown deployment") } + var body: [String: Any] = ["name": parts[1], "deploymentId": parts[0]] + if !parts[2].isEmpty { body["target"] = parts[2] } + let json = try await request("https://api.vercel.com/v13/deployments", method: "POST", + headers: try vercelHeaders(), body: body) + let url = (json as? [String: Any])?["url"] as? String + return url.map { "Building \($0)" } ?? "New deployment started" + case "vercel.promote": + // Vercel only promotes production builds in place: a preview is rebuilt for + // production, as `vercel promote` does. + let parts = target.split(separator: "|", omittingEmptySubsequences: false).map(String.init) + guard parts.count == 2 else { throw Failure(status: 0, message: "Unknown deployment") } + let json = try await request("https://api.vercel.com/v13/deployments", method: "POST", + headers: try vercelHeaders(), + body: ["deploymentId": parts[0], "name": parts[1], "target": "production", + "meta": ["action": "promote"]]) + let url = (json as? [String: Any])?["url"] as? String + return url.map { "Building \($0) for production" } ?? "Production build started" + case "vercel.cancel": + _ = try await request("https://api.vercel.com/v12/deployments/\(target)/cancel", method: "PATCH", + headers: try vercelHeaders()) + return "Build canceled" + case "github.rerun": + // "owner/repo/123" + let parts = target.split(separator: "/").map(String.init) + guard parts.count == 3 else { throw Failure(status: 0, message: "Unknown run") } + _ = try await request("https://api.github.com/repos/\(parts[0])/\(parts[1])/actions/runs/\(parts[2])/rerun-failed-jobs", + method: "POST", headers: try githubHeaders()) + return "Failed jobs started again" + case "github.approve", "github.merge": + // "owner/repo#12" + let pieces = target.split(separator: "#").map(String.init) + guard pieces.count == 2 else { throw Failure(status: 0, message: "Unknown pull request") } + if kind == "github.approve" { + _ = try await request("https://api.github.com/repos/\(pieces[0])/pulls/\(pieces[1])/reviews", method: "POST", + headers: try githubHeaders(), body: ["event": "APPROVE"]) + return "Approved" + } + _ = try await request("https://api.github.com/repos/\(pieces[0])/pulls/\(pieces[1])/merge", method: "PUT", + headers: try githubHeaders(), body: ["merge_method": "squash"]) + return "Merged" + case "n8n.activate", "n8n.deactivate", "n8n.retry": + let key = try secret("n8n-api-key", "n8n") + let base = try secret("n8n-url", "n8n").trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let headers = ["X-N8N-API-KEY": key, "Accept": "application/json"] + switch kind { + case "n8n.activate": + _ = try await request("\(base)/api/v1/workflows/\(target)/activate", method: "POST", headers: headers) + return "Turned on" + case "n8n.deactivate": + _ = try await request("\(base)/api/v1/workflows/\(target)/deactivate", method: "POST", headers: headers) + return "Turned off" + default: + do { + _ = try await request("\(base)/api/v1/executions/\(target)/retry", method: "POST", headers: headers) + } catch let failure as Failure where failure.status == 404 || failure.status == 405 { + // POST /api/v1/executions/{id}/retry only exists from n8n 1.112.0. + throw Failure(status: 0, message: "Couldn't retry this run. It may have been deleted, or your n8n is older than 1.112, which is needed to retry from the iPhone.") + } + return "Running again" + } + default: + throw Failure(status: 0, message: "This action isn't available") + } + } + + private static func vercelHeaders() throws -> [String: String] { + let token = try secret("vercel-token", "Vercel") + return ["Authorization": "Bearer \(token)", "Accept": "application/json"] + } + + private static func githubHeaders() throws -> [String: String] { + let token = try secret("github-token", "GitHub") + return ["Authorization": "Bearer \(token)", "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28"] + } +} +#endif diff --git a/NotchBuddy/Sources/App/PhoneLink/ServicePublisher.swift b/NotchBuddy/Sources/App/PhoneLink/ServicePublisher.swift new file mode 100644 index 0000000..7bb6ab9 --- /dev/null +++ b/NotchBuddy/Sources/App/PhoneLink/ServicePublisher.swift @@ -0,0 +1,395 @@ +#if PHONE_LINK +import AppKit +import CloudKit +import Combine + +// MARK: - Services on the iPhone +// +// One `Service` record per service Mochi (GitHub, Stripe, Vercel, Resend, +// Cal.com, n8n, Notion), built from the data the Mac's pollers already keep in +// AppState: no extra API call, no key sent anywhere. The whole snapshot +// (amounts, titles, emails, links) is one field encrypted with the user's +// iCloud keys; only the pill ID, the state dot and the date are in clear. +// Driven by AppState changes (debounced), never by a timer. + +@MainActor +final class ServicePublisher { + static let shared = ServicePublisher() + + private let container = CKContainer(identifier: CloudProbe.containerID) + private var database: CKDatabase { container.privateCloudDatabase } + private var cancellable: AnyCancellable? + private var published: [String: ServiceSnapshot] = [:] + private var cleanedUp = false + private var writing = false + private var again = false + + func start() { + guard cancellable == nil else { return } + let s = AppState.shared + let changes: [AnyPublisher] = [ + s.$stripePayments.map { _ in () }.eraseToAnyPublisher(), + s.$stripeBalance.map { _ in () }.eraseToAnyPublisher(), + s.$stripeError.map { _ in () }.eraseToAnyPublisher(), + s.$githubPulse.map { _ in () }.eraseToAnyPublisher(), + s.$githubActivity.map { _ in () }.eraseToAnyPublisher(), + s.$vercelDeployments.map { _ in () }.eraseToAnyPublisher(), + s.$resendEmails.map { _ in () }.eraseToAnyPublisher(), + s.$calcomBookings.map { _ in () }.eraseToAnyPublisher(), + s.$calcomError.map { _ in () }.eraseToAnyPublisher(), + s.$notionPages.map { _ in () }.eraseToAnyPublisher(), + s.$notionError.map { _ in () }.eraseToAnyPublisher(), + s.$n8nRuns.map { _ in () }.eraseToAnyPublisher(), + ] + cancellable = Publishers.MergeMany(changes) + .debounce(for: .seconds(2), scheduler: RunLoop.main) + .sink { [weak self] in + MainActor.assumeIsolated { self?.publish() } + } + log("service publisher on") + } + + /// Stops and deletes this Mac's services from iCloud. + func stop() { + cancellable = nil + let ids = PillCatalog.phoneServices.map { + CKRecord.ID(recordName: ServiceSnapshot.recordName(for: $0), zoneID: SessionSnapshot.zoneID) + } + published = [:] + cleanedUp = false + Task { _ = try? await database.modifyRecords(saving: [], deleting: ids, savePolicy: .changedKeys, atomically: false) } + log("service publisher off") + } + + private func publish() { + guard !writing else { again = true; return } + writing = true + let snapshots = ServiceSnapshots.all(from: AppState.shared) + Task { + await write(snapshots) + writing = false + if again { again = false; publish() } + } + } + + private func write(_ snapshots: [String: ServiceSnapshot]) async { + var deletions: [CKRecord.ID] = [] + if !cleanedUp { + // Services this Mac no longer has (key removed since the last run). + deletions = PillCatalog.phoneServices.filter { snapshots[$0] == nil }.map { + CKRecord.ID(recordName: ServiceSnapshot.recordName(for: $0), zoneID: SessionSnapshot.zoneID) + } + cleanedUp = true + } + let changed = snapshots.values.filter { snapshot in + guard let old = published[snapshot.pillId] else { return true } + return !old.sameContent(as: snapshot) + } + let removed = published.keys.filter { snapshots[$0] == nil } + deletions += removed.map { CKRecord.ID(recordName: ServiceSnapshot.recordName(for: $0), zoneID: SessionSnapshot.zoneID) } + guard !changed.isEmpty || !deletions.isEmpty else { return } + + let records = changed.compactMap { record(for: $0) } + do { + let result = try await database.modifyRecords(saving: records, deleting: deletions, + savePolicy: .changedKeys, atomically: false) + for (id, outcome) in result.saveResults { + guard let pillId = ServiceSnapshot.pillId(fromRecordName: id.recordName) else { continue } + if case .success = outcome { published[pillId] = snapshots[pillId] } + } + for id in removed { published[id] = nil } + let names = changed.map { "\($0.pillId.replacingOccurrences(of: "integration_", with: ""))=\($0.tone.rawValue)" } + log("published \(changed.count) service(s) [\(names.sorted().joined(separator: ", "))], removed \(deletions.count)") + } catch { + log("publish failed: \(error.localizedDescription)") + } + } + + private func record(for snapshot: ServiceSnapshot) -> CKRecord? { + guard let json = try? JSONEncoder().encode(snapshot), let payload = String(data: json, encoding: .utf8) else { return nil } + let record = CKRecord(recordType: ServiceSnapshot.recordType, + recordID: CKRecord.ID(recordName: ServiceSnapshot.recordName(for: snapshot.pillId), + zoneID: SessionSnapshot.zoneID)) + record["pillId"] = snapshot.pillId + record["tone"] = snapshot.tone.rawValue + record["updatedAt"] = snapshot.updatedAt + record["macName"] = Host.current().localizedName ?? "" + record.encryptedValues["payload"] = payload + return record + } + + private func log(_ message: String) { + CloudProbe.shared.log("[services] \(message)") + } +} + +// MARK: - Building the snapshots + +/// One snapshot per service that has data on this Mac. A service without its +/// key (nothing loaded) is left out, and the iPhone shows "connect it on your Mac". +@MainActor +enum ServiceSnapshots { + static func all(from s: AppState) -> [String: ServiceSnapshot] { + let built = [github(s), stripe(s), vercel(s), resend(s), calcom(s), n8n(s), notion(s)].compactMap { $0 } + return Dictionary(uniqueKeysWithValues: built.map { ($0.pillId, $0) }) + } + + // MARK: GitHub + + static func github(_ s: AppState) -> ServiceSnapshot? { + guard let pulse = s.githubPulse else { return nil } + func tone(_ ci: CIState) -> ServiceTone { + switch ci { + case .success: .ok + case .failure: .error + case .pending: .warning + case .unknown: .idle + } + } + func ciLabel(_ ci: CIState) -> String { + switch ci { + case .success: "checks passed" + case .failure: "checks failing" + case .pending: "checks running" + case .unknown: "no checks" + } + } + func reviewLabel(_ review: ReviewState) -> String { + switch review { + case .approved: " · approved" + case .changesRequested: " · changes requested" + case .pending: " · review required" + case .unknown: "" + } + } + let failingPR = pulse.myPRs.first { $0.ci == .failure } + let failingMain = pulse.mainCI.first { $0.ci == .failure } + let pending = pulse.myPRs.contains { $0.ci == .pending } || pulse.mainCI.contains { $0.ci == .pending } + let anyGreen = pulse.myPRs.contains { $0.ci == .success } || pulse.mainCI.contains { $0.ci == .success } + + let overall: ServiceTone + let reason: String + if let main = failingMain { + overall = .error + reason = "CI failing on \(main.repo) · \(main.branch)" + } else if let pr = failingPR { + overall = .error + reason = "Checks failing on \(pr.repo) #\(pr.number): \(pr.title)" + } else if pending { + overall = .warning + reason = "Checks are running" + } else if !pulse.toReview.isEmpty { + overall = .warning + reason = pulse.toReview.count == 1 + ? "1 pull request waits for your review" + : "\(pulse.toReview.count) pull requests wait for your review" + } else if anyGreen { + overall = .ok + reason = "All checks passed" + } else { + overall = .idle + reason = "Nothing going on" + } + + var sections: [ServiceSection] = [] + if !pulse.myPRs.isEmpty { + sections.append(ServiceSection(title: "Your pull requests", items: pulse.myPRs.prefix(8).map { + ServiceItem(title: "#\($0.number) \($0.title)", + detail: "\($0.repo) · \(ciLabel($0.ci))\(reviewLabel($0.review))\($0.isDraft ? " · draft" : "")", + tone: tone($0.ci), url: $0.url) + })) + } + if !pulse.toReview.isEmpty { + sections.append(ServiceSection(title: "Waiting for your review", items: pulse.toReview.prefix(8).map { + ServiceItem(title: "#\($0.number) \($0.title)", detail: $0.repo, tone: .warning, url: $0.url) + })) + } + if !pulse.mainCI.isEmpty { + sections.append(ServiceSection(title: "Default branches", items: pulse.mainCI.prefix(8).map { + ServiceItem(title: $0.repo, detail: "\($0.branch) · \(ciLabel($0.ci))", tone: tone($0.ci), url: $0.url) + })) + } + var headline = "\(pulse.myPRs.count) open pull request\(pulse.myPRs.count == 1 ? "" : "s")" + if let total = s.githubActivity?.total { headline += " · \(total) contributions this year" } + return ServiceSnapshot(pillId: "integration_github", tone: overall, headline: headline, reason: reason, + sections: sections, updatedAt: pulse.fetchedAt) + } + + // MARK: Stripe + + static func stripe(_ s: AppState) -> ServiceSnapshot? { + if let error = s.stripeError, !s.stripeLoaded { + return ServiceSnapshot(pillId: "integration_stripe", tone: .error, headline: "Can't reach Stripe", + reason: error, sections: [], updatedAt: Date()) + } + guard s.stripeLoaded else { return nil } + let currency = s.stripeCurrency.uppercased() + func money(_ cents: Int, _ code: String) -> String { + String(format: "%.2f %@", Double(cents) / 100, code.uppercased()) + } + let latest = s.stripePayments.first + let overall: ServiceTone + let reason: String + if let error = s.stripeError { + overall = .error + reason = "Stripe answered with an error: \(error)" + } else if let latest { + let what = latest.description.map { " · \($0)" } ?? "" + switch latest.status { + case "succeeded": + overall = Date().timeIntervalSince(latest.createdAt) < 86_400 ? .ok : .idle + reason = "Last payment received: \(money(latest.amount, latest.currency))\(what)" + case "failed": + overall = .error + reason = "Last payment failed: \(money(latest.amount, latest.currency))\(what)" + default: + overall = .warning + reason = "Last payment pending: \(money(latest.amount, latest.currency))\(what)" + } + } else { + overall = .idle + reason = "No payment yet" + } + let items = s.stripePayments.prefix(10).map { + ServiceItem(title: money($0.amount, $0.currency), + detail: [$0.description, $0.status].compactMap { $0 }.joined(separator: " · "), + tone: $0.isSuccess ? .ok : ($0.status == "failed" ? .error : .warning), + date: $0.createdAt) + } + return ServiceSnapshot(pillId: "integration_stripe", tone: overall, + headline: "Balance \(money(s.stripeBalance, currency))", reason: reason, + sections: items.isEmpty ? [] : [ServiceSection(title: "Recent payments", items: items)], + updatedAt: latest?.createdAt ?? Date()) + } + + // MARK: Vercel + + static func vercel(_ s: AppState) -> ServiceSnapshot? { + let filter = s.vercelProjectFilter + let deployments = filter.isEmpty ? s.vercelDeployments : s.vercelDeployments.filter { filter.contains($0.projectName) } + guard let latest = deployments.first else { return nil } + func tone(_ d: VercelDeployment) -> ServiceTone { + d.isSuccess ? .ok : (d.state == "CANCELED" ? .warning : .error) + } + let branch = latest.branch.map { " · \($0)" } ?? "" + let reason: String + switch latest.state { + case "READY": reason = "Last deploy is live: \(latest.projectName)\(branch)" + case "CANCELED": reason = "Last deploy was canceled: \(latest.projectName)\(branch)" + default: reason = "Last deploy failed: \(latest.projectName)\(branch)" + } + let items = deployments.prefix(10).map { d in + ServiceItem(title: d.projectName, + detail: [d.statusLabel, d.branch, d.commitMessage].compactMap { $0 }.joined(separator: " · "), + tone: tone(d), date: d.createdAt, + url: d.url.isEmpty ? nil : "https://\(d.url)") + } + return ServiceSnapshot(pillId: "integration_vercel", tone: tone(latest), + headline: "\(latest.projectName) · \(latest.statusLabel)", reason: reason, + sections: [ServiceSection(title: "Deployments", items: items)], + updatedAt: latest.createdAt) + } + + // MARK: Resend + + static func resend(_ s: AppState) -> ServiceSnapshot? { + guard let latest = s.resendEmails.first else { return nil } + func tone(_ event: String) -> ServiceTone { + switch event { + case "bounced", "complained", "failed": .error + case "delivered", "opened", "clicked": .ok + case "delivery_delayed": .warning + default: .info + } + } + let reason: String + switch tone(latest.lastEvent) { + case .error: reason = "Last email \(latest.lastEvent): \(latest.subject)" + case .warning: reason = "Last email is delayed: \(latest.subject)" + case .ok: reason = "Last email \(latest.lastEvent): \(latest.subject)" + default: reason = "Last email \(latest.lastEvent): \(latest.subject)" + } + let items = s.resendEmails.prefix(10).map { + ServiceItem(title: $0.subject, detail: "to \($0.to.joined(separator: ", ")) · \($0.lastEvent)", + tone: tone($0.lastEvent), date: $0.createdAt) + } + let total = s.resendTotal.map { "\($0) emails sent" } ?? "Emails" + return ServiceSnapshot(pillId: "integration_resend", tone: tone(latest.lastEvent), headline: total, + reason: reason, sections: [ServiceSection(title: "Recent emails", items: items)], + updatedAt: latest.createdAt) + } + + // MARK: Cal.com + + static func calcom(_ s: AppState) -> ServiceSnapshot? { + if let error = s.calcomError, !s.calcomLoaded { + return ServiceSnapshot(pillId: "integration_calcom", tone: .error, headline: "Can't reach Cal.com", + reason: error, sections: [], updatedAt: Date()) + } + guard s.calcomLoaded else { return nil } + let upcoming = s.calcomBookings.filter { $0.endTime > Date() }.sorted { $0.startTime < $1.startTime } + let next = upcoming.first { $0.isActive } + let time = Date.FormatStyle(date: .abbreviated, time: .shortened) + let overall: ServiceTone + let reason: String + if let next { + let soon = next.startTime.timeIntervalSinceNow < 3600 + overall = soon ? .warning : .info + let with = next.attendeeName.map { " with \($0)" } ?? "" + reason = "\(soon ? "Starting soon" : "Next"): \(next.title)\(with), \(next.startTime.formatted(time))" + } else { + overall = .idle + reason = "No upcoming booking" + } + let items = upcoming.prefix(10).map { + ServiceItem(title: $0.title, + detail: [$0.attendeeName, $0.status.lowercased()].compactMap { $0 }.joined(separator: " · "), + tone: $0.isActive ? .info : .idle, date: $0.startTime) + } + let count = upcoming.filter(\.isActive).count + return ServiceSnapshot(pillId: "integration_calcom", tone: overall, + headline: "\(count) upcoming booking\(count == 1 ? "" : "s")", reason: reason, + sections: items.isEmpty ? [] : [ServiceSection(title: "Upcoming", items: items)], + updatedAt: Date()) + } + + // MARK: n8n + + static func n8n(_ s: AppState) -> ServiceSnapshot? { + guard let latest = s.n8nRuns.first else { return nil } + let reason = latest.success + ? "Last run succeeded: \(latest.workflow)" + : "Last run failed: \(latest.workflow)\(latest.detail.map { " · \($0)" } ?? "")" + let items = s.n8nRuns.map { + ServiceItem(title: $0.workflow, detail: $0.detail ?? ($0.success ? "succeeded" : "failed"), + tone: $0.success ? .ok : .error, date: $0.date) + } + let failed = s.n8nRuns.filter { !$0.success }.count + return ServiceSnapshot(pillId: "integration_n8n", tone: latest.success ? .ok : .error, + headline: failed == 0 ? "All recent runs succeeded" : "\(failed) failed run\(failed == 1 ? "" : "s")", + reason: reason, sections: [ServiceSection(title: "Recent runs", items: items)], + updatedAt: latest.date) + } + + // MARK: Notion + + static func notion(_ s: AppState) -> ServiceSnapshot? { + if let error = s.notionError, !s.notionLoaded { + return ServiceSnapshot(pillId: "integration_notion", tone: .error, headline: "Can't reach Notion", + reason: error, sections: [], updatedAt: Date()) + } + guard s.notionLoaded else { return nil } + let pages = s.notionPages.sorted { $0.lastEditedAt > $1.lastEditedAt } + let items = pages.prefix(10).map { + ServiceItem(title: [$0.emoji, $0.title].compactMap { $0 }.joined(separator: " "), + tone: .info, date: $0.lastEditedAt, url: $0.url) + } + let reason = pages.first.map { "Last edited: \($0.title)" } ?? "No recent page" + let recent = (pages.first?.lastEditedAt.timeIntervalSinceNow ?? -.infinity) > -3600 + return ServiceSnapshot(pillId: "integration_notion", tone: recent ? .info : .idle, + headline: "\(pages.count) recent page\(pages.count == 1 ? "" : "s")", reason: reason, + sections: items.isEmpty ? [] : [ServiceSection(title: "Recently edited", items: items)], + updatedAt: pages.first?.lastEditedAt ?? Date()) + } +} +#endif diff --git a/NotchBuddy/Sources/App/PhoneLink/SessionPublisher.swift b/NotchBuddy/Sources/App/PhoneLink/SessionPublisher.swift new file mode 100644 index 0000000..237e130 --- /dev/null +++ b/NotchBuddy/Sources/App/PhoneLink/SessionPublisher.swift @@ -0,0 +1,257 @@ +#if PHONE_LINK +import AppKit +import CloudKit +import Combine + +// MARK: - iPhone plan, step 4: publish agent sessions to iCloud +// +// One `Session` record per agent pill (VS Code / Claude Code, Cursor, Codex, +// Antigravity, Gemini CLI…) in the private zone `Coucou`, so the iPhone can show +// them. Driven only by AppState changes (debounced), never by a timer: nothing +// runs while no agent changes. Integrations (Stripe, Vercel…) are not sessions. +// +// In clear (needed for sorting and widgets): pillId, state, step counts, flags, +// dates. Encrypted with the user's iCloud keys: project name, steps, cwd, +// last message, the pending command and question. + +@MainActor +final class SessionPublisher { + static let shared = SessionPublisher() + + private let container = CKContainer(identifier: "iCloud.fr.louisraille.Coucou") + private var database: CKDatabase { container.privateCloudDatabase } + private var cancellable: AnyCancellable? + + /// What was last written to iCloud, by pill ID. + private var published: [String: SessionSnapshot] = [:] + private var zoneReady = false + private var cleanedUp = false + private var publishing = false + private var pending: [String: SessionSnapshot]? + + /// Stops publishing and deletes this Mac's sessions from iCloud. + func stop() { + cancellable = nil + pending = nil + Task { + do { + let existing = try await existingSessions() + let ids = existing.keys.map { SessionSnapshot.recordID(for: $0) } + if !ids.isEmpty { + _ = try await database.modifyRecords(saving: [], deleting: ids, savePolicy: .changedKeys, atomically: false) + } + log("publisher off, removed \(ids.count) session(s) from iCloud") + } catch let error as CKError where error.code == .zoneNotFound || error.code == .userDeletedZone { + log("publisher off, nothing in iCloud") + } catch { + log("publisher off, cleanup failed: \(error.localizedDescription)") + } + published = [:] + cleanedUp = false + } + } + + func start() { + guard cancellable == nil else { return } + let state = AppState.shared + cancellable = state.$tasks + .combineLatest(state.$pendingApproval, state.$pendingQuestion) + .map { tasks, approval, question in + SessionSnapshot.all(tasks: tasks, approval: approval, question: question) + } + .removeDuplicates() + .debounce(for: .milliseconds(500), scheduler: RunLoop.main) + .sink { [weak self] snapshots in + MainActor.assumeIsolated { self?.publish(snapshots) } + } + log("session publisher on") + } + + // MARK: Publishing + + private func publish(_ snapshots: [String: SessionSnapshot]) { + guard !publishing else { pending = snapshots; return } + publishing = true + Task { + await write(snapshots) + publishing = false + if let next = pending { + pending = nil + publish(next) + } + } + } + + private func write(_ snapshots: [String: SessionSnapshot]) async { + do { + if !zoneReady { + _ = try await database.modifyRecordZones(saving: [CKRecordZone(zoneID: SessionSnapshot.zoneID)], deleting: []) + zoneReady = true + } + if !cleanedUp { + // Sessions left by a previous run (the Mac quit or crashed). + published = try await existingSessions() + cleanedUp = true + } + + let changed = snapshots.values.filter { published[$0.pillId] != $0 } + let removed = published.keys.filter { snapshots[$0] == nil } + guard !changed.isEmpty || !removed.isEmpty else { return } + + let records = changed.map { $0.record() } + let deletions = removed.map { SessionSnapshot.recordID(for: $0) } + let result = try await database.modifyRecords(saving: records, deleting: deletions, + savePolicy: .changedKeys, atomically: false) + for (id, outcome) in result.saveResults { + guard let pillId = SessionSnapshot.pillId(from: id) else { continue } + switch outcome { + case .success: published[pillId] = snapshots[pillId] + case .failure(let error): log("save \(pillId) failed: \(error.localizedDescription)") + } + } + for (id, outcome) in result.deleteResults { + guard let pillId = SessionSnapshot.pillId(from: id) else { continue } + switch outcome { + case .success: published[pillId] = nil + case .failure(let error): log("delete \(pillId) failed: \(error.localizedDescription)") + } + } + let summary = changed.map { "\($0.pillId)=\($0.state)" }.sorted().joined(separator: ", ") + log("published \(changed.count) session(s)\(summary.isEmpty ? "" : " [\(summary)]"), removed \(removed.count)") + } catch let error as CKError where error.code == .zoneNotFound || error.code == .userDeletedZone { + zoneReady = false + log("zone missing, will recreate on the next change") + } catch { + log("publish failed: \(error.localizedDescription)") + } + } + + /// Session records already in the zone, so the first write can delete stale ones. + private func existingSessions() async throws -> [String: SessionSnapshot] { + var found: [String: SessionSnapshot] = [:] + var token: CKServerChangeToken? + var more = true + while more { + let changes = try await database.recordZoneChanges(inZoneWith: CloudProbe.zoneID, since: token) + for (id, result) in changes.modificationResultsByID { + guard case .success(let mod) = result, mod.record.recordType == SessionSnapshot.recordType, + let pillId = SessionSnapshot.pillId(from: id) else { continue } + // Unknown content: a placeholder that never equals a real snapshot, + // so it is rewritten if the session still exists, deleted otherwise. + found[pillId] = SessionSnapshot.placeholder(pillId: pillId) + } + token = changes.changeToken + more = changes.moreComing + } + return found + } + + private func log(_ message: String) { + CloudProbe.shared.log("[sessions] \(message)") + } +} + +// MARK: - Snapshot + +/// The part of an AgentTask the iPhone needs. Equatable so unchanged sessions +/// are not rewritten. +struct SessionSnapshot: Equatable { + static let recordType = "Session" + + let pillId: String + let name: String + let color: String + let state: String + let stepIndex: Int + let steps: [String] + let cwd: String + let finalLine: String + let approvalTool: String + let approvalCommand: String + let approvalFingerprint: String + let question: String + /// The question's choices (QuestionPayload JSON) and its fingerprint, for answering from the iPhone. + var questionPayload: String = "" + var questionFingerprint: String = "" + + static func all(tasks: [AgentTask], approval: ApprovalInfo?, + question: AskQuestion?) -> [String: SessionSnapshot] { + var result: [String: SessionSnapshot] = [:] + // Services (Stripe, GitHub…) go through ServicePublisher, with their data. + for task in tasks where task.source != .n8n && PillCatalog.isSession(task.id) { + let hasApproval = approval?.pillId == task.id + let questionText = task.state == .question + ? (question?.questions.map(\.question).joined(separator: "\n") ?? "") + : "" + let payload = task.state == .question ? question.map(QuestionPayload.init(ask:)) : nil + result[task.id] = SessionSnapshot( + pillId: task.id, + name: task.name, + color: task.color, + state: task.state.rawValue, + stepIndex: task.stepIndex, + steps: task.steps, + cwd: task.sessionCwd ?? "", + finalLine: task.finalLine ?? "", + approvalTool: hasApproval ? (approval?.tool ?? "") : "", + approvalCommand: hasApproval ? (approval?.command ?? "") : "", + approvalFingerprint: hasApproval ? (approval.map(ApprovalRelay.fingerprint) ?? "") : "", + question: questionText, + questionPayload: payload?.json ?? "", + questionFingerprint: payload?.fingerprint ?? "") + } + return result + } + + static func placeholder(pillId: String) -> SessionSnapshot { + SessionSnapshot(pillId: pillId, name: "", color: "", state: "", stepIndex: -1, steps: [], + cwd: "", finalLine: "", approvalTool: "", approvalCommand: "", approvalFingerprint: "", question: "") + } + + /// Same zone as CloudProbe.zoneID, rebuilt here because that one is main-actor isolated. + static var zoneID: CKRecordZone.ID { + CKRecordZone.ID(zoneName: "Coucou", ownerName: CKCurrentUserDefaultName) + } + + static func recordID(for pillId: String) -> CKRecord.ID { + CKRecord.ID(recordName: "session-\(pillId)", zoneID: zoneID) + } + + static func pillId(from id: CKRecord.ID) -> String? { + let name = id.recordName + guard name.hasPrefix("session-") else { return nil } + return String(name.dropFirst("session-".count)) + } + + func record() -> CKRecord { + let record = CKRecord(recordType: Self.recordType, recordID: Self.recordID(for: pillId)) + record["pillId"] = pillId + record["color"] = color + record["state"] = state + record["stepIndex"] = stepIndex + record["stepCount"] = steps.count + record["needsApproval"] = !approvalCommand.isEmpty || !approvalTool.isEmpty + // Identifies the exact request; the iPhone sends it back with its decision. + record["approvalFingerprint"] = approvalFingerprint + record["needsAnswer"] = !question.isEmpty + record["questionFingerprint"] = questionFingerprint + record["updatedAt"] = Date() + record["macName"] = Host.current().localizedName ?? "" + // Whether this Mac runs instructions sent from the iPhone (GitHub build, switch on). + #if APPSTORE + record["acceptsInstructions"] = false + #else + record["acceptsInstructions"] = InstructionRunner.isEnabled && (pillId == "integration_claude" || pillId == "agent_cursor") + #endif + record.encryptedValues["name"] = name + record.encryptedValues["steps"] = steps + record.encryptedValues["cwd"] = cwd + record.encryptedValues["finalLine"] = finalLine + record.encryptedValues["approvalTool"] = approvalTool + record.encryptedValues["approvalCommand"] = approvalCommand + record.encryptedValues["question"] = question + record.encryptedValues["questionPayload"] = questionPayload + return record + } +} +#endif diff --git a/NotchBuddy/Sources/App/PhoneLink/TurnRecorder.swift b/NotchBuddy/Sources/App/PhoneLink/TurnRecorder.swift new file mode 100644 index 0000000..3aed1dd --- /dev/null +++ b/NotchBuddy/Sources/App/PhoneLink/TurnRecorder.swift @@ -0,0 +1,217 @@ +#if PHONE_LINK +import AppKit +import CloudKit + +// MARK: - The last turn, for the iPhone +// +// Built from the hook events HookServer already receives: UserPromptSubmit +// starts a turn, Pre/PostToolUse add actions (commands with their output, +// edits with their diff), Stop adds the final answer. One encrypted `Turn` +// record per session pill, replaced at each turn; nothing else is kept. +// Runs only while the iPhone sync is on; no timer. + +@MainActor +final class TurnRecorder { + static let shared = TurnRecorder() + + private let container = CKContainer(identifier: CloudProbe.containerID) + private var database: CKDatabase { container.privateCloudDatabase } + private var running = false + private var turns: [String: TurnSnapshot] = [:] + private var dirty: Set = [] + private var flushTask: Task? + + // Size limits: a turn stays well under CloudKit's 1 MB per record. + private let maxActions = 200 + private let maxLinesPerFile = 600 + private let maxTotalLines = 4000 + private let maxOutput = 1500 + private let maxFinal = 12_000 + + /// The Claude Code session last seen on a pill and its folder, for + /// instructions sent from the iPhone (InstructionRunner). + func lastSession(for pillId: String) -> (sessionId: String, cwd: String)? { + guard let info = sessions[pillId], !info.sessionId.isEmpty, !info.cwd.isEmpty else { return nil } + return info + } + private var sessions: [String: (sessionId: String, cwd: String)] = [:] + + func start() { + running = true + log("turn recorder on") + } + + func stop() { + running = false + flushTask?.cancel(); flushTask = nil + let ids = turns.keys.map { + CKRecord.ID(recordName: TurnSnapshot.recordName(for: $0), zoneID: SessionSnapshot.zoneID) + } + turns = [:] + dirty = [] + if !ids.isEmpty { + Task { _ = try? await database.modifyRecords(saving: [], deleting: ids, savePolicy: .changedKeys, atomically: false) } + } + } + + /// Called by HookServer for every event of a session pill. + func record(event: String, payload: [String: Any], pillId: String) { + guard running, PillCatalog.isSession(pillId) else { return } + let sessionId = payload["session_id"] as? String ?? payload["conversation_id"] as? String ?? "" + let cwd = payload["cwd"] as? String ?? "" + let now = Date() + if !sessionId.isEmpty, !cwd.isEmpty { sessions[pillId] = (sessionId, cwd) } + + switch event { + case "UserPromptSubmit": + let prompt = payload["prompt"] as? String ?? "" + turns[pillId] = TurnSnapshot(pillId: pillId, sessionId: sessionId, + project: URL(fileURLWithPath: cwd).lastPathComponent, + prompt: String(prompt.prefix(8000)), actions: [], files: [], + finalMessage: "", startedAt: now, endedAt: nil) + + case "PreToolUse": + let tool = payload["tool_name"] as? String ?? "Tool" + guard tool != "AskUserQuestion" else { return } + var turn = current(pillId, sessionId: sessionId, cwd: cwd) + guard turn.actions.count < maxActions else { return } + let input = payload["tool_input"] as? [String: Any] ?? [:] + turn.actions.append(TurnAction(tool: tool, summary: Self.summary(tool: tool, input: input), date: now)) + turns[pillId] = turn + + case "PostToolUse", "PostToolUseFailure": + let tool = payload["tool_name"] as? String ?? "Tool" + guard tool != "AskUserQuestion" else { return } + var turn = current(pillId, sessionId: sessionId, cwd: cwd) + let input = payload["tool_input"] as? [String: Any] ?? [:] + let summary = Self.summary(tool: tool, input: input) + // The matching PreToolUse action, latest first; one is added if it was missed. + var index = turn.actions.lastIndex { $0.tool == tool && $0.summary == summary && $0.output.isEmpty && $0.fileIndex == nil } + if index == nil, turn.actions.count < maxActions { + turn.actions.append(TurnAction(tool: tool, summary: summary, date: now)) + index = turn.actions.count - 1 + } + guard let index else { return } + turn.actions[index].failed = event == "PostToolUseFailure" + turn.actions[index].output = Self.output(payload, limit: maxOutput) + if event == "PostToolUse", let diff = Self.fileDiff(tool: tool, input: input) { + let used = turn.files.reduce(0) { $0 + $1.lines.count } + let budget = max(0, min(maxLinesPerFile, maxTotalLines - used)) + turn.files.append(TurnFile(diff: diff, maxLines: budget)) + turn.actions[index].fileIndex = turn.files.count - 1 + } + turns[pillId] = turn + + case "Stop", "StopFailure": + var turn = current(pillId, sessionId: sessionId, cwd: cwd) + let final = (payload["last_assistant_message"] as? String) ?? (payload["message"] as? String) ?? "" + turn.finalMessage = String(final.prefix(maxFinal)) + turn.endedAt = now + turns[pillId] = turn + + default: + return + } + dirty.insert(pillId) + scheduleFlush(soon: event == "Stop" || event == "StopFailure" || event == "UserPromptSubmit") + } + + /// The turn in progress, or a new one when the prompt wasn't seen (Codex, app started mid-turn). + private func current(_ pillId: String, sessionId: String, cwd: String) -> TurnSnapshot { + if let turn = turns[pillId], turn.endedAt == nil { return turn } + return TurnSnapshot(pillId: pillId, sessionId: sessionId, + project: URL(fileURLWithPath: cwd).lastPathComponent, + prompt: "", actions: [], files: [], finalMessage: "", startedAt: Date(), endedAt: nil) + } + + // MARK: Writing + + private func scheduleFlush(soon: Bool) { + if soon { flushTask?.cancel(); flushTask = nil } + guard flushTask == nil else { return } + flushTask = Task { + try? await Task.sleep(for: .seconds(soon ? 0.5 : 2)) + guard !Task.isCancelled else { return } + flushTask = nil + await flush() + } + } + + private func flush() async { + let ids = dirty + dirty = [] + let records = ids.compactMap { id -> CKRecord? in + guard let turn = turns[id], let json = try? JSONEncoder().encode(turn), + let payload = String(data: json, encoding: .utf8) else { return nil } + let record = CKRecord(recordType: TurnSnapshot.recordType, + recordID: CKRecord.ID(recordName: TurnSnapshot.recordName(for: id), + zoneID: SessionSnapshot.zoneID)) + record["pillId"] = id + record["updatedAt"] = Date() + record.encryptedValues["payload"] = payload + return record + } + guard !records.isEmpty else { return } + do { + _ = try await database.modifyRecords(saving: records, deleting: [], savePolicy: .allKeys, atomically: false) + } catch { + log("turn publish failed: \(error.localizedDescription)") + } + } + + // MARK: Reading the hook payloads + + static func summary(tool: String, input: [String: Any]) -> String { + let keys = ["command", "file_path", "path", "pattern", "url", "query", "description", "prompt"] + for key in keys { + if let value = input[key] as? String, !value.isEmpty { return String(value.prefix(600)) } + } + return "" + } + + /// What a command printed, or the error. + static func output(_ payload: [String: Any], limit: Int) -> String { + var text = "" + if let error = payload["error"] as? String { + text = error + } else if let response = payload["tool_response"] as? [String: Any] { + let stdout = response["stdout"] as? String ?? "" + let stderr = response["stderr"] as? String ?? "" + text = [stdout, stderr].filter { !$0.isEmpty }.joined(separator: "\n") + } else if let response = payload["tool_response"] as? String { + text = response + } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.count > limit ? String(trimmed.prefix(limit)) + "\n…" : trimmed + } + + static func fileDiff(tool: String, input: [String: Any]) -> FileDiff? { + guard let path = input["file_path"] as? String else { return nil } + switch tool { + case "Edit": + guard let old = input["old_string"] as? String, let new = input["new_string"] as? String else { return nil } + return DiffEngine.fromEdit(old: old, new: new, path: path) + case "MultiEdit": + guard let edits = input["edits"] as? [[String: Any]] else { return nil } + var added = 0, removed = 0, hunks: [DiffHunk] = [], tooLarge = false + for edit in edits { + guard let old = edit["old_string"] as? String, let new = edit["new_string"] as? String else { continue } + let d = DiffEngine.fromEdit(old: old, new: new, path: path) + added += d.added; removed += d.removed + hunks += d.hunks + tooLarge = tooLarge || d.tooLarge + } + return FileDiff(path: path, added: added, removed: removed, hunks: hunks, tooLarge: tooLarge, isNewFile: false) + case "Write": + guard let content = input["content"] as? String else { return nil } + return DiffEngine.fromNew(content: content, path: path) + default: + return nil + } + } + + private func log(_ message: String) { + CloudProbe.shared.log("[turn] \(message)") + } +} +#endif diff --git a/NotchBuddy/Sources/App/ResendPoller.swift b/NotchBuddy/Sources/App/ResendPoller.swift new file mode 100644 index 0000000..a1f6e39 --- /dev/null +++ b/NotchBuddy/Sources/App/ResendPoller.swift @@ -0,0 +1,62 @@ +import Foundation + +final class ResendPoller: @unchecked Sendable { + static let shared = ResendPoller() + private var timer: DispatchSourceTimer? + private init() {} + + func start() { + guard timer == nil else { return } + let t = DispatchSource.makeTimerSource(queue: .global(qos: .background)) + t.schedule(deadline: .now() + 6, repeating: 60) + t.setEventHandler { [weak self] in self?.poll() } + t.resume() + timer = t + } + + private func poll() { + guard let apiKey = KeychainStore.shared.get("resend-api-key") else { return } + guard let url = URL(string: "https://api.resend.com/emails?limit=100") else { return } + var req = URLRequest(url: url, timeoutInterval: 10) + req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + req.setValue("application/json", forHTTPHeaderField: "Accept") + + URLSession.shared.dataTask(with: req) { [weak self] data, response, _ in + guard let self else { return } + let code = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard let data, code == 200 else { return } + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let rawList = json["data"] as? [[String: Any]] else { return } + + let total = (json["total"] as? Int) ?? (json["count"] as? Int) + let emails = rawList.compactMap { self.parseEmail($0) } + + DispatchQueue.main.async { + AppState.shared.resendEmails = Array(emails.prefix(5)) + AppState.shared.resendTotal = total ?? (emails.isEmpty ? nil : emails.count) + } + }.resume() + } + + private func parseEmail(_ d: [String: Any]) -> ResendEmail? { + guard let id = d["id"] as? String, + let createdAt = d["created_at"] as? String else { return nil } + + let to: [String] + if let arr = d["to"] as? [String] { to = arr } + else if let single = d["to"] as? String { to = [single] } + else { to = [] } + + let subject = (d["subject"] as? String) ?? "" + let lastEvent = (d["last_event"] as? String) ?? "" + + // Parse ISO8601 date + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let date = formatter.date(from: createdAt) + ?? ISO8601DateFormatter().date(from: createdAt) + ?? Date() + + return ResendEmail(id: id, to: to, subject: subject, createdAt: date, lastEvent: lastEvent) + } +} diff --git a/NotchBuddy/Sources/App/SafeWebURL.swift b/NotchBuddy/Sources/App/SafeWebURL.swift new file mode 100644 index 0000000..d2e8d9b --- /dev/null +++ b/NotchBuddy/Sources/App/SafeWebURL.swift @@ -0,0 +1,12 @@ +import Foundation + +/// Returns the URL only if it is a plain web link (http/https with a host). Model output +/// and API data can carry file://, smb:// or custom app schemes that would launch local apps +/// or deep links; those never reach NSWorkspace.open. +func safeWebURL(_ string: String?) -> URL? { + guard let string, + let url = URL(string: string.trimmingCharacters(in: .whitespacesAndNewlines)), + let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http", + let host = url.host, !host.isEmpty else { return nil } + return url +} diff --git a/NotchBuddy/Sources/App/SettingsView.swift b/NotchBuddy/Sources/App/SettingsView.swift new file mode 100644 index 0000000..9a3d040 --- /dev/null +++ b/NotchBuddy/Sources/App/SettingsView.swift @@ -0,0 +1,1422 @@ +import SwiftUI +import ServiceManagement +import AppKit + +struct SettingsView: View { + @ObservedObject private var state = AppState.shared + @State private var apiKey: String = KeychainStore.shared.get("anthropic-api-key") ?? "" + + // Claude model — dynamic list fetched from the API, static fallback if unavailable + private static let fallbackModels: [(id: String, label: String)] = [ + ("claude-sonnet-4-6", "Claude Sonnet 4.6"), + ("claude-sonnet-5-5", "Claude Sonnet 5.5"), + ("claude-opus-5-5", "Claude Opus 5.5"), + ("claude-haiku-4-5-20251001", "Claude Haiku 4.5"), + ] + private static let customModelTag = "__custom__" + @State private var fetchedModels: [(id: String, label: String)] = [] + @State private var modelChoice: String = { + let m = AppState.shared.claudeModel + return SettingsView.fallbackModels.contains { $0.id == m } ? m : SettingsView.customModelTag + }() + @State private var customModel: String = { + let m = AppState.shared.claudeModel + return SettingsView.fallbackModels.contains { $0.id == m } ? "" : m + }() + private var displayModels: [(id: String, label: String)] { + fetchedModels.isEmpty ? Self.fallbackModels : fetchedModels + } + @State private var launchAtStartup: Bool = (SMAppService.mainApp.status == .enabled) + @State private var statusMessage: String = "" + @State private var showDiff: Bool = false + @State private var pendingHookJSON: String = "" + @State private var hookNeedsUpdate: Bool = HookServer.hooksNeedUpdate() + + #if !APPSTORE + @State private var showStatusLineDiff: Bool = false + @State private var pendingStatusLineJSON: String = "" + @State private var statusLinePendingInstall: Bool = true + @State private var planTogglePending: Bool = false + + @State private var geminiHooksInstalled: Bool = HookServer.geminiHooksInstalled() + @State private var showGeminiDiff: Bool = false + @State private var pendingGeminiJSON: String = "" + @State private var geminiPendingInstall: Bool = true + + @State private var agyHooksInstalled: Bool = HookServer.agyHooksInstalled() + @State private var showAgyDiff: Bool = false + @State private var pendingAgyJSON: String = "" + @State private var agyPendingInstall: Bool = true + + @State private var codexHooksInstalled: Bool = HookServer.codexHooksInstalled() + @State private var showCodexDiff: Bool = false + @State private var pendingCodexJSON: String = "" + @State private var codexPendingInstall: Bool = true + #endif + + // Multi-provider chat keys + @State private var googleKey: String = KeychainStore.shared.get("google-api-key") ?? "" + @State private var openAIKey: String = KeychainStore.shared.get("openai-api-key") ?? "" + @State private var ollamaURL: String = AppState.shared.ollamaServerURL + @State private var lmstudioURL: String = AppState.shared.lmstudioServerURL + @State private var connectingOllama: Bool = false + @State private var connectingLMStudio: Bool = false + + // Integration keys + @State private var resendKey: String = KeychainStore.shared.get("resend-api-key") ?? "" + @State private var resendFrom: String = KeychainStore.shared.get("resend-from") ?? "" + @State private var n8nUrl: String = KeychainStore.shared.get("n8n-url") ?? "" + @State private var n8nKey: String = KeychainStore.shared.get("n8n-api-key") ?? "" + @State private var vercelToken: String = KeychainStore.shared.get("vercel-token") ?? "" + @State private var githubToken: String = KeychainStore.shared.get("github-token") ?? "" + @State private var stripeKey: String = KeychainStore.shared.get("stripe-api-key") ?? "" + @State private var calcomKey: String = KeychainStore.shared.get("calcom-api-key") ?? "" + @State private var notionKey: String = KeychainStore.shared.get("notion-api-key") ?? "" + + // Hotkey + @State private var hotkeyFlags: UInt = AppState.shared.hotkeyFlags + @State private var hotkeyCode: UInt16 = AppState.shared.hotkeyCode + + // Vercel project filter + @State private var vercelProjects: [String] = [] + @State private var loadingVercel: Bool = false + + // n8n workflow filter + @State private var n8nWorkflows: [String] = [] + @State private var loadingN8n: Bool = false + + // Bindings in minutes for the absence field + private var absenceMinutes: Binding { + Binding( + get: { state.absenceInterval / 60 }, + set: { state.absenceInterval = max(1, $0) * 60 } + ) + } + + // Sidebar selection persisted across sessions + @AppStorage("settingsSection") private var selectedSection: String = "general" + #if PHONE_LINK + @AppStorage("iPhoneSyncEnabled") private var iPhoneSyncEnabled = false + @AppStorage("iPhoneLiveActivityEnabled") private var iPhoneLiveActivityEnabled = false + @AppStorage("iPhoneInstructionsEnabled") private var iPhoneInstructionsEnabled = false + #endif + + private var appVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" + } + + // MARK: - Body + + var body: some View { + HStack(spacing: 0) { + // Sidebar — 200 pt, sidebar visual effect background + ZStack(alignment: .topLeading) { + SidebarBackground() + VStack(alignment: .leading, spacing: 0) { + // Header + HStack(alignment: .center, spacing: 10) { + Image(nsImage: NSApplication.shared.applicationIconImage) + .resizable() + .frame(width: 32, height: 32) + VStack(alignment: .leading, spacing: 1) { + Text("Coucou") + .font(.system(size: 13, weight: .semibold)) + Text(appVersion) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 10) + Divider() + List(selection: Binding( + get: { Optional(selectedSection) }, + set: { if let v = $0 { selectedSection = v; statusMessage = "" } } + )) { + SettingsSidebarRow(title: "General", icon: "gearshape.fill", color: "#8E939C").tag("general") + SettingsSidebarRow(title: "Active pills", icon: "square.grid.2x2.fill", color: "#F5A524").tag("activepills") + SettingsSidebarRow(title: "Agents", icon: "terminal.fill", color: "#3B9EFF").tag("agents") + SettingsSidebarRow(title: "Chat", icon: "bubble.left.and.bubble.right.fill", color: "#E07950").tag("chat") + SettingsSidebarRow(title: "Integrations", icon: "puzzlepiece.extension.fill", color: "#7C5CFF").tag("integrations") + SettingsSidebarRow(title: "Shortcuts", icon: "keyboard.fill", color: "#6366F1").tag("shortcuts") + } + .listStyle(.sidebar) + .scrollContentBackground(.hidden) + } + } + .frame(width: 200) + + Divider() + + // Detail panel + VStack(alignment: .leading, spacing: 0) { + Text(sectionTitle) + .font(.title2) + .fontWeight(.semibold) + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 12) + ScrollView { + VStack(alignment: .leading, spacing: 18) { + sectionContent + } + .padding(.horizontal, 20) + .padding(.bottom, 16) + } + if !statusMessage.isEmpty { + Divider() + Text(statusMessage) + .font(.system(size: 12)) + .foregroundColor(statusMessage.hasPrefix("❌") ? .red : .secondary) + .padding(.horizontal, 20) + .padding(.vertical, 8) + } + } + } + .onAppear { + #if !APPSTORE + state.refreshPlanRelayState() + #endif + guard fetchedModels.isEmpty, + let key = KeychainStore.shared.get("anthropic-api-key"), !key.isEmpty else { return } + Task { + let models = await ClaudeService.fetchModels(apiKey: key) + guard !models.isEmpty else { return } + await MainActor.run { + fetchedModels = models + let m = state.claudeModel + if models.contains(where: { $0.id == m }) { + modelChoice = m + customModel = "" + } else if modelChoice != Self.customModelTag { + modelChoice = Self.customModelTag + customModel = m + } + } + } + } + } + + // MARK: - Section routing + + private var sectionTitle: String { + switch selectedSection { + case "general": return "General" + case "activepills": return "Active pills" + case "agents": return "Agents" + case "chat": return "Chat" + case "integrations": return "Integrations" + case "shortcuts": return "Shortcuts" + default: return "General" + } + } + + @ViewBuilder private var sectionContent: some View { + switch selectedSection { + case "activepills": activePillsSection + case "agents": agentsSection + case "chat": chatSection + case "integrations": integrationsSection + case "shortcuts": ShortcutsSettingsView() + default: generalSection + } + } + + // MARK: - General section + + @ViewBuilder private var generalSection: some View { + GroupBox("Sound") { + VStack(alignment: .leading, spacing: 10) { + Toggle("Enable sounds", isOn: $state.soundEnabled) + HStack(spacing: 8) { + Text("Volume") + .frame(width: 56, alignment: .leading) + Slider(value: $state.soundVolume, in: 0...0.2) + .disabled(!state.soundEnabled) + Text("\(Int(state.soundVolume / 0.2 * 100)) %") + .frame(width: 36, alignment: .trailing) + .monospacedDigit() + } + } + .padding(6) + } + + GroupBox("Behavior") { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Text("Close after") + TextField("60", value: $state.autoCloseInterval, format: .number) + .textFieldStyle(.roundedBorder) + .frame(width: 64) + Text("s inactive") + } + HStack(spacing: 8) { + Text("Hide after") + TextField("3", value: absenceMinutes, format: .number) + .textFieldStyle(.roundedBorder) + .frame(width: 48) + Text("min without movement") + } + } + .padding(6) + } + + GroupBox("Hotkey") { + VStack(alignment: .leading, spacing: 10) { + Toggle("Show island with shortcut", isOn: $state.hotkeyEnabled) + .onChange(of: state.hotkeyEnabled) { _, _ in + HotKeyCenter.shared.reregister(.toggleIsland) + } + if state.hotkeyEnabled { + HStack(spacing: 8) { + Text("Shortcut") + .frame(width: 70, alignment: .leading) + ShortcutRecorderButton(flags: $hotkeyFlags, code: $hotkeyCode) + .onChange(of: hotkeyFlags) { _, v in + state.hotkeyFlags = v + HotKeyCenter.shared.reregister(.toggleIsland) + } + .onChange(of: hotkeyCode) { _, v in + state.hotkeyCode = v + HotKeyCenter.shared.reregister(.toggleIsland) + } + Text("presses this → island opens") + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + } + } + .padding(6) + } + + GroupBox("Startup") { + Toggle("Launch at Mac startup", isOn: $launchAtStartup) + .onChange(of: launchAtStartup) { _, on in toggleStartup(on) } + .padding(6) + } + + #if PHONE_LINK + GroupBox("iPhone") { + VStack(alignment: .leading, spacing: 6) { + Toggle("Show my agent sessions on my iPhone", isOn: $iPhoneSyncEnabled) + .onChange(of: iPhoneSyncEnabled) { _, on in CloudProbe.shared.setEnabled(on) } + Text("Sends your sessions to your private iCloud for the Coucou iPhone app. Project names, commands and questions are encrypted with your iCloud keys. Turning it off deletes them from iCloud.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + Toggle("Move Mochi to my iPhone's Dynamic Island when my Mac is locked", isOn: $iPhoneLiveActivityEnabled) + .disabled(!iPhoneSyncEnabled) + .onChange(of: iPhoneLiveActivityEnabled) { _, on in LiveActivityRelay.shared.setEnabled(on) } + Text("Goes through the Coucou relay to Apple's push service. Only the agent's name and state are sent: no project name, command or path.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + #if !APPSTORE + Toggle("Let my iPhone send instructions to Claude Code", isOn: $iPhoneInstructionsEnabled) + .disabled(!iPhoneSyncEnabled) + .onChange(of: iPhoneInstructionsEnabled) { _, on in InstructionRunner.shared.setEnabled(on) } + Text("An instruction sent from the iPhone (Face ID required) continues your last Claude Code session in the background, in its folder, with claude --resume. This Mac checks for one every 15 s while this is on.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + #endif + } + .padding(6) + } + #endif + } + + // MARK: - Active pills section + + @ViewBuilder private var activePillsSection: some View { + GroupBox { + VStack(alignment: .leading, spacing: 10) { + Text("Choose the tools you use. Coucou only shows what you declare here.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + + Text("\(state.activeIntegrations.count)/4 slots used") + .font(.system(size: 11)) + .foregroundColor(state.activeIntegrations.count >= 4 ? .orange : .secondary) + + Picker("Main", selection: $state.mainPillId) { + ForEach(PillCatalog.available.filter { $0.category == .workspace && !$0.comingSoon }, id: \.id) { def in + Text(def.name).tag(def.id) + } + } + .onChange(of: state.mainPillId) { _, newId in + state.activeIntegrations.remove(newId) + state.loadIntegrationTasks() + state.setFocus(newId) + } + + ForEach(PillCategory.allCases, id: \.self) { cat in + let catPills = PillCatalog.available.filter { $0.category == cat } + if !catPills.isEmpty { + Divider() + Text(cat.title) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.secondary) + ForEach(catPills, id: \.id) { def in + pillRow(def) + } + } + } + } + .padding(6) + } + } + + // MARK: - Agents section + + @ViewBuilder private var agentsSection: some View { + GroupBox("Claude Code Hooks") { + VStack(alignment: .leading, spacing: 10) { + if hookNeedsUpdate { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + Text("Hooks outdated — update them to answer Claude's questions from the notch") + .font(.system(size: 11)) + .foregroundColor(.orange) + } + #if APPSTORE + Button("Update hooks") { installHooksAppStore() } + #else + Button("Update hooks") { installHooks() } + #endif + } + #if APPSTORE + Text("~/.claude/coucou/nb-hook") + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.secondary) + HStack(spacing: 10) { + Button("Install hooks") { installHooksAppStore() } + .buttonStyle(.borderedProminent) + Button("Uninstall") { uninstallHooksAppStore() } + .buttonStyle(.bordered) + } + #else + Text("nb-hook : \(HookServer.hookScriptPath)") + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.secondary) + HStack(spacing: 10) { + Button("Install hooks") { installHooks() } + .buttonStyle(.borderedProminent) + Button("Uninstall") { uninstallHooks() } + .buttonStyle(.bordered) + } + #endif + + #if !APPSTORE + if showDiff { + ScrollView { + Text(pendingHookJSON) + .font(.system(size: 10, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: 140) + .background(Color(NSColor.textBackgroundColor)) + .cornerRadius(6) + + HStack { + Button("Confirm & write") { confirmInstall() } + .buttonStyle(.borderedProminent) + Button("Cancel") { showDiff = false; pendingHookJSON = "" } + .buttonStyle(.bordered) + } + } + #endif + } + .padding(6) + } + + #if !APPSTORE + GroupBox("Gemini CLI Hooks") { + VStack(alignment: .leading, spacing: 10) { + Text(geminiHooksInstalled + ? "Hooks installed — restart Gemini CLI to activate" + : "~/.gemini/settings.json") + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.secondary) + HStack(spacing: 10) { + Button("Install hooks") { triggerGeminiPreview(install: true) } + .buttonStyle(.borderedProminent) + Button("Uninstall") { triggerGeminiPreview(install: false) } + .buttonStyle(.bordered) + } + if showGeminiDiff { + ScrollView { + Text(pendingGeminiJSON) + .font(.system(size: 10, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: 140) + .background(Color(NSColor.textBackgroundColor)) + .cornerRadius(6) + HStack { + Button("Confirm & write") { confirmGeminiOp() } + .buttonStyle(.borderedProminent) + Button("Cancel") { showGeminiDiff = false; pendingGeminiJSON = "" } + .buttonStyle(.bordered) + } + } + } + .padding(6) + } + + GroupBox("Antigravity Hooks") { + VStack(alignment: .leading, spacing: 10) { + Text(agyHooksInstalled + ? "Hooks installed — restart Antigravity to activate" + : "~/.gemini/config/hooks.json") + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.secondary) + HStack(spacing: 10) { + Button("Install hooks") { triggerAgyPreview(install: true) } + .buttonStyle(.borderedProminent) + Button("Uninstall") { triggerAgyPreview(install: false) } + .buttonStyle(.bordered) + } + if showAgyDiff { + ScrollView { + Text(pendingAgyJSON) + .font(.system(size: 10, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: 140) + .background(Color(NSColor.textBackgroundColor)) + .cornerRadius(6) + HStack { + Button("Confirm & write") { confirmAgyOp() } + .buttonStyle(.borderedProminent) + Button("Cancel") { showAgyDiff = false; pendingAgyJSON = "" } + .buttonStyle(.bordered) + } + } + } + .padding(6) + } + + GroupBox("Codex Hooks") { + VStack(alignment: .leading, spacing: 10) { + Text(codexHooksInstalled + ? "Hooks installed — open Codex and run /hooks or open Hooks in the app's settings to trust them" + : "~/.codex/hooks.json") + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.secondary) + HStack(spacing: 10) { + Button("Install hooks") { triggerCodexPreview(install: true) } + .buttonStyle(.borderedProminent) + Button("Uninstall") { triggerCodexPreview(install: false) } + .buttonStyle(.bordered) + } + if showCodexDiff { + ScrollView { + Text(pendingCodexJSON) + .font(.system(size: 10, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: 140) + .background(Color(NSColor.textBackgroundColor)) + .cornerRadius(6) + HStack { + Button("Confirm & write") { confirmCodexOp() } + .buttonStyle(.borderedProminent) + Button("Cancel") { showCodexDiff = false; pendingCodexJSON = "" } + .buttonStyle(.bordered) + } + } + } + .padding(6) + } + + GroupBox("Plan usage") { + VStack(alignment: .leading, spacing: 10) { + Text("Shows your Claude plan usage (5-hour and weekly limits) in the notch header. Coucou adds a status line relay to ~/.claude/settings.json. If you already have a status line, it keeps working as before. Pro and Max plans only.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + Toggle("Show in the notch", isOn: Binding( + get: { state.showPlanInNotch || planTogglePending }, + set: { on in + if on { + if state.planRelayInstalled { + state.showPlanInNotch = true + } else { + planTogglePending = true + installStatusLine() + } + } else { + state.showPlanInNotch = false + planTogglePending = false + } + } + )) + HStack(spacing: 10) { + if state.planRelayInstalled { + Text("Relay: installed") + .font(.system(size: 11)) + .foregroundColor(.secondary) + Button("Uninstall relay") { uninstallStatusLine() } + .buttonStyle(.bordered) + } else { + Text("Relay: not installed") + .font(.system(size: 11)) + .foregroundColor(.secondary) + Button("Install relay") { installStatusLine() } + .buttonStyle(.borderedProminent) + } + } + if showStatusLineDiff { + ScrollView { + Text(pendingStatusLineJSON) + .font(.system(size: 10, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: 100) + .background(Color(NSColor.textBackgroundColor)) + .cornerRadius(6) + HStack { + Button("Confirm & write") { confirmStatusLine() } + .buttonStyle(.borderedProminent) + Button("Cancel") { + showStatusLineDiff = false + pendingStatusLineJSON = "" + planTogglePending = false + } + .buttonStyle(.bordered) + } + } + } + .padding(6) + } + #endif + } + + // MARK: - Chat section + + @ViewBuilder private var chatSection: some View { + GroupBox("Anthropic API") { + VStack(alignment: .leading, spacing: 8) { + SecureField("API key (sk-ant-…)", text: $apiKey) + .textFieldStyle(.roundedBorder) + Button("Save") { + KeychainStore.shared.set("anthropic-api-key", value: apiKey) + statusMessage = "✓ Key saved." + } + .buttonStyle(.borderedProminent) + + Divider().padding(.vertical, 2) + + Picker("Model", selection: $modelChoice) { + ForEach(displayModels, id: \.id) { preset in + Text(preset.label).tag(preset.id) + } + Text("Custom…").tag(Self.customModelTag) + } + .onChange(of: modelChoice) { _, choice in + if choice != Self.customModelTag { + state.claudeModel = choice + } else { + applyCustomModel(customModel) + } + } + + if modelChoice == Self.customModelTag { + TextField("Model ID (e.g. claude-sonnet-4-6)", text: $customModel) + .textFieldStyle(.roundedBorder) + .onChange(of: customModel) { _, value in applyCustomModel(value) } + } + + Text("Used by the chat. The list comes from your Anthropic account.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + .padding(6) + } + + GroupBox("Chat — other providers") { + VStack(alignment: .leading, spacing: 12) { + Text("To use Google Gemini or OpenAI from the chat. Keys are stored in the Keychain.") + .font(.system(size: 12)) + .foregroundColor(.secondary) + + HStack(spacing: 8) { + Circle().fill(Color(hex: "#4285F4")).frame(width: 8, height: 8) + Text("Google AI").font(.system(size: 12, weight: .semibold)) + } + SecureField("API key (AI Studio)", text: $googleKey) + .textFieldStyle(.roundedBorder) + Button("Save") { + KeychainStore.shared.set("google-api-key", value: googleKey) + statusMessage = "✓ Google key saved." + } + .buttonStyle(.borderedProminent) + + Divider() + + HStack(spacing: 8) { + Circle().fill(Color(hex: "#10A37F")).frame(width: 8, height: 8) + Text("OpenAI").font(.system(size: 12, weight: .semibold)) + } + SecureField("API key (sk-…)", text: $openAIKey) + .textFieldStyle(.roundedBorder) + Button("Save") { + KeychainStore.shared.set("openai-api-key", value: openAIKey) + statusMessage = "✓ OpenAI key saved." + } + .buttonStyle(.borderedProminent) + } + .padding(.vertical, 4) + } + + GroupBox("Local models") { + VStack(alignment: .leading, spacing: 12) { + Text("Connect to a local model server. No API key needed.") + .font(.system(size: 12)) + .foregroundColor(.secondary) + + // ── Ollama ────────────────────────────────────────────────────── + HStack(spacing: 8) { + Circle().fill(Color(hex: "#FACC15")).frame(width: 8, height: 8) + Text("Ollama").font(.system(size: 12, weight: .semibold)) + if !state.ollamaServerURL.isEmpty { + Text("Connected") + .font(.system(size: 10)) + .foregroundColor(Color(hex: "#22C55E")) + } + } + if state.ollamaServerURL.isEmpty { + TextField("http://127.0.0.1:11434", text: $ollamaURL) + .textFieldStyle(.roundedBorder) + Button(connectingOllama ? "Connecting…" : "Connect") { + Task { await connectLocal(provider: .ollama) } + } + .buttonStyle(.borderedProminent) + .disabled(connectingOllama) + } else { + Text(state.ollamaServerURL) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.secondary) + Button("Disconnect") { + state.ollamaServerURL = "" + ollamaURL = "" + state.fetchedProviderModels[.ollama] = nil + state.providerModelFetchError[.ollama] = nil + if state.chatProvider == .ollama { state.chatProvider = .anthropic } + statusMessage = "Ollama disconnected." + } + .buttonStyle(.bordered) + } + + Divider() + + // ── LM Studio ─────────────────────────────────────────────────── + HStack(spacing: 8) { + Circle().fill(Color(hex: "#A3E635")).frame(width: 8, height: 8) + Text("LM Studio").font(.system(size: 12, weight: .semibold)) + if !state.lmstudioServerURL.isEmpty { + Text("Connected") + .font(.system(size: 10)) + .foregroundColor(Color(hex: "#22C55E")) + } + } + if state.lmstudioServerURL.isEmpty { + TextField("http://127.0.0.1:1234", text: $lmstudioURL) + .textFieldStyle(.roundedBorder) + Button(connectingLMStudio ? "Connecting…" : "Connect") { + Task { await connectLocal(provider: .lmstudio) } + } + .buttonStyle(.borderedProminent) + .disabled(connectingLMStudio) + } else { + Text(state.lmstudioServerURL) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.secondary) + Button("Disconnect") { + state.lmstudioServerURL = "" + lmstudioURL = "" + state.fetchedProviderModels[.lmstudio] = nil + state.providerModelFetchError[.lmstudio] = nil + if state.chatProvider == .lmstudio { state.chatProvider = .anthropic } + statusMessage = "LM Studio disconnected." + } + .buttonStyle(.bordered) + } + } + .padding(.vertical, 4) + } + } + + // MARK: - Integrations section + + @ViewBuilder private var integrationsSection: some View { + GroupBox { + VStack(alignment: .leading, spacing: 14) { + + // Resend + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 6) { + Circle().fill(Color(hex: "#22C55E")).frame(width: 8, height: 8) + Text("Resend").font(.system(size: 12, weight: .semibold)) + } + SecureField("API key (re_…)", text: $resendKey) + .textFieldStyle(.roundedBorder) + TextField("From address (you@yourdomain.com)", text: $resendFrom) + .textFieldStyle(.roundedBorder) + } + + // n8n + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 6) { + Circle().fill(Color(hex: "#F29B38")).frame(width: 8, height: 8) + Text("n8n").font(.system(size: 12, weight: .semibold)) + } + TextField("Instance URL (https://…)", text: $n8nUrl) + .textFieldStyle(.roundedBorder) + SecureField("API key", text: $n8nKey) + .textFieldStyle(.roundedBorder) + IntegrationFilterRow( + label: "Workflows", + items: n8nWorkflows, + filter: $state.n8nWorkflowFilter, + loading: loadingN8n, + onLoad: loadN8nWorkflows + ) + } + + // Vercel + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 6) { + Circle().fill(Color(hex: "#7C5CFF")).frame(width: 8, height: 8) + Text("Vercel").font(.system(size: 12, weight: .semibold)) + } + SecureField("Token", text: $vercelToken) + .textFieldStyle(.roundedBorder) + IntegrationFilterRow( + label: "Projects", + items: vercelProjects, + filter: $state.vercelProjectFilter, + loading: loadingVercel, + onLoad: loadVercelProjects + ) + } + + // GitHub + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 6) { + Circle().fill(Color(hex: "#F4505E")).frame(width: 8, height: 8) + Text("GitHub").font(.system(size: 12, weight: .semibold)) + } + SecureField("Personal Access Token", text: $githubToken) + .textFieldStyle(.roundedBorder) + Text("Classic token with repo scope, or fine-grained with read access to Pull requests, Commit statuses and Actions.") + .font(.system(size: 10)) + .foregroundColor(Color(hex: "#8E939C")) + } + + // Stripe + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 6) { + Circle().fill(Color(hex: "#0570DE")).frame(width: 8, height: 8) + Text("Stripe").font(.system(size: 12, weight: .semibold)) + } + SecureField("Secret key (sk_live_… or sk_test_…)", text: $stripeKey) + .textFieldStyle(.roundedBorder) + } + + // Cal.com + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 6) { + Circle().fill(Color(hex: "#C9956A")).frame(width: 8, height: 8) + Text("Cal.com").font(.system(size: 12, weight: .semibold)) + } + SecureField("API key (cal_live_…)", text: $calcomKey) + .textFieldStyle(.roundedBorder) + } + + // Notion + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 6) { + Circle().fill(Color(hex: "#E8E8E8")).frame(width: 8, height: 8) + Text("Notion").font(.system(size: 12, weight: .semibold)) + } + SecureField("Integration token (secret_…)", text: $notionKey) + .textFieldStyle(.roundedBorder) + } + + Button("Save integrations") { saveIntegrations() } + .buttonStyle(.borderedProminent) + } + .padding(6) + } + } + + // MARK: - Actions + + private func applyCustomModel(_ value: String) { + let id = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !id.isEmpty { state.claudeModel = id } + } + + private func toggleStartup(_ on: Bool) { + do { + if on { try SMAppService.mainApp.register() } + else { try SMAppService.mainApp.unregister() } + } catch { + statusMessage = "❌ Startup: \(error.localizedDescription)" + launchAtStartup = !on + } + } + + // MARK: - App Store: hooks via NSOpenPanel + security-scoped bookmark + + #if APPSTORE + private func pickClaudeFolder(prompt: String) -> URL? { + let panel = NSOpenPanel() + panel.message = "Select your .claude folder (press ⇧⌘. to show hidden files)" + panel.prompt = prompt + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.showsHiddenFiles = true + let realHomePath = getpwuid(getuid()).flatMap { String(cString: $0.pointee.pw_dir, encoding: .utf8) } + ?? "/Users/\(NSUserName())" + panel.directoryURL = URL(fileURLWithPath: realHomePath) + guard panel.runModal() == .OK, let url = panel.url else { return nil } + guard url.lastPathComponent == ".claude" else { + statusMessage = "❌ Select the .claude folder (hidden, in your Home directory)." + return nil + } + return url + } + + private func installHooksAppStore() { + guard let claudeURL = pickClaudeFolder(prompt: "Select") else { return } + let alert = NSAlert() + alert.messageText = "Install Coucou hooks in ~/.claude?" + alert.informativeText = "Will write:\n• ~/.claude/coucou/nb-hook\n• ~/.claude/settings.json (backup created first)" + alert.addButton(withTitle: "Install") + alert.addButton(withTitle: "Cancel") + alert.alertStyle = .informational + guard alert.runModal() == .alertFirstButtonReturn else { return } + do { + try HookServer.shared.installAndWriteClaudeHooksAppStore(claudeURL: claudeURL) + hookNeedsUpdate = false + statusMessage = "✓ Hooks installed — restart VS Code to activate." + } catch { + statusMessage = "❌ \(error.localizedDescription)" + } + } + + private func uninstallHooksAppStore() { + guard let claudeURL = pickClaudeFolder(prompt: "Select") else { return } + do { + try HookServer.shared.uninstallClaudeHooksAppStore(claudeURL: claudeURL) + statusMessage = "✓ Hooks removed." + } catch { + statusMessage = "❌ \(error.localizedDescription)" + } + } + #endif + + private func connectLocal(provider: ChatProvider) async { + let rawURL = provider == .ollama ? ollamaURL : lmstudioURL + let candidate = rawURL.isEmpty + ? (provider == .ollama ? "http://127.0.0.1:11434" : "http://127.0.0.1:1234") + : rawURL + let normalised = LocalChat.normaliseURL(candidate) + guard normalised.hasPrefix("http://") || normalised.hasPrefix("https://") else { + statusMessage = "Only http:// and https:// URLs are supported." + return + } + if provider == .ollama { connectingOllama = true } else { connectingLMStudio = true } + statusMessage = "" + let result = await LocalChat.fetchModelsResult(baseURL: normalised) + if provider == .ollama { connectingOllama = false } else { connectingLMStudio = false } + let name = provider == .ollama ? "Ollama" : "LM Studio" + switch result { + case .success(let models) where models.isEmpty: + statusMessage = "No models yet — download one in \(name) first." + case .success(let models): + if provider == .ollama { + state.ollamaServerURL = normalised + ollamaURL = normalised + state.fetchedProviderModels[.ollama] = nil + state.providerModelFetchError[.ollama] = nil + } else { + state.lmstudioServerURL = normalised + lmstudioURL = normalised + state.fetchedProviderModels[.lmstudio] = nil + state.providerModelFetchError[.lmstudio] = nil + } + statusMessage = "✓ Connected · \(models.count) model\(models.count == 1 ? "" : "s")" + case .failure: + statusMessage = "Couldn't reach \(name) at \(normalised). Is it running?" + } + } + + private func installHooks() { + do { + pendingHookJSON = try HookServer.shared.previewClaudeHooks() + showDiff = true + statusMessage = "Review the JSON below before confirming." + } catch { + statusMessage = "❌ \(error.localizedDescription)" + } + } + + private func confirmInstall() { + do { + try HookServer.shared.writeClaudeHooks() + showDiff = false + statusMessage = "✓ Hooks installed in ~/.claude/settings.json" + pendingHookJSON = "" + hookNeedsUpdate = false + } catch { + statusMessage = "❌ Write error: \(error.localizedDescription)" + } + } + + private func uninstallHooks() { + do { + try HookServer.shared.uninstallClaudeHooks() + statusMessage = "✓ Hooks removed." + } catch { + statusMessage = "❌ \(error.localizedDescription)" + } + } + + #if !APPSTORE + private func triggerGeminiPreview(install: Bool) { + do { + geminiPendingInstall = install + pendingGeminiJSON = try HookServer.shared.previewGeminiHooks(install: install) + showGeminiDiff = true + statusMessage = "Review the JSON below before confirming." + } catch let e as NSError where e.domain == "CoucouNoop" { + statusMessage = e.localizedDescription + } catch { + statusMessage = "❌ \(error.localizedDescription)" + } + } + + private func confirmGeminiOp() { + do { + try HookServer.shared.writeGeminiHooks() + showGeminiDiff = false + pendingGeminiJSON = "" + geminiHooksInstalled = geminiPendingInstall + statusMessage = geminiPendingInstall + ? "✓ Gemini CLI hooks installed in ~/.gemini/settings.json" + : "✓ Gemini CLI hooks removed." + } catch { + statusMessage = "❌ \(error.localizedDescription)" + } + } + + private func triggerAgyPreview(install: Bool) { + do { + agyPendingInstall = install + pendingAgyJSON = try HookServer.shared.previewAgyHooks(install: install) + showAgyDiff = true + statusMessage = "Review the JSON below before confirming." + } catch let e as NSError where e.domain == "CoucouNoop" { + statusMessage = e.localizedDescription + } catch { + statusMessage = "❌ \(error.localizedDescription)" + } + } + + private func confirmAgyOp() { + do { + try HookServer.shared.writeAgyHooks() + showAgyDiff = false + pendingAgyJSON = "" + agyHooksInstalled = agyPendingInstall + statusMessage = agyPendingInstall + ? "✓ Antigravity hooks installed in ~/.gemini/config/hooks.json" + : "✓ Antigravity hooks removed." + } catch { + statusMessage = "❌ \(error.localizedDescription)" + } + } + + private func triggerCodexPreview(install: Bool) { + do { + codexPendingInstall = install + pendingCodexJSON = try HookServer.shared.previewCodexHooks(install: install) + showCodexDiff = true + statusMessage = "Review the JSON below before confirming." + } catch let e as NSError where e.domain == "CoucouNoop" { + statusMessage = e.localizedDescription + } catch { + statusMessage = "❌ \(error.localizedDescription)" + } + } + + private func confirmCodexOp() { + do { + try HookServer.shared.writeCodexHooks() + showCodexDiff = false + pendingCodexJSON = "" + codexHooksInstalled = codexPendingInstall + statusMessage = codexPendingInstall + ? "✓ Codex hooks installed — run /hooks in Codex or open Hooks in the app's settings to trust them." + : "✓ Codex hooks removed." + } catch { + statusMessage = "❌ \(error.localizedDescription)" + } + } + + private func installStatusLine() { + do { + pendingStatusLineJSON = try HookServer.shared.previewStatusLine(install: true) + showStatusLineDiff = true + statusLinePendingInstall = true + statusMessage = "Review the JSON below before confirming." + } catch { + statusMessage = "❌ \(error.localizedDescription)" + } + } + + private func uninstallStatusLine() { + do { + pendingStatusLineJSON = try HookServer.shared.previewStatusLine(install: false) + showStatusLineDiff = true + statusLinePendingInstall = false + statusMessage = "Review the JSON below before confirming." + } catch { + statusMessage = "❌ \(error.localizedDescription)" + } + } + + private func confirmStatusLine() { + do { + try HookServer.shared.writeStatusLine() + showStatusLineDiff = false + pendingStatusLineJSON = "" + state.refreshPlanRelayState() + if planTogglePending { + state.showPlanInNotch = true + planTogglePending = false + } + if !statusLinePendingInstall { + state.showPlanInNotch = false + } + statusMessage = statusLinePendingInstall + ? "✓ Status line installed." + : "✓ Status line removed." + } catch { + planTogglePending = false + statusMessage = "❌ \(error.localizedDescription)" + } + } + #endif + + private func saveIntegrations() { + saveKey("resend-api-key", value: resendKey) + saveKey("resend-from", value: resendFrom) + saveKey("n8n-url", value: n8nUrl) + saveKey("n8n-api-key", value: n8nKey) + saveKey("vercel-token", value: vercelToken) + + // Detect GitHub token changes before writing + let prevGithubToken = KeychainStore.shared.get("github-token") + saveKey("github-token", value: githubToken) + let nextGithubToken = KeychainStore.shared.get("github-token") + if nextGithubToken != prevGithubToken { + AppState.shared.githubPulse = nil + AppState.shared.githubActivity = nil + if nextGithubToken == nil { AppState.shared.githubStats = nil } + if nextGithubToken != nil { + GithubPoller.shared.triggerPulseNow() + GithubPoller.shared.refreshActivityIfStale() + } + } + + saveKey("stripe-api-key", value: stripeKey) + saveKey("calcom-api-key", value: calcomKey) + saveKey("notion-api-key", value: notionKey) + statusMessage = "✓ Integration keys saved." + } + + private func saveKey(_ key: String, value: String) { + if value.isEmpty { + KeychainStore.shared.remove(key) + } else { + KeychainStore.shared.set(key, value: value) + } + } + + // MARK: - Vercel project list + + private func loadVercelProjects() { + guard let token = KeychainStore.shared.get("vercel-token") else { + statusMessage = "❌ Save Vercel token first." + return + } + loadingVercel = true + guard let url = URL(string: "https://api.vercel.com/v9/projects?limit=100") else { return } + var req = URLRequest(url: url, timeoutInterval: 10) + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + URLSession.shared.dataTask(with: req) { data, response, _ in + let names: [String] + if let data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let projects = json["projects"] as? [[String: Any]] { + names = projects.compactMap { $0["name"] as? String }.sorted() + } else { + names = [] + } + DispatchQueue.main.async { + self.vercelProjects = names + self.loadingVercel = false + if names.isEmpty { self.statusMessage = "❌ No Vercel projects found." } + } + }.resume() + } + + // MARK: - n8n workflow list + + private func loadN8nWorkflows() { + guard let apiKey = KeychainStore.shared.get("n8n-api-key"), + let rawBase = KeychainStore.shared.get("n8n-url") else { + statusMessage = "❌ Save n8n URL and API key first." + return + } + loadingN8n = true + let base = rawBase.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let urls = ["\(base)/api/v1/workflows?limit=100", "\(base)/rest/workflows?limit=100"] + fetchN8nWorkflows(urls: urls, apiKey: apiKey, idx: 0) + } + + private func fetchN8nWorkflows(urls: [String], apiKey: String, idx: Int) { + guard idx < urls.count, let url = URL(string: urls[idx]) else { + DispatchQueue.main.async { self.loadingN8n = false; self.statusMessage = "❌ No n8n workflows found." } + return + } + var req = URLRequest(url: url, timeoutInterval: 10) + req.setValue(apiKey, forHTTPHeaderField: "X-N8N-API-KEY") + URLSession.shared.dataTask(with: req) { data, response, _ in + let code = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard let data, code == 200 else { + self.fetchN8nWorkflows(urls: urls, apiKey: apiKey, idx: idx + 1) + return + } + let items: [[String: Any]] + if let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + let arr = obj["data"] as? [[String: Any]] { items = arr } + else if let arr = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] { items = arr } + else { items = [] } + let names = items.compactMap { $0["name"] as? String }.sorted() + DispatchQueue.main.async { + self.n8nWorkflows = names + self.loadingN8n = false + if names.isEmpty { self.statusMessage = "❌ No n8n workflows found." } + } + }.resume() + } + + @ViewBuilder + private func pillRow(_ def: PillDefinition) -> some View { + let isMain = def.id == state.mainPillId + let isOn = state.activeIntegrations.contains(def.id) + let atMax = state.activeIntegrations.count >= 4 && !isOn && !isMain + let hint: String? = { + if isMain { return nil } + if def.comingSoon { return "Coming soon" } + #if !APPSTORE + if def.id == "agent_gemini" && !HookServer.geminiHooksInstalled() { return "Hooks not installed" } + if def.id == "agent_antigravity" && !HookServer.agyHooksInstalled() { return "Hooks not installed" } + if def.id == "agent_codex" && !HookServer.codexHooksInstalled() { return "Hooks not installed" } + #endif + if def.category == .ai { + if let provider = ChatProvider(pillID: def.id), provider.isLocal { + let url = provider == .ollama ? state.ollamaServerURL : state.lmstudioServerURL + if url.isEmpty { return "Not connected" } + } else { + let keyId = def.id == "ai_anthropic" ? "anthropic-api-key" + : def.id == "ai_google" ? "google-api-key" : "openai-api-key" + if KeychainStore.shared.get(keyId) == nil { return "Key not configured" } + } + } + return nil + }() + HStack(spacing: 8) { + Circle() + .fill(Color(hex: def.color)) + .frame(width: 10, height: 10) + Text(def.name) + .font(.system(size: 12)) + .foregroundColor(atMax ? .secondary : .primary) + Spacer() + if isMain { + Text("Main") + .font(.system(size: 11)) + .foregroundColor(.secondary) + } else { + if let h = hint { + Text(h) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + Toggle("", isOn: Binding( + get: { isOn }, + set: { _ in state.toggleIntegration(def.id) } + )) + .labelsHidden() + .disabled(atMax) + } + } + } +} + +// MARK: - Sidebar background (NSVisualEffectView .sidebar) + +struct SidebarBackground: NSViewRepresentable { + func makeNSView(context: Context) -> NSVisualEffectView { + let v = NSVisualEffectView() + v.material = .sidebar + v.blendingMode = .behindWindow + v.state = .active + return v + } + func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} +} + +// MARK: - Sidebar row (System Settings style icon) + +struct SettingsSidebarRow: View { + let title: String + let icon: String + let color: String + + var body: some View { + Label { + Text(title) + } icon: { + Image(systemName: icon) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.white) + .frame(width: 20, height: 20) + .background(RoundedRectangle(cornerRadius: 5).fill(Color(hex: color))) + } + } +} + +// MARK: - Integration filter row (reusable for Vercel / n8n) + +struct IntegrationFilterRow: View { + let label: String + let items: [String] + @Binding var filter: Set + let loading: Bool + let onLoad: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(label) + .font(.system(size: 11)) + .foregroundColor(.secondary) + Spacer() + if loading { + ProgressView().scaleEffect(0.6) + } else { + Button(items.isEmpty ? "Load list" : "Refresh") { onLoad() } + .buttonStyle(.bordered) + .controlSize(.mini) + } + if !filter.isEmpty { + Button("Clear") { filter = [] } + .buttonStyle(.bordered) + .controlSize(.mini) + .foregroundColor(.secondary) + } + } + if !items.isEmpty { + VStack(alignment: .leading, spacing: 2) { + ForEach(items, id: \.self) { item in + Toggle(item, isOn: Binding( + get: { filter.isEmpty || filter.contains(item) }, + set: { on in + if on { filter.insert(item) } + else { + if filter.isEmpty { filter = Set(items).subtracting([item]) } + else { filter.remove(item) } + if filter.count == items.count { filter = [] } + } + } + )) + .font(.system(size: 11)) + .toggleStyle(.checkbox) + } + } + .padding(.leading, 4) + if !filter.isEmpty { + Text("Watching \(filter.count) of \(items.count)") + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + } + } + } +} + +// MARK: - Shortcut recorder button + +struct ShortcutRecorderButton: View { + @Binding var flags: UInt + @Binding var code: UInt16 + @State private var isRecording = false + + var body: some View { + Button { + guard !isRecording else { return } + isRecording = true + var token: Any? + token = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + let mods = event.modifierFlags.intersection([.command, .control, .option, .shift]) + guard !mods.isEmpty else { return event } + DispatchQueue.main.async { + self.flags = mods.rawValue + self.code = event.keyCode + self.isRecording = false + if let t = token { NSEvent.removeMonitor(t) } + } + return nil + } + } label: { + Text(isRecording ? "Press keys…" : shortcutLabel) + .font(.system(size: 11, design: .monospaced)) + .padding(.horizontal, 8).padding(.vertical, 3) + .background(isRecording ? Color.accentColor.opacity(0.12) : Color(NSColor.controlBackgroundColor)) + .cornerRadius(5) + .overlay(RoundedRectangle(cornerRadius: 5).stroke(Color.gray.opacity(0.3), lineWidth: 1)) + } + .buttonStyle(.plain) + } + + private var shortcutLabel: String { + let f = NSEvent.ModifierFlags(rawValue: flags) + var s = "" + if f.contains(.control) { s += "⌃" } + if f.contains(.option) { s += "⌥" } + if f.contains(.shift) { s += "⇧" } + if f.contains(.command) { s += "⌘" } + s += keyChar(code) + return s.isEmpty ? "None" : s + } + + private func keyChar(_ c: UInt16) -> String { + let map: [UInt16: String] = [ + 0:"A", 1:"S", 2:"D", 3:"F", 4:"H", 5:"G", 6:"Z", 7:"X", 8:"C", 9:"V", + 11:"B", 12:"Q", 13:"W", 14:"E", 15:"R", 16:"Y", 17:"T", 31:"O", 32:"U", + 34:"I", 37:"L", 38:"J", 40:"K", 45:"N", 46:"M", 49:"Space", 50:"`", 27:"-" + ] + return map[c] ?? "·" + } +} diff --git a/NotchBuddy/Sources/App/ShortcutLogic.swift b/NotchBuddy/Sources/App/ShortcutLogic.swift new file mode 100644 index 0000000..7862174 --- /dev/null +++ b/NotchBuddy/Sources/App/ShortcutLogic.swift @@ -0,0 +1,236 @@ +import Foundation + +// MARK: - Shortcut Action + +/// Every keyboard shortcut Coucou knows about. +/// The first 10 cases are *global* (registered with Carbon and effective from any app). +/// Island-local shortcuts are documented in `ShortcutLogic.islandShortcuts` but are +/// not managed here — they are handled by a local NSEvent monitor in IslandWindowController. +enum ShortcutAction: String, CaseIterable, Sendable { + // MARK: Global (Carbon RegisterEventHotKey) + case toggleIsland = "toggleIsland" // ⌘⇧N — open / close island (toggle) + case openChat = "openChat" // ⌃⌥Space — open island on chat, focus field + case goToAlert = "goToAlert" // ⌃⌥A — jump to pending alert + case jumpToTerminal = "jumpToTerminal" // ⌃⌥T — focus agent terminal + case attachFrontWindow = "attachFrontWindow" // ⌃⌥W — attach front window (non-AppStore) + case nextPill = "nextPill" // ⌃⌥] — next pill, open if closed + case prevPill = "prevPill" // ⌃⌥[ — previous pill, open if closed + case muteToggle = "muteToggle" // ⌃⌥M — mute / unmute sounds + case desktopToggle = "desktopToggle" // ⌃⌥D — send Mochi to desktop / bring back + case wardrobeToggle = "wardrobeToggle" // ⌃⌥G — open / close wardrobe + + // MARK: UserDefaults keys + + /// Key code storage key. `toggleIsland` reuses the legacy "hotkeyCode" key. + var udKeyCode: String { + self == .toggleIsland ? "hotkeyCode" : "shortcut.\(rawValue).keyCode" + } + /// Modifier flags storage key. `toggleIsland` reuses the legacy "hotkeyFlags" key. + var udFlags: String { + self == .toggleIsland ? "hotkeyFlags" : "shortcut.\(rawValue).flags" + } + /// Enabled flag storage key. `toggleIsland` reuses the legacy "hotkeyEnabled" key. + var udEnabled: String { + self == .toggleIsland ? "hotkeyEnabled" : "shortcut.\(rawValue).enabled" + } + + // MARK: Metadata + + var displayName: String { + switch self { + case .toggleIsland: return "Open / close island" + case .openChat: return "Open chat" + case .goToAlert: return "Go to alert" + case .jumpToTerminal: return "Jump to terminal" + case .attachFrontWindow: return "Attach front window" + case .nextPill: return "Next pill" + case .prevPill: return "Previous pill" + case .muteToggle: return "Mute / unmute sounds" + case .desktopToggle: return "Mochi on / off desktop" + case .wardrobeToggle: return "Open / close wardrobe" + } + } + + /// Whether this action should be omitted from App Store builds. + var isAppStoreOnly: Bool { false } + var isNonAppStore: Bool { self == .attachFrontWindow } + + /// Whether the shortcut is enabled by default (all new global shortcuts are on by default; + /// `toggleIsland` is off by default to match the pre-existing behaviour). + var enabledByDefault: Bool { self != .toggleIsland } +} + +// MARK: - Shortcut Spec + +/// A key + modifier combination stored as plain values (no AppKit dependency). +struct ShortcutSpec: Equatable, Hashable, Sendable { + /// Virtual key code (matching kVK_* constants and NSEvent.keyCode). + var keyCode: UInt16 + /// NSEvent.ModifierFlags raw value — only the modifier bits (.command / .shift / .option / .control). + /// Stored and compared as a plain UInt so this file stays AppKit-free. + var nsFlags: UInt + + // MARK: NSEvent modifier raw values (stable since macOS 10.x; see AppKit/NSEvent.h) + static let cmdBit: UInt = 1 << 20 // NSEvent.ModifierFlags.command = 1048576 + static let shiftBit: UInt = 1 << 17 // NSEvent.ModifierFlags.shift = 131072 + static let optBit: UInt = 1 << 19 // NSEvent.ModifierFlags.option = 524288 + static let ctrlBit: UInt = 1 << 18 // NSEvent.ModifierFlags.control = 262144 + static let modMask: UInt = cmdBit | shiftBit | optBit | ctrlBit + + /// True when nsFlags contains at least one of ⌘ ⇧ ⌥ ⌃. + var hasModifier: Bool { nsFlags & ShortcutSpec.modMask != 0 } + + // MARK: Convenience values for the default table + + static let cmdShift: UInt = cmdBit | shiftBit // 1179648 + static let ctrlOpt: UInt = ctrlBit | optBit // 786432 +} + +// MARK: - Pure logic + +/// Stateless helpers for shortcut management — no AppKit, fully unit-testable standalone. +enum ShortcutLogic { + + // MARK: - Default table + + static let defaults: [ShortcutAction: ShortcutSpec] = [ + .toggleIsland: ShortcutSpec(keyCode: 45, nsFlags: ShortcutSpec.cmdShift), // ⌘⇧N + .openChat: ShortcutSpec(keyCode: 49, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥Space + .goToAlert: ShortcutSpec(keyCode: 0, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥A + .jumpToTerminal: ShortcutSpec(keyCode: 17, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥T + .attachFrontWindow: ShortcutSpec(keyCode: 13, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥W + .nextPill: ShortcutSpec(keyCode: 30, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥] + .prevPill: ShortcutSpec(keyCode: 33, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥[ + .muteToggle: ShortcutSpec(keyCode: 46, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥M + .desktopToggle: ShortcutSpec(keyCode: 2, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥D + .wardrobeToggle: ShortcutSpec(keyCode: 5, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥G + ] + + // MARK: - Load / save (UserDefaults) + + /// Current spec for `action`, falling back to the default if not stored. + static func hotKey(for action: ShortcutAction) -> ShortcutSpec { + let ud = UserDefaults.standard + let def = defaults[action]! + let kc = ud.object(forKey: action.udKeyCode) as? Int ?? Int(def.keyCode) + let fl = ud.object(forKey: action.udFlags) as? Int ?? Int(def.nsFlags) + return ShortcutSpec(keyCode: UInt16(kc), nsFlags: UInt(fl)) + } + + static func save(_ spec: ShortcutSpec, for action: ShortcutAction) { + UserDefaults.standard.set(Int(spec.keyCode), forKey: action.udKeyCode) + UserDefaults.standard.set(Int(spec.nsFlags), forKey: action.udFlags) + } + + static func isEnabled(_ action: ShortcutAction) -> Bool { + UserDefaults.standard.object(forKey: action.udEnabled) as? Bool + ?? action.enabledByDefault + } + + static func setEnabled(_ enabled: Bool, for action: ShortcutAction) { + UserDefaults.standard.set(enabled, forKey: action.udEnabled) + } + + static func resetToDefault(_ action: ShortcutAction) { + let def = defaults[action]! + save(def, for: action) + setEnabled(action.enabledByDefault, for: action) + } + + // MARK: - Carbon modifier conversion + + // Carbon modifier bit flags (CarbonCore/Events.h) — no import needed, just constants. + private static let cCmdKey: UInt32 = 1 << 8 // cmdKey = 256 + private static let cShiftKey: UInt32 = 1 << 9 // shiftKey = 512 + private static let cOptKey: UInt32 = 1 << 11 // optionKey = 2048 + private static let cCtrlKey: UInt32 = 1 << 12 // controlKey = 4096 + + /// Convert NSEvent modifier flags (as raw UInt) to Carbon modifier bit mask. + static func carbonModifiers(fromNS nsFlags: UInt) -> UInt32 { + let cmd = ShortcutSpec.cmdBit + let shift = ShortcutSpec.shiftBit + let opt = ShortcutSpec.optBit + let ctrl = ShortcutSpec.ctrlBit + var c: UInt32 = 0 + if nsFlags & cmd != 0 { c |= cCmdKey } + if nsFlags & shift != 0 { c |= cShiftKey } + if nsFlags & opt != 0 { c |= cOptKey } + if nsFlags & ctrl != 0 { c |= cCtrlKey } + return c + } + + // MARK: - Conflict detection (within Coucou's own shortcuts) + + /// Returns the set of actions whose (keyCode, nsFlags) pair is shared with another action. + static func duplicates(in specs: [ShortcutAction: ShortcutSpec]) -> Set { + var seen: [ShortcutSpec: ShortcutAction] = [:] + var dups: Set = [] + for (action, spec) in specs { + if let other = seen[spec] { + dups.insert(action) + dups.insert(other) + } else { + seen[spec] = action + } + } + return dups + } + + // MARK: - Display string + + /// Human-readable modifier+key string, e.g. "⌃⌥A" or "⌘⇧N". + static func displayString(for spec: ShortcutSpec) -> String { + let f = spec.nsFlags + var s = "" + if f & ShortcutSpec.ctrlBit != 0 { s += "⌃" } + if f & ShortcutSpec.optBit != 0 { s += "⌥" } + if f & ShortcutSpec.shiftBit != 0 { s += "⇧" } + if f & ShortcutSpec.cmdBit != 0 { s += "⌘" } + s += keyCodeToString(spec.keyCode) + return s + } + + /// Printable name for the most common virtual key codes. + static func keyCodeToString(_ code: UInt16) -> String { + let map: [UInt16: String] = [ + 0:"A", 1:"S", 2:"D", 3:"F", 4:"H", 5:"G", 6:"Z", 7:"X", + 8:"C", 9:"V", 11:"B", 12:"Q", 13:"W", 14:"E", 15:"R", 16:"Y", + 17:"T", 31:"O", 32:"U", 33:"[", 34:"I", 35:"P", 37:"L", 38:"J", + 40:"K", 45:"N", 46:"M", 30:"]", 49:"Space", + 36:"↩", 51:"⌫", 53:"⎋", + 123:"←", 124:"→", 125:"↓", 126:"↑", + 18:"1", 19:"2", 20:"3", 21:"4", 23:"5", 22:"6", 26:"7", 28:"8", 25:"9", + ] + return map[code] ?? "·" + } + + // MARK: - Card navigation (pure, no AppKit dependency) + + /// Compute the next card-selection index. + /// - Returns `nil` when `itemCount == 0`. + /// - Clamps to `0 ..< itemCount`. + /// - `nil` selection + `delta > 0` → first item; `delta < 0` → last item. + static func navigate(selection: Int?, delta: Int, itemCount: Int) -> Int? { + guard itemCount > 0 else { return nil } + if let cur = selection { + return max(0, min(itemCount - 1, cur + delta)) + } + return delta > 0 ? 0 : itemCount - 1 + } + + // MARK: - Island-local shortcut table (read-only, shown in Settings) + + /// Descriptive table of island-local shortcuts for the Settings view. + static let islandShortcuts: [(key: String, description: String)] = [ + ("⌘→ / ⌘←", "Next / previous pill"), + ("⌘1 – ⌘9", "Switch to pill by number"), + ("⌘↓ / ⌘↑", "Navigate list items"), + ("⌘O", "Open selected item"), + ("⌘E", "Open / close current diff"), + ("⌘↩", "Send chat message"), + ("⌘K", "New conversation"), + ("⌘,", "Open Settings"), + ("⌘P", "Pin / unpin island"), + ("⎋", "Close island (if not pinned)"), + ] +} diff --git a/NotchBuddy/Sources/App/ShortcutsSettingsView.swift b/NotchBuddy/Sources/App/ShortcutsSettingsView.swift new file mode 100644 index 0000000..7856462 --- /dev/null +++ b/NotchBuddy/Sources/App/ShortcutsSettingsView.swift @@ -0,0 +1,196 @@ +import SwiftUI +import AppKit + +// MARK: - ShortcutsSettingsView + +/// "Shortcuts" section inside SettingsView. +struct ShortcutsSettingsView: View { + // Conflicts detected by HotKeyCenter (system already owns that combo) + @State private var systemConflicts: Set = [] + // Internal duplicates (two Coucou shortcuts share the same combo) + @State private var internalDups: Set = [] + + // Per-action state (enabled flag + current spec) + @State private var enabled: [ShortcutAction: Bool] = [:] + @State private var specs: [ShortcutAction: ShortcutSpec] = [:] + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + globalSection + islandSection + } + .onAppear { reload() } + } + + // MARK: - Global shortcuts + + private var globalSection: some View { + GroupBox("Global shortcuts — work from any app") { + VStack(alignment: .leading, spacing: 0) { + ForEach(globalActions, id: \.self) { action in + shortcutRow(action) + if action != globalActions.last { + Divider().padding(.leading, 24) + } + } + Divider().padding(.top, 8) + HStack { + Spacer() + Button("Reset all to defaults") { resetAll() } + .buttonStyle(.bordered) + .controlSize(.small) + } + .padding(.top, 6) + } + .padding(6) + } + } + + /// Shortcuts listed here. The App Store build hides the ones it can't run. + private var globalActions: [ShortcutAction] { + #if APPSTORE + ShortcutAction.allCases.filter { !$0.isNonAppStore } + #else + ShortcutAction.allCases + #endif + } + + @ViewBuilder + private func shortcutRow(_ action: ShortcutAction) -> some View { + let isEnabled = enabled[action] ?? action.enabledByDefault + let spec = specs[action] ?? ShortcutLogic.defaults[action]! + let hasSysConf = systemConflicts.contains(action) + let hasIntConf = internalDups.contains(action) + + HStack(spacing: 10) { + Toggle("", isOn: Binding( + get: { isEnabled }, + set: { v in toggleAction(action, enabled: v) } + )) + .labelsHidden() + .toggleStyle(.checkbox) + .frame(width: 18) + + Text(action.displayName) + .font(.system(size: 12)) + .frame(minWidth: 160, alignment: .leading) + + Spacer(minLength: 4) + + ShortcutRecorderButton(flags: specFlagsBinding(action), code: specCodeBinding(action)) + .disabled(!isEnabled) + .onChange(of: specs[action]) { _, _ in specChanged(action) } + + if hasSysConf { + conflictTag("System conflict", color: .orange) + } else if hasIntConf { + conflictTag("Duplicate", color: .red) + } + } + .padding(.vertical, 5) + .opacity(isEnabled ? 1 : 0.5) + } + + private func conflictTag(_ text: String, color: Color) -> some View { + Text(text) + .font(.system(size: 10, weight: .medium)) + .foregroundColor(color) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(color.opacity(0.1)) + .clipShape(Capsule()) + } + + // MARK: - Island-local shortcuts (read-only) + + private var islandSection: some View { + GroupBox("Island shortcuts — active when the island is focused") { + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(ShortcutLogic.islandShortcuts.enumerated()), id: \.offset) { _, pair in + HStack(spacing: 10) { + Text(pair.key) + .font(.system(size: 11, design: .monospaced)) + .frame(width: 90, alignment: .leading) + Text(pair.description) + .font(.system(size: 12)) + .foregroundColor(.secondary) + Spacer() + } + .padding(.vertical, 4) + if pair.key != ShortcutLogic.islandShortcuts.last?.key { + Divider().padding(.leading, 100) + } + } + } + .padding(6) + } + } + + // MARK: - Bindings + + private func specFlagsBinding(_ action: ShortcutAction) -> Binding { + Binding( + get: { (specs[action] ?? ShortcutLogic.defaults[action]!).nsFlags }, + set: { v in + var s = specs[action] ?? ShortcutLogic.defaults[action]! + s.nsFlags = v + specs[action] = s + } + ) + } + + private func specCodeBinding(_ action: ShortcutAction) -> Binding { + Binding( + get: { (specs[action] ?? ShortcutLogic.defaults[action]!).keyCode }, + set: { v in + var s = specs[action] ?? ShortcutLogic.defaults[action]! + s.keyCode = v + specs[action] = s + } + ) + } + + // MARK: - Actions + + private func reload() { + for action in ShortcutAction.allCases { + enabled[action] = ShortcutLogic.isEnabled(action) + specs[action] = ShortcutLogic.hotKey(for: action) + } + systemConflicts = HotKeyCenter.shared.conflicts + updateInternalDups() + } + + private func toggleAction(_ action: ShortcutAction, enabled v: Bool) { + enabled[action] = v + ShortcutLogic.setEnabled(v, for: action) + HotKeyCenter.shared.reregister(action) + systemConflicts = HotKeyCenter.shared.conflicts + } + + private func specChanged(_ action: ShortcutAction) { + guard let s = specs[action] else { return } + ShortcutLogic.save(s, for: action) + HotKeyCenter.shared.reregister(action) + systemConflicts = HotKeyCenter.shared.conflicts + updateInternalDups() + } + + private func resetAll() { + for action in ShortcutAction.allCases { + ShortcutLogic.resetToDefault(action) + HotKeyCenter.shared.reregister(action) + } + reload() + } + + private func updateInternalDups() { + var all: [ShortcutAction: ShortcutSpec] = [:] + for action in ShortcutAction.allCases { + if ShortcutLogic.isEnabled(action) { + all[action] = ShortcutLogic.hotKey(for: action) + } + } + internalDups = ShortcutLogic.duplicates(in: all) + } +} diff --git a/NotchBuddy/Sources/App/SoundEngine.swift b/NotchBuddy/Sources/App/SoundEngine.swift new file mode 100644 index 0000000..b1d08b3 --- /dev/null +++ b/NotchBuddy/Sources/App/SoundEngine.swift @@ -0,0 +1,65 @@ +import AVFoundation +import AppKit + +/// Preloaded WAV players with near-zero latency. +/// Volume default 0.12 (matches prototype: gain ×6 then vol=0.12). +@MainActor +final class SoundEngine { + static let shared = SoundEngine() + + var enabled: Bool = true + var volume: Float = 0.12 { + didSet { players.values.forEach { $0.forEach { $0.volume = volume } } } + } + + // Pool of 3 players per sound to allow overlapping playback + private var players: [String: [AVAudioPlayer]] = [:] + + private init() { + preload() + } + + private func preload() { + let names = ["peek","open","close","hover","blip","slap","annoyed","dizzy","greet", + "work","finish","error","approval","question","approve","gulp","tick", + "send","love","pop","proud","wink","yawn","attach","think","search", + "rate","sleep","greeting"] + for name in names { + guard let url = Bundle.main.url(forResource: name, withExtension: "wav", subdirectory: "sounds") else { continue } + var pool: [AVAudioPlayer] = [] + for _ in 0..<3 { + if let p = try? AVAudioPlayer(contentsOf: url) { + p.volume = volume + p.prepareToPlay() + pool.append(p) + } + } + if !pool.isEmpty { players[name] = pool } + } + } + + /// Fade out all currently-playing instances of `name` over `duration` seconds, + /// then stop and reset them so they can be reused. + func fadeOut(_ name: String, duration: TimeInterval) { + guard let pool = players[name] else { return } + for player in pool where player.isPlaying { + player.setVolume(0, fadeDuration: duration) + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { [weak player] in + guard let p = player else { return } + p.stop() + p.currentTime = 0 + p.volume = self.volume + } + } + } + + func play(_ name: String) { + guard enabled && AppState.shared.soundEnabled else { return } + guard let pool = players[name] else { return } + // Find a player that is not currently playing + let player = pool.first { !$0.isPlaying } ?? pool[0] + player.currentTime = 0 + player.volume = volume + player.play() + } +} diff --git a/NotchBuddy/Sources/App/StripePoller.swift b/NotchBuddy/Sources/App/StripePoller.swift new file mode 100644 index 0000000..6aeec6c --- /dev/null +++ b/NotchBuddy/Sources/App/StripePoller.swift @@ -0,0 +1,161 @@ +import Foundation +import SwiftUI + +// MARK: - StripePoller +// Polls Stripe API every 30s for balance + recent charges. +// On new charge: slides payments list (newest first), then animates balance count-up. + +final class StripePoller: @unchecked Sendable { + static let shared = StripePoller() + private var timer: DispatchSourceTimer? + private var lastChargeId: String = "" + + private init() {} + + func start() { + guard timer == nil else { return } + let t = DispatchSource.makeTimerSource(queue: .global(qos: .background)) + t.schedule(deadline: .now() + 6, repeating: 30) + t.setEventHandler { [weak self] in self?.poll() } + t.resume() + timer = t + } + + // MARK: - Poll + + func pollNow() { poll() } + + private func poll() { + guard let key = KeychainStore.shared.get("stripe-api-key") else { return } + fetchBalance(key: key) + fetchCharges(key: key) + } + + // MARK: - Balance + + private func fetchBalance(key: String) { + guard let url = URL(string: "https://api.stripe.com/v1/balance") else { return } + var req = URLRequest(url: url, timeoutInterval: 10) + // Stripe primary auth: Basic with key as username, empty password + let creds = Data("\(key):".utf8).base64EncodedString() + req.setValue("Basic \(creds)", forHTTPHeaderField: "Authorization") + + URLSession.shared.dataTask(with: req) { data, response, error in + let code = (response as? HTTPURLResponse)?.statusCode ?? 0 + if code != 200 { + let errMsg: String + if code == 401 { errMsg = "Invalid API key (401)" } + else if code == 403 { errMsg = "Use secret key (sk_live_… not pk_live_…)" } + else if code == 0 { errMsg = error?.localizedDescription ?? "No connection" } + else { errMsg = "API error \(code)" } + DispatchQueue.main.async { AppState.shared.stripeError = errMsg } + return + } + guard let data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return } + + // Sum available + pending so recent charges show up immediately + let available = json["available"] as? [[String: Any]] ?? [] + let pending = json["pending"] as? [[String: Any]] ?? [] + let allBuckets = available + pending + guard let currency = (allBuckets.first)?["currency"] as? String else { return } + let amount = allBuckets.compactMap { $0["amount"] as? Int }.reduce(0, +) + + DispatchQueue.main.async { + let state = AppState.shared + state.stripeError = nil + state.stripeCurrency = currency + state.stripeBalance = amount + // Always sync display balance if not yet showing a real value + // (charges may have set stripeLoaded=true before balance arrived) + if state.stripeDisplayBalance == 0 { + state.stripeDisplayBalance = amount + } + state.stripeLoaded = true + } + }.resume() + } + + // MARK: - Charges + + private func fetchCharges(key: String) { + guard let url = URL(string: "https://api.stripe.com/v1/charges?limit=3") else { return } + var req = URLRequest(url: url, timeoutInterval: 10) + let creds = Data("\(key):".utf8).base64EncodedString() + req.setValue("Basic \(creds)", forHTTPHeaderField: "Authorization") + + URLSession.shared.dataTask(with: req) { [weak self] data, response, _ in + guard let self else { return } + let code = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard let data, code == 200 else { return } + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let rawList = json["data"] as? [[String: Any]] else { return } + + let parsed = rawList.compactMap { self.parseCharge($0) } + DispatchQueue.main.async { self.handleCharges(parsed) } + }.resume() + } + + private func parseCharge(_ c: [String: Any]) -> StripePayment? { + guard let id = c["id"] as? String, + let amount = c["amount"] as? Int, + let currency = c["currency"] as? String, + let status = c["status"] as? String else { return nil } + + let ts = (c["created"] as? TimeInterval) ?? 0 + let desc = c["description"] as? String + ?? (c["billing_details"] as? [String: Any]).flatMap { $0["name"] as? String } + + return StripePayment(id: id, amount: amount, currency: currency, + description: desc, createdAt: Date(timeIntervalSince1970: ts), + status: status) + } + + @MainActor + private func handleCharges(_ payments: [StripePayment]) { + let state = AppState.shared + + // First load — populate silently, anchor lastChargeId to avoid duplicate on next poll + if state.stripePayments.isEmpty { + state.stripePayments = payments + lastChargeId = payments.first?.id ?? "" + // Sync display balance (balance may have already arrived; use it) + state.stripeDisplayBalance = state.stripeBalance + state.stripeLoaded = true + return + } + + guard let newest = payments.first, newest.id != lastChargeId else { return } + lastChargeId = newest.id + + guard let idx = state.tasks.firstIndex(where: { $0.id == "integration_stripe" }) else { return } + let focused = state.focusId == "integration_stripe" + + // 1. Slide payments: new enters top, max 3 shown + withAnimation(.spring(response: 0.38, dampingFraction: 0.82)) { + state.stripePayments = Array(([newest] + state.stripePayments).prefix(3)) + } + + state.tasks[idx].state = .finished + state.tasks[idx].steps = [newest.description ?? newest.amountFormatted] + if !focused { state.tasks[idx].pillBadge = .finished } + SoundEngine.shared.play("finish") + + // 2. After slide settles, count up balance + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + withAnimation(.easeOut(duration: 1.2)) { + state.stripeDisplayBalance = state.stripeBalance + } + } + + // 3. Auto-clear task state after 60s + DispatchQueue.main.asyncAfter(deadline: .now() + 60) { + guard let i = state.tasks.firstIndex(where: { $0.id == "integration_stripe" }) else { return } + guard state.tasks[i].state == .finished else { return } + state.tasks[i].state = .idle + state.tasks[i].steps = [] + state.tasks[i].pillBadge = nil + } + } +} + diff --git a/NotchBuddy/Sources/App/UploadCanvasView.swift b/NotchBuddy/Sources/App/UploadCanvasView.swift new file mode 100644 index 0000000..0efebfe --- /dev/null +++ b/NotchBuddy/Sources/App/UploadCanvasView.swift @@ -0,0 +1,582 @@ +import SwiftUI +import AppKit + +// Full 640×176 canvas that drives the upload sequence animation. +// Replaces the header + content area when the upload engine is active. + +struct UploadCanvasView: View { + @ObservedObject var state: AppState + @State private var fileIcon: NSImage? = nil + + private var engine: UploadSequenceEngine { .shared } + + var body: some View { + TimelineView(.animation) { tl in + let f = engine.frame(at: tl.date) + let wallTime = tl.date.timeIntervalSinceReferenceDate + + ZStack(alignment: .topLeading) { + Canvas { ctx, _ in + drawScene(ctx: ctx, f: f, wallTime: wallTime) + } + .frame(width: 640, height: 176) + + // Interactive choose buttons (invisible hit areas at reference positions) + if f.chooseAlpha > 0 { + chooseOverlay(f: f) + .frame(width: 640, height: 176) + } + } + } + .onChange(of: state.droppedFile?.url) { _, url in + if let url { loadIcon(url: url) } + } + .onAppear { + if let url = state.droppedFile?.url { loadIcon(url: url) } + } + .frame(width: 640, height: 176) + } + + // MARK: - File icon + + private func loadIcon(url: URL) { + let img = NSWorkspace.shared.icon(forFile: url.path) + img.size = NSSize(width: 64, height: 64) + fileIcon = img + } + + // MARK: - Choose overlay (transparent SwiftUI buttons over canvas) + + @ViewBuilder + private func chooseOverlay(f: USFrame) -> some View { + // Reference positions: button1 x=100 w=176 y=113 h=26, button2 x=284 w=128 + ZStack(alignment: .topLeading) { + // Primary: "Ask a question about it" + Button { + withAnimation(.easeInOut(duration: 0.22)) { state.view = .prompt } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) { + UploadSequenceEngine.shared.deactivate() + } + } label: { + Color.clear + .frame(width: 168, height: 26) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .frame(width: 168, height: 26) + .position(x: 114 + 84, y: 113 + 13) // center = (198, 126) + + // Secondary: "Send by email" + Button { + withAnimation(.easeInOut(duration: 0.22)) { state.view = .mail } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) { + UploadSequenceEngine.shared.deactivate() + } + } label: { + Color.clear + .frame(width: 120, height: 26) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .frame(width: 120, height: 26) + .position(x: 290 + 60, y: 113 + 13) // center = (350, 126) + } + .opacity(f.chooseAlpha) + .allowsHitTesting(f.chooseAlpha > 0.5) + } + + // MARK: - Main draw + + private func drawScene(ctx: GraphicsContext, f: USFrame, wallTime: Double = 0) { + var c = ctx + + // ── Island background ────────────────────────────────────── + c.fill(Path(CGRect(x:0, y:0, width:640, height:176)), with: .color(Color.black)) + + // ── Card ────────────────────────────────────────────────── + let cardPath = roundedRect(CGRect(x: USC.CARD_X, y: USC.CARD_Y, width: USC.CARD_W, height: USC.CARD_H), r: USC.CARD_R) + + var cardCtx = c + cardCtx.clip(to: cardPath) + cardCtx.fill(Path(CGRect(x: USC.CARD_X, y: USC.CARD_Y, width: USC.CARD_W, height: USC.CARD_H)), + with: .color(Color(red:0.051, green:0.055, blue:0.063))) + + // Green glow from card bottom — grows slowly with upload progress + if f.greenWash > 0 { + // Center at card bottom edge; gradient fans upward through the card + let gx = USC.CARD_X + USC.CARD_W/2 + let gy = USC.CARD_Y + USC.CARD_H // bottom of card + let gGrad = Gradient(stops: [ + .init(color: Color(red:0.157,green:0.831,blue:0.510).opacity(f.greenWash * 0.90), location:0), + .init(color: Color(red:0.157,green:0.831,blue:0.510).opacity(f.greenWash * 0.30), location:0.55), + .init(color: Color(red:0.157,green:0.831,blue:0.510).opacity(0), location:1) + ]) + cardCtx.fill(Path(CGRect(x:USC.CARD_X,y:USC.CARD_Y,width:USC.CARD_W,height:USC.CARD_H)), + with: .radialGradient(gGrad, center:CGPoint(x:gx,y:gy), + startRadius:0, endRadius:USC.CARD_H*1.5)) + } + + // ── Dashed border (animates left→right while on drop zone) ──── + if f.zoneAlpha > 0 { + var borderCtx = c + borderCtx.opacity = f.zoneAlpha + let borderColor = f.zoneOver + ? Color(red:0.204,green:0.831,blue:0.600).opacity(0.55) + : Color.white.opacity(0.14) + let inset = CGRect(x: USC.CARD_X+0.75, y: USC.CARD_Y+0.75, + width: USC.CARD_W-1.5, height: USC.CARD_H-1.5) + // dashPhase increases → pattern marches left-to-right at ~20 pt/s + let dashPhase = CGFloat(wallTime * 20) + borderCtx.stroke(roundedRect(inset, r: USC.CARD_R-0.5), + with: .color(borderColor), + style: StrokeStyle(lineWidth:1.5, dash:[6,5], dashPhase: dashPhase)) + } + + // ── Drop zone text ───────────────────────────────────────── + if f.zoneAlpha > 0 && f.textAlpha > 0 { + drawDropText(ctx: &c, f: f) + } + + // ── Progress bar ────────────────────────────────────────── + if f.barAlpha > 0 || f.barReveal > 0 { + drawProgressBar(ctx: &c, f: f) + } + + // ── Choose view text ───────────────────────────────────── + if f.chooseAlpha > 0 { + drawChooseView(ctx: &c, f: f) + } + + // ── Mochi ───────────────────────────────────────────────── + drawMochi(ctx: &c, f: f) + + // ── File / suction ──────────────────────────────────────── + if f.fileVisible { drawFile(ctx: &c, f: f) } + } + + // MARK: - Drop zone text + chips + + private func drawDropText(ctx: inout GraphicsContext, f: USFrame) { + var tCtx = ctx + tCtx.opacity = f.textAlpha + + let label = Text("Drop your files here") + .font(.system(size:13, weight:.medium)) + .foregroundColor(Color(hex:"#D5D7DB")) + tCtx.draw(label, at: CGPoint(x: USC.TEXT_X, y: USC.TEXT_Y - 4), anchor: .leading) + + let chips = ["PDF","Images","Code","Docs"] + var cx = USC.TEXT_X + for chip in chips { + let chipText = Text(chip).font(.system(size:11, weight:.medium)).foregroundColor(Color(hex:"#B9BDC4")) + // measure approximate width + let estW = Double(chip.count) * 6.5 + 16 + tCtx.fill(roundedRect(CGRect(x:cx, y:USC.TEXT_Y+9, width:estW, height:18), r:9), + with: .color(Color.white.opacity(0.07))) + tCtx.draw(chipText, at: CGPoint(x: cx + 8, y: USC.TEXT_Y + 18), anchor: .leading) + cx += estW + 6 + } + } + + // MARK: - Progress bar + + private func drawProgressBar(ctx: inout GraphicsContext, f: USFrame) { + var pCtx = ctx + pCtx.opacity = max(f.barAlpha, 0.001) + + let x0 = USC.BAR_X0, x1 = USC.BAR_X1, by = USC.BAR_Y + let barLen = (x1-x0) * f.barReveal + + // Filename label + let name = state.droppedFile?.name ?? "file" + let label = Text("Uploading \(name)") + .font(.system(size:12.5, weight:.medium)) + .foregroundColor(Color(hex:"#A9ADB5")) + pCtx.draw(label, at: CGPoint(x: x0, y: by-30), anchor: .leading) + + // Checkmark or percentage + if f.check > 0 { + var ckCtx = pCtx + ckCtx.concatenate(CGAffineTransform(translationX: CGFloat(x1-8), y: CGFloat(by-30))) + ckCtx.concatenate(CGAffineTransform(scaleX: CGFloat(f.check), y: CGFloat(f.check))) + var circle = Path(); circle.addEllipse(in: CGRect(x:-8,y:-8,width:16,height:16)) + ckCtx.fill(circle, with: .color(Color(hex:"#34D399"))) + var ck = Path() + ck.move(to: CGPoint(x:-3.6,y:0.2)); ck.addLine(to: CGPoint(x:-1,y:2.8)); ck.addLine(to: CGPoint(x:3.8,y:-2.6)) + ckCtx.stroke(ck, with: .color(Color(red:0.027,green:0.075,blue:0.055)), + style: StrokeStyle(lineWidth:2, lineCap:.round, lineJoin:.round)) + } else { + let pct = Text("\(Int(f.progress*100)) %") + .font(.system(size:12.5, weight:.medium).monospacedDigit()) + .foregroundColor(Color(hex:"#A9ADB5")) + pCtx.draw(pct, at: CGPoint(x: x1, y: by-30), anchor: .trailing) + } + + // Bar track + if barLen > 0 { + pCtx.fill(roundedRect(CGRect(x:x0, y:by-3, width:barLen, height:6), r:3), + with: .color(Color.white.opacity(0.08))) + } + + // Bar fill + let fx = usLerp(USC.BAR_X0, USC.BAR_X1, f.progress) + if fx > x0 + 1 { + // Flash color at completion + let flashGreen = Color( + red: usLerp(0.204, 0.431, f.flash), + green: usLerp(0.827, 0.906, f.flash), + blue: usLerp(0.600, 0.718, f.flash)) + let fillGrad = Gradient(stops: [ + .init(color: Color(hex:"#1FA87A"), location:0), + .init(color: flashGreen, location:1) + ]) + pCtx.fill(roundedRect(CGRect(x:x0, y:by-3, width:fx-x0, height:6), r:3), + with: .linearGradient(fillGrad, + startPoint: CGPoint(x:x0, y:0), + endPoint: CGPoint(x:fx, y:0))) + } + + // Glow trail + if f.progress > 0.01 && f.progress < 1 { + let v = (usProgressAt(f.t+0.01, progStart:USC.T_PROG_START, progEnd:f.progEnd) + - usProgressAt(f.t, progStart:USC.T_PROG_START, progEnd:f.progEnd)) / 0.01 + let tl = max(8, min(34, 8 + v*40)) + let tGrad = Gradient(stops:[ + .init(color: Color(red:0.204,green:0.831,blue:0.600,opacity:0), location:0), + .init(color: Color(red:0.431,green:0.906,blue:0.718,opacity:0.6), location:1) + ]) + var glowCtx = pCtx + glowCtx.addFilter(.blur(radius:3)) + glowCtx.fill(roundedRect(CGRect(x:fx-tl, y:by-4, width:tl, height:8), r:4), + with: .linearGradient(tGrad, + startPoint:CGPoint(x:fx-tl,y:0), + endPoint: CGPoint(x:fx,y:0))) + } + } + + // MARK: - Choose view text + buttons (canvas layer) + + private func drawChooseView(ctx: inout GraphicsContext, f: USFrame) { + var cCtx = ctx + cCtx.opacity = f.chooseAlpha + // Slide up: translate down by (1-alpha)*4 + cCtx.concatenate(CGAffineTransform(translationX: 0, y: CGFloat((1-f.chooseAlpha)*4))) + + let name = state.droppedFile?.name ?? "file" + let titleText = Text("\(name) is ready.") + .font(.system(size:14, weight:.semibold)) + .foregroundColor(Color(hex:"#F5F6F8")) + cCtx.draw(titleText, at: CGPoint(x:114, y:80), anchor: .leading) + + let subText = Text("What do you want to do with it?") + .font(.system(size:12.5)) + .foregroundColor(Color(hex:"#9398A1")) + cCtx.draw(subText, at: CGPoint(x:114, y:100), anchor: .leading) + + // Primary button (white fill) + cCtx.fill(roundedRect(CGRect(x:114,y:113,width:168,height:26), r:13), + with: .color(Color(hex:"#F5F6F8"))) + let btn1 = Text("Ask a question about it") + .font(.system(size:12.5, weight:.medium)) + .foregroundColor(Color(red:0.043,green:0.047,blue:0.055)) + cCtx.draw(btn1, at: CGPoint(x:198, y:126), anchor: .center) + + // Secondary button (dim fill) + cCtx.fill(roundedRect(CGRect(x:290,y:113,width:120,height:26), r:13), + with: .color(Color.white.opacity(0.09))) + let btn2 = Text("Send by email") + .font(.system(size:12.5, weight:.medium)) + .foregroundColor(Color(hex:"#F1F2F4")) + cCtx.draw(btn2, at: CGPoint(x:350, y:126), anchor: .center) + } + + // MARK: - Mochi (superellipse body + eyes + mouth) + + private func drawMochi(ctx: inout GraphicsContext, f: USFrame) { + let R = f.d / 2 / 1.04 + let m = f.morph + let mc = max(0, min(m, 1.0)) + + var c = ctx + c.concatenate(CGAffineTransform(translationX: CGFloat(f.x), y: CGFloat(f.y + f.hop))) + c.concatenate(CGAffineTransform(rotationAngle: CGFloat(f.tilt))) + c.concatenate(CGAffineTransform(scaleX: CGFloat(f.sx), y: CGFloat(f.sy))) + + let (bp, rx, ry) = usBodyPath(m: m, R: R) + + // ── Body gradient ────────────────────────────────────────── + let bodyGrad = Gradient(stops:[ + .init(color: Color(hex:"#EDEDEF"), location:0), + .init(color: Color(hex:"#C4C5CA"), location:1) + ]) + c.fill(bp, with: .linearGradient(bodyGrad, + startPoint: CGPoint(x: rx*0.7, y: -ry*0.9), + endPoint: CGPoint(x: -rx*0.8, y: ry*0.9))) + + // ── Edge shadow ──────────────────────────────────────────── + let shadowGrad = Gradient(stops:[ + .init(color: .clear, location:0), + .init(color: .clear, location:0.62), + .init(color: Color.black.opacity(0.12), location:1) + ]) + c.fill(bp, with: .radialGradient(shadowGrad, + center: CGPoint(x:0,y:0), startRadius: CGFloat(R*0.2), endRadius: CGFloat(R*1.3))) + + // ── Top rim (box mode) ───────────────────────────────────── + if mc > 0.3 { + let rimAlpha = max(0, min(1, (mc-0.3)/0.7)) + var rimCtx = c + rimCtx.clip(to: bp) + var rim = Path() + rim.move(to: CGPoint(x: -rx*0.72, y: -ry+0.9)) + rim.addLine(to: CGPoint(x: rx*0.72, y: -ry+0.9)) + rimCtx.stroke(rim, with: .color(Color.white.opacity(0.6*rimAlpha)), + style: StrokeStyle(lineWidth:1.2, lineCap:.round)) + } + + // ── Mouth hole ───────────────────────────────────────────── + let mh = f.mouth * R * mc + if mh > 0.3 { + let mw = 2*rx - 0.24*R + let mxO = CGFloat(-mw/2) + let myO = CGFloat(-ry + 0.10*R) + let mhr = CGFloat(min(mw/2, mh/2)) + var mCtx = c + mCtx.clip(to: bp) + let holeGrad = Gradient(stops:[ + .init(color: Color(red:0.012,green:0.012,blue:0.016), location:0), + .init(color: Color(red:0.063,green:0.067,blue:0.078), location:1) + ]) + let holePath = roundedRect(CGRect(x:mxO, y:myO, width:CGFloat(mw), height:CGFloat(mh)), r:Double(mhr)) + mCtx.fill(holePath, with: .linearGradient(holeGrad, + startPoint: CGPoint(x:0, y:myO), + endPoint: CGPoint(x:0, y:myO+CGFloat(mh)))) + // Bottom lip + if mh > 4 { + var lip = Path() + lip.move(to: CGPoint(x:mxO+mhr, y:myO+CGFloat(mh)+0.5)) + lip.addLine(to: CGPoint(x:mxO+CGFloat(mw)-mhr, y:myO+CGFloat(mh)+0.5)) + mCtx.stroke(lip, with: .color(Color.white.opacity(0.55)), + style: StrokeStyle(lineWidth:1, lineCap:.round)) + } + } + + // ── Eyes ─────────────────────────────────────────────────── + let ew = R * 0.25 + let eh = R * (0.62 - 0.16*mc) + let ey = R * (0.02 + 0.28*mc) + let sp = R * 0.30 + let lx = f.lookX * R * (0.34 - 0.08*mc) + let ly = f.lookY * R * (0.16 - 0.09*mc) + + var eCtx = c + eCtx.clip(to: bp) + for sd in [-1.0, 1.0] { + var ec = eCtx + ec.concatenate(CGAffineTransform(translationX: CGFloat(sd*sp+lx), y: CGFloat(ey+ly))) + drawEyeShape(ctx: &ec, shape: f.eye, w: CGFloat(ew), h: CGFloat(eh)) + } + } + + // MARK: - Eye shapes + + private func drawEyeShape(ctx: inout GraphicsContext, shape: USEyeShape, w: CGFloat, h: CGFloat) { + let ink = Color(red:0.055,green:0.059,blue:0.071) + switch shape { + case .pill: + var p = Path() + p.addRoundedRect(in: CGRect(x:-w/2, y:-h/2, width:w, height:h), + cornerSize: CGSize(width:w/2, height:w/2)) + ctx.fill(p, with: .color(ink)) + + case .cup: + // Flat top + semicircle bottom (cup shape) + let hh = h * 0.55 + var p = Path() + p.move(to: CGPoint(x:-w/2, y:-hh/2)) + p.addLine(to: CGPoint(x: w/2, y:-hh/2)) + p.addLine(to: CGPoint(x: w/2, y: hh/2-w/2)) + p.addArc(center: CGPoint(x:0, y:hh/2-w/2), radius:w/2, startAngle:.degrees(0), endAngle:.degrees(180), clockwise:false) + p.closeSubpath() + ctx.fill(p, with: .color(ink)) + + case .content: + // Upward arc (content / happy) + var p = Path() + p.addArc(center: CGPoint(x:0, y:-h*0.12), radius:w*0.85, + startAngle:.degrees(180*0.15), endAngle:.degrees(180*0.85), clockwise:false) + ctx.stroke(p, with: .color(ink), + style: StrokeStyle(lineWidth:w*0.5, lineCap:.round)) + } + } + + // MARK: - File / suction (drawFile port) + + private func drawFile(ctx: inout GraphicsContext, f: USFrame) { + let cx = f.cursorX, cy = f.cursorY + 14 + if f.suck <= 0 { + var fc = ctx; fc.opacity = 0.92 + drawDoc(ctx: &fc, cx: cx, cy: cy, wsc:1, hsc:1) + return + } + + let m = f.mouthRect + let W0 = 34.0, H0 = 42.0 + let p = usEIn(f.suck) + let topY = usLerp(cy - H0/2, m.y - 2, usEInOut(f.suck)) + let hs = usLerp(1.08, 0.55, usEInOut(f.suck)) + let Hh = H0 * hs + let sc = usLerp(1, 0.55, p) + let q = usEOut(f.suck) + let fCx = usLerp(cx, m.x + m.w/2, usEOut(f.suck)) + let wob = sin(f.suck * .pi * 2) * 0.1 * (1-p) + let clipY = m.y + m.h * 0.5 + + // Use withCGContext for the complex strip clipping + ctx.withCGContext { cg in + cg.saveGState() + // Outer clip: above mouth + cg.clip(to: CGRect(x:0, y:0, width:640, height:clipY)) + + for i in 0..<28 { + let v0 = Double(i) / 28 + let wsc = usLerp(1, usLerp(0.92, 0.22*m.w/W0, pow(v0,1.2)), q) * sc + let yy = topY + v0*Hh + let hh = Hh/28 + 0.6 + + cg.saveGState() + cg.translateBy(x: CGFloat(fCx), y: CGFloat(yy)) + cg.rotate(by: CGFloat(wob)) + cg.clip(to: CGRect(x: CGFloat(-W0*wsc/2), y:0, width: CGFloat(W0*wsc), height: CGFloat(hh))) + cg.translateBy(x: CGFloat(-fCx), y: CGFloat(-yy)) + drawDocCG(cg: cg, cx: fCx, cy: topY+Hh/2, wsc: wsc, hsc: hs, fileIcon: fileIcon) + cg.restoreGState() + } + cg.restoreGState() + } + + // Green particles + for i in 0..<4 { + let a = Double(i)/4 * .pi*2 + 0.6 + let r0 = 24.0 + let k = max(0, min(1, (f.suck - Double(i)*0.08) / 0.7)) + guard k > 0 && k < 1 else { continue } + let sx0 = cx + cos(a)*r0, sy0 = cy + sin(a)*r0 + let ex = m.x + m.w/2, ey = m.y + m.h*0.3 + let kk = pow(k, 0.7) + let px = usLerp(sx0,ex,kk), py = usLerp(sy0,ey,kk) - sin(.pi*k)*6 + let rad = 2.2*(1-k*0.5) + var pp = Path(); pp.addEllipse(in: CGRect(x:px-rad, y:py-rad, width:rad*2, height:rad*2)) + ctx.fill(pp, with: .color(Color(red:0.204,green:0.831,blue:0.600).opacity(1-k))) + } + } + + // MARK: - Doc icon (SwiftUI wrapper) + + private func drawDoc(ctx: inout GraphicsContext, cx: Double, cy: Double, wsc: Double, hsc: Double) { + let w = 34*wsc, h = 42*hsc + let x = cx-w/2, y = cy-h/2 + let fold = 8*min(wsc,hsc) + + var bodyCtx = ctx + bodyCtx.addFilter(.shadow(color:.black.opacity(0.45), radius:8, x:0, y:3)) + var body = Path() + body.move(to: CGPoint(x:x+2,y:y)) + body.addLine(to: CGPoint(x:x+w-fold,y:y)) + body.addLine(to: CGPoint(x:x+w,y:y+fold)) + body.addLine(to: CGPoint(x:x+w,y:y+h-2)) + body.addQuadCurve(to: CGPoint(x:x+w-2,y:y+h), control:CGPoint(x:x+w,y:y+h)) + body.addLine(to: CGPoint(x:x+2,y:y+h)) + body.addQuadCurve(to: CGPoint(x:x,y:y+h-2), control:CGPoint(x:x,y:y+h)) + body.addLine(to: CGPoint(x:x,y:y+2)) + body.addQuadCurve(to: CGPoint(x:x+2,y:y), control:CGPoint(x:x,y:y)) + body.closeSubpath() + bodyCtx.fill(body, with: .color(Color(red:0.957,green:0.957,blue:0.965))) + + var foldPath = Path() + foldPath.move(to: CGPoint(x:x+w-fold,y:y)) + foldPath.addLine(to: CGPoint(x:x+w-fold,y:y+fold)) + foldPath.addLine(to: CGPoint(x:x+w,y:y+fold)) + ctx.fill(foldPath, with: .color(Color(red:0.835,green:0.839,blue:0.859))) + + ctx.fill(roundedRect(CGRect(x:x+w*0.18,y:y+h*0.58,width:w*0.64,height:h*0.16),r:2), + with: .color(Color(red:0.231,green:0.510,blue:0.961))) + } +} + +// MARK: - Doc icon in CGContext (for suction strips) + +private func drawDocCG(cg: CGContext, cx: Double, cy: Double, wsc: Double, hsc: Double, fileIcon: NSImage?) { + let w = 34*wsc, h = 42*hsc + let x = cx-w/2, y = cy-h/2 + let fold = 8.0*min(wsc,hsc) + + // Use real file icon if available + if let icon = fileIcon, + let cgImg = icon.cgImage(forProposedRect: nil, context: nil, hints: nil) { + cg.saveGState() + cg.setShadow(offset: CGSize(width:0,height:3), blur:8, color: CGColor(gray:0, alpha:0.45)) + let rect = CGRect(x:x, y:y, width:w, height:h) + cg.draw(cgImg, in: rect) + cg.restoreGState() + return + } + + // Generic document shape + cg.saveGState() + cg.setShadow(offset: CGSize(width:0,height:3), blur:8, color: CGColor(gray:0, alpha:0.45)) + cg.setFillColor(CGColor(red:0.957,green:0.957,blue:0.965,alpha:1)) + let bp = CGMutablePath() + bp.move(to: CGPoint(x:x+2,y:y)) + bp.addLine(to: CGPoint(x:x+w-fold,y:y)) + bp.addLine(to: CGPoint(x:x+w,y:y+fold)) + bp.addLine(to: CGPoint(x:x+w,y:y+h-2)) + bp.addQuadCurve(to: CGPoint(x:x+w-2,y:y+h), control:CGPoint(x:x+w,y:y+h)) + bp.addLine(to: CGPoint(x:x+2,y:y+h)) + bp.addQuadCurve(to: CGPoint(x:x,y:y+h-2), control:CGPoint(x:x,y:y+h)) + bp.addLine(to: CGPoint(x:x,y:y+2)) + bp.addQuadCurve(to: CGPoint(x:x+2,y:y), control:CGPoint(x:x,y:y)) + bp.closeSubpath() + cg.addPath(bp); cg.fillPath() + cg.restoreGState() + + // Fold + cg.setFillColor(CGColor(red:0.835,green:0.839,blue:0.859,alpha:1)) + let fp = CGMutablePath() + fp.move(to: CGPoint(x:x+w-fold,y:y)); fp.addLine(to: CGPoint(x:x+w-fold,y:y+fold)); fp.addLine(to: CGPoint(x:x+w,y:y+fold)) + cg.addPath(fp); cg.fillPath() + + // Blue accent line + cg.setFillColor(CGColor(red:0.231,green:0.510,blue:0.961,alpha:1)) + cg.addRect(CGRect(x:x+w*0.18, y:y+h*0.58, width:w*0.64, height:h*0.16)) + cg.fillPath() +} + +// MARK: - Superellipse body path (port of reference bodyPath(m, R)) + +func usBodyPath(m: Double, R: Double) -> (path: Path, rx: Double, ry: Double) { + let mc = max(0, min(m, 1.0)) + let n = 2.15 + (5.5-2.15)*mc + let rx = R * (1.04 - 0.04*mc) + let ry = R * (0.97 - 0.03*mc) + var path = Path() + for i in 0...96 { + let a = Double(i)/96 * .pi*2 + let ca = cos(a), sa = sin(a) + let px = rx * (ca<0 ? -1 : ca>0 ? 1 : 0) * pow(abs(ca), 2/n) + let py = ry * (sa<0 ? -1 : sa>0 ? 1 : 0) * pow(abs(sa), 2/n) + if i == 0 { path.move(to: CGPoint(x:px,y:py)) } + else { path.addLine(to: CGPoint(x:px,y:py)) } + } + path.closeSubpath() + return (path, rx, ry) +} + +// MARK: - Rounded rect helper (mirrors reference rr()) + +func roundedRect(_ rect: CGRect, r rr: Double) -> Path { + let r = max(0, min(rr, Double(rect.width)/2, Double(rect.height)/2)) + var p = Path() + p.addRoundedRect(in: rect, cornerSize: CGSize(width:r, height:r)) + return p +} diff --git a/NotchBuddy/Sources/App/UploadSequenceEngine.swift b/NotchBuddy/Sources/App/UploadSequenceEngine.swift new file mode 100644 index 0000000..12b091b --- /dev/null +++ b/NotchBuddy/Sources/App/UploadSequenceEngine.swift @@ -0,0 +1,437 @@ +import Foundation +import CoreGraphics + +// ============================================================ +// CONSTANTS — exact mirror of upload-sequence.html +// All values in island-coordinate points (island = 640 × 176) +// ============================================================ + +enum USC { + static let W: Double = 640 + static let ISL_H: Double = 176 + static let CARD_X: Double = 10; static let CARD_Y: Double = 42 + static let CARD_W: Double = 620; static let CARD_H: Double = 124; static let CARD_R: Double = 20 + static let REST_X: Double = 140; static let REST_Y: Double = 104 + static let D_BOX: Double = 62 + static let FOLLOW_MIN: Double = 60 // CARD_X + 50 + static let FOLLOW_MAX: Double = 580 // CARD_X + CARD_W - 50 + static let TEXT_X: Double = 196; static let TEXT_Y: Double = 94 + static let BAR_X0: Double = 46; static let BAR_X1: Double = 520; static let BAR_Y: Double = 118 + static let CHOOSE_X: Double = 60; static let CHOOSE_Y: Double = 101; static let CHOOSE_D: Double = 62 + static let LOCK_IN: Double = 60 + static let LOCK_OUT: Double = 90 + static let MOUTH_AJAR: Double = 0.20 + static let MOUTH_OPEN: Double = 0.42 + static let MOUTH_MAX: Double = 0.50 + // Phase timestamps (t_ref, drop = 1.95) + static let T_DROP: Double = 1.95 + static let T_SUCK_START: Double = 2.03 + static let T_SUCK_END: Double = 2.33 + static let T_CLOSE_END: Double = 2.42 + static let T_CHEW1: Double = 2.60 + static let T_CHEW_END: Double = 2.88 + static let T_SHRINK_END: Double = 3.23 + static let T_BAR_IN: Double = 3.00 + static let T_PROG_START: Double = 3.25 + static let DT: Double = 1.0 / 240.0 + // Entry offset: gives 0.40 s of following before drop + static let ENTRY_T_REF: Double = T_DROP - 0.40 // = 1.55 +} + +// ============================================================ +// EASING — port of reference E.out / E.in / E.inOut / E.back +// ============================================================ + +func usEOut(_ t: Double) -> Double { 1 - pow(1-t, 3) } +func usEIn(_ t: Double) -> Double { t*t*t } +func usEInOut(_ t: Double) -> Double { t < 0.5 ? 4*t*t*t : 1 - pow(-2*t+2,3)/2 } +func usEBack(_ t: Double) -> Double { let c1=1.70158,c3=c1+1; return 1+c3*pow(t-1,3)+c1*pow(t-1,2) } + +func usSeg(_ t: Double, _ a: Double, _ b: Double) -> Double { max(0, min(1,(t-a)/(b-a))) } +func usLerp(_ a: Double, _ b: Double, _ t: Double) -> Double { a+(b-a)*t } + +// Squeeze keyframes for suckEnd→chew1 phase (inline to avoid Swift @escaping issues) +func usSqueezeY(_ t: Double) -> Double { + let t0=USC.T_SUCK_END, t1=t0+0.07, t2=t0+0.20, t3=USC.T_CHEW1 + if t<=t0 { return 1.06 } + if t<=t1 { return usLerp(1.06, 0.82, usEOut(usSeg(t,t0,t1))) } + if t<=t2 { return usLerp(0.82, 1.10, usEOut(usSeg(t,t1,t2))) } + if t<=t3 { return usLerp(1.10, 1.00, usEInOut(usSeg(t,t2,t3))) } + return 1.0 +} +func usSqueezeX(_ t: Double) -> Double { + let t0=USC.T_SUCK_END, t1=t0+0.07, t2=t0+0.20, t3=USC.T_CHEW1 + if t<=t0 { return 0.97 } + if t<=t1 { return usLerp(0.97, 1.14, usEOut(usSeg(t,t0,t1))) } + if t<=t2 { return usLerp(1.14, 0.95, usEOut(usSeg(t,t1,t2))) } + if t<=t3 { return usLerp(0.95, 1.00, usEInOut(usSeg(t,t2,t3))) } + return 1.0 +} + +func usProgressCurve(_ u: Double) -> Double { + if u < 0.40 { return 0.60 * usEOut(u/0.40) } + if u < 0.85 { return 0.60 + 0.32 * usEInOut((u-0.40)/0.45) } + return 0.92 + 0.08 * usEIn((u-0.85)/0.15) +} +func usProgressAt(_ t: Double, progStart: Double, progEnd: Double) -> Double { + t < progStart ? 0 : usProgressCurve(usSeg(t, progStart, progEnd)) +} + +// ============================================================ +// SPRING — port of reference spring(s, target, response, damping, dt) +// ============================================================ + +struct USSpring { + var v: Double + var vel: Double = 0 + mutating func step(target: Double, response: Double, damping: Double, dt: Double) { + let k = pow(2 * .pi / response, 2) + let c = 2 * damping * sqrt(k) + let a = k*(target-v) - c*vel + vel += a*dt; v += vel*dt + } +} + +// ============================================================ +// SIMULATION STATE — mirrors reference newSim() +// ============================================================ + +struct USSimState { + var t: Double = 0 + var bx: USSpring = USSpring(v: USC.REST_X) + var by: USSpring = USSpring(v: USC.REST_Y) + var tilt: Double = 0 + var mouth: USSpring = USSpring(v: 0) + var locked: Bool = false + var lockAt: Double = -9 + var entered: Double = -9 // t_ref of zone entry; -9 = not entered + var gulp: Bool = false + var ok: Bool = false + var lastPct: Int = 0 +} + +// ============================================================ +// FRAME DATA — output of frame(), read by UploadCanvasView +// ============================================================ + +enum USEyeShape { case pill, cup, content } + +struct USMouthRect { var x,y,w,h: Double } + +struct USFrame { + var t: Double = 0 + var cursorX: Double = 600; var cursorY: Double = 280 + var morph: Double = 0 + var x: Double = USC.REST_X; var y: Double = USC.REST_Y; var d: Double = USC.D_BOX + var sx: Double = 1; var sy: Double = 1; var tilt: Double = 0; var hop: Double = 0 + var mouth: Double = 0 + var mouthRect = USMouthRect(x:0,y:0,w:0,h:0) + var eye: USEyeShape = .pill + var lookX: Double = 0; var lookY: Double = 0 + var fileVisible: Bool = true; var suck: Double = 0 + var zoneOver: Bool = false + var zoneAlpha: Double = 1 + var textAlpha: Double = 1 + var barReveal: Double = 0 + var barAlpha: Double = 0 + var progress: Double = 0 + var flash: Double = 0 + var check: Double = 0 + var greenWash: Double = 0 + var chooseAlpha: Double = 0 + var uploadDuration: Double = 2.4 + var progEnd: Double = USC.T_PROG_START + 2.4 + var growStart: Double = USC.T_PROG_START + 2.4 + 0.25 + var growEnd: Double = USC.T_PROG_START + 2.4 + 0.70 +} + +// ============================================================ +// ENGINE +// ============================================================ + +@MainActor +final class UploadSequenceEngine { + static let shared = UploadSequenceEngine() + + var uploadDuration: Double = 2.4 + var progEnd: Double { USC.T_PROG_START + uploadDuration } + var growStart: Double { progEnd + 0.25 } + var growEnd: Double { progEnd + 0.70 } + + private(set) var isActive: Bool = false + private var entryWallTime: Double = 0 // Date().timeIntervalSinceReferenceDate at entry + private var dropWallTime: Double? = nil + + private var sim = USSimState() + + // Real cursor in island coords + var cursorX: Double = 600 + var cursorY: Double = 280 + private var prevCursorX: Double = 600 + private var prevCursorY: Double = 280 + private var prevCursorTime: Double = 0 + private var cursorSpeed: Double = 0 + + // MARK: - Session lifecycle + + func enterZone(x: CGFloat, y: CGFloat) { + let now = Date().timeIntervalSinceReferenceDate + cursorX = Double(x); cursorY = Double(y) + prevCursorX = cursorX; prevCursorY = cursorY; prevCursorTime = now + cursorSpeed = 0 + sim = USSimState() + sim.t = USC.ENTRY_T_REF + sim.entered = USC.ENTRY_T_REF + sim.bx = USSpring(v: USC.REST_X) + sim.by = USSpring(v: USC.REST_Y) + entryWallTime = now + dropWallTime = nil + isActive = true + } + + func updateCursor(x: CGFloat, y: CGFloat) { + let now = Date().timeIntervalSinceReferenceDate + let dt = now - prevCursorTime + if dt > 0.001 { + let dx = Double(x) - prevCursorX, dy = Double(y) - prevCursorY + cursorSpeed = hypot(dx, dy) / dt + } + prevCursorX = Double(x); prevCursorY = Double(y); prevCursorTime = now + cursorX = Double(x); cursorY = Double(y) + } + + func exitZone() { + // Keep engine active — island stays open per spec + } + + func performDrop(uploadDuration ud: Double) { + uploadDuration = ud + let now = Date().timeIntervalSinceReferenceDate + dropWallTime = now + // Reset sim.t to T_DROP so the canonical post-drop timeline starts correctly, + // regardless of how long the user hovered. Spring state (position/velocity) is preserved. + sim.t = USC.T_DROP + } + + func deactivate() { + isActive = false + dropWallTime = nil + } + + // MARK: - t_ref from wall clock + + func tRef(at date: Date) -> Double { + guard isActive else { return 0 } + let now = date.timeIntervalSinceReferenceDate + if let dw = dropWallTime { + return USC.T_DROP + max(0, now - dw) + } + // No cap — spring keeps stepping as long as user hovers. + // computeFrame clamps phase-sensitive outputs to pre-drop state. + let elapsed = max(0, now - entryWallTime) + return USC.ENTRY_T_REF + elapsed + } + + // MARK: - Public entry point + + func frame(at date: Date) -> USFrame { + guard isActive else { return USFrame() } + let t = tRef(at: date) + simulateTo(t) + return computeFrame(t: t) + } + + // MARK: - Simulation + + private func simulateTo(_ tTarget: Double) { + while sim.t < tTarget - 1e-10 { + let dt = min(USC.DT, tTarget - sim.t) + stepOnce(dt: dt) + sim.t += dt + } + } + + private func stepOnce(dt: Double) { + let isDragging = (dropWallTime == nil) + + // Horizontal follow + lock (only while dragging and in zone) + if isDragging && sim.entered >= 0 { + let dist = hypot(cursorX - sim.bx.v, (cursorY + 14) - sim.by.v) + if !sim.locked && dist < USC.LOCK_IN && cursorSpeed < 180 { + sim.locked = true; sim.lockAt = sim.t + } + if sim.locked && dist > USC.LOCK_OUT { sim.locked = false } + let tx = max(USC.FOLLOW_MIN, min(USC.FOLLOW_MAX, cursorX)) + if sim.locked { + sim.bx.step(target: tx, response:0.18, damping:0.75, dt:dt) + sim.by.step(target: USC.REST_Y, response:0.18, damping:0.75, dt:dt) + } else { + sim.bx.step(target: tx, response:0.35, damping:0.70, dt:dt) + sim.by.step(target: USC.REST_Y, response:0.35, damping:0.70, dt:dt) + } + } + + // Tilt + let tiltTarget = isDragging ? max(-0.18, min(0.18, sim.bx.vel * 0.0015)) : 0.0 + sim.tilt = usLerp(sim.tilt, tiltTarget, 1 - pow(0.0005, dt)) + + // Mouth — post-drop close only after drop has actually happened + let t = sim.t + if !isDragging && t >= USC.T_SUCK_END { + sim.mouth.v = max(0, usLerp(USC.MOUTH_MAX, 0, usEIn(usSeg(t, USC.T_SUCK_END, USC.T_CLOSE_END)))) + } else { + var mt = 0.0 + if sim.entered >= 0 { + mt = (sim.locked || !isDragging) ? USC.MOUTH_OPEN : USC.MOUTH_AJAR + } + if !isDragging && t < USC.T_SUCK_END { mt = USC.MOUTH_MAX } + sim.mouth.step(target: mt, response:0.25, damping:0.60, dt:dt) + if sim.mouth.v < 0 { sim.mouth.v = 0 } + } + } + + // MARK: - Frame computation (mirrors reference frame(t)) + + private func computeFrame(t: Double) -> USFrame { + var f = USFrame() + f.t = t + f.cursorX = cursorX; f.cursorY = cursorY + f.uploadDuration = uploadDuration + f.progEnd = progEnd + f.growStart = growStart + f.growEnd = growEnd + + let entered = sim.entered >= 0 ? sim.entered : 1e9 + let isDragging = (dropWallTime == nil) + // For all phase-based calculations, clamp t to just before T_DROP while pre-drop + // so long hovers don't accidentally trigger post-drop visuals. + let pt = isDragging ? min(t, USC.T_DROP - USC.DT) : t + + // Morph: 0→1 (entry), 1→0 (shrink to ball), 0→1 (grow back to box at choose) + var morph: Double + if pt < USC.T_CHEW_END { + morph = usEBack(usSeg(pt, entered, entered + 0.38)) + } else if pt < growStart { + morph = 1 - usEOut(usSeg(pt, USC.T_CHEW_END, USC.T_SHRINK_END)) + } else { + morph = usEBack(usSeg(pt, growStart, growEnd)) + } + morph = max(0, min(morph, 1.08)) + f.morph = morph + + // Position / diameter + var x = sim.bx.v, y = sim.by.v, d = USC.D_BOX + if pt >= USC.T_CHEW_END && pt < USC.T_PROG_START { + let k = usEInOut(usSeg(pt, USC.T_CHEW_END, USC.T_SHRINK_END)) + x = usLerp(sim.bx.v, USC.BAR_X0, k) + y = usLerp(sim.by.v, USC.BAR_Y, k) + d = usLerp(USC.D_BOX, 14, k) + } + if pt >= USC.T_PROG_START { + let p = usProgressAt(pt, progStart: USC.T_PROG_START, progEnd: progEnd) + x = usLerp(USC.BAR_X0, USC.BAR_X1, p); y = USC.BAR_Y; d = 14 + } + if pt >= progEnd { + x = USC.BAR_X1 + y = USC.BAR_Y - 8 * sin(.pi * usSeg(pt, progEnd, progEnd + 0.20)) + } + if pt >= growStart { + x = usLerp(USC.BAR_X1, USC.CHOOSE_X, usEInOut(usSeg(pt, growStart, growEnd))) + y = usLerp(USC.BAR_Y, USC.CHOOSE_Y, usEInOut(usSeg(pt, growStart, growEnd))) + d = usLerp(14, USC.CHOOSE_D, usEBack(usSeg(pt, growStart, growEnd))) + } + f.x = x; f.y = y; f.d = d + + // Squeeze + var sx = 1.0, sy = 1.0 + if pt >= USC.T_DROP && pt < USC.T_SUCK_START { + let k = usEOut(usSeg(pt, USC.T_DROP, USC.T_SUCK_START)) + sy = usLerp(1, 0.92, k); sx = usLerp(1, 1.06, k) + } + if pt >= USC.T_SUCK_START && pt < USC.T_SUCK_END { + let k = usEInOut(usSeg(pt, USC.T_SUCK_START, USC.T_SUCK_END)) + sy = usLerp(0.92, 1.06, k); sx = usLerp(1.06, 0.97, k) + } + if pt >= USC.T_SUCK_END && pt < USC.T_CHEW1 { + sy = usSqueezeY(pt); sx = usSqueezeX(pt) + } + if pt >= USC.T_CHEW1 && pt < USC.T_CHEW_END { + let k = ((pt-USC.T_CHEW1).truncatingRemainder(dividingBy: 0.14)) / 0.14 + sy = 1 - 0.05*sin(.pi*k); sx = 1 + 0.03*sin(.pi*k) + } + if pt >= USC.T_CHEW_END && pt < USC.T_SHRINK_END { + let k = usSeg(pt, USC.T_CHEW_END, USC.T_SHRINK_END) + sy = 1 + 0.12*sin(.pi*k); sx = 1 - 0.06*sin(.pi*k) + } + if pt >= USC.T_PROG_START && pt < progEnd { + let v = (usProgressAt(pt+0.01, progStart: USC.T_PROG_START, progEnd: progEnd) + - usProgressAt(pt, progStart: USC.T_PROG_START, progEnd: progEnd)) / 0.01 + let st = max(0, min(1, v*0.18)) + sx = 1 + 0.25*st; sy = 1 - 0.15*st + } + if pt >= growStart && pt < growEnd { + sy = 1 + 0.06*sin(.pi * usSeg(pt, growStart, growEnd)) + } + f.sx = sx; f.sy = sy; f.tilt = sim.tilt + f.hop = (sim.lockAt > 0 && isDragging) + ? -5 * sin(.pi * usSeg(pt, sim.lockAt, sim.lockAt + 0.15)) : 0 + + f.mouth = sim.mouth.v + + // Eyes + var eye: USEyeShape = .pill + if sim.locked && pt < USC.T_SUCK_END { eye = .cup } + if pt >= USC.T_SUCK_END && pt < USC.T_CHEW_END + 0.10 { eye = .content } + if pt >= progEnd && pt < growEnd + 0.30 { eye = .content } + f.eye = eye + + let lkx = pt < USC.T_SUCK_END ? cursorX - x : (pt < USC.T_PROG_START ? 0.0 : 40.0) + let lky = pt < USC.T_SUCK_END ? (cursorY+10) - y : 0.0 + f.lookX = max(-1, min(1, lkx/200)); f.lookY = max(-1, min(1, lky/150)) + + f.fileVisible = pt < USC.T_SUCK_END + f.suck = usSeg(pt, USC.T_SUCK_START, USC.T_SUCK_END) + + // Content alpha values + f.zoneOver = sim.entered >= 0 && pt < USC.T_CHEW_END + f.zoneAlpha = 1 - usSeg(pt, USC.T_CHEW_END, USC.T_CHEW_END + 0.20) + f.textAlpha = f.zoneAlpha * ((x > USC.TEXT_X-40 && isDragging) ? 0.25 : 1.0) + f.barReveal = usEOut(usSeg(pt, USC.T_BAR_IN, USC.T_BAR_IN+0.25)) + * (1 - usSeg(pt, growStart, growStart+0.20)) + f.barAlpha = usSeg(pt, USC.T_BAR_IN+0.05, USC.T_BAR_IN+0.25) + * (1 - usSeg(pt, growStart, growStart+0.20)) + f.progress = usProgressAt(pt, progStart: USC.T_PROG_START, progEnd: progEnd) + f.flash = pt >= progEnd ? sin(.pi * usSeg(pt, progEnd, progEnd+0.30)) : 0 + f.check = pt >= progEnd ? usEBack(usSeg(pt, progEnd, progEnd+0.25)) : 0 + let hoverGreen = f.zoneOver ? 0.22 : 0.0 + var uploadGreen = 0.0 + if pt >= USC.T_PROG_START { + // Grows gradually from 0→0.50 as upload progresses (tied to f.progress) + let baseGreen = f.progress * 0.50 + // Brief flash burst at completion + let flashExtra = pt >= progEnd ? 0.20 * sin(.pi * usSeg(pt, progEnd, progEnd + 0.40)) : 0 + // Fade out as Mochi grows back to choose position + let fadeOut = 1.0 - usSeg(pt, growEnd, growEnd + 0.60) + uploadGreen = (baseGreen + flashExtra) * fadeOut + } + f.greenWash = max(hoverGreen, uploadGreen) + f.chooseAlpha = usSeg(pt, growStart+0.15, growEnd) + + // Mouth rect in island coords (used by drawFile for clipping) + let R = d / 2 / 1.04 + let mc = max(0, min(morph, 1.0)) + let rx = R * (1.04 - 0.04*mc) + let ry = R * (0.97 - 0.03*mc) + let mh = f.mouth * R * mc + let mw = 2*rx - 0.24*R + let mxOff = -mw/2 + let myOff = -ry + 0.10*R + f.mouthRect = USMouthRect( + x: x + mxOff * sx, + y: y + f.hop + myOff * sy, + w: mw * sx, + h: mh * sy + ) + return f + } +} diff --git a/NotchBuddy/Sources/App/UserIdentity.swift b/NotchBuddy/Sources/App/UserIdentity.swift new file mode 100644 index 0000000..7b2bf45 --- /dev/null +++ b/NotchBuddy/Sources/App/UserIdentity.swift @@ -0,0 +1,33 @@ +import Foundation + +/// First name of the macOS account holder, or nil when the account name is not something +/// you would greet someone by. "Théodore Riant" gives "Théodore"; a login handle like +/// "theodoreriant" or "t.riant2" gives nil, because "Hey theodoreriant!" reads worse than +/// a greeting with no name at all. +func resolveUserFirstName() -> String? { + let fullName = NSFullUserName().trimmingCharacters(in: .whitespacesAndNewlines) + guard !fullName.isEmpty, fullName.count <= maxUserFullNameLength else { return nil } + guard !looksLikeLoginHandle(fullName) else { return nil } + + guard let first = fullName.split(separator: " ").first.map(String.init), + !first.isEmpty, + first.allSatisfy(isNamePart) else { return nil } + return first +} + +/// NSFullUserName() falls back to the short account name when the full name is unset, so a +/// single lowercase word, or one carrying digits or separators, is treated as a handle. +private func looksLikeLoginHandle(_ name: String) -> Bool { + guard !name.contains(" ") else { return false } + return name == name.lowercased() || name.contains(where: isHandleMarker) +} + +private func isHandleMarker(_ character: Character) -> Bool { + character.isNumber || "._-@".contains(character) +} + +private func isNamePart(_ character: Character) -> Bool { + character.isLetter || character == "'" || character == "-" +} + +private let maxUserFullNameLength = 32 diff --git a/NotchBuddy/Sources/App/VercelPoller.swift b/NotchBuddy/Sources/App/VercelPoller.swift new file mode 100644 index 0000000..41b2492 --- /dev/null +++ b/NotchBuddy/Sources/App/VercelPoller.swift @@ -0,0 +1,108 @@ +import Foundation + +// MARK: - VercelPoller +// Polls Vercel API for latest deployments every 30s. +// On new terminal deployment: updates integration_vercel task state + AppState.vercelDeployments. + +final class VercelPoller: @unchecked Sendable { + static let shared = VercelPoller() + private var timer: DispatchSourceTimer? + private var lastDeploymentId: String = "" + + private init() {} + + func start() { + guard timer == nil else { return } + let t = DispatchSource.makeTimerSource(queue: .global(qos: .background)) + t.schedule(deadline: .now() + 5, repeating: 30) + t.setEventHandler { [weak self] in self?.poll() } + t.resume() + timer = t + } + + // MARK: - Poll + + private func poll() { + guard let token = KeychainStore.shared.get("vercel-token") else { return } + + // Fetch last 5 terminal deployments + guard let url = URL(string: "https://api.vercel.com/v6/deployments?limit=5") else { return } + var req = URLRequest(url: url, timeoutInterval: 10) + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + req.setValue("application/json", forHTTPHeaderField: "Accept") + + URLSession.shared.dataTask(with: req) { [weak self] data, response, error in + guard let self else { return } + let code = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard let data, code == 200 else { return } + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let rawList = json["deployments"] as? [[String: Any]] else { return } + + // Only terminal deployments (READY, ERROR, CANCELED) + let terminal = ["READY", "ERROR", "CANCELED"] + let parsed = rawList + .compactMap { self.parseDeployment($0) } + .filter { terminal.contains($0.state) } + guard !parsed.isEmpty else { return } + + DispatchQueue.main.async { self.handleDeployments(parsed) } + }.resume() + } + + private func parseDeployment(_ d: [String: Any]) -> VercelDeployment? { + guard let uid = d["uid"] as? String, + let name = d["name"] as? String, + let state = d["state"] as? String else { return nil } + + let url = (d["url"] as? String) ?? "" + let createdAtMs = (d["createdAt"] as? Double) ?? 0 + let createdAt = Date(timeIntervalSince1970: createdAtMs / 1000) + + let meta = d["meta"] as? [String: Any] + let commitMessage = meta?["githubCommitMessage"] as? String + ?? meta?["gitlabCommitMessage"] as? String + ?? meta?["bitbucketCommitMessage"] as? String + let branch = meta?["githubCommitRef"] as? String + ?? meta?["gitlabCommitRef"] as? String + ?? meta?["bitbucketBranch"] as? String + + return VercelDeployment(id: uid, projectName: name, url: url, state: state, + createdAt: createdAt, commitMessage: commitMessage, branch: branch) + } + + @MainActor + private func handleDeployments(_ deployments: [VercelDeployment]) { + let appState = AppState.shared + appState.vercelDeployments = deployments + + // Apply project filter (empty = all projects) + let filter = appState.vercelProjectFilter + let filtered = filter.isEmpty ? deployments : deployments.filter { filter.contains($0.projectName) } + guard let latest = filtered.first else { return } + guard latest.id != lastDeploymentId else { return } + lastDeploymentId = latest.id + + guard let idx = appState.tasks.firstIndex(where: { $0.id == "integration_vercel" }) else { return } + let focused = appState.focusId == "integration_vercel" + + appState.tasks[idx].state = latest.isSuccess ? .finished : .error + appState.tasks[idx].steps = [latest.projectName] + + if !focused { + appState.tasks[idx].pillBadge = latest.isSuccess ? .finished : .error + } + SoundEngine.shared.play(latest.isSuccess ? "finish" : "error") + + // Reveal compact island so user sees the badge + NotificationCenter.default.post(name: .hookReveal, object: nil) + + // Auto-clear task state after 60s (deployments list stays) + DispatchQueue.main.asyncAfter(deadline: .now() + 60) { + guard let i = appState.tasks.firstIndex(where: { $0.id == "integration_vercel" }) else { return } + guard appState.tasks[i].state == .finished || appState.tasks[i].state == .error else { return } + appState.tasks[i].state = .idle + appState.tasks[i].steps = [] + appState.tasks[i].pillBadge = nil + } + } +} diff --git a/NotchBuddy/Sources/App/WindowContextCapture.swift b/NotchBuddy/Sources/App/WindowContextCapture.swift new file mode 100644 index 0000000..73bf4cb --- /dev/null +++ b/NotchBuddy/Sources/App/WindowContextCapture.swift @@ -0,0 +1,68 @@ +import AppKit +import ApplicationServices + +// MARK: - Window context capture (M8) + +enum WindowContextCapture { + + /// Returns a PromptContext from the given app (typically the last app active before NotchBuddy). + /// Uses AXUIElement for window title (requires Accessibility permission). + /// Uses AppleScript for browser URL (Safari, Chrome, Arc, Firefox, Edge). + static func captureActive(from app: NSRunningApplication? = NSWorkspace.shared.frontmostApplication) -> PromptContext? { + #if APPSTORE + // App Store: no Accessibility API, no screen capture + return nil + #else + guard let app, let appName = app.localizedName else { return nil } + + let pid = app.processIdentifier + let axApp = AXUIElementCreateApplication(pid) + + // --- Window title --- + var title = "" + var windowRef: CFTypeRef? + if AXUIElementCopyAttributeValue(axApp, kAXFocusedWindowAttribute as CFString, &windowRef) == .success, + let windowRef { + // swiftlint:disable force_cast + let axWindow = windowRef as! AXUIElement + var titleRef: CFTypeRef? + if AXUIElementCopyAttributeValue(axWindow, kAXTitleAttribute as CFString, &titleRef) == .success, + let t = titleRef as? String { + title = t + } + } + + // --- Browser URL --- + let url = browserURL(for: app) + + return .window(appName: appName, title: title, url: url) + #endif + } + + // MARK: - Private + + private static let browserScripts: [String: String] = [ + "com.apple.Safari": + "tell application \"Safari\" to return URL of current tab of front window", + "com.google.Chrome": + "tell application \"Google Chrome\" to return URL of active tab of front window", + "company.thebrowser.Browser": + "tell application \"Arc\" to return URL of active tab of front window", + "org.mozilla.firefox": + "tell application \"Firefox\" to return URL of active tab of front window", + "com.microsoft.edgemac": + "tell application \"Microsoft Edge\" to return URL of active tab of front window", + ] + + private static func browserURL(for app: NSRunningApplication) -> String? { + #if APPSTORE + return nil // No AppleScript in App Store sandbox + #else + guard let bundleId = app.bundleIdentifier, + let script = browserScripts[bundleId] else { return nil } + var error: NSDictionary? + let result = NSAppleScript(source: script)?.executeAndReturnError(&error) + return error == nil ? result?.stringValue : nil + #endif + } +} diff --git a/NotchBuddy/Sources/CoucouKit/BotEngine.swift b/NotchBuddy/Sources/CoucouKit/BotEngine.swift new file mode 100644 index 0000000..0443ef9 --- /dev/null +++ b/NotchBuddy/Sources/CoucouKit/BotEngine.swift @@ -0,0 +1,1659 @@ +import Foundation +import CoreGraphics +import QuartzCore +import SwiftUI + +// MARK: - Easing functions (same as prototype: E.out, E.inOut, E.back, E.lin) + +enum Ease { + static func out(_ t: CGFloat) -> CGFloat { 1 - pow(1 - t, 3) } + static func inOut(_ t: CGFloat) -> CGFloat { t < 0.5 ? 4*t*t*t : 1 - pow(-2*t+2, 3)/2 } + static func back(_ t: CGFloat) -> CGFloat { let c1: CGFloat = 1.7; let c3 = c1+1; return 1+c3*pow(t-1,3)+c1*pow(t-1,2) } + static func lin(_ t: CGFloat) -> CGFloat { t } +} + +// MARK: - Tween key: [target, duration_ms, easing] + +struct TweenKey { + let target: CGFloat + let duration: CGFloat // milliseconds + let ease: (CGFloat) -> CGFloat +} + +struct Tween { + let property: String + var keys: [TweenKey] + var keyIndex: Int = 0 + var from: CGFloat + var startTime: Double // CACurrentMediaTime() * 1000 + var onComplete: (() -> Void)? = nil +} + +// MARK: - Particle + +struct Particle { + enum ParticleType { case heart, star, spark, sweat, z } + var type: ParticleType + var x, y, vx, vy: CGFloat + var age: Double // seconds + var life: Double + var rot: CGFloat + var size: CGFloat +} + +// MARK: - Bot state config (mirrors STATES in prototype) + +struct BotStateCfg { + let color: CGColor + let tint: CGFloat + let eye: EyeShape + let badge: BadgeType? + let badgeColor: CGColor + let glow: CGColor + let glowOpacity: CGFloat + let bounces: Bool + let scans: Bool + let breathes: Bool + let zz: Bool + let sweat: Bool + let look: CGPoint? // fixed look direction + let tilt: CGFloat + let sound: String? +} + +enum EyeShape: String { + case pill, wide, dot, line, flat, happy, closed, spiral, heart, star, tired, wink, cup +} + +enum BadgeType { + case dots(CGColor) + case bang(CGColor) + case question(CGColor) + case dot(CGColor) +} + +// MARK: - Mochi track constants (from PISTES.mochi) + +enum MochiConst { + static let eyeW: CGFloat = 0.25 + static let eyeH: CGFloat = 0.27 + static let eyeSp: CGFloat = 0.37 + static let eyeP: CGFloat = -0.12 + static let baseTop = CGColor(red: 0.929, green: 0.929, blue: 0.937, alpha: 1) // #EDEDEF + static let baseBottom = CGColor(red: 0.769, green: 0.773, blue: 0.792, alpha: 1) // #C4C5CA + static let ink = CGColor(red: 0.102, green: 0.082, blue: 0.071, alpha: 1) // #1A1412 + static let miniInk = CGColor(red: 0.063, green: 0.075, blue: 0.102, alpha: 1) // #10131A +} + +// MARK: - Bot state configs + +let BotStates: [BotState: BotStateCfg] = [ + .idle: BotStateCfg( + color: CGColor(red:0.902,green:0.914,blue:0.933,alpha:1), tint:0, + eye:.pill, badge:nil, + badgeColor: .white, glow: CGColor(red:1,green:1,blue:1,alpha:0.35), glowOpacity:0.25, + bounces:false, scans:false, breathes:false, zz:false, sweat:false, + look:nil, tilt:0, sound:nil), + .working: BotStateCfg( + color: CGColor(red:0.231,green:0.620,blue:1,alpha:1), tint:0.72, + eye:.pill, badge:.dots(CGColor(red:0.231,green:0.620,blue:1,alpha:1)), + badgeColor: CGColor(red:0.231,green:0.620,blue:1,alpha:1), + glow: CGColor(red:0.231,green:0.620,blue:1,alpha:1), glowOpacity:0.55, + bounces:false, scans:false, breathes:false, zz:false, sweat:false, + look:nil, tilt:0, sound:"work"), + .thinking: BotStateCfg( + color: CGColor(red:0.545,green:0.361,blue:0.965,alpha:1), tint:0.72, + eye:.pill, badge:.dots(CGColor(red:0.545,green:0.361,blue:0.965,alpha:1)), + badgeColor: CGColor(red:0.545,green:0.361,blue:0.965,alpha:1), + glow: CGColor(red:0.545,green:0.361,blue:0.965,alpha:1), glowOpacity:0.5, + bounces:false, scans:false, breathes:false, zz:false, sweat:false, + look: CGPoint(x:0.55, y:0.55), tilt:0, sound:"think"), + .searching: BotStateCfg( + color: CGColor(red:0.388,green:0.396,blue:0.949,alpha:1), tint:0.72, + eye:.pill, badge:.dots(CGColor(red:0.388,green:0.396,blue:0.949,alpha:1)), + badgeColor: CGColor(red:0.388,green:0.396,blue:0.949,alpha:1), + glow: CGColor(red:0.388,green:0.396,blue:0.949,alpha:1), glowOpacity:0.55, + bounces:false, scans:true, breathes:false, zz:false, sweat:false, + look:nil, tilt:0, sound:"search"), + .approval: BotStateCfg( + color: CGColor(red:0.961,green:0.647,blue:0.141,alpha:1), tint:0.78, + eye:.wide, badge:.bang(CGColor(red:0.961,green:0.647,blue:0.141,alpha:1)), + badgeColor: CGColor(red:0.961,green:0.647,blue:0.141,alpha:1), + glow: CGColor(red:0.961,green:0.647,blue:0.141,alpha:1), glowOpacity:0.6, + bounces:true, scans:false, breathes:false, zz:false, sweat:false, + look:nil, tilt:0, sound:"approval"), + .question: BotStateCfg( + color: CGColor(red:0.133,green:0.827,blue:0.933,alpha:1), tint:0.75, + eye:.pill, badge:.question(CGColor(red:0.133,green:0.827,blue:0.933,alpha:1)), + badgeColor: CGColor(red:0.133,green:0.827,blue:0.933,alpha:1), + glow: CGColor(red:0.133,green:0.827,blue:0.933,alpha:1), glowOpacity:0.55, + bounces:false, scans:false, breathes:false, zz:false, sweat:false, + look:nil, tilt:0.17, sound:"question"), + .error: BotStateCfg( + color: CGColor(red:0.957,green:0.314,blue:0.369,alpha:1), tint:0.78, + eye:.flat, badge:.dot(CGColor(red:0.957,green:0.314,blue:0.369,alpha:1)), + badgeColor: CGColor(red:0.957,green:0.314,blue:0.369,alpha:1), + glow: CGColor(red:0.957,green:0.314,blue:0.369,alpha:1), glowOpacity:0.55, + bounces:false, scans:false, breathes:false, zz:false, sweat:false, + look:nil, tilt:0, sound:"error"), + .finished: BotStateCfg( + color: CGColor(red:0.204,green:0.831,blue:0.600,alpha:1), tint:0.35, + eye:.happy, badge:.dot(CGColor(red:0.204,green:0.831,blue:0.600,alpha:1)), + badgeColor: CGColor(red:0.204,green:0.831,blue:0.600,alpha:1), + glow: CGColor(red:0.204,green:0.831,blue:0.600,alpha:1), glowOpacity:0.5, + bounces:false, scans:false, breathes:false, zz:false, sweat:false, + look:nil, tilt:0, sound:"finish"), + .ratelimit: BotStateCfg( + color: CGColor(red:0.984,green:0.573,blue:0.235,alpha:1), tint:0.72, + eye:.tired, badge:.dot(CGColor(red:0.984,green:0.573,blue:0.235,alpha:1)), + badgeColor: CGColor(red:0.984,green:0.573,blue:0.235,alpha:1), + glow: CGColor(red:0.984,green:0.573,blue:0.235,alpha:1), glowOpacity:0.45, + bounces:false, scans:false, breathes:false, zz:false, sweat:true, + look:nil, tilt:0, sound:"rate"), + .sleeping: BotStateCfg( + color: CGColor(red:0.580,green:0.635,blue:0.722,alpha:1), tint:0.32, + eye:.closed, badge:nil, + badgeColor: .white, + glow: CGColor(red:0.580,green:0.635,blue:0.722,alpha:1), glowOpacity:0.2, + bounces:false, scans:false, breathes:true, zz:true, sweat:false, + look:nil, tilt:0, sound:"sleep"), + .dizzy: BotStateCfg( + color: CGColor(red:0.957,green:0.447,blue:0.714,alpha:1), tint:0.7, + eye:.spiral, badge:nil, + badgeColor: .white, + glow: CGColor(red:0.957,green:0.447,blue:0.714,alpha:1), glowOpacity:0.55, + bounces:false, scans:false, breathes:false, zz:false, sweat:false, + look:nil, tilt:0, sound:"dizzy"), +] + +// MARK: - Bot engine + +@MainActor +final class BotEngine: ObservableObject { + var isMini: Bool = false + var bodyColor: CGColor? = nil // override for mini bots + + // Animation state (mirrors prototype 's' object) + var yaw: CGFloat = 0 + var pitch: CGFloat = 0 + var roll: CGFloat = 0 + var rollTurns: CGFloat = 1 + var tilt: CGFloat = 0 + var open: CGFloat = 1 // eye open amount + var sx: CGFloat = 1 // scale X + var sy: CGFloat = 1 // scale Y + var oy: CGFloat = 0 // offset Y (bounce) + var ox: CGFloat = 0 // offset X (shake) + var tint: CGFloat = 0 + var morph: CGFloat = 0 // morph to rect (for upload bucket) + var hands: CGFloat = 0 + var blush: CGFloat = 0 + var outfit: Outfit = .none + var es: CGFloat = 1 // eye scale + var badgeS: CGFloat = 0 // badge scale + var outfitPresence: CGFloat = 0 // 0=hidden, 1=fully visible (animated) + private var outfitTarget: Outfit = .none + + // Physical spring (hat/pompom lag) — updated in update() + var physDx: CGFloat = 0 // horizontal lag (-1..1) + var physDy: CGFloat = 0 // vertical lag (-1..1) + private var physVx: CGFloat = 0 + private var physVy: CGFloat = 0 + private var prevYaw: CGFloat = 0 + private var prevOy: CGFloat = 0 + private var prevRoll: CGFloat = 0 + + // Targets + var tgYaw: CGFloat = 0 + var tgPitch: CGFloat = 0 + var tgTilt: CGFloat = 0 + var tgSy: CGFloat = 1 + var tgSx: CGFloat = 1 + var tgEs: CGFloat = 1 // eye-scale target (hover love: 1.08, normal: 1) + + // Particle canvas overhang (extra canvas height at top for hearts to fly into) + var particleOverhang: CGFloat = 0 + + // Mouth spring (fraction of R: 0=closed, 0.20=hover, 0.42=open, 0.50=overopen) + var slotH: CGFloat = 0 // current height (fraction of R) + var slotHTarget: CGFloat = 0 // spring target + var slotHVel: CGFloat = 0 // spring velocity (fraction of R / s) + var isChewing: Bool = false // true for ~800ms after gulp swallow + + // Color (animated) + var col: (CGFloat, CGFloat, CGFloat) = (0.902, 0.914, 0.933) // idle + var colT: (CGFloat, CGFloat, CGFloat) = (0.902, 0.914, 0.933) + + // State + var state: BotState = .idle + var cfg: BotStateCfg = BotStates[.idle]! + + // Eye override (emote) + var eyeOverride: EyeShape? = nil + var eyeOverrideUntil: Double = 0 // CACurrentMediaTime() + var permanentEye: EyeShape? = nil // restored after temporary emote/blink expires + var permanentEmote: BotEmote? = nil // stored so doMiniBehaviorLoop can switch on it + var miniNextBehavior: Double = 0 // CACurrentMediaTime() of next periodic mini action + + // Badge animation + var badge: BadgeType? = nil + var badgeKey: String = "none" + var badgeToken: Int = 0 + + // Tweens (keyed by property name) + var tweens: [String: Tween] = [:] + var locks: Set = [] + + // Particles + var particles: [Particle] = [] + + // Look target + var lookX: CGFloat = 0 + var lookY: CGFloat = 0 + + // Timing + var lastTime: Double = CACurrentMediaTime() + var t0: Double = CACurrentMediaTime() - Double.random(in: 0...5) + var nextBlink: Double = CACurrentMediaTime() + 1.5 + Double.random(in: 0...2) + var waveUntil: Double = 0 + var waveStart: Double = 0 // CACurrentMediaTime() when wave animation began + var greetToken: Int = 0 // incremented to invalidate stale greet closures + var lastAmbient: Double = 0 + + // Slap tracking (for dizzy on 3 slaps) + var slapTimes: [Double] = [] + + // Dancing (Apple Music) + var isDancing: Bool = false + var dancingLevel: CGFloat = 0 // 0→1 over 0.3s, 1→0 over 0.5s + + // Mini wandering look (random, ignores mouse) + var miniLookTarget: CGPoint = .zero + var miniLookNextTime: Double = 0 + + // MARK: - Public API + + func setState(_ newState: BotState, force: Bool = false) { + guard state != newState || force else { return } + let prev = state + state = newState + cfg = BotStates[newState]! + colT = cgColorToTuple(cfg.color) + setTarget(key: "tint", value: cfg.tint) + setTarget(key: "tilt", value: cfg.tilt) + setBadge(cfg.badge) + + switch newState { + case .finished: + doRoll(duration: 950, turns: 1) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.emit(.spark, count: 5) + } + case .error: + anim("ox", keys: [ + TweenKey(target: 0.08, duration: 50, ease: Ease.out), + TweenKey(target: -0.08, duration: 70, ease: Ease.inOut), + TweenKey(target: 0.05, duration: 70, ease: Ease.inOut), + TweenKey(target: 0, duration: 90, ease: Ease.out), + ]) + case .approval: + anim("oy", keys: [ + TweenKey(target: -0.2, duration: 150, ease: Ease.out), + TweenKey(target: 0, duration: 300, ease: Ease.back), + ]) + case .dizzy: + doRoll(duration: 1300, turns: 2) + case .question: + blink() + case .ratelimit: + emit(.sweat, count: 1) + default: + if prev != .idle || newState != .idle { blink() } + } + } + + func setBadge(_ b: BadgeType?) { + let key = badgeString(b) + guard key != badgeKey else { return } + badgeKey = key + let tok = badgeToken + 1 + badgeToken = tok + anim("badgeS", keys: [TweenKey(target: 0, duration: 90, ease: Ease.inOut)]) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + guard let self, tok == self.badgeToken else { return } + self.badge = b + if b != nil { + self.anim("badgeS", keys: [TweenKey(target: 1, duration: 280, ease: Ease.back)]) + } + } + } + + func blink() { + guard !locks.contains("open") else { return } + anim("open", keys: [ + TweenKey(target: 0.06, duration: 70, ease: Ease.inOut), + TweenKey(target: 1, duration: 130, ease: Ease.out), + ]) + } + + func squash() { + physVy += 0.6 + anim("sy", keys: [ + TweenKey(target: 0.78, duration: 70, ease: Ease.out), + TweenKey(target: 1.1, duration: 130, ease: Ease.out), + TweenKey(target: 1, duration: 170, ease: Ease.inOut), + ]) + anim("sx", keys: [ + TweenKey(target: 1.16, duration: 70, ease: Ease.out), + TweenKey(target: 0.95, duration: 130, ease: Ease.out), + TweenKey(target: 1, duration: 170, ease: Ease.inOut), + ]) + } + + // MARK: - Gulp (mailbox swallow) + + func gulp() { + // Open mouth wide for the swallow, then close during chewing + slotHTarget = 0.42 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.46) { [weak self] in + self?.slotHTarget = 0 + self?.isChewing = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.80) { [weak self] in + self?.isChewing = false + } + } + anim("sy", keys: [ + TweenKey(target: 0.78, duration: 80, ease: Ease.out), + TweenKey(target: 1.18, duration: 130, ease: Ease.out), + TweenKey(target: 1, duration: 220, ease: Ease.back), + ]) + anim("sx", keys: [ + TweenKey(target: 1.28, duration: 80, ease: Ease.out), + TweenKey(target: 0.92, duration: 130, ease: Ease.out), + TweenKey(target: 1, duration: 220, ease: Ease.back), + ]) + blink() + } + + // MARK: - Slap (dizzy mechanic) + + func slap() { + interruptGreet() + guard state != .dizzy else { return } + let now = CACurrentMediaTime() + slapTimes = slapTimes.filter { now - $0 < 1.7 } + slapTimes.append(now) + SoundEngine.shared.play("slap") + squash() + physVy -= 1.2 + physVx += Bool.random() ? 0.7 : -0.7 + if slapTimes.count >= 3 { + slapTimes = [] + NotificationCenter.default.post(name: .botDizzy, object: nil) + } else { + // Annoyed: line eyes for 800ms, annoyed sound after 60ms delay + eyeOverride = .line + eyeOverrideUntil = now + 0.8 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.06) { + SoundEngine.shared.play("annoyed") + } + } + } + + // MARK: - Dancing + + func setDancing(_ dancing: Bool) { + guard isDancing != dancing else { return } + isDancing = dancing + } + + // MARK: - Mini periodic behavior loop + + func doMiniBehaviorLoop() { + switch permanentEmote { + + case .happy: + // Little jump + squash + guard !locks.contains("oy") else { + miniNextBehavior = CACurrentMediaTime() + 0.4 + return + } + anim("oy", keys: [ + TweenKey(target: -0.30, duration: 120, ease: Ease.out), + TweenKey(target: 0.03, duration: 200, ease: Ease.inOut), + TweenKey(target: 0, duration: 160, ease: Ease.back), + ]) + anim("sy", keys: [ + TweenKey(target: 0.82, duration: 80, ease: Ease.out), + TweenKey(target: 1.18, duration: 130, ease: Ease.out), + TweenKey(target: 0.88, duration: 160, ease: Ease.inOut), + TweenKey(target: 1, duration: 200, ease: Ease.back), + ]) + anim("sx", keys: [ + TweenKey(target: 1.15, duration: 80, ease: Ease.out), + TweenKey(target: 0.88, duration: 130, ease: Ease.out), + TweenKey(target: 1.06, duration: 160, ease: Ease.inOut), + TweenKey(target: 1, duration: 200, ease: Ease.back), + ]) + miniNextBehavior = CACurrentMediaTime() + 2.2 + Double.random(in: 0...1.2) + + case .annoyed: + // Rapid head shake + guard !locks.contains("yaw") else { + miniNextBehavior = CACurrentMediaTime() + 0.5 + return + } + anim("yaw", keys: [ + TweenKey(target: -0.65, duration: 50, ease: Ease.out), + TweenKey(target: 0.65, duration: 90, ease: Ease.inOut), + TweenKey(target: -0.5, duration: 80, ease: Ease.inOut), + TweenKey(target: 0.4, duration: 75, ease: Ease.inOut), + TweenKey(target: -0.2, duration: 70, ease: Ease.inOut), + TweenKey(target: 0, duration: 140, ease: Ease.out), + ]) + miniNextBehavior = CACurrentMediaTime() + 3.0 + Double.random(in: 0...2.5) + + case .wink: + // Brief wink: eye closes, head tilts slightly + let now2 = CACurrentMediaTime() + eyeOverride = .wink + eyeOverrideUntil = now2 + 0.55 + anim("tilt", keys: [ + TweenKey(target: 0.13, duration: 100, ease: Ease.out), + TweenKey(target: 0.13, duration: 320, ease: Ease.lin), + TweenKey(target: 0, duration: 200, ease: Ease.inOut), + ]) + miniNextBehavior = CACurrentMediaTime() + 2.2 + Double.random(in: 0...2.0) + + case .love: + // Emit hearts + gentle sway + emit(.heart, count: 2) + anim("tilt", keys: [ + TweenKey(target: -0.1, duration: 180, ease: Ease.out), + TweenKey(target: 0.1, duration: 340, ease: Ease.inOut), + TweenKey(target: 0, duration: 220, ease: Ease.inOut), + ]) + miniNextBehavior = CACurrentMediaTime() + 2.6 + Double.random(in: 0...1.5) + + default: + miniNextBehavior = CACurrentMediaTime() + 3.0 + Double.random(in: 0...2.0) + } + } + + func doRoll(duration: CGFloat, turns: CGFloat) { + roll = 0 + rollTurns = turns + anim("roll", keys: [TweenKey(target: .pi * 2 * turns, duration: duration, ease: Ease.inOut)]) { [weak self] in + self?.roll = 0 + self?.squash() + } + } + + func setOutfit(_ newOutfit: Outfit, animated: Bool = true) { + guard newOutfit != outfitTarget else { return } + outfitTarget = newOutfit + tweens.removeValue(forKey: "outfitPresence") + locks.remove("outfitPresence") + if !animated { + outfit = newOutfit + outfitPresence = newOutfit != .none ? 1 : 0 + } else if newOutfit == .none { + // Exit: fade out then clear outfit + anim("outfitPresence", keys: [TweenKey(target: 0, duration: 180, ease: Ease.inOut)]) { [weak self] in + self?.outfit = .none + } + } else if outfit == .none { + // Enter: set outfit then animate in (Ease.back applied to position offsets in drawing code) + outfit = newOutfit + outfitPresence = 0 + anim("outfitPresence", keys: [TweenKey(target: 1, duration: 350, ease: Ease.inOut)]) { [weak self] in + self?.squash() + } + } else { + // Change: exit old, set new, enter + anim("outfitPresence", keys: [TweenKey(target: 0, duration: 180, ease: Ease.inOut)]) { [weak self] in + guard let self else { return } + self.outfit = newOutfit + self.anim("outfitPresence", keys: [TweenKey(target: 1, duration: 350, ease: Ease.inOut)]) { [weak self] in + self?.squash() + } + } + } + } + + func greet() { + let now = CACurrentMediaTime() + greetToken += 1 + let tok = greetToken + waveStart = now + 0.45 // wave begins at 0.45s + waveUntil = now + 1.55 // wave ends at 1.55s + physVx += 0.2 + + // 0s: happy eyes for full greeting (2s — no gap, no flicker) + eyeOverride = .happy + eyeOverrideUntil = now + 2.0 + anim("oy", keys: [ + TweenKey(target: -0.06, duration: 220, ease: Ease.out), + TweenKey(target: 0.0, duration: 220, ease: Ease.back), + ]) + + // 0.25s: hands out + body squash + sound + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + guard let self, self.greetToken == tok else { return } + self.anim("hands", keys: [TweenKey(target: 1, duration: 280, ease: Ease.out)]) + self.anim("sy", keys: [ + TweenKey(target: 0.95, duration: 100, ease: Ease.out), + TweenKey(target: 1.0, duration: 260, ease: Ease.back), + ]) + self.anim("sx", keys: [ + TweenKey(target: 1.04, duration: 100, ease: Ease.out), + TweenKey(target: 1.0, duration: 260, ease: Ease.back), + ]) + SoundEngine.shared.play("greet") + } + + // 0.55s: first blink + DispatchQueue.main.asyncAfter(deadline: .now() + 0.55) { [weak self] in + guard let self, self.greetToken == tok else { return } + self.blink() + } + + // 1.50s: second blink + DispatchQueue.main.asyncAfter(deadline: .now() + 1.50) { [weak self] in + guard let self, self.greetToken == tok else { return } + self.blink() + } + + // 1.55s: retract hands + DispatchQueue.main.asyncAfter(deadline: .now() + 1.55) { [weak self] in + guard let self, self.greetToken == tok else { return } + self.waveUntil = 0 + self.anim("hands", keys: [TweenKey(target: 0, duration: 200, ease: Ease.inOut)]) + } + + // 1.75s: brief happy eyes then back to normal + DispatchQueue.main.asyncAfter(deadline: .now() + 1.75) { [weak self] in + guard let self, self.greetToken == tok else { return } + self.eyeOverride = .happy + self.eyeOverrideUntil = CACurrentMediaTime() + 0.30 + } + } + + /// Immediately interrupts an in-progress greeting (hands retract in 150 ms). + func interruptGreet() { + guard hands > 0.01 || CACurrentMediaTime() < waveUntil else { return } + greetToken += 1 // invalidate any pending closures + waveUntil = 0 + waveStart = 0 + anim("hands", keys: [TweenKey(target: 0, duration: 150, ease: Ease.inOut)]) + } + + /// Sets a permanent eye expression that survives blinks and transient emotes. + func setPermanentEmote(_ emote: BotEmote?) { + permanentEmote = emote + // .wink fires periodically — don't freeze the eye (normal between winks) + if emote == .wink { + miniNextBehavior = CACurrentMediaTime() + Double.random(in: 0.8...2.5) + return + } + permanentEye = emote.map { emoteEyeShape($0) } + if let eye = permanentEye { + eyeOverride = eye + eyeOverrideUntil = .greatestFiniteMagnitude + } else { + if eyeOverrideUntil == .greatestFiniteMagnitude { + eyeOverride = nil + eyeOverrideUntil = 0 + } + } + // Stagger first periodic behavior so bots don't all fire at once + miniNextBehavior = CACurrentMediaTime() + Double.random(in: 0.8...2.5) + } + + func triggerEmote(_ emote: BotEmote, duration: Double = 1.8, silent: Bool = false) { + let now = CACurrentMediaTime() + eyeOverride = emoteEyeShape(emote) + eyeOverrideUntil = now + duration + + switch emote { + case .love: + anim("blush", keys: [ + TweenKey(target: 1, duration: 300, ease: Ease.out), + TweenKey(target: 1, duration: CGFloat((duration - 0.6) * 1000), ease: Ease.lin), + TweenKey(target: 0, duration: 300, ease: Ease.inOut), + ]) + emit(.heart, count: 4) + anim("oy", keys: [ + TweenKey(target: -0.1, duration: 160, ease: Ease.out), + TweenKey(target: 0, duration: 300, ease: Ease.back), + ]) + case .surprised: + anim("oy", keys: [ + TweenKey(target: -0.3, duration: 140, ease: Ease.out), + TweenKey(target: 0, duration: 380, ease: Ease.back), + ]) + anim("es", keys: [ + TweenKey(target: 1.25, duration: 120, ease: Ease.out), + TweenKey(target: 1, duration: 500, ease: Ease.inOut), + ]) + case .proud: + squash() + emit(.star, count: 5) + anim("tilt", keys: [ + TweenKey(target: -0.14, duration: 220, ease: Ease.out), + TweenKey(target: -0.14, duration: CGFloat((duration - 0.5) * 1000), ease: Ease.lin), + TweenKey(target: 0, duration: 280, ease: Ease.inOut), + ]) + anim("blush", keys: [ + TweenKey(target: 0.7, duration: 250, ease: Ease.out), + TweenKey(target: 0.7, duration: CGFloat((duration - 0.5) * 1000), ease: Ease.lin), + TweenKey(target: 0, duration: 300, ease: Ease.inOut), + ]) + case .wink: + anim("tilt", keys: [ + TweenKey(target: 0.12, duration: 160, ease: Ease.out), + TweenKey(target: 0.12, duration: CGFloat((duration - 0.4) * 1000), ease: Ease.lin), + TweenKey(target: 0, duration: 240, ease: Ease.inOut), + ]) + case .yawn: + anim("sy", keys: [ + TweenKey(target: 1.12, duration: 500, ease: Ease.inOut), + TweenKey(target: 1, duration: 500, ease: Ease.inOut), + ]) + anim("sx", keys: [ + TweenKey(target: 0.94, duration: 500, ease: Ease.inOut), + TweenKey(target: 1, duration: 500, ease: Ease.inOut), + ]) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { [weak self] in + self?.eyeOverride = .closed + self?.emit(.z, count: 2) + } + case .happy: + anim("blush", keys: [ + TweenKey(target: 0.6, duration: 200, ease: Ease.out), + TweenKey(target: 0, duration: 600, ease: Ease.inOut), + ]) + case .annoyed: + eyeOverride = .line + eyeOverrideUntil = now + 0.8 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.06) { + SoundEngine.shared.play("annoyed") + } + } + } + + func emit(_ type: Particle.ParticleType, count: Int) { + for i in 0..= 1 { + tw.from = k.target + tw.keyIndex += 1 + tw.startTime = now * 1000 + if tw.keyIndex >= tw.keys.count { + tweens.removeValue(forKey: key) + locks.remove(key) + tw.onComplete?() + } else { + tweens[key] = tw + } + } else { + tweens[key] = tw + } + } + + // Compute look targets + let t = CGFloat(now - t0) + var ty: CGFloat = lookX * 0.62 + var tp: CGFloat = lookY * 0.5 + + if let fixedLook = cfg.look { + ty = ty * 0.35 + fixedLook.x * 0.55 + tp = tp * 0.3 + fixedLook.y * 0.5 + } + if cfg.scans { + ty = sin(t * 2.6) * 0.6 + tp = -0.06 + } + if state == .sleeping { ty = 0; tp = -0.14 } + if state == .dizzy { ty = sin(t * 9) * 0.25 } + + // Mini bots: override look with random wandering (never follows mouse) + if isMini && cfg.look == nil && !cfg.scans && state != .sleeping && state != .dizzy { + if now > miniLookNextTime { + miniLookTarget = CGPoint( + x: CGFloat.random(in: -0.88...0.88), + y: CGFloat.random(in: -0.55...0.45) + ) + miniLookNextTime = now + Double.random(in: 0.5...2.0) + } + ty = miniLookTarget.x * 0.62 + tp = miniLookTarget.y * 0.5 + } + + tgYaw = ty + tgPitch = tp + tgTilt = cfg.tilt + + // Body sway during greeting wave + if now > waveStart && now < waveUntil { + let wt = CGFloat(now - waveStart) + tgTilt = -0.06 + sin(2 * .pi * 1.2 * wt) * 0.07 + } + + let bounce = cfg.bounces ? -abs(sin(t * 5.2)) * 0.07 : CGFloat(0) + // oy tween can override if not locked + if !locks.contains("oy") { oy += (bounce - oy) * CGFloat(1 - pow(0.0008, dt)) } + + if cfg.breathes { + let amp: CGFloat = isMini ? 0.07 : 0.035 + tgSy = 1 + sin(t * 1.8) * amp + tgSx = 1 - sin(t * 1.8) * amp * 0.57 + } else if isMini { + // Subtle idle pulse (unique phase per engine via t0) + tgSy = 1 + sin(t * 2.2) * 0.04 + tgSx = 1 - sin(t * 2.2) * 0.02 + } else { + tgSy = 1; tgSx = 1 + } + + // Mini bots: periodic dramatic behaviors + if isMini && now > miniNextBehavior { + doMiniBehaviorLoop() + } + + // Smooth look + let kLook = CGFloat(1 - pow(0.0025, dt)) + let kGen = CGFloat(1 - pow(0.0008, dt)) + + if !locks.contains("yaw") { yaw += (tgYaw - yaw) * kLook } + if !locks.contains("pitch") { pitch += (tgPitch - pitch) * kLook } + if !locks.contains("tilt") { tilt += (tgTilt - tilt) * kGen } + if !locks.contains("sy") { sy += (tgSy - sy) * kGen } + if !locks.contains("sx") { sx += (tgSx - sx) * kGen } + if !locks.contains("es") { es += (tgEs - es) * kGen } + + // Animate color + col = mixColor(col, colT, 1 - pow(0.002, dt)) + + // Blink + if now > nextBlink { + if state != .sleeping && state != .dizzy { + blink() + if Double.random(in: 0...1) < 0.22 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.23) { [weak self] in self?.blink() } + } + } + nextBlink = now + 2.2 + Double.random(in: 0...3.2) + } + + // Clear expired eye override (restore permanent if set) + if eyeOverride != nil && now > eyeOverrideUntil { + eyeOverride = permanentEye + if permanentEye != nil { eyeOverrideUntil = .greatestFiniteMagnitude } + } + + // Ambient particles + if now - lastAmbient > 1.3 { + lastAmbient = now + if cfg.zz { emit(.z, count: 1) } // ZZZ works for mini too + if !isMini && cfg.sweat && Double.random(in: 0...1) < 0.5 { emit(.sweat, count: 1) } + } + + // Age particles + for i in particles.indices { particles[i].age += dt } + particles.removeAll { $0.age >= $0.life } + + // Mouth slot spring — ω₀ ≈ 25 rad/s (T=0.25s), ζ=0.6 (underdamped, slight clack) + let slotOmega: CGFloat = 2 * .pi / 0.25 + let slotZeta: CGFloat = 0.6 + let slotAcc = slotOmega * slotOmega * (slotHTarget - slotH) + - 2 * slotZeta * slotOmega * slotHVel + slotHVel += slotAcc * dtCG + slotH = max(0, slotH + slotHVel * dtCG) + + // Dance level: fade in 0.3s, out 0.5s + let dancingTarget: CGFloat = isDancing ? 1 : 0 + if dancingLevel < dancingTarget { + dancingLevel = min(dancingTarget, dancingLevel + CGFloat(dt) / 0.3) + } else if dancingLevel > dancingTarget { + dancingLevel = max(dancingTarget, dancingLevel - CGFloat(dt) / 0.5) + } + + // Phys spring for hat/pompom lag + let yawVel = (yaw - prevYaw) / CGFloat(dt) + let oyVel = (oy - prevOy) / CGFloat(dt) + let rollVel = (roll - prevRoll) / CGFloat(dt) + prevYaw = yaw; prevOy = oy; prevRoll = roll + // Centrifugal physDx impulse when rigidly rolling with an outfit + let centrifugal: CGFloat = (outfit != .none && outfitPresence > 0.05) ? rollVel * 0.18 : 0 + let tDx = max(-1, min(1, -yawVel * 0.35 - tilt * 2 + centrifugal)) + let tDy = max(-1, min(1, oyVel * 0.50)) + let stiff: CGFloat = 60, damp: CGFloat = 9 + physVx += (stiff * (tDx - physDx) - damp * physVx) * dtCG + physVy += (stiff * (tDy - physDy) - damp * physVy) * dtCG + physDx += physVx * dtCG + physDy += physVy * dtCG + + lastTime = now + } + + // MARK: - Dance transform + + /// Applies a 112-BPM dance bounce/sway around the bottom of the body. + /// Call this on a copy of the GraphicsContext before the three draw passes. + func applyDance(_ ctx: inout GraphicsContext, size: CGSize) { + guard dancingLevel > 0.001 else { return } + let W = size.width, H = size.height, R = W * 0.3 + let px = W / 2 + ox * R + let py = H / 2 + particleOverhang / 2 + oy * R + R * 0.06 + R * 0.88 + let beat = CGFloat(CACurrentMediaTime()) * 112 / 60 + let hop = abs(sin(.pi * beat)), land = pow(1 - hop, 6), l = dancingLevel + ctx.translateBy(x: px + 0.08 * R * sin(.pi * beat) * l, y: py - 0.20 * R * hop * l) + ctx.rotate(by: .radians(0.10 * sin(.pi * beat) * l)) + ctx.scaleBy(x: 1 + 0.045 * land * l, y: 1 - 0.06 * land * l) + ctx.translateBy(x: -px, y: -py) + } + + // MARK: - Draw + + func draw(context: GraphicsContext, size: CGSize) { + let W = size.width + let H = size.height + let R = W * 0.3 + let rx = R * 1.14 + let ry = R * 0.88 + + let cx = W / 2 + ox * R + // particleOverhang shifts the bot body down in canvas coords so hearts can fly into + // the extended canvas above without clipping (BotPlacement compensates with position offset) + let cy = H / 2 + particleOverhang / 2 + oy * R + R * 0.06 + + var ctx = context + ctx.translateBy(x: cx, y: cy) + if tilt != 0 { ctx.rotate(by: .radians(tilt)) } + ctx.scaleBy(x: sx, y: sy) + + // Body path (superellipse for Mochi, morph to rect for upload) + let bodyPath = mochiPath(rx: rx, ry: ry, morph: morph, R: R) + + // Body fill + drawBody(ctx: &ctx, path: bodyPath, R: R, rx: rx, ry: ry, + pumpkinColors: outfit == .pumpkin && !isMini) + + // Blush — always shows a floor proportional to tint (prototype behaviour) + let blushVal = max(blush, tint * 0.5) * (1 - morph) + if blushVal > 0.01 { + drawBlush(ctx: &ctx, path: bodyPath, rx: rx, ry: ry, R: R, blush: blushVal) + } + + // Eyes + drawEyes(ctx: &ctx, path: bodyPath, R: R, rx: rx, ry: ry) + + // Mouth hole — dark pill cutout inside the box face + // Spec: left/right margins 0.10R, top margin 0.08R from box top (-0.94R) + if morph > 0.05 { + let hW = R * 1.80 * morph // hole width = box width (2×1.0R) − 2×0.10R margin + let hH = slotH * R * morph // hole height (spring-animated, scaled by morph) + let hX = -hW / 2 + // Hole Y: box top is -R*0.94 at morph=1, lerped from -R*0.88 at morph=0 + let boxTop = -R * (0.88 + 0.06 * morph) + let hY = boxTop + R * 0.08 * morph // top margin scales with morph + + var boxCtx = ctx + boxCtx.clip(to: bodyPath) // everything clipped inside body + + // Top rim — 1pt white 55% line at box top edge + var rim = Path() + rim.move(to: CGPoint(x: -R * 0.90 * morph, y: boxTop + 1)) + rim.addLine(to: CGPoint(x: R * 0.90 * morph, y: boxTop + 1)) + boxCtx.stroke(rim, with: .color(Color.white.opacity(0.55 * Double(morph))), + style: StrokeStyle(lineWidth: 1, lineCap: .round)) + + // Hole interior — only draw if visibly open + if hH > 0.8 { + let hR = min(hW / 2, hH / 2) // fully rounded when hH < hW (pill shape) + var hole = Path() + hole.addRoundedRect(in: CGRect(x: hX, y: hY, width: hW, height: hH), + cornerSize: CGSize(width: hR, height: hR)) + boxCtx.fill(hole, with: .linearGradient( + Gradient(colors: [Color(red: 0.027, green: 0.031, blue: 0.039), + Color(red: 0.063, green: 0.075, blue: 0.102)]), + startPoint: CGPoint(x: 0, y: hY), + endPoint: CGPoint(x: 0, y: hY + hH) + )) + // Bottom lip — 1pt white 28% highlight + if hH > 4 { + let lipR = min(hR, (hW - 2) / 2) + var lip = Path() + lip.move(to: CGPoint(x: hX + lipR, y: hY + hH - 0.5)) + lip.addLine(to: CGPoint(x: hX + hW - lipR, y: hY + hH - 0.5)) + boxCtx.stroke(lip, with: .color(Color.white.opacity(0.28 * Double(morph))), + style: StrokeStyle(lineWidth: 1, lineCap: .round)) + } + } + } + + // Reset transform for hands, badge, particles which need world coords + // (We'll pass world-space cx/cy to these helpers) + } + + // MARK: - Draw hands behind body (called before draw() so hands appear under Mochi) + + func drawHandsBehind(context: GraphicsContext, size: CGSize) { + guard hands > 0.01, !isMini else { return } + let W = size.width, H = size.height + let R = W * 0.3 + // Only draw hands when Mochi is large enough to be meaningful (not compact/peek) + guard R > 14 else { return } + let rx = R * 1.14 + let ry = R * 0.88 + let cx = W / 2 + ox * R + let cy = H / 2 + particleOverhang / 2 + oy * R + R * 0.06 + + let now = CACurrentMediaTime() + let bodyH = 2 * ry // full body height + + // Hand ellipse half-dims: 0.30×bodyH wide, 0.26×bodyH tall (scaled by hands 0→1) + let hew = 0.30 * ry * hands // half-width + let heh = 0.26 * ry * hands // half-height + + // Body half-dims with current squash scale + let hwB = rx * sx + let hhB = ry * sy + + let isWaving = now >= waveStart && waveStart > 0 && now < waveUntil + + for sd in [-1.0, 1.0] { + var localX: CGFloat + var localY: CGFloat + var handRot: CGFloat = 0 + + if sd > 0 && isWaving { + // Right hand: rise to wave position over first 180ms, then oscillate + let wt = CGFloat(now - waveStart) + let rise = min(1.0, wt / 0.18) + let riseEased: CGFloat = 1 - pow(1 - rise, 3) // easeOut cubic + + // Rest position is lower-side; wave position is upper-side (at eye height) + let restX: CGFloat = hwB * 1.08 + let restY: CGFloat = hhB * 0.70 + let oscX = cos(13 * wt) * 0.06 * bodyH + let oscY = -sin(13 * wt) * 0.14 * bodyH + let waveX: CGFloat = hwB * 1.10 + oscX + let waveY: CGFloat = -hhB * 0.15 + oscY + localX = restX + (waveX - restX) * riseEased + localY = restY + (waveY - restY) * riseEased + handRot = (-0.5 + sin(13 * wt) * 0.35) * riseEased + + } else if sd < 0 && isWaving { + // Left hand: gentle sway at rest position + let wt = CGFloat(now - waveStart) + localX = -hwB * 1.08 + localY = hhB * 0.70 + sin(6 * wt) * 0.04 * bodyH + + } else { + // Rest: lower-side, clearly peeking behind body bottom + localX = CGFloat(sd) * hwB * 1.08 + localY = hhB * 0.70 + } + + // Apply body tilt to get world position + let cosT = cos(tilt), sinT = sin(tilt) + let worldX = cx + cosT * localX - sinT * localY + let worldY = cy + sinT * localX + cosT * localY + + // Draw + var handCtx = context + handCtx.translateBy(x: worldX, y: worldY) + if handRot != 0 { handCtx.rotate(by: .radians(handRot)) } + + let handRect = CGRect(x: -hew, y: -heh, width: hew * 2, height: heh * 2) + var handPath = Path() + handPath.addEllipse(in: handRect) + + // Fill with body material (same gradient as body) + if let bc = bodyColor { + let c0 = mix3(cgColorToTuple(bc), (1, 1, 1), 0.35) + let c1 = cgColorToTuple(bc) + handCtx.fill(handPath, with: .linearGradient( + Gradient(colors: [colorFromTuple(c0), colorFromTuple(c1)]), + startPoint: CGPoint(x: hew * 0.7, y: -heh * 0.85), + endPoint: CGPoint(x: -hew * 0.8, y: heh * 0.9) + )) + } else { + let c0 = cgColorToTuple(MochiConst.baseTop) + let c1 = cgColorToTuple(MochiConst.baseBottom) + handCtx.fill(handPath, with: .linearGradient( + Gradient(colors: [colorFromTuple(c0), colorFromTuple(c1)]), + startPoint: CGPoint(x: hew * 0.7, y: -heh * 0.85), + endPoint: CGPoint(x: -hew * 0.8, y: heh * 0.9) + )) + } + + // Subtle separation border — rgba(0,0,0,0.08) 1pt + handCtx.stroke(handPath, with: .color(Color.black.opacity(0.08)), lineWidth: 1) + } + } + + func drawHandsAndExtras(context: GraphicsContext, size: CGSize) { + let W = size.width + let H = size.height + let R = W * 0.3 + let rx = R * 1.14 + let ry = R * 0.88 + let cx = W / 2 + ox * R + let cy = H / 2 + particleOverhang / 2 + oy * R + R * 0.06 + + // Badge — hidden while morphing to mailbox + if let badge = badge, badgeS > 0.01, morph < 0.25 { + drawBadge(context: context, size: size, badge: badge, R: R, rx: rx, ry: ry, cx: cx, cy: cy) + } + + // Particles + drawParticles(context: context, size: size, R: R, cx: cx, cy: cy) + } + + func drawOutfitBehind(context: GraphicsContext, size: CGSize) { + guard outfit != .none, !isMini else { return } + let W = size.width, H = size.height, R = W * 0.3 + let cx = W / 2 + ox * R + let cy = H / 2 + particleOverhang / 2 + oy * R + R * 0.06 + // Rigid roll: accessories see roll=0 (they rotate with the body via context transform) + let outfitRoll: CGFloat = outfitPresence > 0.05 ? 0 : roll + let mH = MochiH(R: R, yaw: yaw, pitch: pitch, physDx: physDx, physDy: physDy, roll: outfitRoll) + drawOutfitBehindStatic(context: context, outfit: outfit, H: mH, + cx: cx, cy: cy, tilt: tilt, sx: sx, sy: sy, + roll: outfitRoll, morph: morph, isMini: isMini, + presence: outfitPresence, rollTurns: rollTurns) + } + + func drawOutfitFront(context: GraphicsContext, size: CGSize) { + guard outfit != .none, !isMini else { return } + let W = size.width, H = size.height, R = W * 0.3 + let cx = W / 2 + ox * R + let cy = H / 2 + particleOverhang / 2 + oy * R + R * 0.06 + // Rigid roll: accessories see roll=0 (they rotate with the body via context transform) + let outfitRoll: CGFloat = outfitPresence > 0.05 ? 0 : roll + let mH = MochiH(R: R, yaw: yaw, pitch: pitch, physDx: physDx, physDy: physDy, roll: outfitRoll) + drawOutfitFrontStatic(context: context, outfit: outfit, H: mH, + cx: cx, cy: cy, tilt: tilt, sx: sx, sy: sy, + roll: outfitRoll, morph: morph, isMini: isMini, + presence: outfitPresence, rollTurns: rollTurns) + } + + /// World-space center of Mochi's body (used by BotCanvasView for rigid-roll transform) + func bodyCenter(size: CGSize) -> CGPoint { + let W = size.width, R = W * 0.3 + let cx = W / 2 + ox * R + let cy = size.height / 2 + particleOverhang / 2 + oy * R + R * 0.06 + return CGPoint(x: cx, y: cy) + } + + var outfitFollowsRoll: Bool { + switch outfit { + case .sunglasses, .roundGlasses, .bow, .scarf, .pumpkin: return true + default: return false + } + } + + // MARK: - Private draw helpers + + private func mochiPath(rx: CGFloat, ry: CGFloat, morph: CGFloat, R: CGFloat) -> Path { + let n = 72 + let expN: CGFloat = 2.0 / 2.7 + // Target mailbox dims (spec: 1.0R wide, 0.94R tall, 0.42R corner radius) + let tw = R * 1.0 + let th = R * 0.94 + let tr = R * 0.42 + var path = Path() + for i in 0...n { + let a = CGFloat(i) / CGFloat(n) * .pi * 2 + let ca = cos(a), sa = sin(a) + let px0 = rx * (ca >= 0 ? pow(ca, expN) : -pow(-ca, expN)) + let py0 = ry * (sa >= 0 ? pow(sa, expN) : -pow(-sa, expN)) + let px: CGFloat + let py: CGFloat + if morph < 0.005 { + px = px0; py = py0 + } else { + let rr = rrPoint(ca: ca, sa: sa, W: tw, H: th, cr: tr) + px = lerp(px0, rr.x, morph) + py = lerp(py0, rr.y, morph) + } + if i == 0 { path.move(to: CGPoint(x: px, y: py)) } + else { path.addLine(to: CGPoint(x: px, y: py)) } + } + path.closeSubpath() + return path + } + + /// Ray-rounded-rect intersection: find the point on the rounded rect boundary in direction (ca, sa). + private func rrPoint(ca: CGFloat, sa: CGFloat, W: CGFloat, H: CGFloat, cr: CGFloat) -> CGPoint { + let eps: CGFloat = 1e-6 + let kx: CGFloat = ca >= 0 ? 1 : -1 + let ky: CGFloat = sa >= 0 ? 1 : -1 + let cx = kx * (W - cr) + let cy = ky * (H - cr) + + // Try corner arc + let dot = ca * cx + sa * cy + let disc = dot * dot - (cx*cx + cy*cy - cr*cr) + if disc >= 0 { + let t = dot + sqrt(disc) + if t > eps { + let px = ca * t, py = sa * t + if abs(px) >= W - cr - eps && abs(py) >= H - cr - eps { + return CGPoint(x: px, y: py) + } + } + } + + // Horizontal edge |y| = H + if abs(sa) > eps { + let t = (ky * H) / sa + if t > eps { + let x = ca * t + if abs(x) <= W - cr + eps { return CGPoint(x: x, y: ky * H) } + } + } + // Vertical edge |x| = W + if abs(ca) > eps { + let t = (kx * W) / ca + if t > eps { + let y = sa * t + if abs(y) <= H - cr + eps { return CGPoint(x: kx * W, y: y) } + } + } + + return CGPoint(x: kx * W, y: ky * H) + } + + private func drawBody(ctx: inout GraphicsContext, path: Path, R: CGFloat, rx: CGFloat, ry: CGFloat, + pumpkinColors: Bool = false) { + if let bc = bodyColor, !pumpkinColors { + // Mini bots: flat solid fill — no gradient, no reflection, no highlight + ctx.fill(path, with: .color(Color(cgColor: bc))) + } else { + // Main bot: linear gradient body + let top: Color = pumpkinColors ? Color(hex: "#FFA94D") : Color(red: 0.929, green: 0.929, blue: 0.937) + let bot: Color = pumpkinColors ? Color(hex: "#E8590C") : Color(red: 0.769, green: 0.773, blue: 0.792) + ctx.fill(path, with: .linearGradient( + Gradient(colors: [top, bot]), + startPoint: CGPoint(x: rx*0.7, y: -ry*0.85), + endPoint: CGPoint(x: -rx*0.8, y: ry*0.9) + )) + // State tint — fades out as morph increases (mailbox has no tint) + let effectiveTint = tint * (1 - morph) + if effectiveTint > 0.01 { + let tc = colorFromTuple(col) + ctx.fill(path, with: .linearGradient( + Gradient(stops: [ + .init(color: tc.opacity(Double(0.72 * effectiveTint)), location: 0), + .init(color: tc.opacity(0), location: 1) + ]), + startPoint: CGPoint(x: 0, y: ry), + endPoint: CGPoint(x: 0, y: -ry) + )) + } + // Shadow rim + ctx.fill(path, with: .radialGradient( + Gradient(stops: [ + .init(color: .clear, location: 0), + .init(color: .clear, location: 0.6), + .init(color: Color.black.opacity(0.2), location: 1) + ]), + center: .zero, startRadius: R*0.15, endRadius: R*1.25 + )) + // Highlight + ctx.fill(path, with: .radialGradient( + Gradient(stops: [ + .init(color: Color.white.opacity(0.55), location: 0), + .init(color: .clear, location: 1) + ]), + center: CGPoint(x: rx*0.34, y: -ry*0.46), + startRadius: 0, + endRadius: R*0.42 + )) + } + } + + private func drawBlush(ctx: inout GraphicsContext, path: Path, rx: CGFloat, ry: CGFloat, R: CGFloat, blush: CGFloat) { + ctx.clip(to: path) + let yOffset = sin(yaw) * rx * 0.8 + for sd in [-1.0, 1.0] { + let bx = CGFloat(sd) * rx * 0.55 + yOffset + let by = ry * 0.2 + var ellipse = Path() + ellipse.addEllipse(in: CGRect(x: bx - R*0.17, y: by - R*0.1, width: R*0.34, height: R*0.2)) + ctx.fill(ellipse, with: .color(Color(red: 1, green: 0.471, blue: 0.588, opacity: Double(0.5 * blush)))) + } + } + + private func drawEyes(ctx: inout GraphicsContext, path: Path, R: CGFloat, rx: CGFloat, ry: CGFloat) { + var shape = eyeOverride ?? cfg.eye + // Dance: happy eyes in calm states + if isDancing && dancingLevel > 0.15 && !isMini && (state == .idle || state == .finished) { + shape = .happy + } + // In box mode: cup eyes when file over box (slotHTarget set), happy arcs while chewing + if morph > 0.5 { + if isChewing { shape = .happy } + else if slotHTarget > 0.05 || slotH > 0.10 { shape = .cup } + } + ctx.clip(to: path) + + // Build eye frames: use mEyeFrames for position/foreshortening, but keep roll in eyePitch + // When an outfit is rigidly rotating, the whole body turns — eyes do NOT add roll + let rigidRoll = outfit != .none && outfitPresence > 0.05 + let mH = MochiH(R: R, yaw: yaw, pitch: pitch) + for f in mEyeFrames(mH) { + // Re-derive pitch with roll for the roll-through effect (illusion, outfit=none only) + var eyePitch = MochiConst.eyeP + pitch + (rigidRoll ? 0 : roll) + eyePitch = ((eyePitch + .pi).truncatingRemainder(dividingBy: .pi*2) + .pi*2).truncatingRemainder(dividingBy: .pi*2) - .pi + let cp = cos(eyePitch) + let eyeYaw = f.sd * MochiConst.eyeSp + yaw + guard cos(eyeYaw) * cp > 0.04 else { continue } + + let ey = f.y + (morph > 0 ? ry * 0.14 * morph : 0) + let fx = lerp(f.fx, 1, morph * 0.7) + let fy = lerp(f.fy, 1, morph * 0.7) + + let eyeMult: CGFloat = isMini ? 1.9 : 1.0 + let ew = R * MochiConst.eyeW * es * eyeMult + let eh = R * MochiConst.eyeH * es * eyeMult + + var eyeCtx = ctx + eyeCtx.translateBy(x: f.x, y: ey) + eyeCtx.scaleBy(x: fx, y: fy) + drawEyeShape(ctx: &eyeCtx, shape: shape, w: ew, h: eh, open: open, sd: f.sd, R: R) + } + } + + private func drawEyeShape(ctx: inout GraphicsContext, shape: EyeShape, w: CGFloat, h: CGFloat, open: CGFloat, sd: CGFloat, R: CGFloat) { + let ink = isMini ? Color(cgColor: MochiConst.miniInk) : Color(cgColor: MochiConst.ink) + let now = CGFloat(CACurrentMediaTime()) + + switch shape { + case .wide: + drawEyeShape(ctx: &ctx, shape: .pill, w: w*1.16, h: h*1.12, open: open, sd: sd, R: R) + + case .pill: + let hh = max(h * open, w * 0.3) + var p = Path() + p.addRoundedRect(in: CGRect(x: -w/2, y: -hh/2, width: w, height: hh), + cornerSize: CGSize(width: min(w/2, hh/2), height: min(w/2, hh/2))) + ctx.fill(p, with: .color(ink)) + + case .dot: + var p = Path() + p.addEllipse(in: CGRect(x: -w*0.45, y: -w*0.45, width: w*0.9, height: w*0.9)) + ctx.fill(p, with: .color(ink)) + + case .line: + ctx.rotate(by: .radians(-sd * 0.2)) + var p = Path() + p.addRoundedRect(in: CGRect(x: -w*0.78, y: -w*0.21, width: w*1.56, height: w*0.42), + cornerSize: CGSize(width: w*0.21, height: w*0.21)) + ctx.fill(p, with: .color(ink)) + + case .flat: + var p = Path() + p.addRoundedRect(in: CGRect(x: -w*0.72, y: -w*0.2, width: w*1.44, height: w*0.4), + cornerSize: CGSize(width: w*0.2, height: w*0.2)) + ctx.fill(p, with: .color(ink)) + + case .happy: + var p = Path() + p.addArc(center: CGPoint(x: 0, y: h*0.18), radius: w*0.82, + startAngle: .degrees(180 + 12), endAngle: .degrees(180 - 12), clockwise: true) + ctx.stroke(p, with: .color(ink), style: StrokeStyle(lineWidth: w*0.5, lineCap: .round)) + + case .closed: + var p = Path() + p.addArc(center: CGPoint(x: 0, y: -h*0.08), radius: w*0.78, + startAngle: .degrees(15), endAngle: .degrees(165), clockwise: false) + ctx.stroke(p, with: .color(ink), style: StrokeStyle(lineWidth: w*0.36, lineCap: .round)) + + case .spiral: + var p = Path() + var a: CGFloat = 0 + while a < 4.4 * .pi { + let r = w * 0.06 + a * w * 0.058 + let aa = a + now * 9 * sd + let px = cos(aa) * r + let py = sin(aa) * r + if a == 0 { p.move(to: CGPoint(x: px, y: py)) } + else { p.addLine(to: CGPoint(x: px, y: py)) } + a += 0.2 + } + ctx.stroke(p, with: .color(ink), style: StrokeStyle(lineWidth: w*0.22, lineCap: .round)) + + case .heart: + let heartPath = heartShape(size: w * 1.2) + ctx.fill(heartPath, with: .color(Color(hex: "#FF4D6D"))) + + case .star: + ctx.rotate(by: .radians(now * 1.5 * sd)) + let starPath = starShape(outer: w * 1.05, inner: w * 0.46) + ctx.fill(starPath, with: .color(Color(hex: "#F7B32B"))) + + case .tired: + var p1 = Path() + p1.addRoundedRect(in: CGRect(x: -w/2, y: -h*0.02, width: w, height: h*0.38), + cornerSize: CGSize(width: w/2, height: w/2)) + ctx.fill(p1, with: .color(ink)) + var p2 = Path() + p2.addRoundedRect(in: CGRect(x: -w*0.62, y: -h*0.1, width: w*1.24, height: w*0.22), + cornerSize: CGSize(width: w*0.11, height: w*0.11)) + ctx.fill(p2, with: .color(ink)) + + case .wink: + if sd < 0 { + let hh = max(h * open, w * 0.3) + var p = Path() + p.addRoundedRect(in: CGRect(x: -w/2, y: -hh/2, width: w, height: hh), + cornerSize: CGSize(width: min(w/2,hh/2), height: min(w/2,hh/2))) + ctx.fill(p, with: .color(ink)) + } else { + var p = Path() + p.addArc(center: CGPoint(x: 0, y: h*0.18), radius: w*0.82, + startAngle: .degrees(180+12), endAngle: .degrees(180-12), clockwise: true) + ctx.stroke(p, with: .color(ink), style: StrokeStyle(lineWidth: w*0.5, lineCap: .round)) + } + + case .cup: + // Flat top, rounded bottom corners (like a cup / U-shape) + let hh = max(h * open, w * 0.3) + let cr = min(w / 2, hh / 2) // bottom corner radius + var p = Path() + p.move(to: CGPoint(x: -w/2, y: -hh/2)) + p.addLine(to: CGPoint(x: w/2, y: -hh/2)) + p.addLine(to: CGPoint(x: w/2, y: hh/2 - cr)) + p.addQuadCurve(to: CGPoint(x: w/2 - cr, y: hh/2), + control: CGPoint(x: w/2, y: hh/2)) + p.addLine(to: CGPoint(x: -w/2 + cr, y: hh/2)) + p.addQuadCurve(to: CGPoint(x: -w/2, y: hh/2 - cr), + control: CGPoint(x: -w/2, y: hh/2)) + p.closeSubpath() + ctx.fill(p, with: .color(ink)) + } + } + + private func drawBadge(context: GraphicsContext, size: CGSize, badge: BadgeType, R: CGFloat, rx: CGFloat, ry: CGFloat, cx: CGFloat, cy: CGFloat) { + let bs = badgeS * (isMini ? 1.25 : 1) + let bx = cx - R * 0.72 * sx + let by = cy - R * 0.72 * sy + var ctx = context + ctx.translateBy(x: bx, y: by) + ctx.scaleBy(x: bs, y: bs) + let now = CGFloat(CACurrentMediaTime()) + + switch badge { + case .dots(let col): + if isMini { + // Mini: animated pulsing dot + let phase = (now * 2.4).truncatingRemainder(dividingBy: 1) + let dotR = R * 0.22 * (1 + 0.25 * sin(phase * .pi * 2)) + var outer = Path() + outer.addEllipse(in: CGRect(x: -R*0.2, y: -R*0.2, width: R*0.4, height: R*0.4)) + ctx.fill(outer, with: .color(.black)) + var dot = Path() + dot.addEllipse(in: CGRect(x: -dotR, y: -dotR, width: dotR*2, height: dotR*2)) + ctx.fill(dot, with: .color(Color(cgColor: col))) + } else { + // Pill badge with animated dots (prototype style) + let pw: CGFloat = R * 0.72 + let ph: CGFloat = R * 0.36 + var pill = Path() + pill.addRoundedRect(in: CGRect(x: -pw/2, y: -ph/2, width: pw, height: ph), + cornerSize: CGSize(width: ph/2, height: ph/2)) + ctx.fill(pill, with: .color(Color(cgColor: col))) + for i in 0..<3 { + let phase = ((now * 2.4 - CGFloat(i) * 0.22).truncatingRemainder(dividingBy: 1) + 1).truncatingRemainder(dividingBy: 1) + let dotR = R * 0.055 * (1 + 0.4 * max(0, sin(phase * .pi * 2))) + var dot = Path() + dot.addEllipse(in: CGRect(x: (CGFloat(i)-1)*R*0.18 - dotR, y: -dotR, width: dotR*2, height: dotR*2)) + ctx.fill(dot, with: .color(.white)) + } + } + + case .bang(let col), .question(let col): + var ring = Path() + ring.addEllipse(in: CGRect(x: -R*0.3, y: -R*0.3, width: R*0.6, height: R*0.6)) + ctx.fill(ring, with: .color(.black)) + var inner = Path() + inner.addEllipse(in: CGRect(x: -R*0.23, y: -R*0.23, width: R*0.46, height: R*0.46)) + ctx.fill(inner, with: .color(Color(cgColor: col))) + if !isMini { + let text = badge == .bang(col) ? "!" : "?" + ctx.draw(Text(text).font(.system(size: R*0.32, weight: .black)).foregroundColor(.white), + at: CGPoint(x: 0, y: R*0.02)) + } + + case .dot(let col): + var outer = Path() + outer.addEllipse(in: CGRect(x: -R*0.2, y: -R*0.2, width: R*0.4, height: R*0.4)) + ctx.fill(outer, with: .color(.black)) + var inner = Path() + inner.addEllipse(in: CGRect(x: -R*0.135, y: -R*0.135, width: R*0.27, height: R*0.27)) + ctx.fill(inner, with: .color(Color(cgColor: col))) + } + } + + private func drawParticles(context: GraphicsContext, size: CGSize, R: CGFloat, cx: CGFloat, cy: CGFloat) { + for p in particles { + guard p.age > 0 else { continue } + let k = CGFloat(p.age / p.life) + let a = k < 0.2 ? k / 0.2 : 1 - (k - 0.2) / 0.8 + let px = cx + (p.x + p.vx * CGFloat(p.age)) * R * 1.3 + let py = cy + (p.y + p.vy * CGFloat(p.age)) * R * 1.3 + let sz = R * p.size * (1 + k * 0.4) + + var pctx = context + pctx.translateBy(x: px, y: py) + pctx.opacity = Double(min(max(a, 0), 1)) + + switch p.type { + case .heart: + pctx.rotate(by: .radians(sin(CGFloat(p.age) * 6) * 0.3)) + pctx.fill(heartShape(size: sz), with: .color(Color(hex: "#FF4D6D"))) + case .star: + pctx.rotate(by: .radians(p.rot + CGFloat(p.age) * 2)) + pctx.fill(starShape(outer: sz, inner: sz*0.45), with: .color(Color(hex: "#F7B32B"))) + case .spark: + pctx.rotate(by: .radians(p.rot)) + pctx.fill(starShape(outer: sz*0.8, inner: sz*0.18), with: .color(.white)) + case .sweat: + var drop = Path() + drop.move(to: CGPoint(x: 0, y: -sz)) + drop.addQuadCurve(to: CGPoint(x: 0, y: sz*0.6), control: CGPoint(x: sz*0.8, y: sz*0.2)) + drop.addQuadCurve(to: CGPoint(x: 0, y: -sz), control: CGPoint(x: -sz*0.8, y: sz*0.2)) + pctx.fill(drop, with: .color(Color(hex: "#7CC7FF"))) + case .z: + pctx.draw(Text("z").font(.system(size: sz*1.9, weight: .bold)).foregroundColor(Color(red: 0.82, green: 0.86, blue: 0.92)), + at: .zero) + } + } + } + + // MARK: - Tween helpers + + func anim(_ key: String, keys: [TweenKey], onComplete: (() -> Void)? = nil) { + let current = getProperty(key) + tweens[key] = Tween(property: key, keys: keys, keyIndex: 0, + from: current, startTime: CACurrentMediaTime() * 1000, + onComplete: onComplete) + locks.insert(key) + } + + private func setTarget(key: String, value: CGFloat) { + guard !locks.contains(key) else { return } + switch key { + case "tint": tint += (value - tint) // immediate target, smoothed in update + case "tilt": tgTilt = value + default: break + } + } + + private func setProperty(_ key: String, value: CGFloat) { + switch key { + case "yaw": yaw = value + case "pitch": pitch = value + case "roll": roll = value + case "tilt": tilt = value + case "open": open = value + case "sx": sx = value + case "sy": sy = value + case "oy": oy = value + case "ox": ox = value + case "tint": tint = value + case "morph": morph = value + case "hands": hands = value + case "blush": blush = value + case "es": es = value + case "badgeS": badgeS = value + case "outfitPresence": outfitPresence = value + default: break + } + } + + private func getProperty(_ key: String) -> CGFloat { + switch key { + case "yaw": return yaw + case "pitch": return pitch + case "roll": return roll + case "tilt": return tilt + case "open": return open + case "sx": return sx + case "sy": return sy + case "oy": return oy + case "ox": return ox + case "tint": return tint + case "morph": return morph + case "hands": return hands + case "blush": return blush + case "es": return es + case "badgeS": return badgeS + case "outfitPresence": return outfitPresence + default: return 0 + } + } +} + +// MARK: - Math helpers + +func lerp(_ a: CGFloat, _ b: CGFloat, _ t: CGFloat) -> CGFloat { a + (b-a) * t } +private func clamp(_ v: CGFloat, _ lo: CGFloat, _ hi: CGFloat) -> CGFloat { max(lo, min(hi, v)) } + +private func cgColorToTuple(_ c: CGColor) -> (CGFloat, CGFloat, CGFloat) { + guard let comps = c.components, comps.count >= 3 else { return (1,1,1) } + return (comps[0], comps[1], comps[2]) +} + +private func mix3(_ a: (CGFloat,CGFloat,CGFloat), _ b: (CGFloat,CGFloat,CGFloat), _ t: CGFloat) -> (CGFloat,CGFloat,CGFloat) { + (lerp(a.0,b.0,t), lerp(a.1,b.1,t), lerp(a.2,b.2,t)) +} + +private func mixColor(_ a: (CGFloat,CGFloat,CGFloat), _ b: (CGFloat,CGFloat,CGFloat), _ t: CGFloat) -> (CGFloat,CGFloat,CGFloat) { + mix3(a, b, t) +} + +private func colorFromTuple(_ t: (CGFloat,CGFloat,CGFloat)) -> Color { + Color(red: Double(t.0), green: Double(t.1), blue: Double(t.2)) +} + +private func badgeString(_ b: BadgeType?) -> String { + guard let b else { return "none" } + func hex(_ c: CGColor) -> String { + guard let k = c.components, k.count >= 3 else { return "?" } + return "\(Int(k[0]*255)).\(Int(k[1]*255)).\(Int(k[2]*255))" + } + switch b { + case .dots(let c): return "dots-\(hex(c))" + case .bang(let c): return "bang-\(hex(c))" + case .question(let c): return "q-\(hex(c))" + case .dot(let c): return "dot-\(hex(c))" + } +} + +private func emoteEyeShape(_ e: BotEmote) -> EyeShape { + switch e { + case .love: return .heart + case .surprised: return .dot + case .proud: return .star + case .wink: return .wink + case .yawn: return .tired + case .happy: return .happy + case .annoyed: return .line + } +} + +// MARK: - Shape helpers + +private func heartShape(size s: CGFloat) -> Path { + var p = Path() + p.move(to: CGPoint(x: 0, y: s * 0.38)) + p.addCurve(to: CGPoint(x: 0, y: -s * 0.38), + control1: CGPoint(x: -s * 1.05, y: -s * 0.15), + control2: CGPoint(x: -s * 0.5, y: -s * 0.95)) + p.addCurve(to: CGPoint(x: 0, y: s * 0.38), + control1: CGPoint(x: s * 0.5, y: -s * 0.95), + control2: CGPoint(x: s * 1.05, y: -s * 0.15)) + p.closeSubpath() + return p +} + +private func starShape(outer ro: CGFloat, inner ri: CGFloat) -> Path { + var p = Path() + for i in 0..<10 { + let r = i.isMultiple(of: 2) ? ro : ri + let a = -.pi/2 + CGFloat(i) * .pi/5 + let pt = CGPoint(x: cos(a) * r, y: sin(a) * r) + if i == 0 { p.move(to: pt) } else { p.addLine(to: pt) } + } + p.closeSubpath() + return p +} + +// Equatable for BadgeType (needed for comparing) +extension BadgeType: Equatable { + static func == (lhs: BadgeType, rhs: BadgeType) -> Bool { + switch (lhs, rhs) { + case (.dots, .dots): return true + case (.bang, .bang): return true + case (.question, .question): return true + case (.dot, .dot): return true + default: return false + } + } +} diff --git a/NotchBuddy/Sources/CoucouKit/ColorHex.swift b/NotchBuddy/Sources/CoucouKit/ColorHex.swift new file mode 100644 index 0000000..161a5aa --- /dev/null +++ b/NotchBuddy/Sources/CoucouKit/ColorHex.swift @@ -0,0 +1,43 @@ +import SwiftUI +import CoreGraphics + +// Hex color helpers shared by the Mac app and the iPhone app. + +// MARK: - Color from hex string + +extension Color { + init(hex: String) { + let h = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#")) + let val = UInt64(h, radix: 16) ?? 0 + let r = Double((val >> 16) & 0xFF) / 255 + let g = Double((val >> 8) & 0xFF) / 255 + let b = Double( val & 0xFF) / 255 + self.init(red: r, green: g, blue: b) + } +} + +// MARK: - CGColor from hex string + +func cgColorFromHex(_ hex: String) -> CGColor? { + let h = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#")) + guard let val = UInt64(h, radix: 16) else { return nil } + let r = CGFloat((val >> 16) & 0xFF) / 255 + let g = CGFloat((val >> 8) & 0xFF) / 255 + let b = CGFloat( val & 0xFF) / 255 + return CGColor(red: r, green: g, blue: b, alpha: 1) +} + +extension CGColor { + static func from(_ hex: String) -> CGColor { + cgColorFromHex(hex) ?? CGColor(gray: 0.5, alpha: 1) + } +} + +#if !os(macOS) +// CGColor.white / .black / .clear only exist on macOS; BotEngine uses them. +extension CGColor { + static var white: CGColor { CGColor(gray: 1, alpha: 1) } + static var black: CGColor { CGColor(gray: 0, alpha: 1) } + static var clear: CGColor { CGColor(gray: 0, alpha: 0) } +} +#endif diff --git a/NotchBuddy/Sources/CoucouKit/DiffEngine.swift b/NotchBuddy/Sources/CoucouKit/DiffEngine.swift new file mode 100644 index 0000000..2b71f00 --- /dev/null +++ b/NotchBuddy/Sources/CoucouKit/DiffEngine.swift @@ -0,0 +1,301 @@ +import Foundation + +// MARK: - Types + +struct DiffLine: Equatable { + enum Kind: Equatable { case context, added, removed } + var kind: Kind + var text: String + var origLine: Int // 1-based; -1 for pure adds + var newLine: Int // 1-based; -1 for pure removes +} + +struct DiffHunk: Equatable { + var origStart: Int + var newStart: Int + var lines: [DiffLine] +} + +struct FileDiff: Equatable { + var id: Int = 0 // stable identifier assigned by AppState.appendSessionDiff + var path: String + var added: Int + var removed: Int + var hunks: [DiffHunk] + var tooLarge: Bool + var isNewFile: Bool // true when produced by DiffEngine.fromNew (Write tool) + + var name: String { URL(fileURLWithPath: path).lastPathComponent } + + static let maxBytes = 200 * 1024 + static let maxLines = 4000 +} + +// MARK: - DiffEngine + +enum DiffEngine { + + // MARK: Public API + + static func fromEdit(old: String, new: String, path: String) -> FileDiff { + // Size guard + if old.utf8.count + new.utf8.count > FileDiff.maxBytes { + return countFallback(old: old, new: new, path: path, tooLarge: true) + } + let oldLines = splitLines(old) + let newLines = splitLines(new) + if oldLines.count + newLines.count > FileDiff.maxLines { + return countFallback(old: old, new: new, path: path, tooLarge: true) + } + // LCS is O(m*n) — bail out before quadratic blowup + if oldLines.count * newLines.count > 1_000_000 { + return countFallback(old: old, new: new, path: path, tooLarge: true) + } + let flat = buildDiffLines(oldLines: oldLines, newLines: newLines) + let hunks = buildHunks(from: flat, context: 3) + let added = flat.filter { $0.kind == .added }.count + let removed = flat.filter { $0.kind == .removed }.count + return FileDiff(path: path, added: added, removed: removed, hunks: hunks, tooLarge: false, isNewFile: false) + } + + static func fromNew(content: String, path: String) -> FileDiff { + // Size guard (same limits as fromEdit) + if content.utf8.count > FileDiff.maxBytes { + let lineCount = content.components(separatedBy: "\n").count + return FileDiff(path: path, added: lineCount, removed: 0, hunks: [], tooLarge: true, isNewFile: true) + } + let lines = splitLines(content) + if lines.count > FileDiff.maxLines { + return FileDiff(path: path, added: lines.count, removed: 0, hunks: [], tooLarge: true, isNewFile: true) + } + let diffLines = lines.enumerated().map { (i, text) in + DiffLine(kind: .added, text: text, origLine: -1, newLine: i + 1) + } + let hunk = diffLines.isEmpty ? nil : DiffHunk(origStart: 0, newStart: 1, lines: diffLines) + return FileDiff( + path: path, + added: diffLines.count, + removed: 0, + hunks: hunk.map { [$0] } ?? [], + tooLarge: false, + isNewFile: true + ) + } + + // MARK: - Line splitting + + private static func splitLines(_ text: String) -> [String] { + // Normalize CRLF → LF + let normalized = text.replacingOccurrences(of: "\r\n", with: "\n") + var parts = normalized.components(separatedBy: "\n") + // Drop trailing empty element that results from a trailing newline + if parts.last == "" { parts.removeLast() } + return parts + } + + // MARK: - LCS-based diff + + private static func buildDiffLines(oldLines: [String], newLines: [String]) -> [DiffLine] { + let m = oldLines.count + let n = newLines.count + + // Build LCS DP table + // dp[i][j] = LCS length of oldLines[0.. 0 && j > 0 { + if oldLines[i - 1] == newLines[j - 1] { + matches.append((i - 1, j - 1)) + i -= 1; j -= 1 + } else if dp[i - 1][j] >= dp[i][j - 1] { + i -= 1 + } else { + j -= 1 + } + } + matches.reverse() + + // Build flat diff from LCS pairs + var result: [DiffLine] = [] + var prevOld = -1 + var prevNew = -1 + + for (oi, ni) in matches { + // Removed lines between last match and this old match + for k in (prevOld + 1).. [DiffHunk] { + guard !lines.isEmpty else { return [] } + + // Find indices of changed lines + var changedIndices: [Int] = [] + for (i, line) in lines.enumerated() { + if line.kind != .context { changedIndices.append(i) } + } + guard !changedIndices.isEmpty else { return [] } + + // Expand ±context around each changed line + let ranges: [(Int, Int)] = changedIndices.map { + (max(0, $0 - context), min(lines.count - 1, $0 + context)) + } + + // Merge overlapping ranges + var merged: [(Int, Int)] = [] + for r in ranges { + if let last = merged.last, r.0 <= last.1 + 1 { + merged[merged.count - 1] = (last.0, max(last.1, r.1)) + } else { + merged.append(r) + } + } + + // Build hunks + var hunks: [DiffHunk] = [] + for (start, end) in merged { + let hunkLines = Array(lines[start...end]) + let origStart = hunkLines.first(where: { $0.origLine > 0 })?.origLine ?? 1 + let newStart = hunkLines.first(where: { $0.newLine > 0 })?.newLine ?? 1 + hunks.append(DiffHunk(origStart: origStart, newStart: newStart, lines: hunkLines)) + } + return hunks + } + + // MARK: - Count fallback (tooLarge) + + private static func countFallback(old: String, new: String, path: String, tooLarge: Bool) -> FileDiff { + let oldLines = old.components(separatedBy: "\n") + let newLines = new.components(separatedBy: "\n") + let oldSet = Set(oldLines) + let newSet = Set(newLines) + let added = newLines.filter { !$0.isEmpty && !oldSet.contains($0) }.count + let removed = oldLines.filter { !$0.isEmpty && !newSet.contains($0) }.count + return FileDiff(path: path, added: added, removed: removed, hunks: [], tooLarge: tooLarge, isNewFile: false) + } + + // MARK: - toOneLine + + /// Converts a possibly multi-line, markdown-formatted string to a single line of plain text. + /// Uses only the first non-empty paragraph (stops at blank line, horizontal rule, or table row). + /// Strips `**`, `__`, backticks, leading `#`, and leading bullet markers. + static func toOneLine(_ text: String, maxChars: Int = 200) -> String { + let lines = text.components(separatedBy: "\n") + + // Split into paragraphs. Separators: blank line, HR (3+ repeated -/*/_ chars), table row (starts with |). + var paragraphs: [[String]] = [] + var current: [String] = [] + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + let isHR = trimmed.count >= 3 && (trimmed.allSatisfy { $0 == "-" } || + trimmed.allSatisfy { $0 == "*" } || + trimmed.allSatisfy { $0 == "_" }) + let isSep = trimmed.isEmpty || isHR || trimmed.hasPrefix("|") + if isSep { + if !current.isEmpty { paragraphs.append(current); current = [] } + } else { + current.append(line) + } + } + if !current.isEmpty { paragraphs.append(current) } + + // Find first paragraph that yields non-empty text after cleaning. + for paraLines in paragraphs { + var s = paraLines.joined(separator: "\n") + s = s.replacingOccurrences(of: "**", with: "") + s = s.replacingOccurrences(of: "__", with: "") + s = s.replacingOccurrences(of: "`", with: "") + let processed: [String] = s.components(separatedBy: "\n").compactMap { line in + var l = line + while l.hasPrefix("#") { l = String(l.dropFirst()) } + l = l.trimmingCharacters(in: .whitespaces) + // Strip leading bullet markers: -, *, •, or N. (ordered list) + if l.hasPrefix("- ") || l.hasPrefix("* ") || l.hasPrefix("• ") { + l = String(l.dropFirst(2)) + } else if let m = l.range(of: #"^\d+\.\s+"#, options: .regularExpression) { + l = String(l[m.upperBound...]) + } + let trimmed = l.trimmingCharacters(in: .whitespaces) + return trimmed.isEmpty ? nil : trimmed + } + let joined = processed.joined(separator: " ") + let collapsed = joined.components(separatedBy: .whitespaces) + .filter { !$0.isEmpty } + .joined(separator: " ") + if !collapsed.isEmpty { return String(collapsed.prefix(maxChars)) } + } + return "" + } +} + +// MARK: - String diff step encoding + +public extension String { + /// Private-use character used as the diff step marker prefix. + static let diffStepMarker = "\u{E001}" + + /// True if this step string encodes a file diff. + var isDiffStep: Bool { hasPrefix(Self.diffStepMarker) } + + /// Parses a diff step string. + /// Format: `"\u{E001}\t::"` + func parseDiffStep() -> (filename: String, added: Int, removed: Int, diffId: Int)? { + guard isDiffStep else { return nil } + let body = String(dropFirst()) // drop the marker character + guard let tabIdx = body.firstIndex(of: "\t") else { return nil } + let filename = String(body[body.startIndex.. String { + "\(diffStepMarker)\(filename)\t\(added):\(removed):\(diffId)" + } +} diff --git a/NotchBuddy/Sources/CoucouKit/IslandScreenGeometry.swift b/NotchBuddy/Sources/CoucouKit/IslandScreenGeometry.swift new file mode 100644 index 0000000..a06f8a4 --- /dev/null +++ b/NotchBuddy/Sources/CoucouKit/IslandScreenGeometry.swift @@ -0,0 +1,42 @@ +import Foundation + +/// Resting island dimensions, using a physical notch only when the screen has one. +struct IslandScreenGeometry { + static let fallbackNotchWidth: CGFloat = 184 + private static let noNotchWidth: CGFloat = 80 + private static let noNotchHeight: CGFloat = 24 + + let hasNotch: Bool + let width: CGFloat + let height: CGFloat + + init(screenWidth: CGFloat, safeAreaTop: CGFloat, + auxiliaryLeftWidth: CGFloat?, auxiliaryRightWidth: CGFloat?, + menuBarHeight: CGFloat) { + hasNotch = safeAreaTop > 0 + if hasNotch { + if let left = auxiliaryLeftWidth, let right = auxiliaryRightWidth { + let measuredWidth = screenWidth - left - right + width = measuredWidth > 0 && measuredWidth < screenWidth + ? measuredWidth : Self.fallbackNotchWidth + } else { + width = Self.fallbackNotchWidth + } + height = safeAreaTop + } else { + width = Self.noNotchWidth + height = min(Self.noNotchHeight, menuBarHeight) + } + } +} + +/// Shared by the compact view and the greeting's collapse destination. +struct IslandRestingLayout { + let width: CGFloat + let height: CGFloat + + var botDiameter: CGFloat { min(20, max(0, height - 6)) } + var botCenterY: CGFloat { height / 2 } + var miniGridScale: CGFloat { min(1, max(0, height - 4) / 28) } + var miniGridCenterX: CGFloat { width - 40 } +} diff --git a/NotchBuddy/Sources/CoucouKit/IslandTypes.swift b/NotchBuddy/Sources/CoucouKit/IslandTypes.swift new file mode 100644 index 0000000..60fbbe1 --- /dev/null +++ b/NotchBuddy/Sources/CoucouKit/IslandTypes.swift @@ -0,0 +1,230 @@ +import Foundation + +// MARK: - Island Mode + +enum IslandMode: String, CaseIterable { + case hidden, compact, expanded +} + +// MARK: - Island View + +enum IslandView: String, CaseIterable { + case overview, empty, approval, question, error, finished + case confused, upload, uploading, choose, mail, prompt + case searching, result, note, settings, greeting, wardrobe +} + +// MARK: - Bot State + +enum BotState: String, CaseIterable { + case idle, working, thinking, searching + case approval, question, error, finished + case ratelimit, sleeping, dizzy +} + +// MARK: - Bot Emote + +enum BotEmote: String, CaseIterable { + case love, surprised, proud, wink, yawn, happy, annoyed +} + +// MARK: - Approval info (pending PermissionRequest from Claude Code) + +struct ApprovalInfo: Sendable { + var sessionId: String + var tool: String + var command: String + /// tool_input serialized to JSON with sortedKeys, "" if absent — used to match PostToolUse. + var inputKey: String + /// Pill that owns this approval: "integration_claude", "agent_cursor", or "agent_codex". + var pillId: String +} + +// MARK: - Pill badge (shown on pill edge when non-focused task has an alert) + +enum PillBadge { case approval, finished, error } + +// MARK: - Agent Task + +struct AgentTask: Identifiable, Equatable { + var id: String + var name: String + var color: String // hex + var state: BotState + var stepIndex: Int = 0 + var steps: [String] + var source: AgentSource + var isIntegration: Bool = false // true for persistent integration pills + var emote: BotEmote? = nil + var miniEye: EyeShape? = nil + var pillBadge: PillBadge? = nil // alert badge shown on pill when not focused + var sessionCwd: String? = nil // last known working directory (Claude Code sessions) + var finalLine: String? = nil // last assistant message shown as static text after Stop +} + +enum AgentSource: Equatable { + case claudeCode + case n8n + case agent // third-party agent via coucou_agent field +} + +// MARK: - Chat provider + +enum ChatProvider: String, CaseIterable, Codable { + case anthropic = "anthropic" + case google = "google" + case openai = "openai" + case ollama = "ollama" + case lmstudio = "lmstudio" + + var displayName: String { + switch self { + case .anthropic: "Anthropic" + case .google: "Google" + case .openai: "OpenAI" + case .ollama: "Ollama" + case .lmstudio: "LM Studio" + } + } + + var accentHex: String { + switch self { + case .anthropic: "#E07950" + case .google: "#4285F4" + case .openai: "#10A37F" + case .ollama: "#FACC15" + case .lmstudio: "#A3E635" + } + } + + var defaultModel: String { + switch self { + case .anthropic: "claude-sonnet-4-6" + case .google: "gemini-2.0-flash" + case .openai: "gpt-4o" + case .ollama: "llama3.2" + case .lmstudio: "local-model" + } + } + + var keychainKey: String { + switch self { + case .anthropic: "anthropic-api-key" + case .google: "google-api-key" + case .openai: "openai-api-key" + case .ollama: "" + case .lmstudio: "" + } + } + + var isLocal: Bool { + self == .ollama || self == .lmstudio + } + + var pillID: String { + switch self { + case .anthropic: "ai_anthropic" + case .google: "ai_google" + case .openai: "ai_openai" + case .ollama: "ai_ollama" + case .lmstudio: "ai_lmstudio" + } + } + + init?(pillID: String) { + switch pillID { + case "ai_anthropic": self = .anthropic + case "ai_google": self = .google + case "ai_openai": self = .openai + case "ai_ollama": self = .ollama + case "ai_lmstudio": self = .lmstudio + default: return nil + } + } +} + +// MARK: - View dimensions (from VIEWS in prototype) + +struct ViewLayout { + let height: CGFloat + let botX: CGFloat + let botY: CGFloat? // nil = auto-centered + let botDiameter: CGFloat + let agentMode: AgentLayoutMode +} + +enum AgentLayoutMode { + case none, grid, pills, column +} + +// MARK: - Constants (from NW, NH, EW in prototype) + +enum IslandConst { + static let notchWidth: CGFloat = IslandScreenGeometry.fallbackNotchWidth + static let notchHeight: CGFloat = 32 + static let expandedWidth: CGFloat = 640 + static let earRadius: CGFloat = 14 + static let roundedCorner: CGFloat = 14 // hidden/peek/compact + static let expandedCorner: CGFloat = 22 + + static let viewLayouts: [IslandView: ViewLayout] = [ + // Home is the reference: height 150 + .overview: ViewLayout(height: 160, botX: 68, botY: nil, botDiameter: 58, agentMode: .pills), + // All non-chat views match home height (150) — law + .empty: ViewLayout(height: 160, botX: 70, botY: nil, botDiameter: 62, agentMode: .none), + .approval: ViewLayout(height: 160, botX: 62, botY: nil, botDiameter: 56, agentMode: .column), + .question: ViewLayout(height: 160, botX: 62, botY: nil, botDiameter: 56, agentMode: .column), + .error: ViewLayout(height: 160, botX: 62, botY: nil, botDiameter: 58, agentMode: .column), + .finished: ViewLayout(height: 160, botX: 62, botY: nil, botDiameter: 58, agentMode: .column), + .confused: ViewLayout(height: 160, botX: 76, botY: nil, botDiameter: 66, agentMode: .column), + .upload: ViewLayout(height: 176, botX: 140, botY: 104, botDiameter: 62, agentMode: .column), + .uploading: ViewLayout(height: 176, botX: 46, botY: 118, botDiameter: 20, agentMode: .none), + .choose: ViewLayout(height: 176, botX: 60, botY: 101, botDiameter: 52, agentMode: .column), + .mail: ViewLayout(height: 240, botX: 56, botY: nil, botDiameter: 46, agentMode: .column), + .prompt: ViewLayout(height: 160, botX: 52, botY: nil, botDiameter: 44, agentMode: .column), + .searching: ViewLayout(height: 160, botX: 52, botY: nil, botDiameter: 44, agentMode: .column), + .result: ViewLayout(height: 160, botX: 52, botY: nil, botDiameter: 44, agentMode: .column), + .note: ViewLayout(height: 160, botX: 60, botY: nil, botDiameter: 50, agentMode: .column), + .settings: ViewLayout(height: 160, botX: 54, botY: nil, botDiameter: 46, agentMode: .none), + // Greeting: bot drawn by GreetingCanvasView; no BotPlacement needed + .greeting: ViewLayout(height: 150, botX: 320, botY: 90, botDiameter: 0, agentMode: .none), + .wardrobe: ViewLayout(height: 160, botX: 68, botY: nil, botDiameter: 58, agentMode: .none), + ] + + // Project colors — keyed by lowercase display name or slug + static let projectColors: [String: String] = [ + "korus": "#FF5A4E", + "sbe hub": "#2EC4A0", + "morning ai brief": "#F29B38", + "publication ig": "#7C5CFF", + "ig post": "#7C5CFF", + "louisraille.fr": "#38BDF8", + "louisraille": "#38BDF8", + "notch buddy": "#EC4899", + "notch-buddy": "#EC4899", + "notchbuddy": "#EC4899", + ] + + static let fallbackColors = ["#22C55E", "#EAB308", "#60A5FA", "#E879F9"] + + /// Returns the fixed project color for a display name, or a stable fallback. + static func colorForProject(_ name: String) -> String { + let key = name.lowercased().trimmingCharacters(in: .whitespaces) + if let c = projectColors[key] { return c } + // partial match (e.g. "korus-api" → "korus") + for (k, c) in projectColors where key.hasPrefix(k) || key.contains(k) { return c } + return fallbackColors[abs(name.hashValue) % fallbackColors.count] + } + + // State card wash colors (radial gradient from bottom) + static let washColors: [IslandView: String] = [ + .approval: "rgba(245,165,36,0.42)", + .question: "rgba(34,211,238,0.38)", + .error: "rgba(244,80,94,0.55)", + .finished: "rgba(52,211,153,0.5)", + .confused: "rgba(244,114,182,0.55)", + .searching: "rgba(99,102,241,0.5)", + .result: "rgba(52,211,153,0.22)", + .prompt: "rgba(99,102,241,0.22)", + ] +} diff --git a/NotchBuddy/Sources/CoucouKit/MochiActivityState.swift b/NotchBuddy/Sources/CoucouKit/MochiActivityState.swift new file mode 100644 index 0000000..4bb5130 --- /dev/null +++ b/NotchBuddy/Sources/CoucouKit/MochiActivityState.swift @@ -0,0 +1,69 @@ +import Foundation + +// What the iPhone's Live Activity (Lock Screen and Dynamic Island) shows: +// the most urgent agent session, while the Mac is locked. The Mac builds it, +// the relay forwards it as the Live Activity's "content-state", ActivityKit +// decodes it on the iPhone. It passes through the relay and Apple's push +// service, so it holds no project name, step text, command or path: only the +// agent, its state and counts. + +struct MochiActivityState: Codable, Hashable, Sendable { + var pillId: String + var agent: String // "VS Code", "Codex"… + var color: String // agent color, hex + var state: String // BotState raw value + var statusText: String // "working · 3/7", "waiting for your OK"… + var tone: String // waiting, question, error, working, done, idle + var stepIndex: Int + var stepCount: Int + var others: Int // other sessions still going + /// When Mochi left for the iPhone (Unix seconds): the iPhone counts the + /// time from it, live. Optional so older pushes still decode. + var since: Int? = nil + /// The fingerprint of the command waiting for your OK (an opaque hash, + /// never the command): the Lock Screen's Allow and Deny answer this one. + var approval: String? = nil + + var botState: BotState { BotState(rawValue: state) ?? .idle } + var isActive: Bool { ["waiting", "question", "working", "error"].contains(tone) } + + /// Lower = more urgent, same order as the iPhone list. + static func urgency(state: BotState, waitingForOK: Bool, hasQuestion: Bool) -> Int { + if waitingForOK || state == .approval { return 0 } + if hasQuestion || state == .question { return 1 } + switch state { + case .error, .ratelimit, .dizzy: return 2 + case .working, .thinking, .searching: return 3 + case .finished: return 4 + default: return 5 + } + } + + static func tone(urgency: Int) -> String { + ["waiting", "question", "error", "working", "done"].indices.contains(urgency) + ? ["waiting", "question", "error", "working", "done"][urgency] : "idle" + } + + static func statusText(state: BotState, urgency: Int, stepIndex: Int, stepCount: Int) -> String { + switch urgency { + case 0: return "waiting for your OK" + case 1: return "has a question" + default: break + } + switch state { + case .working, .thinking, .searching: + return stepCount == 0 ? "working" : "working · \(min(stepIndex + 1, stepCount))/\(stepCount)" + case .finished: return "✓ done" + case .error: return "error" + case .ratelimit: return "rate limited" + case .sleeping: return "asleep" + case .dizzy: return "dizzy" + default: return "idle" + } + } + + static let placeholder = MochiActivityState( + pillId: "integration_claude", agent: "VS Code", color: "#4A86E8", + state: "working", statusText: "working · 3/7", tone: "working", + stepIndex: 2, stepCount: 7, others: 1) +} diff --git a/NotchBuddy/Sources/CoucouKit/MochiOutfitDrawing.swift b/NotchBuddy/Sources/CoucouKit/MochiOutfitDrawing.swift new file mode 100644 index 0000000..3da2227 --- /dev/null +++ b/NotchBuddy/Sources/CoucouKit/MochiOutfitDrawing.swift @@ -0,0 +1,1464 @@ +import SwiftUI +import CoreGraphics + +// MARK: - Constants (mirror JS) + +private let kEXP: CGFloat = 2.7 +private let kVIEW_TILT: CGFloat = -0.30 +private let kACC_PITCH: CGFloat = 0.4 +private let kEYE_W: CGFloat = 0.25 +private let kEYE_H: CGFloat = 0.27 +private let kEYE_SP: CGFloat = 0.37 +private let kEYE_P: CGFloat = -0.12 + +// MARK: - MochiH (head geometry + physics) + +struct MochiH { + let R, rx, ry: CGFloat + let yaw, pitch: CGFloat + let view: CGFloat // = VIEW_TILT (-0.30) + let physDx, physDy: CGFloat + let roll: CGFloat + + init(R: CGFloat, yaw: CGFloat = 0, pitch: CGFloat = 0, + physDx: CGFloat = 0, physDy: CGFloat = 0, roll: CGFloat = 0) { + self.R = R + self.rx = R * 1.14 + self.ry = R * 0.88 + self.yaw = yaw + self.pitch = pitch + self.view = kVIEW_TILT + self.physDx = physDx + self.physDy = physDy + self.roll = roll + } +} + +// MARK: - EyeFrame (replaces mochiEyePositions) + +struct EyeFrame { + let sd: CGFloat // -1 left, +1 right + let x, y: CGFloat + let fx, fy: CGFloat + let visible: Bool + let w, h: CGFloat +} + +func mEyeFrames(_ H: MochiH) -> [EyeFrame] { + var out: [EyeFrame] = [] + for sdD: Double in [-1.0, 1.0] { + let sd = CGFloat(sdD) + let eyeYaw = sd * kEYE_SP + H.yaw + let eyePitch = kEYE_P + H.pitch + let cp = cos(eyePitch) + let visible = cos(eyeYaw) * cp > 0.04 + out.append(EyeFrame( + sd: sd, + x: sin(eyeYaw) * cp * H.rx, + y: -sin(eyePitch) * H.ry, + fx: max(0.18, cos(eyeYaw)), + fy: max(0.18, cp), + visible: visible, + w: H.R * kEYE_W, + h: H.R * kEYE_H + )) + } + return out +} + +/// Backward-compat shim (used by existing callers that haven't been updated yet). +func mochiEyePositions(yaw: CGFloat, pitch: CGFloat, rx: CGFloat, ry: CGFloat) + -> [(ex: CGFloat, ey: CGFloat, fx: CGFloat, fy: CGFloat)] { + let H = MochiH(R: rx / 1.14, yaw: yaw, pitch: pitch) + return mEyeFrames(H).filter { $0.visible }.map { (ex: $0.x, ey: $0.y, fx: $0.fx, fy: $0.fy) } +} + +// MARK: - P3 (projected screen point with depth) + +private struct P3 { + let x, y, z: CGFloat +} + +// MARK: - 3D helpers (faithful port) + +// ringR(y) → radius of horizontal ring at head-local y +private func mRingR(_ y: CGFloat) -> CGFloat { + let a = min(1, abs(y)) + return pow(1 - pow(a, kEXP), 1 / kEXP) +} + +// rot(p, yaw, pitch) — rotate head-local (x right, y up, z viewer) by yaw then pitch +private func mRot3(_ p: (CGFloat, CGFloat, CGFloat), yaw: CGFloat, pitch: CGFloat) -> (CGFloat, CGFloat, CGFloat) { + var (x, y, z) = p + let cy = cos(yaw), sy = sin(yaw) + let x1 = x * cy + z * sy + let z1 = -x * sy + z * cy + x = x1 + let cp = cos(pitch), sp = sin(pitch) + let y2 = y * cp + z1 * sp + let z2 = -y * sp + z1 * cp + return (x, y2, z2) +} + +// proj(H, p) — head-local -> screen (body space) +private func mProj(_ H: MochiH, _ p: (CGFloat, CGFloat, CGFloat)) -> P3 { + let r = mRot3(p, yaw: H.yaw, pitch: H.view + H.pitch * kACC_PITCH) + return P3(x: r.0 * H.rx, y: -r.1 * H.ry, z: r.2) +} + +// surf(y, lon, s) — point on head surface at height y, longitude lon +private func mSurf(_ y: CGFloat, _ lon: CGFloat, _ s: CGFloat = 1) -> (CGFloat, CGFloat, CGFloat) { + let r = mRingR(y) * s + return (r * sin(lon), y, r * cos(lon)) +} + +// Shared: given projected ring points, return front arc ordered L→R via silhouette tangents +private func frontSilhouetteArc(_ pts: [P3]) -> [P3] { + let n = pts.count + guard n > 1 else { return pts } + let minIdx = pts.indices.min(by: { pts[$0].x < pts[$1].x })! + let maxIdx = pts.indices.max(by: { pts[$0].x < pts[$1].x })! + guard minIdx != maxIdx else { return [pts[minIdx]] } + // Arc A: minIdx→maxIdx going forward (+1 steps) + var arcA: [P3] = []; var i = minIdx + while true { arcA.append(pts[i]); if i == maxIdx { break }; i = (i+1)%n; if arcA.count > n { break } } + // Arc B: minIdx→maxIdx going backward (-1 steps) + var arcB: [P3] = []; i = minIdx + while true { arcB.append(pts[i]); if i == maxIdx { break }; i = (i-1+n)%n; if arcB.count > n { break } } + let zA = arcA.reduce(0) { $0+$1.z } / CGFloat(max(1, arcA.count)) + let zB = arcB.reduce(0) { $0+$1.z } / CGFloat(max(1, arcB.count)) + return zA >= zB ? arcA : arcB +} + +// frontArc(H, y, s) — front arc of ring at height y, ordered left→right (silhouette tangent method) +private func mFrontArc(_ H: MochiH, y: CGFloat, s: CGFloat) -> [P3] { + let n = 120 + let pts: [P3] = (0.. P3 { + let r = mRot3(p, yaw: H.yaw, pitch: H.view + H.pitch * kACC_PITCH + H.roll) + return P3(x: r.0 * H.rx, y: -r.1 * H.ry, z: r.2) +} + +// frontArc using roll projection (for scarf) +private func mFrontArcRoll(_ H: MochiH, y: CGFloat, s: CGFloat) -> [P3] { + let n = 120 + let pts: [P3] = (0.. Path { + let arc = mFrontArc(H, y: y, s: s) + guard !arc.isEmpty else { return Path() } + var p = Path() + p.move(to: CGPoint(x: arc[0].x - H.rx, y: arc[0].y)) + for q in arc { p.addLine(to: CGPoint(x: q.x, y: q.y)) } + p.addLine(to: CGPoint(x: arc.last!.x + H.rx, y: arc.last!.y)) + p.addLine(to: CGPoint(x: H.rx * 2, y: -H.ry * extraTop)) + p.addLine(to: CGPoint(x: -H.rx * 2, y: -H.ry * extraTop)) + p.closeSubpath() + return p +} + +// frontRun — front arc of a CLOSED ring, ordered left→right (silhouette tangent method) +private func mFrontRun(_ ring: [P3]) -> [P3] { + frontSilhouetteArc(ring) +} + +// invert(p, H) — complement of path p inside a large rect, with evenodd fill +private func mInvert(_ p: Path, H: MochiH) -> Path { + var q = Path() + q.addRect(CGRect(x: -H.rx * 4, y: -H.ry * 4, width: H.rx * 8, height: H.ry * 8)) + q.addPath(p) + return q +} + +// mochiOutfitPath — clean superellipse (n=96), same exponent as JS mochiPath +func mochiOutfitPath(_ rx: CGFloat, _ ry: CGFloat) -> Path { + let n = 96 + let e: CGFloat = 2.0 / kEXP + var p = Path() + for i in 0...n { + let a = CGFloat(i) / CGFloat(n) * .pi * 2 + let ca = cos(a), sa = sin(a) + let x = rx * (ca >= 0 ? pow(ca, e) : -pow(-ca, e)) + let y = ry * (sa >= 0 ? pow(sa, e) : -pow(-sa, e)) + if i == 0 { p.move(to: CGPoint(x: x, y: y)) } + else { p.addLine(to: CGPoint(x: x, y: y)) } + } + p.closeSubpath() + return p +} + +// ringPoints — all 120+1 projected points on ring (full circle, for frontRun) +private func mRingPoints(_ H: MochiH, y: CGFloat, s: CGFloat, n: Int = 72) -> [P3] { + (0...n).map { i -> P3 in + let lon = -.pi + CGFloat(i) / CGFloat(n) * 2 * .pi + return mProj(H, mSurf(y, lon, s)) + } +} + +// MARK: - pompom + +private func drawPompom(_ ctx: inout GraphicsContext, x: CGFloat, y: CGFloat, r: CGFloat, + base: Color = .white, + shade: Color = Color(red: 0.835, green: 0.851, blue: 0.886)) { + var g = ctx + g.translateBy(x: x, y: y) + // fluffy rim bumps + let n = 11 + for i in 0..= 2 else { return } + // shade stroke + var sp = Path() + sp.move(to: CGPoint(x: arc[0].x, y: arc[0].y)) + for i in 1.. GraphicsContext { + var ctx = context + ctx.translateBy(x: cx, y: cy) + if tilt != 0 { ctx.rotate(by: .radians(tilt)) } + ctx.scaleBy(x: sx, y: sy) + return ctx +} + +// MARK: - Front dispatcher + +func drawOutfitFrontStatic( + context: GraphicsContext, + outfit: Outfit, H: MochiH, + cx: CGFloat, cy: CGFloat, tilt: CGFloat, sx: CGFloat, sy: CGFloat, + roll: CGFloat, morph: CGFloat, isMini: Bool, + presence: CGFloat = 1, rollTurns: CGFloat = 1 +) { + guard !isMini, outfit != .none, outfit != .auto else { return } + let morphFade = 1 - min(1, max(0, (morph - 0.3) / 0.2)) + guard morphFade > 0.01 else { return } + + // Roll-following (glasses, bow, scarf, pumpkin): skip front pass when z < 0 → behind pass handles it. + // bunnyEars: always in behind pass; not in this set. + switch outfit { + case .sunglasses, .roundGlasses, .bow, .scarf, .pumpkin: + if mProjRoll(H, (0, 0, 1)).z < 0 { return } + default: break + } + + // Opacity: opaque early so movement carries the transition; drawLayer prevents ghost overlaps. + let p = presence + let posP = Ease.back(p) + let layerOpacity = Double(morphFade * min(1, p * 2.5)) + guard layerOpacity > 0.005 else { return } + + let simplified = H.R < 16 + let baseCtx = outfitBodyTransform(context: context, cx: cx, cy: cy, tilt: tilt, sx: sx, sy: sy) + let bodyPath = mochiOutfitPath(H.rx, H.ry) + + // ── Hat fly-off during roll (beanie, santaHat, partyHat, crown, witchHat) ────────────────── + let isHatType: Bool + switch outfit { + case .beanie, .santaHat, .partyHat, .crown, .witchHat: isHatType = true + default: isHatType = false + } + if isHatType && abs(H.roll) > 0.01 { + let u = min(1, abs(H.roll) / (2 * .pi * max(1, rollTurns))) + let flyHeight = H.ry * 0.45 * sin(u * .pi) + let flyDrift = H.physDx * H.rx * 0.2 * sin(u * .pi) + let swingAngle = sin(2 * .pi * u) * 0.35 // gentle flat swing, hat stays upright + var c = baseCtx; c.opacity = layerOpacity + c.translateBy(x: flyDrift, y: -flyHeight) + c.rotate(by: .radians(swingAngle)) + c.drawLayer { lCtx in + var l = lCtx + switch outfit { + case .beanie: + drawBeaniesFront(ctx: &l, H: H, bodyPath: bodyPath, simplified: simplified) + case .santaHat: + drawSantaHatFront(ctx: &l, H: H, bodyPath: bodyPath) + case .partyHat: + drawPartyHatFront(ctx: &l, H: H, bodyPath: bodyPath, simplified: simplified) + case .crown: + var g = l; g.clip(to: bodyPath) + g.clip(to: mCapClip(H, y: crownYb(H) - 0.1, s: 1)) + var g2 = g + g2.clip(to: mInvert(mCapClip(H, y: crownYb(H), s: 1), H: H), style: FillStyle(eoFill: true)) + var shp = Path(); shp.addRect(CGRect(x: -H.rx*4, y: -H.ry*4, width: H.rx*8, height: H.ry*8)) + g2.fill(shp, with: .color(Color(red: 0.314, green: 0.196, blue: 0, opacity: 0.12))) + drawCrownPart(ctx: &l, H: H, side: 1, simplified: simplified) + case .witchHat: + drawWitchHatFront(ctx: &l, H: H, bodyPath: bodyPath) + default: break + } + } + return + } + + // ── Normal presence transitions (drawLayer eliminates ghost overlaps) ──────────────────────── + let hatScale = 0.85 + 0.15 * posP // hats / crown: scale up as they settle + + switch outfit { + case .beanie: + var c = baseCtx; c.opacity = layerOpacity + c.translateBy(x: 0, y: -(1 - posP) * H.ry * 1.0); c.scaleBy(x: hatScale, y: hatScale) + c.drawLayer { lCtx in var l = lCtx; drawBeaniesFront(ctx: &l, H: H, bodyPath: bodyPath, simplified: simplified) } + + case .santaHat: + var c = baseCtx; c.opacity = layerOpacity + c.translateBy(x: 0, y: -(1 - posP) * H.ry * 1.0); c.scaleBy(x: hatScale, y: hatScale) + c.drawLayer { lCtx in var l = lCtx; drawSantaHatFront(ctx: &l, H: H, bodyPath: bodyPath) } + + case .partyHat: + var c = baseCtx; c.opacity = layerOpacity + c.translateBy(x: 0, y: -(1 - posP) * H.ry * 1.0); c.scaleBy(x: hatScale, y: hatScale) + c.drawLayer { lCtx in var l = lCtx; drawPartyHatFront(ctx: &l, H: H, bodyPath: bodyPath, simplified: simplified) } + + case .crown: + var c = baseCtx; c.opacity = layerOpacity + c.translateBy(x: 0, y: -(1 - posP) * H.ry * 1.0); c.scaleBy(x: hatScale, y: hatScale) + c.drawLayer { lCtx in + var l = lCtx + var g = l; g.clip(to: bodyPath) + g.clip(to: mCapClip(H, y: crownYb(H) - 0.1, s: 1)) + var g2 = g + g2.clip(to: mInvert(mCapClip(H, y: crownYb(H), s: 1), H: H), style: FillStyle(eoFill: true)) + var shp = Path(); shp.addRect(CGRect(x: -H.rx*4, y: -H.ry*4, width: H.rx*8, height: H.ry*8)) + g2.fill(shp, with: .color(Color(red: 0.314, green: 0.196, blue: 0, opacity: 0.12))) + drawCrownPart(ctx: &l, H: H, side: 1, simplified: simplified) + } + + case .witchHat: + var c = baseCtx; c.opacity = layerOpacity + c.translateBy(x: 0, y: -(1 - posP) * H.ry * 1.0); c.scaleBy(x: hatScale, y: hatScale) + c.drawLayer { lCtx in var l = lCtx; drawWitchHatFront(ctx: &l, H: H, bodyPath: bodyPath) } + + case .sunglasses: + var c = baseCtx; c.opacity = layerOpacity + c.translateBy(x: 0, y: (1 - p) * 0.25 * H.ry) + c.drawLayer { lCtx in var l = lCtx; drawSunglassesFront(ctx: &l, H: H, bodyPath: bodyPath) } + + case .roundGlasses: + var c = baseCtx; c.opacity = layerOpacity + c.translateBy(x: 0, y: (1 - p) * 0.25 * H.ry) + c.drawLayer { lCtx in var l = lCtx; drawRoundGlassesFront(ctx: &l, H: H, bodyPath: bodyPath) } + + case .scarf: + var c = baseCtx; c.opacity = layerOpacity + c.translateBy(x: 0, y: (1 - p) * 0.3 * H.ry) + c.drawLayer { lCtx in var l = lCtx; drawScarfFront(ctx: &l, H: H) } + + case .pumpkin: + var c = baseCtx; c.opacity = layerOpacity + c.drawLayer { lCtx in var l = lCtx; drawPumpkinFront(ctx: &l, H: H, bodyPath: bodyPath, simplified: simplified) } + + case .bow: + var c = baseCtx; c.opacity = layerOpacity + c.scaleBy(x: max(0.001, posP), y: max(0.001, posP)) + c.drawLayer { lCtx in var l = lCtx; drawBowFront(ctx: &l, H: H, bodyPath: bodyPath) } + + default: + break + } +} + +// MARK: - Behind dispatcher + +func drawOutfitBehindStatic( + context: GraphicsContext, + outfit: Outfit, H: MochiH, + cx: CGFloat, cy: CGFloat, tilt: CGFloat, sx: CGFloat, sy: CGFloat, + roll: CGFloat, morph: CGFloat, isMini: Bool, + presence: CGFloat = 1, rollTurns: CGFloat = 1 +) { + guard !isMini, outfit != .none, outfit != .auto else { return } + let morphFade = 1 - min(1, max(0, (morph - 0.3) / 0.2)) + guard morphFade > 0.01 else { return } + + let layerOpacity = Double(morphFade * min(1, presence * 2.5)) + guard layerOpacity > 0.005 else { return } + + let simplified = H.R < 16 + var ctx = outfitBodyTransform(context: context, cx: cx, cy: cy, tilt: tilt, sx: sx, sy: sy) + let bodyPath = mochiOutfitPath(H.rx, H.ry) + + // Roll-following accessories (glasses, bow, scarf, pumpkin — not bunnyEars): behind when z < 0 + switch outfit { + case .sunglasses, .roundGlasses, .bow, .scarf, .pumpkin: + guard mProjRoll(H, (0, 0, 1)).z < 0 else { return } + ctx.opacity = layerOpacity + ctx.drawLayer { lCtx in + var l = lCtx + switch outfit { + case .sunglasses: drawSunglassesFront(ctx: &l, H: H, bodyPath: bodyPath) + case .roundGlasses: drawRoundGlassesFront(ctx: &l, H: H, bodyPath: bodyPath) + case .scarf: drawScarfFront(ctx: &l, H: H) + case .pumpkin: drawPumpkinFront(ctx: &l, H: H, bodyPath: bodyPath, simplified: simplified) + case .bow: drawBowFront(ctx: &l, H: H, bodyPath: bodyPath) + default: break + } + } + return + default: break + } + + // Behind accessories: bunnyEars (always), crown back, witchHat back + let posP = Ease.back(presence) + let hatScale = 0.85 + 0.15 * posP + switch outfit { + case .bunnyEars: + // Always behind body; no 3D roll — ears flatten/tilt during roll. + let u: CGFloat = abs(H.roll) > 0.01 + ? min(1, abs(H.roll) / (2 * .pi * max(1, rollTurns))) + : 0 + var c = ctx; c.opacity = layerOpacity + // Presence transition: descend from above (same as hats) + c.translateBy(x: 0, y: -(1 - posP) * H.ry * 1.0) + c.scaleBy(x: hatScale, y: hatScale) + c.drawLayer { lCtx in + var l = lCtx + drawBunnyEarsBack(ctx: &l, H: H, rollProgress: u) + } + + case .crown: + var c = ctx; c.opacity = layerOpacity + if abs(H.roll) > 0.01 { + let u = min(1, abs(H.roll) / (2 * .pi * max(1, rollTurns))) + c.translateBy(x: H.physDx * H.rx * 0.2 * sin(u * .pi), y: -H.ry * 0.45 * sin(u * .pi)) + c.rotate(by: .radians(sin(2 * .pi * u) * 0.35)) + } else { + c.translateBy(x: 0, y: -(1 - posP) * H.ry * 1.0); c.scaleBy(x: hatScale, y: hatScale) + } + c.drawLayer { lCtx in var l = lCtx; drawCrownPart(ctx: &l, H: H, side: -1, simplified: simplified) } + + case .witchHat: + var c = ctx; c.opacity = layerOpacity + if abs(H.roll) > 0.01 { + let u = min(1, abs(H.roll) / (2 * .pi * max(1, rollTurns))) + c.translateBy(x: H.physDx * H.rx * 0.2 * sin(u * .pi), y: -H.ry * 0.45 * sin(u * .pi)) + c.rotate(by: .radians(sin(2 * .pi * u) * 0.35)) + } else { + c.translateBy(x: 0, y: -(1 - posP) * H.ry * 1.0); c.scaleBy(x: hatScale, y: hatScale) + } + c.drawLayer { lCtx in var l = lCtx; drawWitchHatBack(ctx: &l, H: H) } + + default: + break + } +} + +// MARK: - Crown geometry helper + +private func crownYb(_ H: MochiH) -> CGFloat { 0.46 } + +// MARK: - Bunny ears (behind body) + +// rollProgress: 0 = upright, 1 = max roll (ears fully flattened). Drawn with mProj (no 3D roll). +private func drawBunnyEarsBack(ctx: inout GraphicsContext, H: MochiH, rollProgress: CGFloat = 0) { + let R = H.R + let earH = R * 0.85 + + for sd: CGFloat in [-1.0, 1.0] { + // Standard projection — ears do not follow 3D roll + let earRoot = mProj(H, (sd * 0.45, 0.92, 0)) + let earRootL = mProj(H, (sd * 0.45 - 0.22, 0.92, 0)) + let earRootR = mProj(H, (sd * 0.45 + 0.22, 0.92, 0)) + let visHW = max(R * 0.04, abs(earRootR.x - earRootL.x) / 2) + + // During roll: ears flatten (shrink height) and tilt outward + let flatten = sin(rollProgress * .pi) + let effEarH = earH * (1 - 0.8 * flatten) + let tiltAngle = sd * 0.6 * flatten // left ear tilts left, right tilts right + + let earCX = earRoot.x + let earCY = earRoot.y - effEarH * 0.65 + effEarH * 0.5 // visual centre of ear + + var eCtx = ctx + eCtx.translateBy(x: earCX, y: earCY) + eCtx.rotate(by: .radians(tiltAngle)) + + var outer = Path() + outer.addEllipse(in: CGRect(x: -visHW, y: -effEarH / 2, width: visHW * 2, height: effEarH)) + eCtx.fill(outer, with: .color(Color(hex: "#F9F0F0"))) + eCtx.stroke(outer, with: .color(Color.black.opacity(0.06)), lineWidth: 0.8) + var inner = Path() + inner.addEllipse(in: CGRect(x: -visHW * 0.50, y: -effEarH / 2 + R * 0.10, + width: visHW, height: effEarH * 0.65)) + eCtx.fill(inner, with: .color(Color(hex: "#FCA5A5").opacity(0.70))) + } +} + +// MARK: - Beanie + +private func drawBeaniesFront(ctx: inout GraphicsContext, H: MochiH, bodyPath: Path, simplified: Bool = false) { + let s: CGFloat = 1.035, yEdge: CGFloat = 0.42, yCuff: CGFloat = 0.58 + let head = mochiOutfitPath(H.rx * s, H.ry * s) + + // shadow on head under the cuff + var shadow = ctx + shadow.clip(to: bodyPath) + shadow.clip(to: mCapClip(H, y: yEdge - 0.12, s: 1)) + var shRect = Path() + shRect.addRect(CGRect(x: -H.rx * 4, y: -H.ry * 4, width: H.rx * 8, height: H.ry * 8)) + shadow.fill(shRect, with: .color(Color(red: 30/255, green: 40/255, blue: 70/255, opacity: 0.10))) + + // knit body + var knitCtx = ctx + knitCtx.clip(to: mCapClip(H, y: yCuff, s: s)) + knitCtx.fill(head, with: .linearGradient( + Gradient(stops: [ + .init(color: Color(hex: "#7DB6FF"), location: 0), + .init(color: Color(hex: "#2F6FE0"), location: 1) + ]), + startPoint: CGPoint(x: H.rx * 0.5, y: -H.ry * 1.1), + endPoint: CGPoint(x: -H.rx * 0.6, y: H.ry * 0.2) + )) + // vertical knit ribs (skip when simplified) + if !simplified { + var ribCtx = knitCtx + ribCtx.clip(to: head) + for k in -6...6 { + let lon = CGFloat(k) * 0.24 + var pts: [P3] = [] + for i in 0...16 { + let y = yCuff + (1.05 - yCuff) * CGFloat(i) / 16 + let q = mProj(H, mSurf(y, lon, s)) + if q.z > 0 { pts.append(q) } + } + guard pts.count >= 2 else { continue } + var rp = Path() + rp.move(to: CGPoint(x: pts[0].x, y: pts[0].y)) + for pt in pts.dropFirst() { rp.addLine(to: CGPoint(x: pt.x, y: pt.y)) } + ribCtx.stroke(rp, with: .color(Color(red: 20/255, green: 50/255, blue: 140/255, opacity: 0.16)), + style: StrokeStyle(lineWidth: H.R * 0.045, lineCap: .round)) + } + } + + // cuff (folded band) — clip to [yEdge, yCuff] band using invert + var cuffCtx = ctx + cuffCtx.clip(to: mCapClip(H, y: yEdge, s: s * 1.04)) + cuffCtx.clip(to: mInvert(mCapClip(H, y: yCuff, s: s * 1.04), H: H), style: FillStyle(eoFill: true)) + let cuffHead = mochiOutfitPath(H.rx * s * 1.04, H.ry * s * 1.04) + cuffCtx.fill(cuffHead, with: .linearGradient( + Gradient(stops: [ + .init(color: Color(hex: "#3C7BEA"), location: 0), + .init(color: Color(hex: "#2257C4"), location: 1) + ]), + startPoint: CGPoint(x: 0, y: -H.ry * 0.6), + endPoint: CGPoint(x: 0, y: -H.ry * 0.2) + )) + var cuffRib = cuffCtx + cuffRib.clip(to: cuffHead) + for k in -14...14 { + let lon = CGFloat(k) * 0.115 + let a = mProj(H, mSurf(yEdge, lon, s * 1.04)) + let b = mProj(H, mSurf(yCuff, lon, s * 1.04)) + if a.z < 0 { continue } + var cp = Path() + cp.move(to: CGPoint(x: a.x, y: a.y)) + cp.addLine(to: CGPoint(x: b.x, y: b.y)) + cuffRib.stroke(cp, with: .color(Color(red: 10/255, green: 30/255, blue: 100/255, opacity: 0.22)), + style: StrokeStyle(lineWidth: H.R * 0.035, lineCap: .butt)) + } + + // top highlight + var hiCtx = ctx + hiCtx.clip(to: mCapClip(H, y: yCuff, s: s)) + hiCtx.clip(to: head) + hiCtx.fill(head, with: .radialGradient( + Gradient(stops: [ + .init(color: Color.white.opacity(0.35), location: 0), + .init(color: .clear, location: 1) + ]), + center: CGPoint(x: H.rx * 0.3, y: -H.ry * 0.85), + startRadius: 0, endRadius: H.R * 0.45 + )) + + // pompom on short spring + let top = mProj(H, (0, 1.08 * s, 0)) + drawPompom(&ctx, x: top.x + H.physDx * H.rx * 0.25, + y: top.y - H.R * 0.12 + H.physDy * H.ry * 0.15, + r: H.R * 0.24) +} + +// MARK: - Santa hat + +private func drawSantaHatFront(ctx: inout GraphicsContext, H: MochiH, bodyPath: Path) { + let s: CGFloat = 1.05, yEdge: CGFloat = 0.52 + let arc = mFrontArc(H, y: yEdge, s: s) + guard !arc.isEmpty else { return } + let L = arc.first! + let Rt = arc.last! + let crown = mProj(H, (0, 1.05, 0)) + // tip flops to right + spring lag + let side: CGFloat = 1 + let tip = CGPoint( + x: crown.x + side * H.rx * (0.95 + H.physDx * 0.35), + y: crown.y + H.ry * (0.05 + H.physDy * 0.2) + ) + let peak = CGPoint( + x: crown.x + side * H.rx * 0.25, + y: crown.y - H.ry * 0.62 + ) + var bag = Path() + bag.move(to: CGPoint(x: L.x, y: L.y)) + bag.addCurve( + to: CGPoint(x: peak.x, y: peak.y), + control1: CGPoint(x: L.x - H.rx * 0.05, y: L.y - H.ry * 0.7), + control2: CGPoint(x: peak.x - H.rx * 0.55, y: peak.y - H.ry * 0.05) + ) + bag.addQuadCurve( + to: CGPoint(x: tip.x, y: tip.y), + control: CGPoint(x: tip.x - H.rx * 0.05, y: peak.y - H.ry * 0.02) + ) + bag.addQuadCurve( + to: CGPoint(x: peak.x + H.rx * 0.18, y: peak.y + H.ry * 0.32), + control: CGPoint(x: tip.x - H.rx * 0.12, y: tip.y - H.ry * 0.22) + ) + bag.addCurve( + to: CGPoint(x: Rt.x, y: Rt.y), + control1: CGPoint(x: Rt.x + H.rx * 0.05, y: peak.y + H.ry * 0.45), + control2: CGPoint(x: Rt.x + H.rx * 0.08, y: Rt.y - H.ry * 0.35) + ) + for i in stride(from: arc.count - 1, through: 0, by: -1) { + bag.addLine(to: CGPoint(x: arc[i].x, y: arc[i].y)) + } + bag.closeSubpath() + + // shadow on head + var sCtx = ctx + sCtx.clip(to: bodyPath) + sCtx.clip(to: mCapClip(H, y: yEdge - 0.14, s: 1)) + var sRect = Path() + sRect.addRect(CGRect(x: -H.rx * 4, y: -H.ry * 4, width: H.rx * 8, height: H.ry * 8)) + sCtx.fill(sRect, with: .color(Color(red: 120/255, green: 10/255, blue: 10/255, opacity: 0.10))) + + // bag fill + ctx.fill(bag, with: .linearGradient( + Gradient(stops: [ + .init(color: Color(hex: "#FF6B6B"), location: 0), + .init(color: Color(hex: "#E53935"), location: 0.55), + .init(color: Color(hex: "#B71C1C"), location: 1) + ]), + startPoint: CGPoint(x: -H.rx * 0.6, y: -H.ry * 1.6), + endPoint: CGPoint(x: H.rx * 0.7, y: -H.ry * 0.3) + )) + + // folds + var fCtx = ctx + fCtx.clip(to: bag) + for (a, b, w): (CGFloat, CGFloat, CGFloat) in [(0.15, 0.55, 0.10), (0.45, 0.85, 0.08)] { + var fold = Path() + fold.move(to: CGPoint( + x: peak.x - H.rx * 0.1 + (Rt.x - L.x) * a * 0.3, + y: peak.y + H.ry * 0.15 + )) + fold.addQuadCurve( + to: CGPoint(x: tip.x - H.rx * (0.45 - b * 0.3), y: tip.y - H.ry * 0.12), + control: CGPoint(x: peak.x + H.rx * 0.35, y: peak.y + H.ry * (0.05 + a * 0.3)) + ) + fCtx.stroke(fold, with: .color(Color(red: 90/255, green: 0, blue: 0, opacity: 0.20)), + style: StrokeStyle(lineWidth: H.R * w, lineCap: .round)) + } + fCtx.fill(bag, with: .radialGradient( + Gradient(stops: [ + .init(color: Color.white.opacity(0.32), location: 0), + .init(color: .clear, location: 1) + ]), + center: CGPoint(x: peak.x - H.rx * 0.25, y: peak.y + H.ry * 0.05), + startRadius: 0, endRadius: H.R * 0.5 + )) + + // trim + pompom + drawFuzzyBand(&ctx, arc: arc, thick: H.R * 0.3) + drawPompom(&ctx, x: tip.x, y: tip.y + H.R * 0.04, r: H.R * 0.22) +} + +// MARK: - Party hat + +private func drawPartyHatFront(ctx: inout GraphicsContext, H: MochiH, bodyPath: Path, simplified: Bool = false) { + let baseY: CGFloat = 0.82, baseR: CGFloat = 0.42 + let lean: CGFloat = -0.24 + H.physDx * 0.12 + let c = mProj(H, (0.16, baseY + 0.06, 0)) + // full ring for left/right extremes + var ring: [P3] = [] + for i in 0...48 { + let a = CGFloat(i) / 48 * .pi * 2 + ring.append(mProj(H, (0.16 + baseR * sin(a), baseY + 0.06, baseR * cos(a)))) + } + let left = ring.min(by: { $0.x < $1.x })! + let right = ring.max(by: { $0.x < $1.x })! + let h = H.ry * 1.6 + let apex = CGPoint(x: c.x + sin(lean) * h, y: c.y - cos(lean) * h) + let front = mFrontRun(ring) + + var cone = Path() + cone.move(to: CGPoint(x: left.x, y: left.y)) + cone.addQuadCurve( + to: CGPoint(x: apex.x - H.R * 0.05, y: apex.y + H.R * 0.06), + control: CGPoint(x: (left.x + apex.x) / 2 - H.rx * 0.06, y: (left.y + apex.y) / 2) + ) + cone.addQuadCurve( + to: CGPoint(x: apex.x + H.R * 0.05, y: apex.y + H.R * 0.06), + control: CGPoint(x: apex.x, y: apex.y - H.R * 0.03) + ) + cone.addQuadCurve( + to: CGPoint(x: right.x, y: right.y), + control: CGPoint(x: (right.x + apex.x) / 2 + H.rx * 0.06, y: (right.y + apex.y) / 2) + ) + for i in stride(from: front.count - 1, through: 0, by: -1) { + cone.addLine(to: CGPoint(x: front[i].x, y: front[i].y)) + } + cone.closeSubpath() + + ctx.fill(cone, with: .linearGradient( + Gradient(stops: [ + .init(color: Color(hex: "#FF9BD0"), location: 0), + .init(color: Color(hex: "#F15BAE"), location: 0.5), + .init(color: Color(hex: "#C2187A"), location: 1) + ]), + startPoint: CGPoint(x: left.x, y: apex.y), + endPoint: CGPoint(x: right.x, y: left.y) + )) + + var dotCtx = ctx + dotCtx.clip(to: cone) + // polka dots (skip when simplified) + if !simplified { + let dots: [(CGFloat, CGFloat)] = [ + (0.25, -0.35), (0.3, 0.3), (0.55, -0.05), (0.72, 0.28), + (0.8, -0.3), (0.45, 0.6), (0.48, -0.65) + ] + for (t, u) in dots { + let bx = left.x + (right.x - left.x) * (0.5 + u * 0.5) + let by = left.y + (right.y - left.y) * (0.5 + u * 0.5) + let x = bx + (apex.x - bx) * (1 - t) + let y = by + (apex.y - by) * (1 - t) + let r = H.R * 0.075 * (0.6 + t * 0.5) + var dot = Path() + dot.addEllipse(in: CGRect(x: x - r, y: y - r * 0.9, width: r * 2, height: r * 0.9 * 2)) + dotCtx.fill(dot, with: .color(Color.white.opacity(0.92))) + } + } + dotCtx.fill(cone, with: .linearGradient( + Gradient(stops: [ + .init(color: Color.white.opacity(0.28), location: 0), + .init(color: .clear, location: 0.35), + .init(color: Color(red: 80/255, green: 0, blue: 40/255, opacity: 0.18), location: 1) + ]), + startPoint: CGPoint(x: left.x, y: 0), + endPoint: CGPoint(x: right.x, y: 0) + )) + + // rim at base + if !front.isEmpty { + var rim = Path() + rim.move(to: CGPoint(x: front[0].x, y: front[0].y)) + for i in 1.. 0 ? $0.z >= 0 : $0.z < 0.02 } + guard keep.count >= 2 else { return } + keep.sort { $0.b.x < $1.b.x } + + var shape = Path() + shape.move(to: CGPoint(x: keep[0].tt.x, y: keep[0].tt.y)) + for i in 1.. [P3] { + let y: CGFloat = 0.70, rr: CGFloat = 1.42 + return (0...120).map { i -> P3 in + let a = -.pi + CGFloat(i) / 120 * 2 * .pi + let wob = 1 + 0.035 * sin(a * 3 + 0.6) + let droop = -0.10 * pow(abs(sin(a)), 2) + return mProj(H, (rr * wob * sin(a), y + droop, rr * wob * cos(a))) + } +} + +private func drawWitchHatFront(ctx: inout GraphicsContext, H: MochiH, bodyPath: Path) { + let all = witchBrimPts(H) + var brim = Path() + brim.move(to: CGPoint(x: all[0].x, y: all[0].y)) + for q in all.dropFirst() { brim.addLine(to: CGPoint(x: q.x, y: q.y)) } + brim.closeSubpath() + let fr = all.filter { $0.z >= 0 }.sorted { $0.x < $1.x } + + // shadow on head + var sCtx = ctx + sCtx.clip(to: bodyPath) + sCtx.clip(to: mCapClip(H, y: 0.50, s: 1)) + var sRect = Path() + sRect.addRect(CGRect(x: -H.rx * 4, y: -H.ry * 4, width: H.rx * 8, height: H.ry * 8)) + sCtx.fill(sRect, with: .color(Color(red: 40/255, green: 0, blue: 70/255, opacity: 0.10))) + + // full brim front + ctx.fill(brim, with: .linearGradient( + Gradient(stops: [ + .init(color: Color(hex: "#5B21B6"), location: 0), + .init(color: Color(hex: "#3B0764"), location: 1) + ]), + startPoint: CGPoint(x: 0, y: -H.ry * 0.9), + endPoint: CGPoint(x: 0, y: -H.ry * 0.3) + )) + if !fr.isEmpty { + var frLine = Path() + frLine.move(to: CGPoint(x: fr[0].x, y: fr[0].y)) + for i in 1..= bl.x - 1 && $0.x <= br.x + 1 + } + + var cone = Path() + cone.move(to: CGPoint(x: bl.x, y: bl.y)) + cone.addCurve( + to: CGPoint(x: top.x - H.rx * 0.02, y: top.y - H.ry * 0.02), + control1: CGPoint(x: bl.x + H.rx * 0.12, y: bl.y - H.ry * 0.5), + control2: CGPoint(x: top.x - H.rx * 0.28, y: top.y + H.ry * 0.25) + ) + cone.addQuadCurve( + to: CGPoint(x: tip.x, y: tip.y), + control: CGPoint(x: top.x + H.rx * 0.25, y: top.y - H.ry * 0.08) + ) + cone.addQuadCurve( + to: CGPoint(x: top.x + H.rx * 0.14, y: top.y + H.ry * 0.22), + control: CGPoint(x: top.x + H.rx * 0.22, y: top.y + H.ry * 0.08) + ) + cone.addCurve( + to: CGPoint(x: br.x, y: br.y), + control1: CGPoint(x: br.x - H.rx * 0.18, y: c.y - H.ry * 0.45), + control2: CGPoint(x: br.x - H.rx * 0.02, y: br.y - H.ry * 0.2) + ) + for i in stride(from: capFront.count - 1, through: 0, by: -1) { + cone.addLine(to: CGPoint(x: capFront[i].x, y: capFront[i].y)) + } + cone.closeSubpath() + + ctx.fill(cone, with: .linearGradient( + Gradient(stops: [ + .init(color: Color(hex: "#7C3AED"), location: 0), + .init(color: Color(hex: "#4C1D95"), location: 0.55), + .init(color: Color(hex: "#2E1065"), location: 1) + ]), + startPoint: CGPoint(x: bl.x, y: top.y), + endPoint: CGPoint(x: br.x, y: bl.y) + )) + + var coneCtx = ctx + coneCtx.clip(to: cone) + coneCtx.fill(cone, with: .linearGradient( + Gradient(stops: [ + .init(color: Color.white.opacity(0.22), location: 0), + .init(color: .clear, location: 0.4), + .init(color: Color.black.opacity(0.15), location: 1) + ]), + startPoint: CGPoint(x: bl.x, y: 0), + endPoint: CGPoint(x: br.x, y: 0) + )) + // crease + var crease = Path() + crease.move(to: CGPoint(x: top.x - H.rx * 0.05, y: top.y + H.ry * 0.05)) + crease.addQuadCurve( + to: CGPoint(x: top.x + H.rx * 0.2, y: top.y + H.ry * 0.06), + control: CGPoint(x: top.x + H.rx * 0.1, y: top.y + H.ry * 0.12) + ) + coneCtx.stroke(crease, with: .color(Color(red: 20/255, green: 0, blue: 40/255, opacity: 0.35)), + style: StrokeStyle(lineWidth: H.R * 0.05, lineCap: .round)) + + // orange band + let fc = mProj(H, (0, by, baseR)) + let lift = H.ry * 0.11 + var band = Path() + band.move(to: CGPoint(x: bl.x - 2, y: bl.y - lift)) + band.addQuadCurve( + to: CGPoint(x: br.x + 2, y: br.y - lift), + control: CGPoint(x: fc.x, y: 2 * (fc.y - lift) - (bl.y + br.y) / 2) + ) + coneCtx.stroke(band, with: .color(Color(hex: "#F97316")), + style: StrokeStyle(lineWidth: H.ry * 0.17, lineCap: .butt)) + + // buckle + let bk0 = mProj(H, (0, by, baseR)) + let bk = CGPoint(x: bk0.x, y: bk0.y - H.ry * 0.11) + let bw = H.R * 0.2, bh = H.R * 0.16 + var bkCtx = ctx + bkCtx.translateBy(x: bk.x, y: bk.y) + var buckle = Path() + buckle.addRoundedRect( + in: CGRect(x: -bw / 2, y: -bh / 2, width: bw, height: bh), + cornerSize: CGSize(width: bh * 0.25, height: bh * 0.25) + ) + bkCtx.fill(buckle, with: .color(Color(hex: "#FCD34D"))) + var hole = Path() + hole.addRoundedRect( + in: CGRect(x: -bw / 2 + bw * 0.24, y: -bh / 2 + bh * 0.28, + width: bw * 0.52, height: bh * 0.44), + cornerSize: CGSize(width: bh * 0.10, height: bh * 0.10) + ) + bkCtx.fill(hole, with: .color(Color(hex: "#C2410C"))) +} + +// MARK: - Sunglasses + +private func drawSunglassesFront(ctx: inout GraphicsContext, H: MochiH, bodyPath: Path) { + let rollH = MochiH(R: H.R, yaw: H.yaw, pitch: H.pitch + H.roll, physDx: H.physDx, physDy: H.physDy) + let eyes = mEyeFrames(rollH) + let w = H.R * 0.62, h = H.R * 0.46 + + var g = ctx + g.clip(to: bodyPath) + + let le = eyes[0], re = eyes[1] + if le.visible && re.visible { + var bridge = Path() + bridge.move(to: CGPoint(x: le.x + w / 2 * le.fx * 0.9, y: le.y - h * 0.18)) + bridge.addQuadCurve( + to: CGPoint(x: re.x - w / 2 * re.fx * 0.9, y: re.y - h * 0.18), + control: CGPoint(x: (le.x + re.x) / 2, y: (le.y + re.y) / 2 - h * 0.42) + ) + g.stroke(bridge, with: .color(Color(hex: "#111317")), + style: StrokeStyle(lineWidth: H.R * 0.07, lineCap: .round)) + } + for e in eyes { + guard e.visible else { continue } + let ox = e.x + e.sd * w / 2 * e.fx + var temple = Path() + temple.move(to: CGPoint(x: ox, y: e.y - h * 0.2)) + temple.addLine(to: CGPoint(x: e.sd * H.rx * 1.05, y: e.y - h * 0.35)) + g.stroke(temple, with: .color(Color(hex: "#111317")), + style: StrokeStyle(lineWidth: H.R * 0.06, lineCap: .round)) + } + for e in eyes { + guard e.visible else { continue } + var lens = Path() + lens.addRoundedRect( + in: CGRect(x: e.x - w / 2, y: e.y - h * 0.5, width: w, height: h), + cornerSize: CGSize(width: h * 0.42, height: h * 0.42) + ) + // foreshorten lens via scale + var lg = g + lg.translateBy(x: e.x, y: e.y) + lg.scaleBy(x: e.fx, y: e.fy) + var lensLocal = Path() + lensLocal.addRoundedRect( + in: CGRect(x: -w / 2, y: -h / 2, width: w, height: h), + cornerSize: CGSize(width: h * 0.42, height: h * 0.42) + ) + lg.fill(lensLocal, with: .color(Color(red: 17/255, green: 19/255, blue: 23/255, opacity: 0.82))) + lg.stroke(lensLocal, with: .color(Color(hex: "#0B0C0F")), + style: StrokeStyle(lineWidth: H.R * 0.05)) + // glare + var glare = Path() + glare.move(to: CGPoint(x: -w * 0.28, y: -h * 0.05)) + glare.addLine(to: CGPoint(x: -w * 0.05, y: -h * 0.3)) + lg.stroke(glare, with: .color(Color.white.opacity(0.45)), + style: StrokeStyle(lineWidth: H.R * 0.05, lineCap: .round)) + } +} + +// MARK: - Round glasses + +private func drawRoundGlassesFront(ctx: inout GraphicsContext, H: MochiH, bodyPath: Path) { + let rollH = MochiH(R: H.R, yaw: H.yaw, pitch: H.pitch + H.roll, physDx: H.physDx, physDy: H.physDy) + let eyes = mEyeFrames(rollH) + let d = H.R * 0.56 + + var g = ctx + g.clip(to: bodyPath) + + let le = eyes[0], re = eyes[1] + if le.visible && re.visible { + var bridge = Path() + bridge.move(to: CGPoint(x: le.x + d / 2 * le.fx, y: le.y - d * 0.08)) + bridge.addQuadCurve( + to: CGPoint(x: re.x - d / 2 * re.fx, y: re.y - d * 0.08), + control: CGPoint(x: (le.x + re.x) / 2, y: (le.y + re.y) / 2 - d * 0.3) + ) + g.stroke(bridge, with: .color(Color(hex: "#8A4B12")), + style: StrokeStyle(lineWidth: H.R * 0.055, lineCap: .round)) + } + for e in eyes { + guard e.visible else { continue } + var temple = Path() + temple.move(to: CGPoint(x: e.x + e.sd * d / 2 * e.fx, y: e.y - d * 0.1)) + temple.addLine(to: CGPoint(x: e.sd * H.rx * 1.05, y: e.y - d * 0.25)) + g.stroke(temple, with: .color(Color(hex: "#8A4B12")), + style: StrokeStyle(lineWidth: H.R * 0.05, lineCap: .round)) + } + for e in eyes { + guard e.visible else { continue } + var lg = g + lg.translateBy(x: e.x, y: e.y) + lg.scaleBy(x: e.fx, y: e.fy) + // circle fill + var circle = Path() + circle.addEllipse(in: CGRect(x: -d / 2, y: -d / 2, width: d, height: d)) + lg.fill(circle, with: .color(Color(red: 190/255, green: 225/255, blue: 1, opacity: 0.18))) + lg.stroke(circle, with: .color(Color(hex: "#9A5A1A")), + style: StrokeStyle(lineWidth: H.R * 0.065, lineCap: .round)) + // highlight arc + var arcPath = Path() + arcPath.addArc(center: .zero, radius: d / 2 - H.R * 0.03, + startAngle: .radians(.pi * 1.1), endAngle: .radians(.pi * 1.45), clockwise: false) + lg.stroke(arcPath, with: .color(Color.white.opacity(0.55)), + style: StrokeStyle(lineWidth: H.R * 0.03, lineCap: .round)) + } +} + +// MARK: - Scarf + +private func drawScarfFront(ctx: inout GraphicsContext, H: MochiH) { + let s: CGFloat = 1.05, y0: CGFloat = -0.34, y1: CGFloat = -0.66 + let top = mFrontArcRoll(H, y: y0, s: s) + let bot = mFrontArcRoll(H, y: y1, s: s) + guard !top.isEmpty, !bot.isEmpty else { return } + + var band = Path() + band.move(to: CGPoint(x: top[0].x, y: top[0].y)) + for q in top.dropFirst() { band.addLine(to: CGPoint(x: q.x, y: q.y)) } + for i in stride(from: bot.count - 1, through: 0, by: -1) { + band.addLine(to: CGPoint(x: bot[i].x, y: bot[i].y)) + } + band.closeSubpath() + + var g = ctx + g.clip(to: mochiOutfitPath(H.rx * s, H.ry * s)) + g.fill(band, with: .linearGradient( + Gradient(stops: [ + .init(color: Color(hex: "#F87171"), location: 0), + .init(color: Color(hex: "#B91C1C"), location: 1) + ]), + startPoint: CGPoint(x: 0, y: -H.ry * 0.2), + endPoint: CGPoint(x: 0, y: H.ry * 0.7) + )) + var g2 = g + g2.clip(to: band) + // stripes along meridians + for lon: CGFloat in [-1.0, -0.45, 0.1, 0.65, 1.2] { + let a = mProj(H, mSurf(y0, lon, s)) + let b = mProj(H, mSurf(y1, lon, s)) + if a.z < 0 { continue } + var stripe = Path() + stripe.move(to: CGPoint(x: a.x, y: a.y - 4)) + stripe.addLine(to: CGPoint(x: b.x, y: b.y + 4)) + g2.stroke(stripe, with: .color(Color.white.opacity(0.85)), + style: StrokeStyle(lineWidth: H.R * 0.09 * max(0.3, a.z), lineCap: .round)) + } + g.fill(band, with: .linearGradient( + Gradient(stops: [ + .init(color: Color.white.opacity(0.18), location: 0), + .init(color: Color.black.opacity(0.10), location: 1) + ]), + startPoint: CGPoint(x: 0, y: -H.ry * 0.5), + endPoint: CGPoint(x: 0, y: H.ry * 0.3) + )) + + // hanging end + let k = mProj(H, mSurf((y0 + y1) / 2, -0.55, s * 1.03)) + if k.z > 0 { + let sw = H.physDx * H.rx * 0.12 + var end = Path() + end.move(to: CGPoint(x: k.x - H.R * 0.16, y: k.y)) + end.addQuadCurve( + to: CGPoint(x: k.x - H.R * 0.2 + sw * 1.4, y: k.y + H.ry * 0.62), + control: CGPoint(x: k.x - H.R * 0.24 + sw, y: k.y + H.ry * 0.35) + ) + end.addLine(to: CGPoint(x: k.x + H.R * 0.06 + sw * 1.4, y: k.y + H.ry * 0.60)) + end.addQuadCurve( + to: CGPoint(x: k.x + H.R * 0.12, y: k.y), + control: CGPoint(x: k.x + H.R * 0.02 + sw, y: k.y + H.ry * 0.3) + ) + end.closeSubpath() + ctx.fill(end, with: .linearGradient( + Gradient(stops: [ + .init(color: Color(hex: "#EF4444"), location: 0), + .init(color: Color(hex: "#B91C1C"), location: 1) + ]), + startPoint: CGPoint(x: 0, y: k.y), + endPoint: CGPoint(x: 0, y: k.y + H.ry * 0.6) + )) + var eCtx = ctx + eCtx.clip(to: end) + // stripes in hanging end + for t: CGFloat in [0.35, 0.7] { + var stripe = Path() + stripe.addRect(CGRect( + x: k.x - H.R * 0.4 + sw, + y: k.y + H.ry * 0.62 * t, + width: H.R * 0.8, height: H.R * 0.07 + )) + eCtx.fill(stripe, with: .color(Color.white.opacity(0.85))) + } + // fringe + for i in 0..<4 { + let fx = k.x - H.R * 0.17 + sw * 1.4 + CGFloat(i) * H.R * 0.075 + var fringe = Path() + fringe.move(to: CGPoint(x: fx, y: k.y + H.ry * 0.6)) + fringe.addLine(to: CGPoint(x: fx, y: k.y + H.ry * 0.72)) + ctx.stroke(fringe, with: .color(Color(hex: "#DC2626")), + style: StrokeStyle(lineWidth: H.R * 0.035, lineCap: .round)) + } + // knot (rotated ellipse) + var knotCtx = ctx + knotCtx.translateBy(x: k.x, y: k.y) + knotCtx.rotate(by: .radians(0.2)) + var knot = Path() + knot.addEllipse(in: CGRect(x: -H.R * 0.17, y: -H.R * 0.14, width: H.R * 0.34, height: H.R * 0.28)) + knotCtx.fill(knot, with: .radialGradient( + Gradient(stops: [ + .init(color: Color(hex: "#F87171"), location: 0), + .init(color: Color(hex: "#B91C1C"), location: 1) + ]), + center: CGPoint(x: -H.R * 0.05, y: -H.R * 0.05), + startRadius: 0, endRadius: H.R * 0.2 + )) + } +} + +// MARK: - Pumpkin + +private func drawPumpkinFront(ctx: inout GraphicsContext, H: MochiH, bodyPath: Path, simplified: Bool = false) { + // ribs only — body recolor is done in BotEngine.drawBody(pumpkinColors:) + if !simplified { + var g = ctx + g.clip(to: bodyPath) + for lon: CGFloat in [-1.15, -0.55, 0.0, 0.55, 1.15] { + var pts: [P3] = [] + for i in 0...30 { + let y = -0.98 + 1.96 * CGFloat(i) / 30 + let q = mProjRoll(H, mSurf(y, lon, 1)) + if q.z > 0 { pts.append(q) } + } + guard pts.count >= 2 else { continue } + var rib = Path() + rib.move(to: CGPoint(x: pts[0].x, y: pts[0].y)) + for pt in pts.dropFirst() { rib.addLine(to: CGPoint(x: pt.x, y: pt.y)) } + let zz = pts[pts.count / 2].z + g.stroke(rib, with: .color(Color(red: 150/255, green: 50/255, blue: 0, opacity: 0.22 * zz)), + style: StrokeStyle(lineWidth: H.R * 0.12, lineCap: .round)) + // highlight offset stripe + var hi = Path() + hi.move(to: CGPoint(x: pts[0].x + H.R * 0.07, y: pts[0].y)) + for pt in pts.dropFirst() { hi.addLine(to: CGPoint(x: pt.x + H.R * 0.07, y: pt.y)) } + g.stroke(hi, with: .color(Color(red: 1, green: 220/255, blue: 170/255, opacity: 0.18 * zz)), + style: StrokeStyle(lineWidth: H.R * 0.04, lineCap: .round)) + } + } + + let t = mProjRoll(H, (0.02, 1.0, 0)) + // stem + var stem = Path() + stem.move(to: CGPoint(x: t.x - H.R * 0.09, y: t.y + H.R * 0.04)) + stem.addQuadCurve( + to: CGPoint(x: t.x + H.R * 0.08, y: t.y - H.R * 0.3), + control: CGPoint(x: t.x - H.R * 0.08, y: t.y - H.R * 0.22) + ) + stem.addLine(to: CGPoint(x: t.x + H.R * 0.13, y: t.y - H.R * 0.22)) + stem.addQuadCurve( + to: CGPoint(x: t.x + H.R * 0.08, y: t.y + H.R * 0.04), + control: CGPoint(x: t.x + H.R * 0.04, y: t.y - H.R * 0.15) + ) + stem.closeSubpath() + ctx.fill(stem, with: .linearGradient( + Gradient(stops: [ + .init(color: Color(hex: "#65A30D"), location: 0), + .init(color: Color(hex: "#3F6212"), location: 1) + ]), + startPoint: CGPoint(x: t.x - H.R * 0.1, y: 0), + endPoint: CGPoint(x: t.x + H.R * 0.1, y: 0) + )) + + // leaf + var leafCtx = ctx + leafCtx.translateBy(x: t.x - H.R * 0.06, y: t.y - H.R * 0.02) + leafCtx.rotate(by: .radians(-0.5)) + var leaf = Path() + leaf.move(to: .zero) + leaf.addQuadCurve( + to: CGPoint(x: -H.R * 0.38, y: -H.R * 0.02), + control: CGPoint(x: -H.R * 0.18, y: -H.R * 0.2) + ) + leaf.addQuadCurve( + to: .zero, + control: CGPoint(x: -H.R * 0.18, y: H.R * 0.1) + ) + leafCtx.fill(leaf, with: .linearGradient( + Gradient(stops: [ + .init(color: Color(hex: "#84CC16"), location: 0), + .init(color: Color(hex: "#4D7C0F"), location: 1) + ]), + startPoint: CGPoint(x: 0, y: -H.R * 0.15), + endPoint: CGPoint(x: -H.R * 0.3, y: 0) + )) + var vein = Path() + vein.move(to: CGPoint(x: -H.R * 0.02, y: -H.R * 0.01)) + vein.addQuadCurve( + to: CGPoint(x: -H.R * 0.32, y: -H.R * 0.03), + control: CGPoint(x: -H.R * 0.18, y: -H.R * 0.08) + ) + leafCtx.stroke(vein, with: .color(Color(red: 30/255, green: 60/255, blue: 0, opacity: 0.4)), + style: StrokeStyle(lineWidth: H.R * 0.02, lineCap: .round)) + + // tendril (skip when simplified) + if !simplified { + var tendril = Path() + tendril.move(to: CGPoint(x: t.x + H.R * 0.1, y: t.y - H.R * 0.12)) + tendril.addCurve( + to: CGPoint(x: t.x + H.R * 0.22, y: t.y - H.R * 0.06), + control1: CGPoint(x: t.x + H.R * 0.3, y: t.y - H.R * 0.25), + control2: CGPoint(x: t.x + H.R * 0.35, y: t.y - H.R * 0.02) + ) + ctx.stroke(tendril, with: .color(Color(hex: "#4D7C0F")), + style: StrokeStyle(lineWidth: H.R * 0.03, lineCap: .round)) + } +} + +// MARK: - Bow + +private func drawBowFront(ctx: inout GraphicsContext, H: MochiH, bodyPath: Path) { + let a = mProjRoll(H, mSurf(0.86, 0.55, 1.02)) + guard a.z >= -0.2 else { return } + let s = H.R * 0.26 + let sq = max(0.45, cos(0.55 + H.yaw)) + + var g = ctx + g.translateBy(x: a.x, y: a.y) + g.rotate(by: .radians(0.35 + H.yaw * 0.3)) + g.scaleBy(x: sq, y: 1) + + for sdD: Double in [-1.0, 1.0] { + let sd = CGFloat(sdD) + var wing = Path() + wing.move(to: .zero) + wing.addCurve( + to: CGPoint(x: sd * s * 1.15, y: 0), + control1: CGPoint(x: sd * s * 0.6, y: -s * 0.85), + control2: CGPoint(x: sd * s * 1.35, y: -s * 0.55) + ) + wing.addCurve( + to: .zero, + control1: CGPoint(x: sd * s * 1.35, y: s * 0.55), + control2: CGPoint(x: sd * s * 0.6, y: s * 0.85) + ) + g.fill(wing, with: .linearGradient( + Gradient(stops: [ + .init(color: Color(hex: "#FF8CC6"), location: 0), + .init(color: Color(hex: "#DB2777"), location: 1) + ]), + startPoint: CGPoint(x: 0, y: -s), + endPoint: CGPoint(x: 0, y: s) + )) + // crease + var crease = Path() + crease.move(to: CGPoint(x: sd * s * 0.25, y: -s * 0.05)) + crease.addQuadCurve( + to: CGPoint(x: sd * s * 0.95, y: -s * 0.05), + control: CGPoint(x: sd * s * 0.7, y: -s * 0.15) + ) + g.stroke(crease, with: .color(Color(red: 140/255, green: 10/255, blue: 70/255, opacity: 0.35)), + style: StrokeStyle(lineWidth: s * 0.08, lineCap: .round)) + } + // centre knot + var knot = Path() + knot.addEllipse(in: CGRect(x: -s * 0.24, y: -s * 0.30, width: s * 0.48, height: s * 0.60)) + g.fill(knot, with: .radialGradient( + Gradient(stops: [ + .init(color: Color(hex: "#FFB3D9"), location: 0), + .init(color: Color(hex: "#C2185B"), location: 1) + ]), + center: CGPoint(x: -s * 0.06, y: -s * 0.1), + startRadius: 0, endRadius: s * 0.35 + )) +} diff --git a/NotchBuddy/Sources/CoucouKit/MochiStill.swift b/NotchBuddy/Sources/CoucouKit/MochiStill.swift new file mode 100644 index 0000000..c74edce --- /dev/null +++ b/NotchBuddy/Sources/CoucouKit/MochiStill.swift @@ -0,0 +1,89 @@ +import SwiftUI + +/// Mochi in a fixed pose: one frame of BotEngine, no animation, no timer. +/// For surfaces that can't animate (widgets, lists on the iPhone). The Mac +/// island keeps its animated BotCanvasView / MiniBotCanvasView. +struct MochiStill: View { + var state: BotState = .idle + /// Overrides the state's eyes (e.g. .closed for a sleeping agent). + var eye: EyeShape? = nil + /// Body color; white by default, like the mockups. + var bodyHex: String = "#FFFFFF" + var showBadge: Bool = true + /// Where he looks and how his eyes are, for a still that isn't always the same. + var pose: MochiPose = .neutral + + var body: some View { + Canvas { context, size in + let engine = BotEngine() + engine.isMini = true + engine.bodyColor = cgColorFromHex(bodyHex) + engine.state = state + engine.cfg = BotStates[state]! + engine.yaw = pose.yaw + engine.pitch = pose.pitch + engine.tilt = pose.tilt + engine.open = pose.open + if let eye = eye ?? pose.eye { + engine.permanentEye = eye + engine.eyeOverride = eye + engine.eyeOverrideUntil = .greatestFiniteMagnitude + } + engine.draw(context: context, size: size) + if showBadge, let badge = engine.cfg.badge { + engine.badge = badge + engine.badgeS = 1 + engine.drawHandsAndExtras(context: context, size: size) + } + } + .aspectRatio(1, contentMode: .fit) + } +} + +/// One frame of the little life the Mac's mini Mochi have: looking around, +/// blinking, a happy squint. Widgets can't animate, so each refresh of the +/// timeline picks a new pose per Mochi; the system cross-fades between them. +struct MochiPose: Equatable, Sendable { + var yaw: CGFloat = 0 + var pitch: CGFloat = 0 + var tilt: CGFloat = 0 + var open: CGFloat = 1 + var eye: EyeShape? = nil + + static let neutral = MochiPose() + + static let idlePoses: [MochiPose] = [ + MochiPose(), + MochiPose(yaw: -0.45), + MochiPose(yaw: 0.45), + MochiPose(yaw: -0.3, pitch: -0.25), + MochiPose(yaw: 0.35, pitch: 0.2, tilt: 0.08), + MochiPose(pitch: -0.3, tilt: -0.06), + MochiPose(eye: .closed), // blink + MochiPose(yaw: 0.2, eye: .happy), + MochiPose(yaw: -0.5, tilt: -0.1), + MochiPose(yaw: 0.15, pitch: 0.25), + ] + + /// A pose that differs per Mochi and per tick, the same every time for a + /// given pair (so a widget redraw doesn't jump). + static func idle(id: String, tick: Int) -> MochiPose { + var hash: UInt64 = 5381 + for byte in id.utf8 { hash = (hash &* 33) &+ UInt64(byte) } + hash = (hash &* 31) &+ UInt64(truncatingIfNeeded: tick &* 7919) + hash ^= hash >> 13 + return idlePoses[Int(hash % UInt64(idlePoses.count))] + } +} + +extension Color { + /// Background for a white Mochi on an agent's color. Very light colors + /// (VS Code's is near-white) would hide him, so they get a dark gray tile. + static func mochiTile(hex: String) -> Color { + guard let c = cgColorFromHex(hex), let comps = c.components, comps.count >= 3 else { + return Color(hex: hex) + } + let luminance = 0.2126 * comps[0] + 0.7152 * comps[1] + 0.0722 * comps[2] + return luminance > 0.7 ? Color(white: 0.32) : Color(hex: hex) + } +} diff --git a/NotchBuddy/Sources/CoucouKit/MochiWardrobe.swift b/NotchBuddy/Sources/CoucouKit/MochiWardrobe.swift new file mode 100644 index 0000000..98b930e --- /dev/null +++ b/NotchBuddy/Sources/CoucouKit/MochiWardrobe.swift @@ -0,0 +1,89 @@ +import Foundation + +enum Outfit: String, CaseIterable { + case auto, none, partyHat, beanie, crown, sunglasses, roundGlasses, + bow, scarf, witchHat, pumpkin, santaHat, bunnyEars + + var displayName: String { + switch self { + case .auto: return "Auto (seasons)" + case .none: return "None" + case .partyHat: return "Party hat" + case .beanie: return "Beanie" + case .crown: return "Crown" + case .sunglasses: return "Sunglasses" + case .roundGlasses: return "Round glasses" + case .bow: return "Bow" + case .scarf: return "Scarf" + case .witchHat: return "Witch hat" + case .pumpkin: return "Pumpkin" + case .santaHat: return "Santa hat" + case .bunnyEars: return "Bunny ears" + } + } + + // Returns the seasonal outfit for the given date (user's local calendar). + // Priority: partyHat > santaHat > witchHat > bunnyEars > sunglasses > none + static func seasonal(for date: Date, calendar: Calendar) -> Outfit { + let day = calendar.component(.day, from: date) + let month = calendar.component(.month, from: date) + let year = calendar.component(.year, from: date) + + // Dec 31 – Jan 2 → party hat + if (month == 12 && day == 31) || (month == 1 && day <= 2) { return .partyHat } + + // Dec 1–26 → santa hat + if month == 12 && day <= 26 { return .santaHat } + + // Oct 1 – Nov 1 → witch hat + if month == 10 || (month == 11 && day == 1) { return .witchHat } + + // Easter −2 / +1 → bunny ears (Meeus/Jones/Butcher algorithm) + let (eMonth, eDay) = easterDate(year: year) + let eComps = DateComponents(year: year, month: eMonth, day: eDay) + if let easterDate = calendar.date(from: eComps) { + let delta = calendar.dateComponents([.day], from: easterDate, to: date).day ?? 0 + if delta >= -2 && delta <= 1 { return .bunnyEars } + } + + // Jun 21 – Aug 31 → sunglasses + if (month == 6 && day >= 21) || month == 7 || month == 8 { return .sunglasses } + + return .none + } + + // If selection == .auto → seasonal; otherwise selection itself. + static func resolved(selection: Outfit, date: Date, calendar: Calendar) -> Outfit { + selection == .auto ? seasonal(for: date, calendar: calendar) : selection + } + + // UserDefaults key "mochiOutfit", default "auto", unknown value → .auto + static var stored: Outfit { + get { + let raw = UserDefaults.standard.string(forKey: "mochiOutfit") ?? "auto" + return Outfit(rawValue: raw) ?? .auto + } + set { + UserDefaults.standard.set(newValue.rawValue, forKey: "mochiOutfit") + } + } +} + +// Meeus/Jones/Butcher algorithm — returns (month, day) of Easter Sunday for the given year. +private func easterDate(year: Int) -> (Int, Int) { + let a = year % 19 + let b = year / 100 + let c = year % 100 + let d = b / 4 + let e = b % 4 + let f = (b + 8) / 25 + let g = (b - f + 1) / 3 + let h = (19 * a + b - d - g + 15) % 30 + let i = c / 4 + let k = c % 4 + let l = (32 + 2 * e + 2 * i - h - k) % 7 + let m = (a + 11 * h + 22 * l) / 451 + let month = (h + l - 7 * m + 114) / 31 + let day = (h + l - 7 * m + 114) % 31 + 1 + return (month, day) +} diff --git a/NotchBuddy/Sources/CoucouKit/PillCatalog.swift b/NotchBuddy/Sources/CoucouKit/PillCatalog.swift new file mode 100644 index 0000000..530b5ec --- /dev/null +++ b/NotchBuddy/Sources/CoucouKit/PillCatalog.swift @@ -0,0 +1,108 @@ +import Foundation + +// MARK: - Pill category + +enum PillCategory: String, CaseIterable { + case workspace + case agent + case ai + case service + + var title: String { + switch self { + case .workspace: return "Where you code" + case .agent: return "Agents" + case .ai: return "AI for the chat" + case .service: return "Services" + } + } +} + +// MARK: - Pill definition + +struct PillDefinition { + let id: String + let name: String + let color: String + let category: PillCategory + /// Label shown next to the task name in the idle card header. + let subtitle: String + let source: AgentSource + var comingSoon: Bool = false + var githubOnly: Bool = false + + /// Label shown in the active-session card header (workspace/agent pills only). + var sessionSubtitle: String { + switch id { + case "integration_claude": return "Claude Code" + case "agent_cursor": return "Cursor" + case "agent_codex": return "Codex" + default: return "Agent" + } + } +} + +// MARK: - Catalog + +enum PillCatalog { + // All declared pills in display order. + static let all: [PillDefinition] = [ + // ── Where you code ─────────────────────────────────────────────────── + .init(id: "integration_claude", name: "VS Code", color: "#F5F6F8", + category: .workspace, subtitle: "Integration", source: .claudeCode), + .init(id: "agent_cursor", name: "Cursor", color: "#C0C4CC", + category: .workspace, subtitle: "Integration", source: .agent), + .init(id: "agent_antigravity", name: "Antigravity", color: "#E879F9", + category: .workspace, subtitle: "Integration", source: .agent, githubOnly: true), + .init(id: "agent_codex", name: "Codex", color: "#2DD4BF", + category: .workspace, subtitle: "Integration", source: .agent, githubOnly: true), + // ── Agents ─────────────────────────────────────────────────────────── + .init(id: "agent_gemini", name: "Gemini CLI", color: "#8AB4F8", + category: .agent, subtitle: "Agent", source: .agent, githubOnly: true), + // ── AI for the chat ────────────────────────────────────────────────── + .init(id: "ai_anthropic", name: "Anthropic", color: ChatProvider.anthropic.accentHex, + category: .ai, subtitle: "Chat", source: .n8n), + .init(id: "ai_google", name: "Google AI", color: ChatProvider.google.accentHex, + category: .ai, subtitle: "Chat", source: .n8n), + .init(id: "ai_openai", name: "OpenAI", color: ChatProvider.openai.accentHex, + category: .ai, subtitle: "Chat", source: .n8n), + .init(id: "ai_ollama", name: "Ollama", color: ChatProvider.ollama.accentHex, + category: .ai, subtitle: "Chat", source: .n8n), + .init(id: "ai_lmstudio", name: "LM Studio", color: ChatProvider.lmstudio.accentHex, + category: .ai, subtitle: "Chat", source: .n8n), + // ── Services ───────────────────────────────────────────────────────── + .init(id: "integration_resend", name: "Resend", color: "#22C55E", + category: .service, subtitle: "Integration", source: .n8n), + .init(id: "integration_n8n", name: "n8n", color: "#F29B38", + category: .service, subtitle: "Integration", source: .n8n), + .init(id: "integration_vercel", name: "Vercel", color: "#7C5CFF", + category: .service, subtitle: "Integration", source: .n8n), + .init(id: "integration_github", name: "GitHub", color: "#F4505E", + category: .service, subtitle: "Integration", source: .n8n), + .init(id: "integration_notion", name: "Notion", color: "#8C8C8C", + category: .service, subtitle: "Integration", source: .n8n), + .init(id: "integration_calcom", name: "Cal.com", color: "#C9956A", + category: .service, subtitle: "Integration", source: .n8n), + .init(id: "integration_stripe", name: "Stripe", color: "#0570DE", + category: .service, subtitle: "Integration", source: .n8n), + .init(id: "integration_music", name: "Apple Music", color: "#FA2D48", + category: .service, subtitle: "Integration", source: .n8n, githubOnly: true), + ] + + /// Pills available in the current build target. + static var available: [PillDefinition] { + #if APPSTORE + all.filter { !$0.githubOnly } + #else + all + #endif + } + + /// Default ID for the always-on main workspace pill. + static let defaultMainPillId = "integration_claude" + + /// Looks up a definition by task ID (nil if not in catalog). + static func definition(for id: String) -> PillDefinition? { + all.first { $0.id == id } + } +} diff --git a/NotchBuddy/Sources/CoucouKit/QuestionPayload.swift b/NotchBuddy/Sources/CoucouKit/QuestionPayload.swift new file mode 100644 index 0000000..ae2a12e --- /dev/null +++ b/NotchBuddy/Sources/CoucouKit/QuestionPayload.swift @@ -0,0 +1,64 @@ +import CryptoKit +import Foundation + +// A question Claude Code asks (AskUserQuestion), as the iPhone gets it: the +// questions with their choices. The Mac writes it, encrypted, in the session +// record; the iPhone answers with an `Answer` record carrying the same +// fingerprint and the labels picked. The Mac only accepts labels that are +// among the choices of the question still waiting. + +struct QuestionPayload: Codable, Equatable, Sendable { + struct Option: Codable, Equatable, Sendable { + var label: String + var description: String + } + + struct Item: Codable, Equatable, Sendable { + var question: String + var header: String + var options: [Option] + var multiSelect: Bool + } + + var items: [Item] + + /// Stable identifier of this exact question. + var fingerprint: String { + let raw = items.map { item in + ([item.question, item.multiSelect ? "multi" : "single"] + item.options.map(\.label)).joined(separator: "\u{1F}") + }.joined(separator: "\u{1E}") + return SHA256.hash(data: Data(raw.utf8)).map { String(format: "%02x", $0) }.joined() + } + + var json: String { + guard let data = try? JSONEncoder().encode(self) else { return "" } + return String(decoding: data, as: UTF8.self) + } + + static func decode(_ json: String) -> QuestionPayload? { + guard !json.isEmpty else { return nil } + return try? JSONDecoder().decode(QuestionPayload.self, from: Data(json.utf8)) + } + + /// The picks sent back: one list of labels per question. + static func encodeSelections(_ selections: [[String]]) -> String { + guard let data = try? JSONEncoder().encode(selections) else { return "[]" } + return String(decoding: data, as: UTF8.self) + } + + static func decodeSelections(_ json: String) -> [[String]]? { + try? JSONDecoder().decode([[String]].self, from: Data(json.utf8)) + } + + /// True when every pick is one of that question's choices, with one pick + /// per single-choice question. + func accepts(_ selections: [[String]]) -> Bool { + guard selections.count == items.count else { return false } + for (item, picks) in zip(items, selections) { + let labels = Set(item.options.map(\.label)) + guard !picks.isEmpty, picks.allSatisfy(labels.contains) else { return false } + if !item.multiSelect && picks.count != 1 { return false } + } + return true + } +} diff --git a/NotchBuddy/Sources/CoucouKit/ServiceDetail.swift b/NotchBuddy/Sources/CoucouKit/ServiceDetail.swift new file mode 100644 index 0000000..da4af81 --- /dev/null +++ b/NotchBuddy/Sources/CoucouKit/ServiceDetail.swift @@ -0,0 +1,81 @@ +import Foundation + +// A service seen up close, for the iPhone: figures, lists and what can be +// done from there. When a service's screen opens, the iPhone asks the Mac for +// it (a `ServiceAction` record of kind "refresh"); the Mac reads the service's +// API with the key in its Keychain and writes a `ServiceDetail` record, +// encrypted. An action (redeploy, re-run, merge…) is the same kind of record: +// the Mac runs it only if the iPhone asked for one it offered on that item, +// then writes the detail again with the result. No key ever leaves the Mac. + +struct ServiceActionDef: Codable, Equatable, Sendable, Hashable { + /// What the Mac does, e.g. "vercel.redeploy", "github.merge". + var kind: String + var title: String + var symbol: String + /// The thing it acts on (deployment id, "owner/repo#12"…). + var target: String + var destructive: Bool = false + /// Asked before doing it, when it can't be undone. + var confirm: String? = nil +} + +struct DetailStat: Codable, Equatable, Sendable { + var label: String + var value: String + var tone: ServiceTone = .idle +} + +struct DetailItem: Codable, Equatable, Sendable { + var title: String + var subtitle: String = "" + var tone: ServiceTone = .idle + var date: Date? = nil + var url: String? = nil + /// A short tag on the right ("Production", "Draft", "Active"…). + var badge: String? = nil + var actions: [ServiceActionDef] = [] +} + +struct DetailSection: Codable, Equatable, Sendable { + var title: String + var items: [DetailItem] + var footer: String? = nil +} + +struct ServiceActionResult: Codable, Equatable, Sendable { + var title: String + var ok: Bool + var message: String + var date: Date +} + +struct ServiceDetail: Codable, Equatable, Sendable { + var pillId: String + var stats: [DetailStat] = [] + var sections: [DetailSection] = [] + var fetchedAt: Date + /// Why the API couldn't be read (no key, 401…). + var error: String? = nil + var lastAction: ServiceActionResult? = nil + + static let recordType = "ServiceDetail" + static let requestType = "ServiceAction" + static let refreshKind = "refresh" + + static func recordName(for pillId: String) -> String { "detail-\(pillId)" } + + static func pillId(fromRecordName name: String) -> String? { + name.hasPrefix("detail-") ? String(name.dropFirst("detail-".count)) : nil + } + + /// The action offered on one of the items, if the Mac offered it. + func offered(kind: String, target: String) -> ServiceActionDef? { + for section in sections { + for item in section.items { + if let action = item.actions.first(where: { $0.kind == kind && $0.target == target }) { return action } + } + } + return nil + } +} diff --git a/NotchBuddy/Sources/CoucouKit/ServiceSnapshot.swift b/NotchBuddy/Sources/CoucouKit/ServiceSnapshot.swift new file mode 100644 index 0000000..66cba84 --- /dev/null +++ b/NotchBuddy/Sources/CoucouKit/ServiceSnapshot.swift @@ -0,0 +1,67 @@ +import Foundation + +// What the iPhone shows for one service Mochi (GitHub, Stripe, Vercel, Resend, +// Cal.com, n8n, Notion): its state dot, why it has that color, and the latest +// items the Mac fetched. The Mac builds it from the data its pollers already +// keep (ServicePublisher) and writes it to the private iCloud zone, encrypted; +// the iPhone only reads it. No API key ever leaves the Mac. + +enum ServiceTone: String, Codable, Sendable { + case ok, warning, error, info, idle +} + +struct ServiceItem: Codable, Equatable, Sendable { + var title: String + var detail: String = "" + var tone: ServiceTone = .idle + var date: Date? = nil + var url: String? = nil +} + +struct ServiceSection: Codable, Equatable, Sendable { + var title: String + var items: [ServiceItem] +} + +struct ServiceSnapshot: Codable, Equatable, Sendable { + var pillId: String + var tone: ServiceTone + /// One line under the name ("Balance 1 240,00 EUR", "2 pull requests"). + var headline: String + /// Why the dot has its color ("CI failing on coucou · main"). + var reason: String + var sections: [ServiceSection] + var updatedAt: Date + + static let recordType = "Service" + + static func recordName(for pillId: String) -> String { "service-\(pillId)" } + + static func pillId(fromRecordName name: String) -> String? { + name.hasPrefix("service-") ? String(name.dropFirst("service-".count)) : nil + } + + /// Equal apart from the date: nothing to write again. + func sameContent(as other: ServiceSnapshot) -> Bool { + var a = self, b = other + a.updatedAt = .distantPast + b.updatedAt = .distantPast + return a == b + } +} + +extension PillCatalog { + /// The service Mochi the iPhone shows, in this order. + static let phoneServices = [ + "integration_github", "integration_stripe", "integration_vercel", "integration_resend", + "integration_calcom", "integration_n8n", "integration_notion", + ] + + /// Agent sessions (VS Code, Cursor, Codex…), as opposed to services and chat providers. + static func isSession(_ id: String) -> Bool { + switch definition(for: id)?.category { + case .service, .ai: false + default: true + } + } +} diff --git a/NotchBuddy/Sources/CoucouKit/TurnSnapshot.swift b/NotchBuddy/Sources/CoucouKit/TurnSnapshot.swift new file mode 100644 index 0000000..15f95b0 --- /dev/null +++ b/NotchBuddy/Sources/CoucouKit/TurnSnapshot.swift @@ -0,0 +1,78 @@ +import Foundation + +// The last turn of an agent session, for the iPhone: the prompt you sent, what +// the agent did (commands, reads, searches, edits with their diffs) and its +// final answer. The Mac builds it from the hook events it already receives +// (TurnRecorder) and writes it, encrypted, to the private iCloud zone. Only +// the latest turn per session is kept. + +struct TurnAction: Codable, Equatable, Sendable { + var tool: String // "Bash", "Edit", "Read"… + var summary: String // the command, file or pattern + var output: String = "" // what a command printed (trimmed) + var failed: Bool = false + var date: Date + /// Index in `files` when this action changed a file. + var fileIndex: Int? = nil +} + +struct TurnDiffLine: Codable, Equatable, Sendable { + enum Kind: String, Codable, Sendable { case context, added, removed, gap } + var kind: Kind + var text: String +} + +struct TurnFile: Codable, Equatable, Sendable { + var path: String + var added: Int + var removed: Int + var isNew: Bool + var lines: [TurnDiffLine] + /// Some lines were left out to keep the turn small. + var truncated: Bool = false + + var name: String { URL(fileURLWithPath: path).lastPathComponent } +} + +struct TurnSnapshot: Codable, Equatable, Sendable { + var pillId: String + var sessionId: String + var project: String + var prompt: String + var actions: [TurnAction] + var files: [TurnFile] + var finalMessage: String + var startedAt: Date + var endedAt: Date? + + static let recordType = "Turn" + + static func recordName(for pillId: String) -> String { "turn-\(pillId)" } + + static func pillId(fromRecordName name: String) -> String? { + name.hasPrefix("turn-") ? String(name.dropFirst("turn-".count)) : nil + } +} + +extension TurnFile { + /// The diff lines of a FileDiff (DiffEngine), hunks separated by a gap line. + init(diff: FileDiff, maxLines: Int) { + var lines: [TurnDiffLine] = [] + var truncated = diff.tooLarge + for (index, hunk) in diff.hunks.enumerated() { + if index > 0 { lines.append(TurnDiffLine(kind: .gap, text: "")) } + for line in hunk.lines { + guard lines.count < maxLines else { truncated = true; break } + let kind: TurnDiffLine.Kind + switch line.kind { + case .added: kind = .added + case .removed: kind = .removed + case .context: kind = .context + } + lines.append(TurnDiffLine(kind: kind, text: String(line.text.prefix(400)))) + } + } + self.init(path: diff.path, added: diff.added, removed: diff.removed, + isNew: diff.isNewFile, lines: lines, truncated: truncated) + } +} diff --git a/NotchBuddy/project.yml b/NotchBuddy/project.yml new file mode 100644 index 0000000..114433f --- /dev/null +++ b/NotchBuddy/project.yml @@ -0,0 +1,395 @@ +name: NotchBuddy +options: + bundleIdPrefix: fr.louisraille + deploymentTarget: + macOS: "15.0" + xcodeVersion: "27.0" + createIntermediateGroups: true + +# DebugCloud = Debug, but signed with iCloud (CloudKit) and push entitlements +# and compiled with PHONE_LINK, for the iPhone link spike. ReleaseCloud = the +# same for a Developer ID build with its provisioning profile +# (scripts/cloud-test-build.sh); a Developer ID profile only allows the +# Production CloudKit environment, so the iPhone joins it with the +# CoucouPhoneProduction scheme. Release also compiles PHONE_LINK (the sync +# stays off until turned on in Settings); Debug stays unsigned without it. +configs: + Debug: debug + DebugCloud: debug + Release: release + ReleaseCloud: release + +settings: + base: + SWIFT_VERSION: "6.0" + ENABLE_HARDENED_RUNTIME: YES + CODE_SIGN_STYLE: Manual + OTHER_SWIFT_FLAGS: "-strict-concurrency=complete" + configs: + Debug: + CODE_SIGNING_REQUIRED: NO + CODE_SIGNING_ALLOWED: NO + DebugCloud: + CODE_SIGNING_REQUIRED: YES + CODE_SIGNING_ALLOWED: YES + CODE_SIGN_STYLE: Automatic + CODE_SIGN_IDENTITY: "Apple Development" + DEVELOPMENT_TEAM: 256AUJ9555 + Release: + CODE_SIGNING_REQUIRED: YES + CODE_SIGNING_ALLOWED: YES + CODE_SIGN_IDENTITY: "Developer ID Application" + DEVELOPMENT_TEAM: 256AUJ9555 + OTHER_CODE_SIGN_FLAGS: "--timestamp" + CODE_SIGN_INJECT_BASE_ENTITLEMENTS: NO + CODE_SIGN_ENTITLEMENTS: Resources/Coucou.entitlements + ReleaseCloud: + CODE_SIGNING_REQUIRED: YES + CODE_SIGNING_ALLOWED: YES + CODE_SIGN_IDENTITY: "Developer ID Application" + DEVELOPMENT_TEAM: 256AUJ9555 + OTHER_CODE_SIGN_FLAGS: "--timestamp" + CODE_SIGN_INJECT_BASE_ENTITLEMENTS: NO + +schemes: + NotchBuddy: + build: + targets: + NotchBuddy: all + run: + config: Debug + archive: + config: Release + CoucouAppStore: + build: + targets: + CoucouAppStore: all + run: + config: Debug + archive: + config: Release + NotchBuddyCloud: + build: + targets: + NotchBuddy: all + run: + config: DebugCloud + archive: + config: Release + CoucouAppStoreCloud: + build: + targets: + CoucouAppStore: all + run: + config: DebugCloud + archive: + config: Release + CoucouPhone: + build: + targets: + CoucouPhone: all + run: + config: Debug + archive: + config: Release + # Same iPhone app on the Production CloudKit environment, to talk to the + # Developer ID build (scripts/cloud-test-build.sh), which can only use Production. + CoucouPhoneProduction: + build: + targets: + CoucouPhone: all + run: + config: DebugCloud + archive: + config: Release + +targets: + NotchBuddy: + 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/Info.plist + properties: + CFBundleName: Coucou + CFBundleDisplayName: Coucou + CFBundleIdentifier: fr.louisraille.NotchBuddy + CFBundleVersion: "10" + CFBundleShortVersionString: "0.1.9" + CFBundlePackageType: APPL + LSUIElement: YES + NSHighResolutionCapable: YES + NSSupportsAutomaticGraphicsSwitching: YES + NSAppleEventsUsageDescription: "Pour sauter au terminal, envoyer des mails et piloter Musique." + NSAccessibilityUsageDescription: "Pour lire le titre de la fenêtre active et l'attacher comme contexte." + NSAppTransportSecurity: + NSAllowsLocalNetworking: true + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: fr.louisraille.NotchBuddy + PRODUCT_NAME: Coucou + INFOPLIST_FILE: Resources/Info.plist + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + MACOSX_DEPLOYMENT_TARGET: "15.0" + ENABLE_SANDBOX: NO + configs: + DebugCloud: + CODE_SIGN_ENTITLEMENTS: Resources/CoucouCloud.entitlements + SWIFT_ACTIVE_COMPILATION_CONDITIONS: "DEBUG PHONE_LINK" + # The distributed build carries the iPhone sync (off until the user + # turns it on in Settings), so it needs iCloud and its Developer ID profile. + Release: + CODE_SIGN_ENTITLEMENTS: Resources/CoucouReleaseCloud.entitlements + SWIFT_ACTIVE_COMPILATION_CONDITIONS: PHONE_LINK + PROVISIONING_PROFILE_SPECIFIER: "Coucou Developer ID" + ReleaseCloud: + CODE_SIGN_ENTITLEMENTS: Resources/CoucouReleaseCloud.entitlements + SWIFT_ACTIVE_COMPILATION_CONDITIONS: PHONE_LINK + PROVISIONING_PROFILE_SPECIFIER: "Coucou Developer ID" + + CoucouAppStore: + type: application + platform: macOS + sources: + - path: Sources + type: group + excludes: + - "Phone/**" + - "Widgets/**" + - "NotificationContent/**" + - path: Assets.xcassets + buildPhase: resources + - path: Resources/sounds + buildPhase: resources + type: folder + info: + path: Resources/InfoAppStore.plist + properties: + CFBundleName: Coucou + CFBundleDisplayName: Coucou + CFBundleIdentifier: fr.louisraille.Coucou + CFBundleVersion: "5" + CFBundleShortVersionString: "1.1" + CFBundlePackageType: APPL + LSUIElement: YES + NSHighResolutionCapable: YES + NSSupportsAutomaticGraphicsSwitching: YES + LSApplicationCategoryType: public.app-category.developer-tools + ITSAppUsesNonExemptEncryption: NO + NSAppTransportSecurity: + NSAllowsLocalNetworking: true + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: fr.louisraille.Coucou + PRODUCT_NAME: Coucou + INFOPLIST_FILE: Resources/InfoAppStore.plist + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + MACOSX_DEPLOYMENT_TARGET: "15.0" + CODE_SIGN_STYLE: Automatic + CODE_SIGN_IDENTITY: "Apple Development" + CODE_SIGN_ENTITLEMENTS: Resources/CoucouAppStore.entitlements + DEVELOPMENT_TEAM: 256AUJ9555 + SWIFT_ACTIVE_COMPILATION_CONDITIONS: APPSTORE + CODE_SIGNING_ALLOWED: YES + configs: + DebugCloud: + CODE_SIGN_ENTITLEMENTS: Resources/CoucouAppStoreCloud.entitlements + SWIFT_ACTIVE_COMPILATION_CONDITIONS: "APPSTORE PHONE_LINK" + # App Store archive: same iCloud entitlements; Xcode switches push to + # production when exporting for the App Store. + Release: + CODE_SIGN_ENTITLEMENTS: Resources/CoucouAppStoreCloud.entitlements + SWIFT_ACTIVE_COMPILATION_CONDITIONS: "APPSTORE PHONE_LINK" + + # iPhone app. Same bundle ID as the Mac App Store build, for universal purchase. + CoucouPhone: + type: application + platform: iOS + deploymentTarget: "18.0" + dependencies: + - target: CoucouWidgets + - target: CoucouNotificationContent + sources: + - path: Sources/Phone + type: group + # Shared with the Mac targets (they get it through Sources/). + - path: Sources/CoucouKit + type: group + - path: PhoneAssets.xcassets + buildPhase: resources + # Mochi's sounds for the iPhone's notifications. + - path: Resources/sounds/approval.wav + buildPhase: resources + - path: Resources/sounds/finish.wav + buildPhase: resources + - path: Resources/sounds/question.wav + buildPhase: resources + - path: Resources/sounds/error.wav + buildPhase: resources + info: + path: Resources/InfoPhone.plist + properties: + CFBundleName: Coucou + CFBundleDisplayName: Coucou + CFBundleIdentifier: fr.louisraille.Coucou + CFBundleVersion: "3" + CFBundleShortVersionString: "1.0" + CFBundlePackageType: APPL + LSRequiresIPhoneOS: YES + UILaunchScreen: {} + UIApplicationSupportsIndirectInputEvents: YES + UIBackgroundModes: + - remote-notification + # Portrait, except a diff opened full screen (PhoneAppDelegate decides). + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + ITSAppUsesNonExemptEncryption: NO + NSFaceIDUsageDescription: "Coucou asks for Face ID before allowing a command from your iPhone." + NSMicrophoneUsageDescription: "Coucou uses the microphone when you dictate an instruction for Claude Code." + NSSpeechRecognitionUsageDescription: "Coucou turns your voice into the text of an instruction for Claude Code." + NSSupportsLiveActivities: YES + # Lets Mochi move at 120 Hz on ProMotion screens, like the Mac's notch. + CADisableMinimumFrameDurationOnPhone: YES + # Steps change often while an agent works: without this, iOS holds back + # updates once its budget is spent, and "waiting for your OK" came late. + NSSupportsLiveActivitiesFrequentUpdates: YES + # coucou://session/: the widgets open a session in the app. + CFBundleURLTypes: + - CFBundleURLName: fr.louisraille.Coucou + CFBundleURLSchemes: + - coucou + # Which APNs environment the Live Activity tokens belong to (read by the Mac). + CoucouAPNsEnvironment: $(COUCOU_APNS_ENV) + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: fr.louisraille.Coucou + PRODUCT_NAME: Coucou + INFOPLIST_FILE: Resources/InfoPhone.plist + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + # Mochi in other colors, picked in Settings (UIApplication.setAlternateIconName). + ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES: "AppIcon-Blue AppIcon-Green AppIcon-Purple AppIcon-Pink AppIcon-Graphite" + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS: NO + COUCOU_APNS_ENV: production + IPHONEOS_DEPLOYMENT_TARGET: "18.0" + TARGETED_DEVICE_FAMILY: "1" + SUPPORTS_MACCATALYST: NO + CODE_SIGN_STYLE: Automatic + CODE_SIGN_IDENTITY: "Apple Development" + CODE_SIGN_ENTITLEMENTS: Resources/CoucouPhone.entitlements + DEVELOPMENT_TEAM: 256AUJ9555 + CODE_SIGNING_REQUIRED: YES + CODE_SIGNING_ALLOWED: YES + ENABLE_HARDENED_RUNTIME: NO + OTHER_CODE_SIGN_FLAGS: "" + configs: + Debug: + COUCOU_APNS_ENV: development + DebugCloud: + CODE_SIGN_ENTITLEMENTS: Resources/CoucouPhoneProduction.entitlements + COUCOU_APNS_ENV: development + + # Home Screen and Lock Screen widgets (Solo, Team, List, accessories). They + # read the sessions the iPhone app saves to the shared App Group. + CoucouWidgets: + type: app-extension + platform: iOS + deploymentTarget: "18.0" + sources: + - path: Sources/Widgets + type: group + - path: Sources/CoucouKit + type: group + - path: Sources/Phone/SharedSessions.swift + - path: Sources/Phone/MacStubs.swift + - path: Sources/Phone/MochiActivityAttributes.swift + - path: Sources/Phone/OpenLeadMochiIntent.swift + - path: Sources/Phone/DenyApprovalIntent.swift + info: + path: Resources/InfoWidgets.plist + properties: + CFBundleName: CoucouWidgets + CFBundleDisplayName: Coucou + CFBundleIdentifier: fr.louisraille.Coucou.Widgets + CFBundleVersion: "3" + CFBundleShortVersionString: "1.0" + CFBundlePackageType: XPC! + NSExtension: + NSExtensionPointIdentifier: com.apple.widgetkit-extension + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: fr.louisraille.Coucou.Widgets + SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) WIDGET_EXTENSION" + PRODUCT_NAME: CoucouWidgets + INFOPLIST_FILE: Resources/InfoWidgets.plist + IPHONEOS_DEPLOYMENT_TARGET: "18.0" + TARGETED_DEVICE_FAMILY: "1" + SUPPORTS_MACCATALYST: NO + CODE_SIGN_STYLE: Automatic + CODE_SIGN_IDENTITY: "Apple Development" + CODE_SIGN_ENTITLEMENTS: Resources/CoucouWidgets.entitlements + DEVELOPMENT_TEAM: 256AUJ9555 + CODE_SIGNING_REQUIRED: YES + CODE_SIGNING_ALLOWED: YES + ENABLE_HARDENED_RUNTIME: NO + OTHER_CODE_SIGN_FLAGS: "" + SKIP_INSTALL: YES + APPLICATION_EXTENSION_API_ONLY: YES + + # The expanded approval notification: Mochi alive, with the command. + CoucouNotificationContent: + type: app-extension + platform: iOS + deploymentTarget: "18.0" + sources: + - path: Sources/NotificationContent + type: group + - path: Sources/CoucouKit + type: group + - path: Sources/Phone/MochiLive.swift + - path: Sources/Phone/MacStubs.swift + info: + path: Resources/InfoNotificationContent.plist + properties: + CFBundleName: CoucouNotificationContent + CFBundleDisplayName: Coucou + CFBundleIdentifier: fr.louisraille.Coucou.NotificationContent + CFBundleVersion: "3" + CFBundleShortVersionString: "1.0" + CFBundlePackageType: XPC! + NSExtension: + NSExtensionPointIdentifier: com.apple.usernotifications.content-extension + NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).NotificationViewController + NSExtensionAttributes: + UNNotificationExtensionCategory: COUCOU_APPROVAL + UNNotificationExtensionInitialContentSizeRatio: 0.45 + UNNotificationExtensionDefaultContentHidden: true + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: fr.louisraille.Coucou.NotificationContent + PRODUCT_NAME: CoucouNotificationContent + INFOPLIST_FILE: Resources/InfoNotificationContent.plist + IPHONEOS_DEPLOYMENT_TARGET: "18.0" + TARGETED_DEVICE_FAMILY: "1" + SUPPORTS_MACCATALYST: NO + CODE_SIGN_STYLE: Automatic + CODE_SIGN_IDENTITY: "Apple Development" + DEVELOPMENT_TEAM: 256AUJ9555 + CODE_SIGNING_REQUIRED: YES + CODE_SIGNING_ALLOWED: YES + ENABLE_HARDENED_RUNTIME: NO + OTHER_CODE_SIGN_FLAGS: "" + SKIP_INSTALL: YES + APPLICATION_EXTENSION_API_ONLY: YES diff --git a/README.md b/README.md new file mode 100644 index 0000000..d02ad1d --- /dev/null +++ b/README.md @@ -0,0 +1,284 @@ +
+ +Coucou icon + +# Coucou + +**A tiny friend that lives in your Mac's notch — or at the top of your screen on Windows and Linux — and keeps an eye on your AI coding agent sessions. And now on your iPhone too.** + +Approve permissions, watch your agents work, drop a file, chat with Claude — all without leaving what you're doing. Walk away from your Mac and Mochi follows you to your iPhone: Lock Screen, Dynamic Island, widgets, Siri. + +[![Version](https://img.shields.io/github/v/release/Louis-CFM/coucou?filter=v*&label=version&color=0A84FF)](https://github.com/Louis-CFM/coucou/releases) +![macOS 15+](https://img.shields.io/badge/macOS-15%2B-black?logo=apple) +![iOS 18+](https://img.shields.io/badge/iOS-18%2B-black?logo=apple) +![Windows 10/11](https://img.shields.io/badge/Windows-10%2F11-0078D4?logo=windows&logoColor=white) +![Linux](https://img.shields.io/badge/Linux-AppImage%20%7C%20deb%20%7C%20rpm-FCC624?logo=linux&logoColor=black) +![Swift 6](https://img.shields.io/badge/Swift-6-F05138?logo=swift&logoColor=white) +![SwiftUI](https://img.shields.io/badge/SwiftUI-native-0A84FF) +![Tauri 2](https://img.shields.io/badge/Tauri-2-FFC131?logo=tauri&logoColor=black) +![License: MIT](https://img.shields.io/badge/license-MIT-green) +![GitHub stars](https://img.shields.io/github/stars/Louis-CFM/coucou?style=social) + +Coucou in action + +
+ +--- + +## Why + +Some studios showed off gorgeous notch companions… and never let anyone use them. +**Coucou is the open version.** Every line of code, every animation, every sound — free to use, read, fork and remix. + +Meet **Mochi**: a soft little squircle with big eyes that pops out of your notch, waves hello, follows your cursor with its eyes, gets annoyed when you poke it (and dizzy if you insist), and tells you the moment Claude Code needs you. + +## Features + +- 🤖 **Claude Code, Cursor, Codex, Gemini CLI, Antigravity and other agents, live** — see every session in your notch: what it reads, edits and runs, step by step. Tag a hook payload with `coucou_agent` to give any agent its own pill (see [`docs/AGENTS.md`](docs/AGENTS.md)). Finished? Mochi does a happy little jump. +- See what Claude is editing, live in the notch: each file modification shows the file name and +N −M counts in the ticker, tap to read the full diff +- ✅ **Approve and answer from the notch** — Claude Code permission requests show up with **Allow / Deny / Always**; `AskUserQuestion` prompts show the choices right in the notch (single or multi-select, up to 4 questions). One click, or "Reply in terminal" to fall back to the CLI. Codex also gets Allow / Deny. +- 🧑‍💻 **Jump to the right terminal** — open the exact terminal window of a session *(macOS)*. +- 💬 **Chat with Claude, Gemini, OpenAI, or a local model (Ollama / LM Studio)** — click the model name above the chat box to switch provider and pick a model. Cloud providers use your own API key; local providers connect to a server running on your Mac. *(Gemini, OpenAI and local models: macOS)* +- 📊 **Claude plan usage** *(macOS, GitHub build)* — a small pill in the notch header shows your 5-hour and weekly Claude plan limits. Enable it from Settings → Agents → Plan usage. Pro and Max plans only. +- 📋 **Declare the tools you use** — open Settings → Active pills and pick your main workspace tool (VS Code, Cursor, Codex or Antigravity), then toggle up to 4 more: Gemini CLI, Anthropic, Google AI, OpenAI, Ollama, LM Studio and service integrations *(macOS)*. +- 📎 **Drop a file on the notch** — Mochi turns into a box and swallows it, then ask a question about it or send it by email *(email: macOS, Mail.app)*. +- 🖥️ **Mochi on the desktop** — drag Mochi out of the island to set him loose on your desktop: he floats as a 120 pt companion, follows your cursor, wears his outfit, reacts to alerts by flying home and flying back, and comes back where you left him on next launch. +- 🪟 **Drag Mochi onto any window** — attach that window as context for Claude *(macOS)*. +- 🔌 **Integrations** — Stripe payments, n8n workflows, GitHub (open PRs, reviews requested, CI status), Vercel deployments, Resend emails, Notion, Cal.com. Each one gets its own little colored Mochi. +- 🎵 **Apple Music pill** *(macOS, GitHub build)* — add the Apple Music pill in Settings → Active pills to see what's playing and control playback from the notch; Mochi dances while it plays. +- 👗 **Dress Mochi up** — right-click him for the wardrobe. He also dresses up for the seasons on his own. +- ⌨️ **Keyboard shortcuts** — open the chat, jump to an alert or a terminal, switch pills, mute, send Mochi to the desktop or open the wardrobe from anywhere; all customizable in Settings → Shortcuts. +- 🎭 **A real character** — idle breathing, blinks, eyes on a sphere that follow your mouse, emotes, 28 handcrafted sounds, a greeting on launch. +- 🫥 **Invisible when idle** — hides away when nothing is running, peeks out when you hover the notch (the top edge of the screen on Windows and Linux). +- 🖥️ **Any Mac, notch or not** — on an iMac, a Mac mini, or a MacBook with its lid closed on an external display, Mochi sits in a small bar at the top of the screen. +- 📱 **Coucou on iPhone** — your sessions, approvals and services in your pocket, with Live Activities, widgets and Siri. See [Coucou on iPhone](#coucou-on-iphone). +- 🔒 **Private by design** — no telemetry, no account. Keys live in your macOS Keychain, Windows Credential Manager or Linux Secret Service (GNOME Keyring, KWallet). The app only talks to the services you plug in. + +## Coucou on iPhone + +The Mac app does the work; the iPhone app keeps you in the loop when you step away. It goes as far into the Apple ecosystem as a dev tool can: + + + + + + + + + + +
Mochi on the Lock Screen, waiting for your OK with Deny and Allow
A permission request on the iPhone, Allow with Face IDA Claude Code session on the iPhone: the prompt, what it did, Claude's answer and the next instructionHome Screen widgets with each agent's and service's Mochi
+ +- 🏝️ **Live Activity and Dynamic Island** — lock your Mac while an agent works and Mochi moves to your iPhone's Lock Screen and Dynamic Island with the agent's state, then comes back to the notch when you unlock. **Allow** or **Deny** a permission right from the Lock Screen, without opening the app. +- 🔔 **Notifications you can act on** — Allow, Review or Deny a permission, pick an answer to Claude's question, or reply to a finished agent, straight from the notification. +- 🔐 **Face ID on every Allow** — nothing runs on your Mac without your explicit tap; your Mac only applies a decision meant for the exact request it is waiting on. +- 💬 **Talk to your agents** — follow each session live (steps, diffs, the last turn), answer questions, and send the next instruction to your Mac by text or voice. +- 🧩 **Widgets, Control Center, Siri and Shortcuts** — home and Lock Screen widgets, a Control Center control, ask Siri how your agents are doing or send Claude an instruction, in Siri and Shortcuts. +- 🔎 **Spotlight and Focus** — find past turns in Spotlight; a Focus filter shows only the agents waiting for you. +- 📊 **Your services up close** — GitHub, Vercel, Stripe, Cal.com, n8n, Notion, Resend: your Mac reads their APIs with the keys in its Keychain and the iPhone shows the figures and lists. Safe actions (re-run CI, redeploy, merge a PR, pause a workflow) behind Face ID; nothing that moves money or sends an email. +- 🫧 **Liquid Glass, native to the bone** — SwiftUI, tabs, zoom transitions, context menus, swipe actions, alternate icons, Mochi at 120 Hz. +- 🔒 **Through your own iCloud** — sessions sync through your private CloudKit database; project names, commands and questions are encrypted with your iCloud keys. No Coucou server sees your projects, commands or keys. Turn it on in the Mac app: Settings → General → iPhone. + +**Get it on your iPhone in 3 steps:** install Coucou on your iPhone ([Join the TestFlight beta](https://testflight.apple.com/join/3GpeHv2b) — free, App Store coming soon), turn on **Settings → General → iPhone** in the Mac app (0.1.9 or later), and use the same Apple Account in iCloud on both. Full guide, troubleshooting and build-it-yourself: [docs/IPHONE.md](docs/IPHONE.md). + + + + + + + + + + +
Claude Code sessionStripe payments
Chat with ClaudeToo many hits
+ +## Versions + +macOS releases are published as `v*` tags. See [CHANGELOG.md](CHANGELOG.md) for the full notes of each version. + +| Version | Date | Highlights | +|---------|------|------------| +| [0.1.9](https://github.com/Louis-CFM/coucou/releases/tag/v0.1.9) | Oct 6, 2026 | iPhone services with live details and actions, smarter Live Activity | +| [0.1.8](https://github.com/Louis-CFM/coucou/releases/tag/v0.1.8) | Oct 5, 2026 | Coucou on iPhone: sessions, widgets, approvals with Face ID, Mochi in the Dynamic Island | +| [0.1.7](https://github.com/Louis-CFM/coucou/releases/tag/v0.1.7) | Oct 4, 2026 | Keyboard shortcuts | +| [0.1.6](https://github.com/Louis-CFM/coucou/releases/tag/v0.1.6) | Oct 4, 2026 | Mochi on the desktop | +| [0.1.5](https://github.com/Louis-CFM/coucou/releases/tag/v0.1.5) | Oct 4, 2026 | Wardrobe and seasonal outfits, new launch greeting | +| [0.1.4](https://github.com/Louis-CFM/coucou/releases/tag/v0.1.4) | Oct 3, 2026 | Live diffs, GitHub pull requests, CI, reviews and contribution grid | +| [0.1.3](https://github.com/Louis-CFM/coucou/releases/tag/v0.1.3) | Oct 3, 2026 | Answer Claude's questions from the notch, plan usage, local models, Apple Music | +| [0.1.2](https://github.com/Louis-CFM/coucou/releases/tag/v0.1.2) | Oct 2, 2026 | Codex and Cursor support, the permission card stays until you answer | +| [0.1.1](https://github.com/Louis-CFM/coucou/releases/tag/v0.1.1) | Oct 2, 2026 | Gemini and OpenAI chat, Linux build, more agents and pills, security hardening | +| [0.1.0](https://github.com/Louis-CFM/coucou/releases/tag/v0.1.0) | Sep 27, 2026 | First release: Mochi, Claude Code sessions, chat, file drop, integrations | + +Windows 0.1.1 and Linux 0.1.1 (beta) are in Releases under the `windows-v*` and `linux-v*` tags. + +## Install + +### App Store + +Coucou is coming to the **Mac App Store** and the **iPhone App Store**: one click to install, automatic updates, and the iPhone app pairs with your Mac through your iCloud account, with nothing to configure. The links will be here as soon as Apple publishes them. + +The App Store build of the Mac app runs in Apple's sandbox, so a few features stay in the GitHub build: Claude plan usage, the Apple Music pill and attaching the front window to the chat. + +### iPhone + +1. **Install Coucou on your iPhone** (iOS 18 or later): install [TestFlight](https://apps.apple.com/app/testflight/id899247664) from the App Store, then [Join the TestFlight beta](https://testflight.apple.com/join/3GpeHv2b). The beta is free; Apple limits it to 10,000 testers. The App Store version is coming soon. + *Apple is reviewing the beta: it opens within a day or two — if the link isn't accepting testers yet, check back soon.* +2. **On your Mac**, with Coucou 0.1.9 or later: **Settings… → General → iPhone**, turn on **Show my agent sessions on my iPhone**, and **Move Mochi to my iPhone's Dynamic Island when my Mac is locked** for the Lock Screen. +3. **Same Apple Account** in iCloud on the Mac and the iPhone. That's the whole link: no account, no pairing code. +4. Open Coucou on the iPhone, allow notifications, and start a Claude Code session on the Mac. + +Everything else, troubleshooting included, is in [docs/IPHONE.md](docs/IPHONE.md). + +### Download for macOS + +1. Grab the latest `Coucou.zip` from [Releases](https://github.com/Louis-CFM/coucou/releases). +2. Unzip and move **Coucou.app** to `/Applications`. +3. Launch it, and click **Open** when macOS asks you to confirm. Updating from 0.1.0? macOS may ask you, once for each key you saved, to let Coucou use it: enter your Mac password and click **Always Allow**. + +### Windows + +The Windows installer is **temporarily unavailable**. Microsoft Defender wrongly +flags the unsigned installer as malware; a false-positive report is under review +at Microsoft and the installer will come back once it is cleared and signed. +Until then you can [build it from source](#build-from-source). + +There is no notch on a PC, so the island slides out of the top edge of the screen +instead of hiding inside one. See [`windows/README.md`](windows/README.md) for the +rest of the differences. + +### Linux + +The first Linux build is out as a beta: download it from [Coucou for Linux 0.1.1 (beta)](https://github.com/Louis-CFM/coucou/releases/tag/linux-v0.1.1), x86_64 only for now. Later versions will be in [Releases](https://github.com/Louis-CFM/coucou/releases) under `linux-v*` tags. + +- **AppImage** (any distribution): `chmod +x Coucou-Linux-*.AppImage`, then run it. +- **Debian / Ubuntu**: `sudo apt install ./Coucou-Linux-*.deb` +- **Fedora / openSUSE**: `sudo dnf install ./Coucou-Linux-*.rpm` + +Check a download with `sha256sum -c SHA256SUMS --ignore-missing`. Gemini CLI, Antigravity, Google AI, OpenAI and local model (Ollama / LM Studio) chat are macOS only for now. + +The island sits on the top edge on compositors with layer-shell — COSMIC, KDE +Plasma, Hyprland, Sway and other wlroots compositors. GNOME has no layer-shell, +so there it opens as a regular window. See [`windows/README.md`](windows/README.md#linux). + +### Build from source + +**macOS** — requirements: macOS 15+, Xcode 16+, [XcodeGen](https://github.com/yonaskolb/XcodeGen). + +```bash +brew install xcodegen +git clone https://github.com/Louis-CFM/coucou.git +cd coucou/NotchBuddy +xcodegen +open NotchBuddy.xcodeproj # then ⌘R +``` + +**Windows** — requirements: [Rust](https://rustup.rs), Node 20+, MSVC build tools. + +```powershell +git clone https://github.com/Louis-CFM/coucou.git +cd coucou/windows +npm install +npm run pack # installer lands in windows/release/ +``` + +**Linux** — requirements: [Rust](https://rustup.rs), Node 20+, and the WebKitGTK, +gtk-layer-shell and appindicator development packages (Debian/Ubuntu names below). + +```bash +sudo apt install build-essential pkg-config \ + libwebkit2gtk-4.1-dev libgtk-layer-shell-dev libayatana-appindicator3-dev \ + librsvg2-dev libssl-dev libdbus-1-dev patchelf \ + gstreamer1.0-plugins-base gstreamer1.0-plugins-good +git clone https://github.com/Louis-CFM/coucou.git +cd coucou/windows +npm install +npm run pack # AppImage, .deb and .rpm land in windows/release/ +``` + +## Setup + +Click the Coucou icon in the menu bar (macOS) or in the system tray (Windows, Linux) → **Settings…** + +| What | Why | Where the key goes | +|---|---|---| +| **Claude Code hooks** | live sessions and approvals | **Install hooks** — Coucou backs up `~/.claude/settings.json`, merges its hooks and shows you the diff before writing anything | +| **Claude plan** *(macOS, GitHub build)* | Plan usage gauge in the notch header | **Install relay** in Settings → Agents → Plan usage, then enable "Show in the notch" | +| **Gemini CLI hooks** *(macOS)* | Gemini CLI sessions in the island | **Install hooks** in Settings → Gemini CLI — backs up `~/.gemini/settings.json` | +| **Antigravity (agy) hooks** *(macOS)* | agy sessions in the island | **Install hooks** in Settings → Antigravity — backs up `~/.gemini/config/hooks.json` | +| **Anthropic API key** | chat and questions about files | Settings → Anthropic API · Keychain / Windows Credential Manager / Secret Service | +| **Google AI API key** *(macOS)* | chat with Google AI (Gemini) | Settings → Chat — other providers · Keychain | +| **OpenAI API key** *(macOS)* | chat with OpenAI | Settings → Chat — other providers · Keychain | +| **Ollama server** *(macOS)* | chat with local models via Ollama | Settings → Chat → Local models → **Connect** | +| **LM Studio server** *(macOS)* | chat with local models via LM Studio | Settings → Chat → Local models → **Connect** | +| **iPhone** *(macOS)* | sessions, approvals, questions and Mochi on your iPhone | Settings → General → iPhone · your private iCloud, see [docs/IPHONE.md](docs/IPHONE.md) | +| **Active pills** *(macOS)* | choose which tools and agents appear in the island | Settings → Active pills | +| Stripe, n8n, GitHub, Vercel, Resend, Notion, Cal.com | the service pills | Keychain / Windows Credential Manager / Secret Service, all optional | + +If Coucou isn't running, the hook exits immediately: **Claude Code is never blocked.** + +## Things to try + +| Do this | Mochi does that | +|---|---| +| Hover the notch (top edge on Windows and Linux) | peeks out and says hi 👋 | +| Click it | opens | +| Hover Mochi | blinks, eyes grow | +| Click Mochi | squish + annoyed | +| Click 3 times fast | 😵‍💫 dizzy for a few seconds | +| Drag a file onto the island | turns into a box and swallows it | +| Drag Mochi onto a window *(macOS)* | attaches it as context | +| Click the model name above the chat box *(macOS)* | switch AI provider or model | + +## How it works + +**macOS** + +- **Island**: a borderless `NSPanel` hugging the notch, driven by a small state machine (`hidden → petit → home`). +- **Character**: drawn in SwiftUI `Canvas` + `TimelineView` at 60 fps — squircle body, eyes projected on a sphere, spring animations. No Rive, no Lottie, no images. +- **Claude Code**: a tiny `nb-hook` script receives hook events and forwards them over a Unix socket to the app. For approvals it waits for your click, then answers the hook. +- **Integrations**: lightweight pollers, paused when nothing is watching. +- **Declared pills**: `PillCatalog.swift` is the single source of truth — every pill (coding tools, agents, AI providers, services) is declared there with its ID, color and category. +- **Sounds**: 28 short WAVs played through preloaded `AVAudioPlayer`s. + +The macOS app is native Swift 6 / SwiftUI / AppKit with **zero third-party dependencies**. + +**iPhone** + +- A native SwiftUI app sharing Mochi's engine, the pills and the diff engine with the Mac through `CoucouKit`. +- The Mac publishes sessions, turns and services to a private CloudKit zone in your iCloud (sensitive fields encrypted with your iCloud keys); the iPhone reads them and writes back decisions, answers and instructions, which the Mac only applies when they match what it is waiting on. +- Live Activities are started and updated by APNs pushes through [`relay/`](relay/), a stateless Cloudflare Worker that holds the APNs key and only sees the agent's name and state. + +**Windows** + +- A [Tauri 2](https://tauri.app) app (Rust + TypeScript): the island is a transparent, always-on-top window that never steals focus, Mochi is drawn in Canvas 2D with the same shapes, timings and sounds as on the Mac. +- Claude Code hooks go through a tiny `coucou-hook.exe` and a named pipe; keys live in Windows Credential Manager. +- Details and differences in [`windows/README.md`](windows/README.md). + +**Linux** + +- The same Tauri app as Windows. On Wayland the island is a gtk-layer-shell + overlay anchored to the top edge, and click-through is its input region. +- Claude Code hooks go through the same `coucou-hook`, over a Unix socket in + `$XDG_RUNTIME_DIR`; keys live in the Secret Service. + +## Contributing + +Issues and PRs are very welcome — new integrations, new emotes, new sounds, bug fixes. See [CONTRIBUTING.md](CONTRIBUTING.md). + +## Credits + +Built by [Louis Raillé](https://louisraille.fr) with Claude Code. +Inspired by the notch-companion concepts shared by design studios — this project is independent and not affiliated with any of them. + +## License + +- **Code:** [MIT](LICENSE) — use it, fork it, learn from it, just keep the copyright notice. +- **Name, Mochi character, icon, sounds and media:** © Louis Raillé, all rights reserved — see [LICENSE-ASSETS.md](LICENSE-ASSETS.md). Shipping your own fork? Give it your own name and character. + +
+ +**If Mochi made you smile, a ⭐ helps a lot.** + +[Website](https://louis-cfm.github.io/coucou/) · [Privacy](https://louis-cfm.github.io/coucou/privacy.html) · [Terms](https://louis-cfm.github.io/coucou/terms.html) · [Support](https://louis-cfm.github.io/coucou/support.html) + +
diff --git a/scripts/RenderOutfits.swift b/scripts/RenderOutfits.swift new file mode 100644 index 0000000..3e1642c --- /dev/null +++ b/scripts/RenderOutfits.swift @@ -0,0 +1,331 @@ +// RenderOutfits.swift — standalone planche renderer for Mochi outfits +// Compile + run via: bash scripts/render-outfits.sh +// Output: /tmp/coucou-outfits.png + +import Foundation +import SwiftUI +import AppKit + +// MARK: - Stubs (substitutes for app-only types) + +@MainActor +final class SoundEngine { + static let shared = SoundEngine() + var enabled: Bool = false + func play(_ name: String) {} +} + +extension Notification.Name { + static let botDizzy = Notification.Name("notchBuddy.botDizzy") + static let botGreet = Notification.Name("notchBuddy.botGreet") + static let botBlink = Notification.Name("notchBuddy.botBlink") + static let botSetTgEs = Notification.Name("notchBuddy.botSetTgEs") + static let botGulp = Notification.Name("notchBuddy.botGulp") + static let botMorphTo = Notification.Name("notchBuddy.botMorphTo") + static let triggerEmote = Notification.Name("notchBuddy.triggerEmote") + static let triggerSlap = Notification.Name("notchBuddy.triggerSlap") + static let greetComplete = Notification.Name("notchBuddy.greetComplete") + static let greetingHover = Notification.Name("notchBuddy.greetingHover") + static let greetingInterrupt = Notification.Name("notchBuddy.greetingInterrupt") + static let islandAction = Notification.Name("notchBuddy.islandAction") + static let islandCollapse = Notification.Name("notchBuddy.islandCollapse") + static let hookReveal = Notification.Name("notchBuddy.hookReveal") + static let musicReveal = Notification.Name("notchBuddy.musicReveal") + static let openFullSettings = Notification.Name("notchBuddy.openFullSettings") +} + +extension Color { + init(hex: String) { + let h = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#")) + let val = UInt64(h, radix: 16) ?? 0 + let r = Double((val >> 16) & 0xFF) / 255 + let g = Double((val >> 8) & 0xFF) / 255 + let b = Double( val & 0xFF) / 255 + self.init(red: r, green: g, blue: b) + } +} + +// MARK: - Cell view — one outfit at one pose + +struct MochiCell: View { + let outfit: Outfit + let yaw: CGFloat + let pitch: CGFloat + let tilt: CGFloat + let phys: (dx: CGFloat, dy: CGFloat) + let cellSize: CGFloat + var roll: CGFloat = 0 + var presence: CGFloat = 1 + + var body: some View { + Canvas { context, sz in + let W = sz.width, H = sz.height + // scale=0.62, R = W * 0.62 * 0.3 + let R = W * 0.62 * 0.3 + let rx = R * 1.14 + let ry = R * 0.88 + let cx = W / 2 + let cy = H / 2 + R * 0.06 + let sx: CGFloat = 1, sy: CGFloat = 1 + let morph: CGFloat = 0 + + let mH = MochiH(R: R, yaw: yaw, pitch: pitch, physDx: phys.dx, physDy: phys.dy, roll: roll) + + // 1. Behind-body outfit + drawOutfitBehindStatic( + context: context, outfit: outfit, H: mH, + cx: cx, cy: cy, tilt: tilt, sx: sx, sy: sy, + roll: roll, morph: morph, isMini: false, presence: presence + ) + + // 2. Body (replicate BotEngine.drawBody for idle / pumpkin state) + var bCtx = context + bCtx.translateBy(x: cx, y: cy) + if tilt != 0 { bCtx.rotate(by: .radians(tilt)) } + bCtx.scaleBy(x: sx, y: sy) + let body = mochiOutfitPath(rx, ry) + + // Base gradient (pumpkin-aware) + let pumpkin = outfit == .pumpkin + let cTop: Color = pumpkin ? Color(hex: "#FFA94D") : Color(red: 0.929, green: 0.929, blue: 0.937) + let cBot: Color = pumpkin ? Color(hex: "#E8590C") : Color(red: 0.769, green: 0.773, blue: 0.792) + bCtx.fill(body, with: .linearGradient( + Gradient(colors: [cTop, cBot]), + startPoint: CGPoint(x: rx * 0.7, y: -ry * 0.85), + endPoint: CGPoint(x: -rx * 0.8, y: ry * 0.9) + )) + // Shadow rim + bCtx.fill(body, with: .radialGradient( + Gradient(stops: [ + .init(color: .clear, location: 0.6), + .init(color: Color.black.opacity(0.2), location: 1) + ]), + center: .zero, startRadius: R * 0.15, endRadius: R * 1.25 + )) + // Top-left highlight + bCtx.fill(body, with: .radialGradient( + Gradient(stops: [ + .init(color: Color.white.opacity(0.55), location: 0), + .init(color: .clear, location: 1) + ]), + center: CGPoint(x: rx * 0.34, y: -ry * 0.46), + startRadius: 0, endRadius: R * 0.42 + )) + + // 3. Eyes (clipped to body) + var eyeBase = bCtx + eyeBase.clip(to: body) + let ink = Color(red: 0.102, green: 0.082, blue: 0.071) + for f in mEyeFrames(mH) { + guard f.visible else { continue } + var eCtx = eyeBase + eCtx.translateBy(x: f.x, y: f.y) + eCtx.scaleBy(x: f.fx, y: f.fy) + let hh = max(f.h, f.w * 0.3) + var pill = Path() + pill.addRoundedRect( + in: CGRect(x: -f.w / 2, y: -hh / 2, width: f.w, height: hh), + cornerSize: CGSize(width: min(f.w / 2, hh / 2), height: min(f.w / 2, hh / 2)) + ) + eCtx.fill(pill, with: .color(ink)) + } + + // 4. Front outfit + drawOutfitFrontStatic( + context: context, outfit: outfit, H: mH, + cx: cx, cy: cy, tilt: tilt, sx: sx, sy: sy, + roll: roll, morph: morph, isMini: false, presence: presence + ) + } + .frame(width: cellSize, height: cellSize) + } +} + +// MARK: - Grid view + +// Outfit order matching sheet.html +private let targetOutfits: [Outfit] = [ + .none, .beanie, .santaHat, .partyHat, .crown, .witchHat, + .sunglasses, .roundGlasses, .scarf, .pumpkin, .bow, +] + +private struct PoseSpec { + let label: String + let yaw: CGFloat + let pitch: CGFloat + let tilt: CGFloat + let phys: (dx: CGFloat, dy: CGFloat) + let size: CGFloat +} + +// Columns matching sheet.html cols array + small pill-preview column (W=190, scale=0.62; small=64) +private let poses: [PoseSpec] = [ + PoseSpec(label: "L", yaw: -0.5, pitch: 0, tilt: 0, phys: (0.6, 0), size: 190), + PoseSpec(label: "front", yaw: 0, pitch: 0, tilt: 0, phys: (0, 0), size: 190), + PoseSpec(label: "R", yaw: 0.5, pitch: 0, tilt: 0, phys: (-0.6, 0), size: 190), + PoseSpec(label: "up", yaw: 0.15, pitch: 0.4, tilt: 0, phys: (0, 0.4), size: 190), + PoseSpec(label: "down", yaw: -0.2, pitch: -0.5, tilt: 0, phys: (0, -0.4), size: 190), + PoseSpec(label: "tilt", yaw: 0.3, pitch: -0.25, tilt: 0.12, phys: (-0.3, 0), size: 190), + PoseSpec(label: "mini", yaw: 0, pitch: 0, tilt: 0, phys: (0, 0), size: 64), +] + +private let labelW: CGFloat = 90 +private let gap: CGFloat = 6 + +struct OutfitGrid: View { + var body: some View { + VStack(alignment: .leading, spacing: gap) { + // Column headers + HStack(spacing: gap) { + Spacer().frame(width: labelW) + ForEach(poses.indices, id: \.self) { i in + Text(poses[i].label) + .font(.system(size: 9, design: .monospaced)) + .foregroundColor(.gray) + .frame(width: poses[i].size, alignment: .center) + } + } + // One row per outfit + ForEach(targetOutfits.indices, id: \.self) { oi in + let outfit = targetOutfits[oi] + HStack(spacing: gap) { + Text(outfit.rawValue) + .font(.system(size: 10)) + .foregroundColor(.white) + .frame(width: labelW, alignment: .trailing) + ForEach(poses.indices, id: \.self) { pi in + let p = poses[pi] + MochiCell(outfit: outfit, yaw: p.yaw, pitch: p.pitch, + tilt: p.tilt, phys: p.phys, cellSize: p.size) + .background(pi == poses.count - 1 + ? Color.black + : Color(red: 0.083, green: 0.090, blue: 0.106)) + .clipShape(RoundedRectangle(cornerRadius: pi == poses.count - 1 ? 0 : 8)) + } + } + } + } + .padding(12) + .background(Color(red: 0.043, green: 0.047, blue: 0.055)) + } +} + +// MARK: - Roll planche + +private let rollOutfits: [Outfit] = [ + .none, .beanie, .santaHat, .witchHat, .crown, + .sunglasses, .roundGlasses, .scarf, .bow, .pumpkin, .bunnyEars +] + +private let rollValues: [CGFloat] = [0, CGFloat.pi/3, CGFloat.pi*2/3, CGFloat.pi, CGFloat.pi*4/3, CGFloat.pi*5/3] + +struct RollGrid: View { + var body: some View { + VStack(alignment: .leading, spacing: gap) { + // Column headers + HStack(spacing: gap) { + Spacer().frame(width: labelW) + ForEach(rollValues.indices, id: \.self) { i in + Text(String(format: "%.2fπ", rollValues[i] / .pi)) + .font(.system(size: 9, design: .monospaced)) + .foregroundColor(.gray) + .frame(width: 120, alignment: .center) + } + } + ForEach(rollOutfits.indices, id: \.self) { oi in + let outfit = rollOutfits[oi] + HStack(spacing: gap) { + Text(outfit.rawValue) + .font(.system(size: 10)) + .foregroundColor(.white) + .frame(width: labelW, alignment: .trailing) + ForEach(rollValues.indices, id: \.self) { ri in + MochiCell(outfit: outfit, yaw: 0, pitch: 0, tilt: 0, phys: (0, 0), + cellSize: 120, roll: rollValues[ri]) + .background(Color(red: 0.083, green: 0.090, blue: 0.106)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + } + } + .padding(12) + .background(Color(red: 0.043, green: 0.047, blue: 0.055)) + } +} + +// MARK: - Transition planche + +private let transitionOutfits: [Outfit] = [.witchHat, .santaHat, .bunnyEars, .scarf] +private let presenceValues: [CGFloat] = [0, 0.3, 0.6, 1] + +struct TransitionGrid: View { + var body: some View { + VStack(alignment: .leading, spacing: gap) { + // Column headers + HStack(spacing: gap) { + Spacer().frame(width: labelW) + ForEach(presenceValues.indices, id: \.self) { i in + Text(String(format: "p=%.1f", presenceValues[i])) + .font(.system(size: 9, design: .monospaced)) + .foregroundColor(.gray) + .frame(width: 120, alignment: .center) + } + } + ForEach(transitionOutfits.indices, id: \.self) { oi in + let outfit = transitionOutfits[oi] + HStack(spacing: gap) { + Text(outfit.rawValue) + .font(.system(size: 10)) + .foregroundColor(.white) + .frame(width: labelW, alignment: .trailing) + ForEach(presenceValues.indices, id: \.self) { pi in + MochiCell(outfit: outfit, yaw: 0, pitch: 0, tilt: 0, phys: (0, 0), + cellSize: 120, presence: presenceValues[pi]) + .background(Color(red: 0.083, green: 0.090, blue: 0.106)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + } + } + .padding(12) + .background(Color(red: 0.043, green: 0.047, blue: 0.055)) + } +} + +// MARK: - Helper to render and save a view + +@MainActor +private func renderAndSave(_ view: V, path: String) { + let renderer = ImageRenderer(content: view) + renderer.scale = 2.0 + guard let nsImage = renderer.nsImage else { + print("Error: ImageRenderer returned nil for \(path)") + return + } + guard let tiff = nsImage.tiffRepresentation, + let rep = NSBitmapImageRep(data: tiff), + let png = rep.representation(using: .png, properties: [:]) + else { + print("Error: PNG conversion failed for \(path)") + return + } + do { + try png.write(to: URL(fileURLWithPath: path)) + print("✓ \(path) — \(Int(nsImage.size.width))×\(Int(nsImage.size.height)) @ 2×") + } catch { + print("Error writing \(path): \(error)") + } +} + +// MARK: - Entry point + +@main +struct RenderOutfits { + static func main() { + MainActor.assumeIsolated { + renderAndSave(OutfitGrid(), path: "/tmp/coucou-outfits.png") + renderAndSave(RollGrid(), path: "/tmp/coucou-roll.png") + renderAndSave(TransitionGrid(), path: "/tmp/coucou-transition.png") + } + } +} diff --git a/scripts/cloud-test-build.sh b/scripts/cloud-test-build.sh new file mode 100755 index 0000000..7491313 --- /dev/null +++ b/scripts/cloud-test-build.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# iPhone plan, step 2: builds the GitHub app (fr.louisraille.NotchBuddy) the +# way release.sh does — Release optimizations, Developer ID signature — plus +# the "Coucou Developer ID" provisioning profile, the iCloud entitlements and +# the PHONE_LINK probe. Not notarized, not published: it only proves that a +# Developer ID build can talk to iCloud. A Developer ID profile only allows the +# Production CloudKit environment: the Ping/Pong schema must be deployed to +# Production in the CloudKit Console first. +# +# ./scripts/cloud-test-build.sh +# +# The normal release (scripts/release.sh, Release configuration) is unchanged. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BUILD_DIR="/tmp/coucou-cloud-test" +APP="$BUILD_DIR/Coucou.app" +PROFILE_NAME="Coucou Developer ID" +APP_ID="256AUJ9555.fr.louisraille.NotchBuddy" +CONTAINER="iCloud.fr.louisraille.Coucou" + +die() { echo "error: $*" >&2; exit 1; } + +# ── 1. Developer ID identity ────────────────────────────────────────────────── +IDENTITY=$(security find-identity -v -p codesigning | grep "Developer ID Application" | head -1 | sed 's/.*"\(Developer ID Application[^"]*\)".*/\1/' || true) +[ -n "$IDENTITY" ] || die "no 'Developer ID Application' certificate found. Install it via Xcode → Settings → Accounts." +echo "Signing with: $IDENTITY" + +# ── 2. Provisioning profile ─────────────────────────────────────────────────── +# Xcode looks in both folders depending on its version. Double-clicking a +# downloaded .provisionprofile on macOS doesn't reliably put it in either. +TMP_PLIST=$(mktemp) +trap 'rm -f "$TMP_PLIST"' EXIT +FOUND="" +for dir in "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" "$HOME/Library/MobileDevice/Provisioning Profiles"; do + [ -d "$dir" ] || continue + for f in "$dir"/*.provisionprofile; do + [ -f "$f" ] || continue + security cms -D -i "$f" > "$TMP_PLIST" 2>/dev/null || continue + NAME=$(/usr/libexec/PlistBuddy -c "Print :Name" "$TMP_PLIST" 2>/dev/null || true) + [ "$NAME" = "$PROFILE_NAME" ] || continue + FOUND="$f" + break 2 + done +done +[ -n "$FOUND" ] || die "no provisioning profile named '$PROFILE_NAME' is installed. + developer.apple.com → Profiles → + → Developer ID → App ID fr.louisraille.NotchBuddy, + name it exactly '$PROFILE_NAME' and download it. Then copy it for Xcode: + mkdir -p ~/Library/Developer/Xcode/UserData/Provisioning\\ Profiles + cp ~/Downloads/*.provisionprofile ~/Library/Developer/Xcode/UserData/Provisioning\\ Profiles/" +echo "Profile: $FOUND" + +PROFILE_APP_ID=$(/usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.application-identifier" "$TMP_PLIST" 2>/dev/null || true) +[ "$PROFILE_APP_ID" = "$APP_ID" ] || die "the profile is for '$PROFILE_APP_ID', not $APP_ID" +/usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.developer.icloud-container-identifiers" "$TMP_PLIST" 2>/dev/null | grep -q "$CONTAINER" \ + || die "the profile has no iCloud container $CONTAINER. Turn on iCloud (CloudKit, with that container) on the App ID fr.louisraille.NotchBuddy, then regenerate and reinstall the profile." +EXPIRY=$(/usr/libexec/PlistBuddy -c "Print :ExpirationDate" "$TMP_PLIST") +echo "Profile expires: $EXPIRY" + +# ── 3. Build ────────────────────────────────────────────────────────────────── +cd "$REPO_ROOT/NotchBuddy" +xcodegen generate +rm -rf "$BUILD_DIR" && mkdir -p "$BUILD_DIR" +xcodebuild \ + -project NotchBuddy.xcodeproj \ + -scheme NotchBuddy \ + -configuration ReleaseCloud \ + build \ + CODE_SIGN_IDENTITY="$IDENTITY" \ + CONFIGURATION_BUILD_DIR="$BUILD_DIR" \ + -quiet \ + || die "the build failed (see the errors above)" + +# ── 4. Checks ───────────────────────────────────────────────────────────────── +[ -f "$APP/Contents/embedded.provisionprofile" ] || die "the profile was not embedded in the app" +codesign --verify --deep --strict "$APP" || die "the signature does not verify" +echo +echo "Entitlements of the built app:" +codesign -d --entitlements - --xml "$APP" 2>/dev/null | plutil -p - || true + +echo +echo "✓ Built $APP (Developer ID, not notarized)." +echo +echo "To test:" +echo " 1. Quit any other Coucou (menu bar icon → Quit) and stop it in Xcode." +echo " 2. open $APP" +echo " 3. tail -f ~/Library/Logs/NotchBuddy/nb.log | grep PhoneLink" +echo " 4. Developer ID builds use the Production CloudKit environment: run the iPhone" +echo " app with the CoucouPhoneProduction scheme, pull to refresh, then tap Send pong." diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..bd3fe4b --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# Builds, notarizes and publishes Coucou for macOS (GitHub build). +# +# ./scripts/release.sh 0.1.2 build, sign, notarize, staple, tag, publish +# ./scripts/release.sh 0.1.2 --finish finish after an interrupted notarization wait +# +# Run it from a clean checkout of main. CFBundleShortVersionString in +# NotchBuddy/project.yml must match the version, and CHANGELOG.md needs a +# "## " section: it becomes the release notes. +set -euo pipefail + +VERSION="${1:?Usage: $0 [--finish]}" +MODE="${2:-}" +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BUILD_DIR="/tmp/coucou-release-$VERSION" +APP="$BUILD_DIR/Coucou.app" +ZIP="$BUILD_DIR/Coucou.zip" +COMMIT_FILE="$BUILD_DIR/commit" +TAG="v$VERSION" + +die() { echo "error: $*" >&2; exit 1; } + +case "$MODE" in + ""|--finish) ;; + *) die "unknown option '$MODE' (the only option is --finish)" ;; +esac + +cd "$REPO_ROOT" + +# ── Checks shared by both modes ─────────────────────────────────────────────── +command -v gh >/dev/null || die "gh is not installed (brew install gh)" + +# The "## " section of CHANGELOG.md, without leading or trailing blank lines. +CHANGES=$(awk -v head="## $VERSION" ' + index($0, head " ") == 1 || $0 == head { found = 1; next } + found && /^## / { exit } + found { + if ($0 ~ /^[ \t\r]*$/) { if (started) blanks++; next } + while (blanks > 0) { print ""; blanks-- } + print; started = 1 + } +' CHANGELOG.md) +[ -n "$CHANGES" ] || die "CHANGELOG.md has no '## $VERSION' section" + +grep -q "| \[$VERSION\]" README.md || die "README.md has no row for $VERSION in the Versions table" + +if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + die "tag $TAG already exists here" +fi +if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then + die "tag $TAG already exists on GitHub" +fi + +if [ "$MODE" != "--finish" ]; then + # The release must match a commit: no uncommitted changes to tracked files. + if ! git diff --quiet || ! git diff --cached --quiet; then + die "commit or stash your changes first, the release has to match a commit" + fi + COMMIT=$(git rev-parse HEAD) + + # ── 1. Developer ID identity ──────────────────────────────────────────────── + # "|| true": with pipefail, grep finding nothing would end the script silently. + IDENTITY=$(security find-identity -v -p codesigning | grep "Developer ID Application" | head -1 | sed 's/.*"\(Developer ID Application[^"]*\)".*/\1/' || true) + [ -n "$IDENTITY" ] || die "no 'Developer ID Application' certificate found. Install it via Xcode → Settings → Accounts." + echo "Signing with: $IDENTITY" + + # ── 1b. Developer ID provisioning profile (iCloud for the iPhone sync) ────── + PROFILE_NAME="Coucou Developer ID" + PROFILE_FOUND="" + TMP_PLIST=$(mktemp) + for dir in "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" "$HOME/Library/MobileDevice/Provisioning Profiles"; do + [ -d "$dir" ] || continue + for f in "$dir"/*.provisionprofile; do + [ -f "$f" ] || continue + security cms -D -i "$f" > "$TMP_PLIST" 2>/dev/null || continue + [ "$(/usr/libexec/PlistBuddy -c "Print :Name" "$TMP_PLIST" 2>/dev/null || true)" = "$PROFILE_NAME" ] || continue + PROFILE_FOUND="$f" + break 2 + done + done + [ -n "$PROFILE_FOUND" ] || { rm -f "$TMP_PLIST"; die "no provisioning profile named '$PROFILE_NAME'. developer.apple.com → Profiles → Developer ID (Mac) for fr.louisraille.NotchBuddy, then copy it to ~/Library/Developer/Xcode/UserData/Provisioning Profiles/"; } + EXPIRY=$(/usr/libexec/PlistBuddy -c "Print :ExpirationDate" "$TMP_PLIST") + rm -f "$TMP_PLIST" + echo "Profile: $PROFILE_FOUND (expires $EXPIRY)" + + # ── 2. xcodegen + Release build ───────────────────────────────────────────── + cd "$REPO_ROOT/NotchBuddy" + PLIST_VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" Resources/Info.plist 2>/dev/null || true) + [ "$PLIST_VERSION" = "$VERSION" ] || die "NotchBuddy/Resources/Info.plist is version $PLIST_VERSION, not $VERSION: run xcodegen and commit Info.plist" + xcodegen generate + PLIST_VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" Resources/Info.plist) + [ "$PLIST_VERSION" = "$VERSION" ] || die "project.yml says $PLIST_VERSION, not $VERSION: update CFBundleShortVersionString in NotchBuddy/project.yml" + + rm -rf "$BUILD_DIR" && mkdir -p "$BUILD_DIR" + echo "$COMMIT" > "$COMMIT_FILE" + + xcodebuild \ + -project NotchBuddy.xcodeproj \ + -scheme NotchBuddy \ + -configuration Release \ + build \ + CODE_SIGN_IDENTITY="$IDENTITY" \ + CODE_SIGNING_REQUIRED=YES \ + CODE_SIGNING_ALLOWED=YES \ + CONFIGURATION_BUILD_DIR="$BUILD_DIR" + + [ -f "$APP/Contents/embedded.provisionprofile" ] || die "the provisioning profile was not embedded in the app" + codesign -d --entitlements - --xml "$APP" 2>/dev/null | grep -q "iCloud.fr.louisraille.Coucou" \ + || die "the app is not signed with the iCloud entitlements" + + # ── 3. Zip + notarize ─────────────────────────────────────────────────────── + ditto -c -k --keepParent "$APP" "$ZIP" + echo + echo "Sending to Apple for notarization. If you stop the wait (Ctrl+C), Apple keeps going:" + echo " check it with xcrun notarytool history --keychain-profile coucou-notary" + echo " once Accepted ./scripts/release.sh $VERSION --finish" + echo + xcrun notarytool submit "$ZIP" --keychain-profile coucou-notary --wait \ + || die "notarization failed: see xcrun notarytool log --keychain-profile coucou-notary (the id is printed above)" + cd "$REPO_ROOT" +else + [ -d "$APP" ] && [ -f "$COMMIT_FILE" ] || die "nothing to finish in $BUILD_DIR, run ./scripts/release.sh $VERSION first" + COMMIT=$(cat "$COMMIT_FILE") +fi + +# ── 4. Staple + verify ──────────────────────────────────────────────────────── +xcrun stapler staple "$APP" \ + || die "stapling failed: Apple has not accepted the build yet. Check xcrun notarytool history --keychain-profile coucou-notary, then run ./scripts/release.sh $VERSION --finish" +spctl -a -vv "$APP" + +# ── 5. Re-zip (with the stapled app) ────────────────────────────────────────── +rm -f "$ZIP" +ditto -c -k --keepParent "$APP" "$ZIP" +echo "Release zip ready: $ZIP" + +# ── 6. Tag the built commit + GitHub release ────────────────────────────────── +NOTES="Coucou $VERSION for macOS 15 or later (Apple silicon and Intel). + +Signed with a Developer ID and notarized by Apple. + +## What's new + +$CHANGES + +## Install + +1. Download Coucou.zip below and unzip it. +2. Move Coucou.app to your Applications folder, replacing the old one if you have it. +3. Launch it, and click Open when macOS asks you to confirm. + +Linux and Windows: see the [README](https://github.com/Louis-CFM/coucou#readme)." + +echo +echo "──────── Release notes ────────" +echo "$NOTES" +echo "───────────────────────────────" +echo +read -r -p "Tag $(git rev-parse --short "$COMMIT") as $TAG and publish this release? [y/N] " ANSWER +case "$ANSWER" in + y|Y|yes|oui|o|O) ;; + *) die "stopped before tagging. Run ./scripts/release.sh $VERSION --finish to publish later." ;; +esac + +git tag "$TAG" "$COMMIT" +git push origin "$TAG" + +gh release create "$TAG" "$ZIP" \ + --repo Louis-CFM/coucou \ + --title "Coucou $VERSION" \ + --latest \ + --notes "$NOTES" + +echo "✓ $TAG released: https://github.com/Louis-CFM/coucou/releases/tag/$TAG" diff --git a/scripts/render-outfits.sh b/scripts/render-outfits.sh new file mode 100644 index 0000000..88b704f --- /dev/null +++ b/scripts/render-outfits.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# render-outfits.sh — compile and run the Mochi outfit planche renderer +# NOT in CI. Run manually: bash scripts/render-outfits.sh +# Output: /tmp/coucou-outfits.png + +set -e +cd "$(dirname "$0")/.." + +SDK=$(xcrun --sdk macosx --show-sdk-path) + +echo "Compiling renderer..." +swiftc \ + -parse-as-library \ + -sdk "$SDK" \ + -target arm64-apple-macosx15.0 \ + NotchBuddy/Sources/CoucouKit/IslandScreenGeometry.swift \ + NotchBuddy/Sources/CoucouKit/IslandTypes.swift \ + NotchBuddy/Sources/CoucouKit/MochiWardrobe.swift \ + NotchBuddy/Sources/CoucouKit/BotEngine.swift \ + NotchBuddy/Sources/CoucouKit/MochiOutfitDrawing.swift \ + scripts/RenderOutfits.swift \ + -framework AppKit \ + -framework SwiftUI \ + -o /tmp/coucou-render-outfits \ + 2>&1 + +echo "Running renderer..." +/tmp/coucou-render-outfits +echo "Opening..." +open /tmp/coucou-outfits.png diff --git a/scripts/test-ask-question.sh b/scripts/test-ask-question.sh new file mode 100755 index 0000000..c1c5aba --- /dev/null +++ b/scripts/test-ask-question.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-ask-question.XXXXXX")" +trap 'rm -rf "$TEST_DIR"' EXIT +swiftc NotchBuddy/Sources/App/AskQuestion.swift \ + tests/AskQuestionTests.swift -o "$TEST_DIR/ask-question-tests" +"$TEST_DIR/ask-question-tests" diff --git a/scripts/test-chat-parsing.sh b/scripts/test-chat-parsing.sh new file mode 100755 index 0000000..9ad898f --- /dev/null +++ b/scripts/test-chat-parsing.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." + +TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-chat-parsing.XXXXXX")" +SERVER_PID="" +trap 'rm -rf "$TEST_DIR"; [ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null || true' EXIT + +PORT_FILE="$TEST_DIR/port.txt" + +# ── Start fake LLM server ──────────────────────────────────────────────────── +python3 tests/fake_local_llm.py "$PORT_FILE" & +SERVER_PID=$! + +# ── Compile test binary (runs in parallel with server startup) ─────────────── +# Compilation typically takes a few seconds, which gives the server plenty of +# time to bind and write its port — avoiding a busy-wait on fast machines. +swiftc \ + NotchBuddy/Sources/App/LocalChat.swift \ + NotchBuddy/Sources/App/ChatMarkdown.swift \ + tests/ChatParsingTests.swift \ + -o "$TEST_DIR/chat-parsing-tests" + +# ── Wait for server port file (up to 30 s) ─────────────────────────────────── +for i in $(seq 1 300); do + # Bail out immediately if the server process has already died + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "ERROR: fake LLM server process exited unexpectedly" >&2 + exit 1 + fi + [ -s "$PORT_FILE" ] && break + sleep 0.1 +done + +if [ ! -s "$PORT_FILE" ]; then + echo "ERROR: fake LLM server did not write its port within 30 s" >&2 + exit 1 +fi + +PORT=$(cat "$PORT_FILE") +echo "Fake LLM server listening on port $PORT (PID $SERVER_PID)" + +# ── Run tests ──────────────────────────────────────────────────────────────── +"$TEST_DIR/chat-parsing-tests" "$PORT" diff --git a/scripts/test-desktop-mochi.sh b/scripts/test-desktop-mochi.sh new file mode 100755 index 0000000..b36c789 --- /dev/null +++ b/scripts/test-desktop-mochi.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-desktop.XXXXXX")" +trap 'rm -rf "$TEST_DIR"' EXIT +swiftc NotchBuddy/Sources/App/DesktopMochiLogic.swift \ + tests/DesktopMochiTests.swift -o "$TEST_DIR/desktop-mochi-tests" +"$TEST_DIR/desktop-mochi-tests" diff --git a/scripts/test-diff-engine.sh b/scripts/test-diff-engine.sh new file mode 100755 index 0000000..115a7df --- /dev/null +++ b/scripts/test-diff-engine.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-diff-engine.XXXXXX")" +trap 'rm -rf "$TEST_DIR"' EXIT +swiftc NotchBuddy/Sources/CoucouKit/DiffEngine.swift \ + tests/DiffEngineTests.swift -o "$TEST_DIR/diff-engine-tests" +"$TEST_DIR/diff-engine-tests" diff --git a/scripts/test-github-activity.sh b/scripts/test-github-activity.sh new file mode 100755 index 0000000..b09de35 --- /dev/null +++ b/scripts/test-github-activity.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-github-activity.XXXXXX")" +trap 'rm -rf "$TEST_DIR"' EXIT +swiftc NotchBuddy/Sources/App/GitHubActivity.swift \ + tests/GitHubActivityTests.swift -o "$TEST_DIR/github-activity-tests" +"$TEST_DIR/github-activity-tests" diff --git a/scripts/test-github-pulse.sh b/scripts/test-github-pulse.sh new file mode 100755 index 0000000..7b6e984 --- /dev/null +++ b/scripts/test-github-pulse.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-github-pulse.XXXXXX")" +trap 'rm -rf "$TEST_DIR"' EXIT +swiftc NotchBuddy/Sources/App/GitHubPulse.swift \ + tests/GitHubPulseTests.swift -o "$TEST_DIR/github-pulse-tests" +"$TEST_DIR/github-pulse-tests" diff --git a/scripts/test-plan-gauge.sh b/scripts/test-plan-gauge.sh new file mode 100755 index 0000000..9133fad --- /dev/null +++ b/scripts/test-plan-gauge.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-plan-gauge.XXXXXX")" +trap 'rm -rf "$TEST_DIR"' EXIT +swiftc NotchBuddy/Sources/App/ClaudePlanGauge.swift \ + tests/ClaudePlanGaugeTests.swift -o "$TEST_DIR/plan-gauge-tests" +"$TEST_DIR/plan-gauge-tests" diff --git a/scripts/test-safe-links.sh b/scripts/test-safe-links.sh new file mode 100755 index 0000000..cfc112c --- /dev/null +++ b/scripts/test-safe-links.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-safe-links.XXXXXX")" +trap 'rm -rf "$TEST_DIR"' EXIT +swiftc NotchBuddy/Sources/App/SafeWebURL.swift \ + tests/SafeWebURLTests.swift -o "$TEST_DIR/safe-links-tests" +"$TEST_DIR/safe-links-tests" diff --git a/scripts/test-screen-geometry.sh b/scripts/test-screen-geometry.sh new file mode 100644 index 0000000..71e0026 --- /dev/null +++ b/scripts/test-screen-geometry.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-geometry.XXXXXX")" +trap 'rm -rf "$TEST_DIR"' EXIT +swiftc NotchBuddy/Sources/CoucouKit/IslandScreenGeometry.swift \ + tests/IslandScreenGeometryTests.swift -o "$TEST_DIR/geometry-tests" +"$TEST_DIR/geometry-tests" diff --git a/scripts/test-shortcuts.sh b/scripts/test-shortcuts.sh new file mode 100755 index 0000000..bfa4cff --- /dev/null +++ b/scripts/test-shortcuts.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-shortcuts.XXXXXX")" +trap 'rm -rf "$TEST_DIR"' EXIT +swiftc NotchBuddy/Sources/App/ShortcutLogic.swift \ + tests/ShortcutTests.swift -o "$TEST_DIR/shortcut-tests" +"$TEST_DIR/shortcut-tests" diff --git a/scripts/test-wardrobe.sh b/scripts/test-wardrobe.sh new file mode 100755 index 0000000..14b1854 --- /dev/null +++ b/scripts/test-wardrobe.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-wardrobe.XXXXXX")" +trap 'rm -rf "$TEST_DIR"' EXIT +swiftc NotchBuddy/Sources/CoucouKit/MochiWardrobe.swift \ + tests/MochiWardrobeTests.swift -o "$TEST_DIR/wardrobe-tests" +"$TEST_DIR/wardrobe-tests" diff --git a/tests/AskQuestionTests.swift b/tests/AskQuestionTests.swift new file mode 100644 index 0000000..981b724 --- /dev/null +++ b/tests/AskQuestionTests.swift @@ -0,0 +1,121 @@ +import Foundation + +@main +enum AskQuestionTests { + + static var failures = 0 + + static func check(_ label: String, _ got: String, _ expected: String) { + if got == expected { + print(" ✓ \(label)") + } else { + print(" ✗ \(label)") + print(" got: \(got.debugDescription)") + print(" expected: \(expected.debugDescription)") + failures += 1 + } + } + + static func checkTrue(_ label: String, _ value: Bool) { + if value { print(" ✓ \(label)") } + else { print(" ✗ \(label)"); failures += 1 } + } + + static func main() { + let opt1: [String: Any] = ["label": "Postgres", "description": "Full-text search"] + let opt2: [String: Any] = ["label": "Meilisearch", "description": ""] + let opt3: [String: Any] = ["label": "Algolia", "description": "Managed search"] + + // ── parse ────────────────────────────────────────────────────────────── + print("AskQuestion.parse") + + let single: [String: Any] = ["questions": [ + ["question": "Which search engine?", "header": "Search", "options": [opt1, opt2, opt3], "multiSelect": false] + ]] + let parsed = AskQuestion.parse(toolInput: single) + checkTrue("single question parses", parsed != nil) + checkTrue("question text", parsed?.questions[0].question == "Which search engine?") + checkTrue("header", parsed?.questions[0].header == "Search") + checkTrue("3 options", parsed?.questions[0].options.count == 3) + checkTrue("multiSelect false", parsed?.questions[0].multiSelect == false) + checkTrue("option label", parsed?.questions[0].options[0].label == "Postgres") + + // header truncated at 12 chars + let longHeader: [String: Any] = ["questions": [ + ["question": "Q?", "header": "This is a very long header", "options": [opt1, opt2], "multiSelect": false] + ]] + let lh = AskQuestion.parse(toolInput: longHeader) + checkTrue("header truncated to 12", lh?.questions[0].header.count == 12) + + // multiSelect true + let multi: [String: Any] = ["questions": [ + ["question": "Pick features", "header": "Features", "options": [opt1, opt2, opt3], "multiSelect": true] + ]] + let mParsed = AskQuestion.parse(toolInput: multi) + checkTrue("multiSelect true", mParsed?.questions[0].multiSelect == true) + + // 4 questions (max) + let q: [String: Any] = ["question": "Q?", "header": "", "options": [opt1, opt2], "multiSelect": false] + let four: [String: Any] = ["questions": [q, q, q, q]] + checkTrue("4 questions parses", AskQuestion.parse(toolInput: four) != nil) + + // 5 questions → nil (over max) + let five: [String: Any] = ["questions": [q, q, q, q, q]] + checkTrue("5 questions → nil", AskQuestion.parse(toolInput: five) == nil) + + // 1 option → nil (under min) + let oneOpt: [String: Any] = ["questions": [ + ["question": "Q?", "header": "", "options": [opt1], "multiSelect": false] + ]] + checkTrue("1 option → nil", AskQuestion.parse(toolInput: oneOpt) == nil) + + // missing question text → nil + let noQ: [String: Any] = ["questions": [ + ["header": "", "options": [opt1, opt2], "multiSelect": false] + ]] + checkTrue("missing question → nil", AskQuestion.parse(toolInput: noQ) == nil) + + // empty questions array → nil + checkTrue("empty questions → nil", AskQuestion.parse(toolInput: ["questions": []]) == nil) + + // missing questions key → nil + checkTrue("missing questions → nil", AskQuestion.parse(toolInput: [:]) == nil) + + // option with empty label → nil + let emptyLabel: [String: Any] = ["questions": [ + ["question": "Q?", "header": "", "options": [["label": "", "description": ""], opt2], "multiSelect": false] + ]] + checkTrue("empty option label → nil", AskQuestion.parse(toolInput: emptyLabel) == nil) + + // ── buildAnswers ─────────────────────────────────────────────────────── + print("AskQuestion.buildAnswers") + + let items = AskQuestion.parse(toolInput: multi)!.questions + + AskQuestion.parse(toolInput: single)!.questions + + // items[0] = "Pick features" (multiSelect: true), items[1] = "Which search engine?" (multiSelect: false) + let sel1 = [["Postgres"], ["Meilisearch"]] + let ans1 = AskQuestion.buildAnswers(questions: items, selections: sel1) + checkTrue("multi-select single pick → [String]", (ans1["Pick features"] as? [String]) == ["Postgres"]) + checkTrue("single-select answer → String", (ans1["Which search engine?"] as? String) == "Meilisearch") + + // Multi-select with multiple picks → [String] array + let sel2 = [["Postgres", "Algolia"], []] + let ans2 = AskQuestion.buildAnswers(questions: items, selections: sel2) + checkTrue("multi-select multiple picks → [String]", (ans2["Pick features"] as? [String]) == ["Postgres", "Algolia"]) + checkTrue("unanswered question omitted", ans2["Which search engine?"] == nil) + + // Empty selections → empty dict + let ans3 = AskQuestion.buildAnswers(questions: items, selections: [[], []]) + checkTrue("all empty → empty dict", ans3.isEmpty) + + // ── finish ───────────────────────────────────────────────────────────── + if failures == 0 { + print("\nAll tests passed.") + exit(0) + } else { + print("\n\(failures) test(s) failed.") + exit(1) + } + } +} diff --git a/tests/ChatParsingTests.swift b/tests/ChatParsingTests.swift new file mode 100644 index 0000000..8e3da27 --- /dev/null +++ b/tests/ChatParsingTests.swift @@ -0,0 +1,207 @@ +import Foundation + +// MARK: - Test harness + +@main +enum ChatParsingTests { + + static var failures = 0 + + static func check(_ label: String, _ got: String, _ expected: String) { + if got == expected { + print(" ✓ \(label)") + } else { + print(" ✗ \(label)") + print(" got: \(got.debugDescription)") + print(" expected: \(expected.debugDescription)") + failures += 1 + } + } + + static func checkTrue(_ label: String, _ value: Bool) { + if value { print(" ✓ \(label)") } + else { print(" ✗ \(label)"); failures += 1 } + } + + // MARK: - Entry point + + static func main() async { + + // ── Unit tests (no network) ────────────────────────────────────────── + + print("LocalChat.normaliseURL") + check("strips trailing slash", LocalChat.normaliseURL("http://localhost:11434/"), "http://localhost:11434") + check("strips /api suffix", LocalChat.normaliseURL("http://localhost:11434/api"), "http://localhost:11434") + check("strips /v1 suffix", LocalChat.normaliseURL("http://localhost:1234/v1"), "http://localhost:1234") + check("no-op clean URL", LocalChat.normaliseURL("http://localhost:11434"), "http://localhost:11434") + check("trims whitespace", LocalChat.normaliseURL(" http://localhost:11434 "), "http://localhost:11434") + + print("LocalChat.parseSSEDelta") + let sseData = #"data: {"id":"1","choices":[{"delta":{"content":"hello"}}]}"# + check("parses delta", LocalChat.parseSSEDelta(sseData) ?? "", "hello") + checkTrue("ignores [DONE]", LocalChat.parseSSEDelta("data: [DONE]") == nil) + checkTrue("ignores non-data", LocalChat.parseSSEDelta(": heartbeat") == nil) + checkTrue("ignores null content", + LocalChat.parseSSEDelta(#"data: {"choices":[{"delta":{"content":null}}]}"#) == nil) + checkTrue("ignores missing content", + LocalChat.parseSSEDelta(#"data: {"choices":[{"delta":{}}]}"#) == nil) + + print("LocalChat.filterThinkingBlocks") + check("removes closed block", + LocalChat.filterThinkingBlocks("internalanswer"), "answer") + check("no-op without block", + LocalChat.filterThinkingBlocks("hello"), "hello") + check("multiline block", + LocalChat.filterThinkingBlocks("\nstep1\nstep2\nresult"), "result") + + print("LocalChat.progressiveFilter") + check("open block → hide", + LocalChat.progressiveFilter("\nstep one"), "") + check("open after text → keep prefix", + LocalChat.progressiveFilter("visiblehidden"), "visible") + check("closed block removed", + LocalChat.progressiveFilter("doneanswer"), "answer") + + print("ChatMarkdown.parse") + let blocks = ChatMarkdown.parse( + "## Hello\n\nThis is a paragraph.\n\n- item 1\n- item 2\n\n```swift\nlet x = 1\n```") + checkTrue("heading count", blocks.filter { if case .heading = $0 { return true }; return false }.count == 1) + checkTrue("paragraph count", blocks.filter { if case .paragraph = $0 { return true }; return false }.count == 1) + checkTrue("list item count", blocks.filter { if case .listItem = $0 { return true }; return false }.count == 2) + checkTrue("code block count", blocks.filter { if case .codeBlock = $0 { return true }; return false }.count == 1) + if case .heading(let level, let text) = + blocks.first(where: { if case .heading = $0 { return true }; return false })! { + checkTrue("heading level 2", level == 2) + checkTrue("heading text", text == "Hello") + } else { print(" ✗ heading not found"); failures += 1 } + + print("ChatMarkdown.parse — extended") + // Numbered list preserves number + let numBlocks = ChatMarkdown.parse("1. first\n2. second") + let numItems = numBlocks.filter { if case .listItem = $0 { return true }; return false } + checkTrue("ordered list count", numItems.count == 2) + if case .listItem(let prefix, _, _) = numItems.first! { + checkTrue("ordered prefix is '1.'", prefix == "1.") + } + // Heading requires space after # + checkTrue("heading with space", ChatMarkdown.parse("## Hi").contains { if case .heading = $0 { return true }; return false }) + checkTrue("#nospace is paragraph", ChatMarkdown.parse("#nospace").contains { if case .paragraph = $0 { return true }; return false }) + // Nested list indent + let nested = ChatMarkdown.parse("- top\n - nested") + let items = nested.filter { if case .listItem = $0 { return true }; return false } + checkTrue("nested list count", items.count == 2) + if case .listItem(_, _, let indent) = items[1] { checkTrue("nested indent = 1", indent == 1) } + // Blockquote + let qBlocks = ChatMarkdown.parse("> quoted text") + checkTrue("blockquote parsed", qBlocks.contains { if case .quote = $0 { return true }; return false }) + if case .quote(let text) = qBlocks.first! { checkTrue("quote text", text == "quoted text") } + // Paragraph stops before ordered list + let mixBlocks = ChatMarkdown.parse("intro\n1. item") + checkTrue("paragraph + ordered list", mixBlocks.filter { if case .paragraph = $0 { return true }; return false }.count == 1 + && mixBlocks.filter { if case .listItem = $0 { return true }; return false }.count == 1) + // progressiveFilter hides open think block + check("open think → empty", LocalChat.progressiveFilter("\nhalf"), "") + check("open after text", LocalChat.progressiveFilter("answerhidden"), "answer") + check("closed think removed", LocalChat.progressiveFilter("doneresult"), "result") + + // ── End-to-end tests (fake server) ─────────────────────────────────── + + let baseURL: String = { + guard CommandLine.arguments.count > 1 else { return "" } + return "http://127.0.0.1:\(CommandLine.arguments[1])" + }() + + guard !baseURL.isEmpty else { + print("\n(Skipping end-to-end tests — no server port provided.)") + finish() + } + + print("LocalChat.fetchModels (fake server)") + + let models = await LocalChat.fetchModels(baseURL: baseURL) + checkTrue("models list non-empty", !models.isEmpty) + checkTrue("llama3.2 present", models.contains { $0.id == "llama3.2" }) + checkTrue("nomic-embed-text filtered", !models.contains { $0.id == "nomic-embed-text" }) + + print("LocalChat.streamChat — happy path (fake server)") + + var tokens: [String] = [] + do { + let response = try await LocalChat.streamChat( + baseURL: baseURL, + model: "llama3.2", + messages: [["role": "user", "content": "hello"]], + onToken: { visible in tokens.append(visible) } + ) + checkTrue("sent multiple tokens", tokens.count > 1) + checkTrue("intermediate tokens non-empty", tokens.contains { !$0.isEmpty }) + checkTrue("think block removed from response", !response.contains("")) + checkTrue("markdown heading in response", response.contains("## Answer")) + checkTrue("list item in response", response.contains("- **item 1**")) + checkTrue("code block in response", response.contains("```python")) + } catch { + print(" ✗ unexpected error: \(error)") + failures += 1 + } + + print("LocalChat.streamChat — unknown model (fake server)") + + do { + _ = try await LocalChat.streamChat( + baseURL: baseURL, + model: "unknown-model", + messages: [["role": "user", "content": "hello"]], + onToken: { _ in } + ) + print(" ✗ should have thrown for unknown model") + failures += 1 + } catch let e as LocalChatError { + if case .modelNotFound(let m) = e { + checkTrue("model name in error", m == "unknown-model") + } else { + print(" ✗ wrong error case: \(e)") + failures += 1 + } + } catch { + print(" ✗ unexpected error type: \(error)") + failures += 1 + } + + print("LocalChat.streamChat — unreachable server") + + do { + _ = try await LocalChat.streamChat( + baseURL: "http://127.0.0.1:1", // nothing on port 1 + model: "llama3.2", + messages: [["role": "user", "content": "hello"]], + onToken: { _ in } + ) + print(" ✗ should have thrown for unreachable server") + failures += 1 + } catch let e as LocalChatError { + if case .serverUnreachable = e { + print(" ✓ serverUnreachable error") + } else { + print(" ✗ wrong error case: \(e)") + failures += 1 + } + } catch { + print(" ✗ unexpected error type: \(error)") + failures += 1 + } + + finish() + } + + // MARK: - Finish + + private static func finish() -> Never { + if failures == 0 { + print("\nAll tests passed.") + exit(0) + } else { + print("\n\(failures) test(s) failed.") + exit(1) + } + } +} diff --git a/tests/ClaudePlanGaugeTests.swift b/tests/ClaudePlanGaugeTests.swift new file mode 100644 index 0000000..91fa1ce --- /dev/null +++ b/tests/ClaudePlanGaugeTests.swift @@ -0,0 +1,133 @@ +import Foundation + +@main +enum ClaudePlanGaugeTests { + + static var failures = 0 + + static func check(_ label: String, _ got: String, _ expected: String) { + if got == expected { + print(" ✓ \(label)") + } else { + print(" ✗ \(label)") + print(" got: \(got.debugDescription)") + print(" expected: \(expected.debugDescription)") + failures += 1 + } + } + + static func checkTrue(_ label: String, _ value: Bool) { + if value { print(" ✓ \(label)") } + else { print(" ✗ \(label)"); failures += 1 } + } + + static func main() { + let futureEpoch: Double = Date().timeIntervalSince1970 + 7200 // 2 hours from now + let pastEpoch: Double = Date().timeIntervalSince1970 - 100 // already passed + + // ── parse ────────────────────────────────────────────────────────────── + print("ClaudePlanGauge.parse") + + let full: [String: Any] = ["rate_limits": [ + "five_hour": ["used_percentage": 23.5, "resets_at": futureEpoch], + "seven_day": ["used_percentage": 67.0, "resets_at": futureEpoch], + ]] + let usage = ClaudePlanGauge.parse(payload: full) + checkTrue("parse full payload → non-nil", usage != nil) + checkTrue("five_hour usedPct 23.5", usage?.fiveHour?.usedPct == 23.5) + checkTrue("seven_day usedPct 67.0", usage?.sevenDay?.usedPct == 67.0) + + // Missing rate_limits → nil + checkTrue("missing rate_limits → nil", ClaudePlanGauge.parse(payload: [:]) == nil) + + // Absurd pct (>200) → nil window (150 is now clamped to 100) + let badPct: [String: Any] = ["rate_limits": [ + "five_hour": ["used_percentage": 250.0, "resets_at": futureEpoch], + ]] + let badUsage = ClaudePlanGauge.parse(payload: badPct) + checkTrue("absurd pct (250) → nil window", badUsage?.fiveHour == nil) + + // pct 150 → clamped to 100 (not rejected) + let clampPct: [String: Any] = ["rate_limits": [ + "five_hour": ["used_percentage": 150.0, "resets_at": futureEpoch], + ]] + let clampUsage = ClaudePlanGauge.parse(payload: clampPct) + checkTrue("pct 150 → clamped to 100", clampUsage?.fiveHour?.usedPct == 100.0) + + // pct 201 → rejected + let tooBig: [String: Any] = ["rate_limits": [ + "five_hour": ["used_percentage": 201.0, "resets_at": futureEpoch], + ]] + checkTrue("pct 201 → nil window", ClaudePlanGauge.parse(payload: tooBig)?.fiveHour == nil) + + // resets_at > 400 days → rejected + let farFuture = Date().timeIntervalSince1970 + 401 * 86400 + let msEpoch: [String: Any] = ["rate_limits": [ + "five_hour": ["used_percentage": 50.0, "resets_at": farFuture], + ]] + checkTrue("resets_at > 400d → nil window", ClaudePlanGauge.parse(payload: msEpoch)?.fiveHour == nil) + + // Negative pct → nil window + let negPct: [String: Any] = ["rate_limits": [ + "five_hour": ["used_percentage": -5.0, "resets_at": futureEpoch], + ]] + checkTrue("negative pct → nil window", ClaudePlanGauge.parse(payload: negPct)?.fiveHour == nil) + + // Int pct accepted + let intPct: [String: Any] = ["rate_limits": [ + "five_hour": ["used_percentage": 42, "resets_at": Int(futureEpoch)], + ]] + let intUsage = ClaudePlanGauge.parse(payload: intPct) + checkTrue("Int pct parsed", intUsage?.fiveHour?.usedPct == 42.0) + + // ── effectivePct (expired window) ───────────────────────────────────── + print("ClaudePlanGauge.effectivePct") + + let expiredWindow = PlanWindow(usedPct: 80, resetsAt: Date(timeIntervalSince1970: pastEpoch)) + let futureWindow = PlanWindow(usedPct: 80, resetsAt: Date(timeIntervalSince1970: futureEpoch)) + checkTrue("expired window → 0", ClaudePlanGauge.effectivePct(expiredWindow) == 0) + checkTrue("future window → original pct", ClaudePlanGauge.effectivePct(futureWindow) == 80) + + // ── dominantPct ──────────────────────────────────────────────────────── + print("ClaudePlanGauge.dominantPct") + + let both = PlanUsage(fiveHour: PlanWindow(usedPct: 30, resetsAt: Date(timeIntervalSince1970: futureEpoch)), + sevenDay: PlanWindow(usedPct: 70, resetsAt: Date(timeIntervalSince1970: futureEpoch)), + updatedAt: Date()) + let fhOnly = PlanUsage(fiveHour: PlanWindow(usedPct: 55, resetsAt: Date(timeIntervalSince1970: futureEpoch)), + sevenDay: nil, updatedAt: Date()) + let empty = PlanUsage(fiveHour: nil, sevenDay: nil, updatedAt: Date()) + + checkTrue("dominant picks higher (70)", ClaudePlanGauge.dominantPct(both) == 70) + checkTrue("dominant with one window (55)", ClaudePlanGauge.dominantPct(fhOnly) == 55) + checkTrue("dominant with no windows → nil", ClaudePlanGauge.dominantPct(empty) == nil) + + // ── color ────────────────────────────────────────────────────────────── + print("ClaudePlanGauge.color") + + check("nil → grey", ClaudePlanGauge.color(for: nil), "#6B7079") + check("0 → green", ClaudePlanGauge.color(for: 0), "#22C55E") + check("49 → green", ClaudePlanGauge.color(for: 49), "#22C55E") + check("50 → orange", ClaudePlanGauge.color(for: 50), "#F59E0B") + check("79 → orange", ClaudePlanGauge.color(for: 79), "#F59E0B") + check("80 → red", ClaudePlanGauge.color(for: 80), "#F4505E") + check("100 → red", ClaudePlanGauge.color(for: 100), "#F4505E") + + // ── pillLabel ────────────────────────────────────────────────────────── + print("ClaudePlanGauge.pillLabel") + + check("nil usage → 'Claude plan'", ClaudePlanGauge.pillLabel(nil), "Claude plan") + check("no windows → 'Claude plan'", ClaudePlanGauge.pillLabel(empty), "Claude plan") + check("70 pct → 'Claude 70%'", ClaudePlanGauge.pillLabel(both), "Claude 70%") + check("55 pct → 'Claude 55%'", ClaudePlanGauge.pillLabel(fhOnly), "Claude 55%") + + // ── finish ───────────────────────────────────────────────────────────── + if failures == 0 { + print("\nAll tests passed.") + exit(0) + } else { + print("\n\(failures) test(s) failed.") + exit(1) + } + } +} diff --git a/tests/DesktopMochiTests.swift b/tests/DesktopMochiTests.swift new file mode 100644 index 0000000..b2d5e02 --- /dev/null +++ b/tests/DesktopMochiTests.swift @@ -0,0 +1,129 @@ +import Foundation + +@main +enum DesktopMochiTests { + static func main() { + testShouldSleep() + testIsOverBody() + testLookOrigin() + testClampOrigin() + testShouldRetractOnLanding() + print("DesktopMochiLogic: all cases passed") + } + + // MARK: - shouldSleep + + static func testShouldSleep() { + // Agent recently active → awake regardless of mouse distance + precondition(!DesktopMochiLogic.shouldSleep(lastAgentActiveInterval: 5, mouseDistanceToPanelCenter: 300), + "active agent must not sleep") + + // Long idle but mouse near panel → awake + precondition(!DesktopMochiLogic.shouldSleep(lastAgentActiveInterval: 200, mouseDistanceToPanelCenter: 50), + "mouse near panel must not sleep") + + // Long idle AND mouse far → sleep + precondition(DesktopMochiLogic.shouldSleep(lastAgentActiveInterval: 200, mouseDistanceToPanelCenter: 200), + "long idle + far mouse must sleep") + + // Exactly at timeout boundary → still awake (strictly greater than) + precondition(!DesktopMochiLogic.shouldSleep(lastAgentActiveInterval: 120, mouseDistanceToPanelCenter: 300), + "exactly at timeout must not sleep") + + // Just past timeout boundary + far mouse → sleep + precondition(DesktopMochiLogic.shouldSleep(lastAgentActiveInterval: 120.1, mouseDistanceToPanelCenter: 300), + "just over timeout must sleep") + + // Far mouse at exact distance threshold → sleep + precondition(DesktopMochiLogic.shouldSleep(lastAgentActiveInterval: 200, mouseDistanceToPanelCenter: 150), + "at distance threshold must sleep") + } + + // MARK: - isOverBody + + static func testIsOverBody() { + let s: CGFloat = 120 + let r = s * DesktopMochiLogic.bodyRadiusFraction // 28.8 + + // Center → inside + precondition(DesktopMochiLogic.isOverBody(localPoint: CGPoint(x: 60, y: 60), panelSize: s), + "center must be inside body") + + // Just inside radius + precondition(DesktopMochiLogic.isOverBody(localPoint: CGPoint(x: 60 + r - 0.5, y: 60), panelSize: s), + "inside radius must hit") + + // Just outside radius + precondition(!DesktopMochiLogic.isOverBody(localPoint: CGPoint(x: 60 + r + 0.5, y: 60), panelSize: s), + "outside radius must miss") + + // Corner → outside + precondition(!DesktopMochiLogic.isOverBody(localPoint: CGPoint(x: 0, y: 0), panelSize: s), + "corner must miss") + + // Diagonal at radius — distance = r/√2 from each axis + let diag = r / sqrt(2.0) - 0.5 + precondition(DesktopMochiLogic.isOverBody(localPoint: CGPoint(x: 60 + diag, y: 60 + diag), panelSize: s), + "diagonal inside must hit") + } + + // MARK: - lookOrigin + + static func testLookOrigin() { + // Panel at (200, 300) on a 1440×900 screen starting at x=0 + let o = DesktopMochiLogic.lookOrigin(panelMinX: 200, panelMinY: 300, + screenMinX: 0, screenHeight: 900, + panelSize: 120) + // cx = 200 + 60 = 260 → x = 260 - 0 = 260 + precondition(o.x == 260, "lookOrigin x must be panel center relative to screen left") + // cy = 300 + 60 = 360 → y = 900 - 360 = 540 + precondition(o.y == 540, "lookOrigin y must be flipped from bottom-left to top-left") + + // Panel on a secondary screen starting at x=1440 + let o2 = DesktopMochiLogic.lookOrigin(panelMinX: 1540, panelMinY: 100, + screenMinX: 1440, screenHeight: 1080, + panelSize: 120) + // cx = 1540 + 60 = 1600 → x = 1600 - 1440 = 160 + precondition(o2.x == 160, "lookOrigin x must be relative to screen minX") + // cy = 100 + 60 = 160 → y = 1080 - 160 = 920 + precondition(o2.y == 920, "lookOrigin y on secondary screen") + } + + // MARK: - shouldRetractOnLanding + + static func testShouldRetractOnLanding() { + precondition(DesktopMochiLogic.shouldRetractOnLanding(alertActive: true), + "must retract when alert is active on landing") + precondition(!DesktopMochiLogic.shouldRetractOnLanding(alertActive: false), + "must not retract when no alert on landing") + } + + // MARK: - clampOrigin + + static func testClampOrigin() { + // Typical macOS visible frame (below menu bar) + let vf = CGRect(x: 0, y: 23, width: 1440, height: 877) // maxX=1440, maxY=900 + let s: CGFloat = 120 + let m: CGFloat = 24 + + // Normal position — within bounds + let normal = DesktopMochiLogic.clampOrigin(CGPoint(x: 600, y: 400), panelSize: s, visibleFrame: vf, margin: m) + precondition(normal == CGPoint(x: 600, y: 400), "in-bounds origin must be unchanged") + + // Too far left + let left = DesktopMochiLogic.clampOrigin(CGPoint(x: -50, y: 400), panelSize: s, visibleFrame: vf, margin: m) + precondition(left.x == vf.minX + m, "too-left must clamp to minX + margin") + + // Too far right + let right = DesktopMochiLogic.clampOrigin(CGPoint(x: 2000, y: 400), panelSize: s, visibleFrame: vf, margin: m) + precondition(right.x == vf.maxX - s - m, "too-right must clamp to maxX - panelSize - margin") + + // Too low + let low = DesktopMochiLogic.clampOrigin(CGPoint(x: 400, y: -50), panelSize: s, visibleFrame: vf, margin: m) + precondition(low.y == vf.minY + m, "too-low must clamp to minY + margin") + + // Too high + let high = DesktopMochiLogic.clampOrigin(CGPoint(x: 400, y: 2000), panelSize: s, visibleFrame: vf, margin: m) + precondition(high.y == vf.maxY - s - m, "too-high must clamp to maxY - panelSize - margin") + } +} diff --git a/tests/DiffEngineTests.swift b/tests/DiffEngineTests.swift new file mode 100644 index 0000000..bff589c --- /dev/null +++ b/tests/DiffEngineTests.swift @@ -0,0 +1,222 @@ +import Foundation + +@main +enum DiffEngineTests { + + static var failures = 0 + + static func checkTrue(_ label: String, _ value: Bool) { + if value { print(" ✓ \(label)") } + else { print(" ✗ \(label)"); failures += 1 } + } + + static func checkInt(_ label: String, _ got: Int, _ expected: Int) { + if got == expected { print(" ✓ \(label)") } + else { print(" ✗ \(label) got: \(got) expected: \(expected)"); failures += 1 } + } + + static func main() { + + // ── DiffEngine.fromEdit — additions ─────────────────────────────────── + print("DiffEngine.fromEdit — additions") + do { + let d = DiffEngine.fromEdit(old: "", new: "hello\nworld\n", path: "/a/b.swift") + checkInt("added == 2", d.added, 2) + checkInt("removed == 0", d.removed, 0) + checkTrue("!tooLarge", !d.tooLarge) + checkTrue("1 hunk", d.hunks.count == 1) + checkTrue("name == b.swift", d.name == "b.swift") + } + + // ── DiffEngine.fromEdit — removals ──────────────────────────────────── + print("DiffEngine.fromEdit — removals") + do { + let d = DiffEngine.fromEdit(old: "hello\nworld\n", new: "", path: "/x.py") + checkInt("added == 0", d.added, 0) + checkInt("removed == 2", d.removed, 2) + checkTrue("1 hunk", d.hunks.count == 1) + } + + // ── DiffEngine.fromEdit — replacement ───────────────────────────────── + print("DiffEngine.fromEdit — replacement") + do { + let d = DiffEngine.fromEdit(old: "foo\nbar\nbaz\n", + new: "foo\nqux\nbaz\n", + path: "/f.ts") + checkInt("added == 1", d.added, 1) + checkInt("removed == 1", d.removed, 1) + checkTrue("has context lines", + d.hunks.first?.lines.contains(where: { $0.kind == .context }) == true) + } + + // ── DiffEngine.fromNew — new file ────────────────────────────────────── + print("DiffEngine.fromNew — new file") + do { + let d = DiffEngine.fromNew(content: "line1\nline2\nline3\n", path: "/new.rs") + checkInt("added == 3", d.added, 3) + checkInt("removed == 0", d.removed, 0) + checkTrue("all lines .added", + d.hunks.flatMap { $0.lines }.allSatisfy { $0.kind == .added }) + } + + // ── DiffEngine.fromEdit — MultiEdit (two edits) ──────────────────────── + print("DiffEngine.fromEdit — MultiEdit (two edits)") + do { + let d1 = DiffEngine.fromEdit(old: "aaa\n", new: "bbb\n", path: "/m.kt") + let d2 = DiffEngine.fromEdit(old: "ccc\n", new: "ddd\n", path: "/m.kt") + checkInt("d1 added == 1", d1.added, 1) + checkInt("d1 removed == 1", d1.removed, 1) + checkInt("d2 added == 1", d2.added, 1) + checkInt("d2 removed == 1", d2.removed, 1) + } + + // ── DiffEngine — tooLarge ────────────────────────────────────────────── + print("DiffEngine — tooLarge") + do { + // Generate > 200 KB combined content + let bigOld = String(repeating: "x", count: 150 * 1024) + let bigNew = String(repeating: "y", count: 60 * 1024) + let d = DiffEngine.fromEdit(old: bigOld, new: bigNew, path: "/big.swift") + checkTrue("tooLarge == true", d.tooLarge) + checkTrue("hunks.isEmpty", d.hunks.isEmpty) + } + + // ── DiffEngine — CRLF ───────────────────────────────────────────────── + print("DiffEngine — CRLF") + do { + let d = DiffEngine.fromEdit(old: "a\r\nb\r\n", new: "a\r\nc\r\n", path: "/win.txt") + checkInt("added == 1", d.added, 1) + checkInt("removed == 1", d.removed, 1) + } + + // ── DiffEngine — no trailing newline ────────────────────────────────── + print("DiffEngine — no trailing newline") + do { + let d = DiffEngine.fromEdit(old: "hello", new: "hello\nworld", path: "/t.txt") + checkInt("added == 1", d.added, 1) + checkInt("removed == 0", d.removed, 0) + } + + // ── DiffEngine — 3-line context ─────────────────────────────────────── + print("DiffEngine — 3-line context") + do { + // 10-line file, change line 5 + let oldContent = (1...10).map { "line\($0)" }.joined(separator: "\n") + "\n" + let newContent = (1...10).map { $0 == 5 ? "changed" : "line\($0)" }.joined(separator: "\n") + "\n" + let d = DiffEngine.fromEdit(old: oldContent, new: newContent, path: "/ctx.swift") + checkInt("added == 1", d.added, 1) + checkInt("removed == 1", d.removed, 1) + checkTrue("1 hunk", d.hunks.count == 1) + let hunk = d.hunks[0] + let contextLines = hunk.lines.filter { $0.kind == .context } + checkTrue("has context before change", contextLines.count >= 3) + } + + // ── DiffEngine — m*n > 1_000_000 → tooLarge ───────────────────────── + print("DiffEngine — m*n > 1_000_000 → tooLarge") + do { + // 1001 old lines × 1001 new lines = > 1M + let many = (0..<1001).map { "line\($0)" }.joined(separator: "\n") + let d = DiffEngine.fromEdit(old: many, new: many + "\nextra", path: "/big.swift") + checkTrue("tooLarge when m*n > 1M", d.tooLarge) + checkTrue("hunks empty", d.hunks.isEmpty) + } + + // ── DiffEngine.fromNew — too large ──────────────────────────────────── + print("DiffEngine.fromNew — too large") + do { + let bigContent = String(repeating: "x\n", count: FileDiff.maxLines + 1) + let d = DiffEngine.fromNew(content: bigContent, path: "/new.swift") + checkTrue("fromNew tooLarge", d.tooLarge) + checkTrue("fromNew isNewFile", d.isNewFile) + checkTrue("fromNew hunks empty", d.hunks.isEmpty) + checkTrue("fromNew added > 0", d.added > 0) + } + + // ── String.makeDiffStep / parseDiffStep ─────────────────────────────── + print("String.makeDiffStep / parseDiffStep") + do { + let s = String.makeDiffStep(filename: "foo.swift", added: 3, removed: 1, diffId: 7) + checkTrue("isDiffStep", s.isDiffStep) + let parsed = s.parseDiffStep() + checkTrue("parsed != nil", parsed != nil) + checkTrue("filename round-trips", parsed?.filename == "foo.swift") + checkTrue("added round-trips", parsed?.added == 3) + checkTrue("removed round-trips", parsed?.removed == 1) + checkTrue("diffId round-trips", parsed?.diffId == 7) + + // id > 9 (multi-digit) round-trips correctly + let s2 = String.makeDiffStep(filename: "bar.ts", added: 0, removed: 2, diffId: 42) + checkTrue("diffId 42 round-trips", s2.parseDiffStep()?.diffId == 42) + + // Non-diff step should not parse + checkTrue("normal step !isDiffStep", !"Edit foo.swift".isDiffStep) + checkTrue("normal step parseDiffStep == nil", "Edit foo.swift".parseDiffStep() == nil) + } + + // ── DiffEngine.toOneLine ────────────────────────────────────────────── + print("DiffEngine.toOneLine") + do { + // multi-line joined with space + checkTrue("multi-line joined", + DiffEngine.toOneLine("line one\nline two\nline three") == "line one line two line three") + + // bold stripped + checkTrue("bold stripped", + DiffEngine.toOneLine("**hello** world") == "hello world") + + // heading stripped + checkTrue("heading stripped", + DiffEngine.toOneLine("## My Title\nsome text") == "My Title some text") + + // empty input → empty + checkTrue("empty → empty", DiffEngine.toOneLine("").isEmpty) + + // truncation + let long = DiffEngine.toOneLine(String(repeating: "x ", count: 200), maxChars: 10) + checkTrue("truncated to maxChars", long.count <= 10) + + // stop at blank line + checkTrue("blank line → first para only", + DiffEngine.toOneLine("First para.\n\nSecond para.") == "First para.") + + // stop at --- separator + checkTrue("--- separator → first para only", + DiffEngine.toOneLine("Done. Single commit 450a657 on github-pulse.\n\n---\n\nFiles touched (7)…") + == "Done. Single commit 450a657 on github-pulse.") + + // stop at *** separator + checkTrue("*** separator → first para only", + DiffEngine.toOneLine("Summary line.\n***\nMore details.") == "Summary line.") + + // stop at table row (|) + checkTrue("table row → first para only", + DiffEngine.toOneLine("Result:\n| Col1 | Col2 |\n|---|---|\n| A | B |") == "Result:") + + // strip leading bullet - + checkTrue("strip bullet -", + DiffEngine.toOneLine("- item one\n- item two") == "item one item two") + + // strip leading bullet * + checkTrue("strip bullet *", + DiffEngine.toOneLine("* first\n* second") == "first second") + + // strip ordered list + checkTrue("strip ordered list", + DiffEngine.toOneLine("1. step one\n2. step two") == "step one step two") + + // first paragraph empty → fall through to next + checkTrue("empty first para → next", + DiffEngine.toOneLine("\n\nActual content.") == "Actual content.") + } + + // ── finish ───────────────────────────────────────────────────────────── + if failures == 0 { + print("\nAll tests passed.") + exit(0) + } else { + print("\n\(failures) test(s) failed.") + exit(1) + } + } +} diff --git a/tests/GitHubActivityTests.swift b/tests/GitHubActivityTests.swift new file mode 100644 index 0000000..861abef --- /dev/null +++ b/tests/GitHubActivityTests.swift @@ -0,0 +1,157 @@ +import Foundation + +@main +enum GitHubActivityTests { + + static var failures = 0 + + static func check(_ label: String, _ got: Bool) { + if got { print(" ✓ \(label)") } + else { print(" ✗ \(label)"); failures += 1 } + } + + // MARK: - Fixture JSON + // + // Week 1 (complete, Jan 5–11 2026): + // Jan 5 wd=0 NONE(0), Jan 6 wd=1 FIRST_QUARTILE(1), Jan 7 wd=2 SECOND_QUARTILE(2), + // Jan 8 wd=3 THIRD_QUARTILE(3), Jan 9 wd=4 FOURTH_QUARTILE(4), + // Jan 10 wd=5 FIRST_QUARTILE(1), Jan 11 wd=6 NONE(0) + // counts: 0+1+4+8+12+2+0 = 27 + // + // Week 2 (incomplete, Jan 12–13 2026): + // Jan 12 wd=0 SECOND_QUARTILE(2), Jan 13 wd=1 THIRD_QUARTILE(3) + // counts: 5+10 = 15 + // + // Total: 42 + static let validJSON: Data = """ + { + "data": { + "viewer": { + "login": "testuser", + "contributionsCollection": { + "contributionCalendar": { + "totalContributions": 42, + "weeks": [ + { + "contributionDays": [ + {"date": "2026-01-05", "contributionCount": 0, "contributionLevel": "NONE", "weekday": 0}, + {"date": "2026-01-06", "contributionCount": 1, "contributionLevel": "FIRST_QUARTILE", "weekday": 1}, + {"date": "2026-01-07", "contributionCount": 4, "contributionLevel": "SECOND_QUARTILE", "weekday": 2}, + {"date": "2026-01-08", "contributionCount": 8, "contributionLevel": "THIRD_QUARTILE", "weekday": 3}, + {"date": "2026-01-09", "contributionCount": 12, "contributionLevel": "FOURTH_QUARTILE", "weekday": 4}, + {"date": "2026-01-10", "contributionCount": 2, "contributionLevel": "FIRST_QUARTILE", "weekday": 5}, + {"date": "2026-01-11", "contributionCount": 0, "contributionLevel": "NONE", "weekday": 6} + ] + }, + { + "contributionDays": [ + {"date": "2026-01-12", "contributionCount": 5, "contributionLevel": "SECOND_QUARTILE", "weekday": 0}, + {"date": "2026-01-13", "contributionCount": 10, "contributionLevel": "THIRD_QUARTILE", "weekday": 1} + ] + } + ] + } + } + } + } + } + """.data(using: .utf8)! + + static let unknownLevelJSON: Data = """ + { + "data": { + "viewer": { + "login": "testuser", + "contributionsCollection": { + "contributionCalendar": { + "totalContributions": 1, + "weeks": [ + { + "contributionDays": [ + {"date": "2026-03-01", "contributionCount": 1, "contributionLevel": "EXTRA_SPECIAL", "weekday": 0} + ] + } + ] + } + } + } + } + } + """.data(using: .utf8)! + + // MARK: - Main + + static func main() { + + // ── GitHubActivity.parse ────────────────────────────────────────────── + print("GitHubActivity.parse") + do { + let act = GitHubActivity.parse(validJSON) + check("parse returns non-nil", act != nil) + check("total == 42", act?.total == 42) + check("2 weeks", act?.weeks.count == 2) + check("week 0 has 7 days", act?.weeks[0].count == 7) + check("week 1 has 2 days (incomplete)", act?.weeks[1].count == 2) + // Level mapping + check("NONE → level 0", act?.weeks[0][0].level == 0) + check("FIRST_QUARTILE → level 1", act?.weeks[0][1].level == 1) + check("SECOND_QUARTILE → level 2", act?.weeks[0][2].level == 2) + check("THIRD_QUARTILE → level 3", act?.weeks[0][3].level == 3) + check("FOURTH_QUARTILE → level 4", act?.weeks[0][4].level == 4) + // Dates and counts + check("first day date", act?.weeks[0][0].date == "2026-01-05") + check("first day count", act?.weeks[0][0].count == 0) + check("first day wd", act?.weeks[0][0].weekday == 0) + check("last day date", act?.weeks[1][1].date == "2026-01-13") + check("last day count", act?.weeks[1][1].count == 10) + } + + // ── GitHubActivity.parse — unknown level ───────────────────────────── + print("GitHubActivity.parse — unknown level → 0") + do { + let act = GitHubActivity.parse(unknownLevelJSON) + check("unknown level → 0", act?.weeks[0][0].level == 0) + } + + // ── GitHubActivity.parse — bad data → nil ──────────────────────────── + print("GitHubActivity.parse — bad data") + do { + check("garbage → nil", GitHubActivity.parse("garbage".data(using: .utf8)!) == nil) + check("no data key → nil", GitHubActivity.parse("{}".data(using: .utf8)!) == nil) + } + + // ── GitHubActivity.lastWeeks ───────────────────────────────────────── + print("GitHubActivity.lastWeeks") + do { + let act = GitHubActivity.parse(validJSON)! + check("lastWeeks(2) returns both", act.lastWeeks(2).count == 2) + check("lastWeeks(1) returns last week", act.lastWeeks(1).count == 1) + check("lastWeeks(1)[0] is incomplete", act.lastWeeks(1)[0].count == 2) + check("lastWeeks(1)[0][0].date", act.lastWeeks(1)[0][0].date == "2026-01-12") + check("lastWeeks(99) clamps to available", act.lastWeeks(99).count == 2) + check("lastWeeks(0) returns empty", act.lastWeeks(0).isEmpty) + } + + // ── GitHubActivity.lastDays ────────────────────────────────────────── + print("GitHubActivity.lastDays") + do { + let act = GitHubActivity.parse(validJSON)! + // All 9 days flat, last 3 = Jan 11, Jan 12, Jan 13 + check("lastDays(3) count", act.lastDays(3).count == 3) + check("lastDays(3)[0].date", act.lastDays(3)[0].date == "2026-01-11") + check("lastDays(3)[2].date", act.lastDays(3)[2].date == "2026-01-13") + check("lastDays(9) all days", act.lastDays(9).count == 9) + check("lastDays(99) clamps to all", act.lastDays(99).count == 9) + check("lastDays(0) empty", act.lastDays(0).isEmpty) + } + + // ── finish ──────────────────────────────────────────────────────────── + if failures == 0 { + print("\nAll tests passed.") + exit(0) + } else { + print("\n\(failures) test(s) failed.") + exit(1) + } + } +} diff --git a/tests/GitHubPulseTests.swift b/tests/GitHubPulseTests.swift new file mode 100644 index 0000000..b3d7423 --- /dev/null +++ b/tests/GitHubPulseTests.swift @@ -0,0 +1,355 @@ +import Foundation + +@main +enum GitHubPulseTests { + + static var failures = 0 + + static func check(_ label: String, _ got: Bool) { + if got { print(" ✓ \(label)") } + else { print(" ✗ \(label)"); failures += 1 } + } + + // MARK: - Fixture JSON + + // Valid response: PR #42 SUCCESS/APPROVED, PR #43 null CI (unknown)/null review, + // repo "testuser/myrepo" PENDING, archived repo filtered out, + // one to-review PR from another repo. + static let validJSON: Data = """ + { + "data": { + "viewer": { + "login": "testuser", + "pullRequests": { + "nodes": [ + { + "number": 42, + "title": "Add feature", + "url": "https://github.com/testuser/myrepo/pull/42", + "isDraft": false, + "reviewDecision": "APPROVED", + "repository": {"nameWithOwner": "testuser/myrepo", "url": "https://github.com/testuser/myrepo"}, + "commits": {"nodes": [{"commit": {"statusCheckRollup": {"state": "SUCCESS"}}}]} + }, + { + "number": 43, + "title": "Fix bug", + "url": "https://github.com/testuser/myrepo/pull/43", + "isDraft": true, + "reviewDecision": null, + "repository": {"nameWithOwner": "testuser/myrepo", "url": "https://github.com/testuser/myrepo"}, + "commits": {"nodes": [{"commit": {"statusCheckRollup": null}}]} + } + ] + }, + "repositories": { + "nodes": [ + { + "nameWithOwner": "testuser/myrepo", + "url": "https://github.com/testuser/myrepo", + "isArchived": false, + "defaultBranchRef": { + "name": "main", + "target": {"statusCheckRollup": {"state": "PENDING"}} + } + }, + { + "nameWithOwner": "testuser/archived", + "url": "https://github.com/testuser/archived", + "isArchived": true, + "defaultBranchRef": { + "name": "main", + "target": {"statusCheckRollup": {"state": "SUCCESS"}} + } + } + ] + } + }, + "reviewRequested": { + "issueCount": 1, + "nodes": [ + { + "number": 7, + "title": "Review this", + "url": "https://github.com/other/repo/pull/7", + "isDraft": false, + "author": {"login": "otheruser"}, + "repository": {"nameWithOwner": "other/repo", "url": "https://github.com/other/repo"} + } + ] + } + } + } + """.data(using: .utf8)! + + static let emptyJSON: Data = """ + { + "data": { + "viewer": { + "login": "testuser", + "pullRequests": {"nodes": []}, + "repositories": {"nodes": []} + }, + "reviewRequested": {"issueCount": 0, "nodes": []} + } + } + """.data(using: .utf8)! + + // MARK: - Main + + static func main() { + + // ── GitHubPulse.parse ───────────────────────────────────────────────── + print("GitHubPulse.parse") + do { + let pulse = GitHubPulse.parse(validJSON) + check("parse returns non-nil", pulse != nil) + check("login", pulse?.login == "testuser") + check("2 myPRs", pulse?.myPRs.count == 2) + check("PR#42 id", pulse?.myPRs[0].id == "testuser/myrepo#42") + check("PR#42 ci success", pulse?.myPRs[0].ci == .success) + check("PR#42 review approved", pulse?.myPRs[0].review == .approved) + check("PR#43 ci unknown (null rollup)",pulse?.myPRs[1].ci == .unknown) + check("PR#43 review unknown (null)", pulse?.myPRs[1].review == .unknown) + check("PR#43 isDraft", pulse?.myPRs[1].isDraft == true) + check("1 mainCI (archived filtered)", pulse?.mainCI.count == 1) + check("mainCI repo", pulse?.mainCI[0].repo == "testuser/myrepo") + check("mainCI ci pending", pulse?.mainCI[0].ci == .pending) + check("mainCI branch main", pulse?.mainCI[0].branch == "main") + check("1 toReview", pulse?.toReview.count == 1) + check("toReview id", pulse?.toReview[0].id == "other/repo#7") + check("hasPending (mainCI pending)", pulse?.hasPending == true) + } + + // ── GitHubPulse.parse — empty ───────────────────────────────────────── + print("GitHubPulse.parse — empty") + do { + let pulse = GitHubPulse.parse(emptyJSON) + check("empty parses ok", pulse != nil) + check("no myPRs", pulse?.myPRs.isEmpty == true) + check("no mainCI", pulse?.mainCI.isEmpty == true) + check("no toReview", pulse?.toReview.isEmpty == true) + check("!hasPending", pulse?.hasPending == false) + } + + // ── GitHubPulse.parse — bad data ────────────────────────────────────── + print("GitHubPulse.parse — bad data") + do { + check("garbage → nil", GitHubPulse.parse("garbage".data(using: .utf8)!) == nil) + check("empty obj → nil", GitHubPulse.parse("{}".data(using: .utf8)!) == nil) + } + + // ── CIState init ────────────────────────────────────────────────────── + print("CIState init") + do { + check("nil → unknown", CIState(rawGitHub: nil) == .unknown) + check("PENDING → pending", CIState(rawGitHub: "PENDING") == .pending) + check("EXPECTED → pending", CIState(rawGitHub: "EXPECTED") == .pending) + check("SUCCESS → success", CIState(rawGitHub: "SUCCESS") == .success) + check("FAILURE → failure", CIState(rawGitHub: "FAILURE") == .failure) + check("ERROR → failure", CIState(rawGitHub: "ERROR") == .failure) + check("other → unknown", CIState(rawGitHub: "WAITING") == .unknown) + } + + // ── GitHubPulse.events — first poll silent ──────────────────────────── + print("GitHubPulse.events — first poll silent") + do { + let pulse = GitHubPulse.parse(validJSON)! + let events = GitHubPulse.events(old: nil, new: pulse) + check("first poll → no events", events.isEmpty) + } + + // ── GitHubPulse.events — pending → success ──────────────────────────── + print("GitHubPulse.events — pending → success") + do { + var oldPulse = GitHubPulse.parse(emptyJSON)! + oldPulse.myPRs = [GitHubPR(id: "r/p#1", title: "T", url: "", repo: "r/p", + number: 1, isDraft: false, ci: .pending, review: .unknown)] + var newPulse = oldPulse + newPulse.myPRs[0].ci = .success + let events = GitHubPulse.events(old: oldPulse, new: newPulse) + check("pending→success → ciPassed", events == [.ciPassed(prId: "r/p#1")]) + } + + // ── GitHubPulse.events — success → failure ──────────────────────────── + print("GitHubPulse.events — success → failure") + do { + var oldPulse = GitHubPulse.parse(emptyJSON)! + oldPulse.myPRs = [GitHubPR(id: "r/p#2", title: "T", url: "", repo: "r/p", + number: 2, isDraft: false, ci: .success, review: .unknown)] + var newPulse = oldPulse + newPulse.myPRs[0].ci = .failure + let events = GitHubPulse.events(old: oldPulse, new: newPulse) + check("success→failure → ciFailed", events == [.ciFailed(prId: "r/p#2")]) + } + + // ── GitHubPulse.events — main CI failure ────────────────────────────── + print("GitHubPulse.events — main CI failure") + do { + var oldPulse = GitHubPulse.parse(emptyJSON)! + oldPulse.mainCI = [GitHubRepoCI(repo: "a/b", url: "", branch: "main", ci: .success)] + var newPulse = oldPulse + newPulse.mainCI[0].ci = .failure + let events = GitHubPulse.events(old: oldPulse, new: newPulse) + check("main success→failure → mainFailed", events == [.mainFailed(repo: "a/b")]) + } + + // ── GitHubPulse.events — new review request ─────────────────────────── + print("GitHubPulse.events — new review request") + do { + let pr = GitHubPR(id: "o/r#7", title: "R", url: "", repo: "o/r", + number: 7, isDraft: false, ci: .unknown, review: .pending) + var oldPulse = GitHubPulse.parse(emptyJSON)! + var newPulse = oldPulse + newPulse.toReview = [pr] + let events = GitHubPulse.events(old: oldPulse, new: newPulse) + check("new review → reviewRequested", events == [.reviewRequested(prId: "o/r#7")]) + + // Already-known review: no new event + oldPulse.toReview = [pr] + newPulse.toReview = [pr] + let noEvents = GitHubPulse.events(old: oldPulse, new: newPulse) + check("known review → no event", noEvents.isEmpty) + } + + // ── GitHubPulse.parse — duplicate ids ──────────────────────────────────── + print("GitHubPulse.parse — duplicate ids") + do { + // JSON with PR #42 duplicated: should keep only first occurrence + let dupJSON: Data = """ + { + "data": { + "viewer": { + "login": "testuser", + "pullRequests": { + "nodes": [ + { + "number": 42, "title": "Add feature", + "url": "https://github.com/testuser/myrepo/pull/42", + "isDraft": false, "reviewDecision": "APPROVED", + "repository": {"nameWithOwner": "testuser/myrepo", "url": "https://github.com/testuser/myrepo"}, + "commits": {"nodes": [{"commit": {"statusCheckRollup": {"state": "SUCCESS"}}}]} + }, + { + "number": 42, "title": "Duplicate entry", + "url": "https://github.com/testuser/myrepo/pull/42", + "isDraft": true, "reviewDecision": null, + "repository": {"nameWithOwner": "testuser/myrepo", "url": "https://github.com/testuser/myrepo"}, + "commits": {"nodes": [{"commit": {"statusCheckRollup": {"state": "FAILURE"}}}]} + } + ] + }, + "repositories": {"nodes": []} + }, + "reviewRequested": {"issueCount": 0, "nodes": []} + } + } + """.data(using: .utf8)! + let pulse = GitHubPulse.parse(dupJSON) + check("dup PR → 1 myPR", pulse?.myPRs.count == 1) + check("dup PR → keeps first title", pulse?.myPRs[0].title == "Add feature") + check("dup PR → keeps first ci", pulse?.myPRs[0].ci == .success) + } + + // ── GitHubPulse.events — duplicate ids in old pulse ─────────────────── + print("GitHubPulse.events — duplicate-safe Dictionary") + do { + // OLD has duplicate ids (uniquingKeysWith keeps first); NEW has one entry → fires once + var oldPulse = GitHubPulse.parse(emptyJSON)! + let pr1 = GitHubPR(id: "r/p#1", title: "T", url: "", repo: "r/p", + number: 1, isDraft: false, ci: .pending, review: .unknown) + oldPulse.myPRs = [pr1, pr1] // intentional duplicate in old + var newPulse = GitHubPulse.parse(emptyJSON)! + var pr1Passed = pr1; pr1Passed.ci = .success + newPulse.myPRs = [pr1Passed] // single entry in new + let events = GitHubPulse.events(old: oldPulse, new: newPulse) + check("dup old ids → no crash", true) + check("dup old ids → ciPassed fires once", + events.filter { if case .ciPassed = $0 { return true }; return false }.count == 1) + } + + // ── GitHubPulse.events — headSha: new PR already green ─────────────── + print("GitHubPulse.events — headSha: new PR already green") + do { + let old = GitHubPulse.parse(emptyJSON)! + var newPulse = old + newPulse.myPRs = [GitHubPR(id: "r/p#10", title: "T", url: "", repo: "r/p", + number: 10, isDraft: false, ci: .success, review: .unknown, + headSha: "abc111")] + let events = GitHubPulse.events(old: old, new: newPulse) + check("new PR green → ciPassed", events == [.ciPassed(prId: "r/p#10")]) + } + + // ── GitHubPulse.events — headSha: new PR pending → nothing ─────────── + print("GitHubPulse.events — headSha: new PR pending → nothing") + do { + let old = GitHubPulse.parse(emptyJSON)! + var newPulse = old + newPulse.myPRs = [GitHubPR(id: "r/p#11", title: "T", url: "", repo: "r/p", + number: 11, isDraft: false, ci: .pending, review: .unknown, + headSha: "abc222")] + let events = GitHubPulse.events(old: old, new: newPulse) + check("new PR pending → no events", events.isEmpty) + } + + // ── GitHubPulse.events — headSha: new commit already red ───────────── + print("GitHubPulse.events — headSha: new commit already red → ciFailed") + do { + var oldPulse = GitHubPulse.parse(emptyJSON)! + oldPulse.myPRs = [GitHubPR(id: "r/p#12", title: "T", url: "", repo: "r/p", + number: 12, isDraft: false, ci: .success, review: .unknown, + headSha: "sha-old")] + var newPulse = oldPulse + newPulse.myPRs[0].ci = .failure + newPulse.myPRs[0].headSha = "sha-new" + let events = GitHubPulse.events(old: oldPulse, new: newPulse) + check("new commit red → ciFailed", events == [.ciFailed(prId: "r/p#12")]) + } + + // ── GitHubPulse.events — headSha: same sha success→success → nothing ─ + print("GitHubPulse.events — headSha: same sha success→success → nothing") + do { + var oldPulse = GitHubPulse.parse(emptyJSON)! + oldPulse.myPRs = [GitHubPR(id: "r/p#13", title: "T", url: "", repo: "r/p", + number: 13, isDraft: false, ci: .success, review: .unknown, + headSha: "same")] + let newPulse = oldPulse // identical SHA and CI + let events = GitHubPulse.events(old: oldPulse, new: newPulse) + check("same sha success→success → no event", events.isEmpty) + } + + // ── GitHubPulse.events — headSha: new commit on main red → mainFailed ─ + print("GitHubPulse.events — headSha: new commit on main red → mainFailed") + do { + var oldPulse = GitHubPulse.parse(emptyJSON)! + oldPulse.mainCI = [GitHubRepoCI(repo: "a/b", url: "", branch: "main", + ci: .success, headSha: "sha-old")] + var newPulse = oldPulse + newPulse.mainCI[0].ci = .failure + newPulse.mainCI[0].headSha = "sha-new" + let events = GitHubPulse.events(old: oldPulse, new: newPulse) + check("new commit on main red → mainFailed", events == [.mainFailed(repo: "a/b")]) + } + + // ── GitHubPulse.isStale ─────────────────────────────────────────────── + print("GitHubPulse.isStale") + do { + let now = Date() + check("nil fetchedAt → stale", + GitHubPulse.isStale(fetchedAt: nil, now: now, maxAge: 60)) + check("fresh (same instant) → not stale", + !GitHubPulse.isStale(fetchedAt: now, now: now, maxAge: 60)) + check("61 s ago → stale", + GitHubPulse.isStale(fetchedAt: now.addingTimeInterval(-61), now: now, maxAge: 60)) + } + + // ── finish ───────────────────────────────────────────────────────────── + if failures == 0 { + print("\nAll tests passed.") + exit(0) + } else { + print("\n\(failures) test(s) failed.") + exit(1) + } + } +} diff --git a/tests/IslandScreenGeometryTests.swift b/tests/IslandScreenGeometryTests.swift new file mode 100644 index 0000000..79c57f3 --- /dev/null +++ b/tests/IslandScreenGeometryTests.swift @@ -0,0 +1,53 @@ +import Foundation + +@main +enum IslandScreenGeometryTests { + static func main() { + // Regression: absent auxiliary areas must never mean "screen-wide notch". + for screenWidth: CGFloat in [1080, 1920, 2560, 3840] { + let geometry = IslandScreenGeometry( + screenWidth: screenWidth, safeAreaTop: 0, + auxiliaryLeftWidth: nil, auxiliaryRightWidth: nil, menuBarHeight: 30 + ) + precondition(!geometry.hasNotch) + precondition(geometry.width == 80) + precondition(geometry.height == 24) + } + + // A shorter menu bar must also contain the resting island. + let shortMenuBar = IslandScreenGeometry( + screenWidth: 1920, safeAreaTop: 0, + auxiliaryLeftWidth: nil, auxiliaryRightWidth: nil, menuBarHeight: 22 + ) + precondition(shortMenuBar.height == 22) + + // Real MacBook notch measurements retain their physical dimensions. + let macBook = IslandScreenGeometry( + screenWidth: 1512, safeAreaTop: 32, + auxiliaryLeftWidth: 660, auxiliaryRightWidth: 660, menuBarHeight: 32 + ) + precondition(macBook.hasNotch) + precondition(macBook.width == 192 && macBook.height == 32) + + // Incomplete or invalid measurements use the notch fallback, not the screen. + for auxiliaryWidth: CGFloat? in [nil, 0, 1000] { + let geometry = IslandScreenGeometry( + screenWidth: 1512, safeAreaTop: 32, + auxiliaryLeftWidth: auxiliaryWidth, auxiliaryRightWidth: auxiliaryWidth, + menuBarHeight: 32 + ) + precondition(geometry.width == 184 && geometry.height == 32) + } + // Compact/greeting destinations share the measured resting height. + for height: CGFloat in [22, 24, 32, 38] { + let compact = IslandRestingLayout(width: 240, height: height) + precondition(compact.botCenterY == height / 2) + precondition(compact.botDiameter == min(20, height - 6)) + precondition(compact.botCenterY - compact.botDiameter / 2 >= 3) + precondition(compact.botCenterY + compact.botDiameter / 2 <= height - 3) + precondition(compact.miniGridCenterX == 200) + precondition(compact.miniGridScale * 28 <= height - 4) + } + print("Island screen geometry and resting layout: 13 cases passed") + } +} diff --git a/tests/MochiWardrobeTests.swift b/tests/MochiWardrobeTests.swift new file mode 100644 index 0000000..2cdf5d7 --- /dev/null +++ b/tests/MochiWardrobeTests.swift @@ -0,0 +1,110 @@ +import Foundation + +@main +enum MochiWardrobeTests { + + static var failures = 0 + + static func check(_ label: String, _ got: Bool) { + if got { print(" ✓ \(label)") } + else { print(" ✗ \(label)"); failures += 1 } + } + + static func date(year: Int, month: Int, day: Int) -> Date { + var c = DateComponents(); c.year = year; c.month = month; c.day = day + return Calendar(identifier: .gregorian).date(from: c)! + } + + static func seasonal(_ year: Int, _ month: Int, _ day: Int) -> Outfit { + Outfit.seasonal(for: date(year: year, month: month, day: day), + calendar: Calendar(identifier: .gregorian)) + } + + static func main() { + print("Outfit.seasonal — witch hat (Oct 1 – Nov 1)") + check("Sep 30 → none", seasonal(2026, 9, 30) == .none) + check("Oct 1 → witchHat", seasonal(2026, 10, 1) == .witchHat) + check("Oct 31 → witchHat", seasonal(2026, 10, 31) == .witchHat) + check("Nov 1 → witchHat", seasonal(2026, 11, 1) == .witchHat) + check("Nov 2 → none", seasonal(2026, 11, 2) == .none) + + print("Outfit.seasonal — santa hat (Dec 1–26)") + check("Nov 30 → none", seasonal(2026, 11, 30) == .none) + check("Dec 1 → santaHat", seasonal(2026, 12, 1) == .santaHat) + check("Dec 26 → santaHat", seasonal(2026, 12, 26) == .santaHat) + check("Dec 27 → none", seasonal(2026, 12, 27) == .none) + + print("Outfit.seasonal — party hat (Dec 31 – Jan 2)") + check("Dec 30 → none", seasonal(2026, 12, 30) == .none) + check("Dec 31 → partyHat", seasonal(2026, 12, 31) == .partyHat) + check("Jan 1 → partyHat", seasonal(2027, 1, 1) == .partyHat) + check("Jan 2 → partyHat", seasonal(2027, 1, 2) == .partyHat) + check("Jan 3 → none", seasonal(2027, 1, 3) == .none) + + print("Outfit.seasonal — Feb 13–16 → none (heartsHeadband removed)") + check("Feb 12 → none", seasonal(2026, 2, 12) == .none) + check("Feb 13 → none", seasonal(2026, 2, 13) == .none) + check("Feb 15 → none", seasonal(2026, 2, 15) == .none) + check("Feb 16 → none", seasonal(2026, 2, 16) == .none) + + print("Outfit.seasonal — bunny ears (Easter ±)") + // Easter 2026 = April 5 + check("Apr 3 2026 → bunnyEars", seasonal(2026, 4, 3) == .bunnyEars) // -2 + check("Apr 4 2026 → bunnyEars", seasonal(2026, 4, 4) == .bunnyEars) // -1 + check("Apr 5 2026 → bunnyEars", seasonal(2026, 4, 5) == .bunnyEars) // 0 + check("Apr 6 2026 → bunnyEars", seasonal(2026, 4, 6) == .bunnyEars) // +1 + check("Apr 7 2026 → none", seasonal(2026, 4, 7) == .none) // +2 + check("Apr 2 2026 → none", seasonal(2026, 4, 2) == .none) // -3 + // Easter 2027 = March 28 + check("Mar 26 2027 → bunnyEars", seasonal(2027, 3, 26) == .bunnyEars) + check("Mar 29 2027 → bunnyEars", seasonal(2027, 3, 29) == .bunnyEars) + check("Mar 30 2027 → none", seasonal(2027, 3, 30) == .none) + // Easter 2028 = April 16 + check("Apr 14 2028 → bunnyEars", seasonal(2028, 4, 14) == .bunnyEars) + check("Apr 17 2028 → bunnyEars", seasonal(2028, 4, 17) == .bunnyEars) + check("Apr 18 2028 → none", seasonal(2028, 4, 18) == .none) + + print("Outfit.seasonal — sunglasses (Jun 21 – Aug 31)") + check("Jun 20 → none", seasonal(2026, 6, 20) == .none) + check("Jun 21 → sunglasses", seasonal(2026, 6, 21) == .sunglasses) + check("Aug 31 → sunglasses", seasonal(2026, 8, 31) == .sunglasses) + check("Sep 1 → none", seasonal(2026, 9, 1) == .none) + + print("Outfit.resolved") + let d = date(year: 2026, month: 10, day: 15) + let cal = Calendar(identifier: .gregorian) + check("auto → seasonal", Outfit.resolved(selection: .auto, date: d, calendar: cal) == .witchHat) + check("none → none", Outfit.resolved(selection: .none, date: d, calendar: cal) == .none) + check("beanie → beanie", Outfit.resolved(selection: .beanie, date: d, calendar: cal) == .beanie) + + print("Outfit.rawValue stability") + check("auto rawValue", Outfit.auto.rawValue == "auto") + check("none rawValue", Outfit.none.rawValue == "none") + check("partyHat rawValue", Outfit.partyHat.rawValue == "partyHat") + check("beanie rawValue", Outfit.beanie.rawValue == "beanie") + check("crown rawValue", Outfit.crown.rawValue == "crown") + check("sunglasses rawValue", Outfit.sunglasses.rawValue == "sunglasses") + check("roundGlasses rawValue", Outfit.roundGlasses.rawValue == "roundGlasses") + check("bow rawValue", Outfit.bow.rawValue == "bow") + check("scarf rawValue", Outfit.scarf.rawValue == "scarf") + check("witchHat rawValue", Outfit.witchHat.rawValue == "witchHat") + check("pumpkin rawValue", Outfit.pumpkin.rawValue == "pumpkin") + check("santaHat rawValue", Outfit.santaHat.rawValue == "santaHat") + check("bunnyEars rawValue", Outfit.bunnyEars.rawValue == "bunnyEars") + + print("Outfit.stored — removed rawValues → auto") + for removed in ["topHat", "cap", "heartsHeadband", "strawHat"] { + UserDefaults.standard.set(removed, forKey: "mochiOutfit") + check("'\(removed)' stored → .auto", Outfit.stored == .auto) + } + + print("Outfit.stored unknown → auto") + UserDefaults.standard.set("totallyUnknown", forKey: "mochiOutfit") + check("unknown → .auto", Outfit.stored == .auto) + UserDefaults.standard.removeObject(forKey: "mochiOutfit") + check("missing → .auto", Outfit.stored == .auto) + + if failures == 0 { print("\nAll tests passed.") } + else { print("\n\(failures) test(s) FAILED."); exit(1) } + } +} diff --git a/tests/SafeWebURLTests.swift b/tests/SafeWebURLTests.swift new file mode 100644 index 0000000..e889682 --- /dev/null +++ b/tests/SafeWebURLTests.swift @@ -0,0 +1,27 @@ +import Foundation + +@main +enum SafeWebURLTests { + static func main() { + // Accepted: plain http/https URLs + precondition(safeWebURL("https://example.com") != nil) + precondition(safeWebURL("http://example.com/a?b=1") != nil) + precondition(safeWebURL("HTTPS://EXAMPLE.COM") != nil) + precondition(safeWebURL(" https://example.com \n") != nil) + precondition(safeWebURL("http://localhost:5678") != nil) + + // Rejected: nil, empty, non-web schemes, malformed + precondition(safeWebURL(nil) == nil) + precondition(safeWebURL("") == nil) + precondition(safeWebURL("file:///etc/hosts") == nil) + precondition(safeWebURL("javascript:alert(1)") == nil) + precondition(safeWebURL("vscode://file/etc/hosts") == nil) + precondition(safeWebURL("smb://server/share") == nil) + precondition(safeWebURL("mailto:a@b.c") == nil) + precondition(safeWebURL("https://") == nil) + precondition(safeWebURL("https:example.com") == nil) + precondition(safeWebURL("//example.com") == nil) + + print("Safe web links: 15 cases passed") + } +} diff --git a/tests/ShortcutTests.swift b/tests/ShortcutTests.swift new file mode 100644 index 0000000..3d98763 --- /dev/null +++ b/tests/ShortcutTests.swift @@ -0,0 +1,235 @@ +import Foundation + +@main +enum ShortcutTests { + static func main() { + testDefaultsExhaustive() + testAllDefaultsHaveModifier() + testNoDefaultDuplicates() + testCarbonModifiers() + testDisplayString() + testDuplicateDetection() + testLoadSaveRoundTrip() + testLegacyKeysForToggleIsland() + testEnabledByDefault() + testKeyCodeToString() + testCardNavigation() + print("ShortcutLogic: all cases passed") + } + + // MARK: - testDefaultsExhaustive + + static func testDefaultsExhaustive() { + for action in ShortcutAction.allCases { + precondition(ShortcutLogic.defaults[action] != nil, + "missing default for \(action.rawValue)") + } + precondition(ShortcutLogic.defaults.count == ShortcutAction.allCases.count, + "defaults table must cover every ShortcutAction") + } + + // MARK: - testAllDefaultsHaveModifier + + static func testAllDefaultsHaveModifier() { + for (action, spec) in ShortcutLogic.defaults { + precondition(spec.hasModifier, + "default for \(action.rawValue) has no modifier — bare keys not allowed") + } + } + + // MARK: - testNoDefaultDuplicates + + static func testNoDefaultDuplicates() { + let dups = ShortcutLogic.duplicates(in: ShortcutLogic.defaults) + precondition(dups.isEmpty, + "duplicate default shortcuts: \(dups.map { $0.rawValue })") + } + + // MARK: - testCarbonModifiers + + static func testCarbonModifiers() { + // cmdShift = ⌘⇧ + let cmdShiftCarbon = ShortcutLogic.carbonModifiers(fromNS: ShortcutSpec.cmdShift) + // Carbon: cmdKey = 256, shiftKey = 512 + precondition(cmdShiftCarbon & 256 != 0, "⌘ must set cmdKey bit in Carbon") + precondition(cmdShiftCarbon & 512 != 0, "⇧ must set shiftKey bit in Carbon") + precondition(cmdShiftCarbon & 2048 == 0, "⌘⇧ must not set optionKey") + precondition(cmdShiftCarbon & 4096 == 0, "⌘⇧ must not set controlKey") + + // ctrlOpt = ⌃⌥ + let ctrlOptCarbon = ShortcutLogic.carbonModifiers(fromNS: ShortcutSpec.ctrlOpt) + precondition(ctrlOptCarbon & 256 == 0, "⌃⌥ must not set cmdKey") + precondition(ctrlOptCarbon & 512 == 0, "⌃⌥ must not set shiftKey") + precondition(ctrlOptCarbon & 2048 != 0, "⌥ must set optionKey bit in Carbon") + precondition(ctrlOptCarbon & 4096 != 0, "⌃ must set controlKey bit in Carbon") + + // Zero flags → zero Carbon flags + precondition(ShortcutLogic.carbonModifiers(fromNS: 0) == 0, + "no modifiers must produce zero Carbon flags") + + // All four modifiers + let allNS = ShortcutSpec.cmdBit | ShortcutSpec.shiftBit | + ShortcutSpec.optBit | ShortcutSpec.ctrlBit + let allCarbon = ShortcutLogic.carbonModifiers(fromNS: allNS) + precondition(allCarbon & (256 | 512 | 2048 | 4096) == (256 | 512 | 2048 | 4096), + "all four NS modifiers must map to all four Carbon bits") + } + + // MARK: - testDisplayString + + static func testDisplayString() { + // ⌘⇧N + let cmdShiftN = ShortcutSpec(keyCode: 45, nsFlags: ShortcutSpec.cmdShift) + let s1 = ShortcutLogic.displayString(for: cmdShiftN) + precondition(s1.contains("⌘"), "⌘⇧N display must contain ⌘") + precondition(s1.contains("⇧"), "⌘⇧N display must contain ⇧") + precondition(s1.contains("N"), "⌘⇧N display must contain N") + + // ⌃⌥A + let ctrlOptA = ShortcutSpec(keyCode: 0, nsFlags: ShortcutSpec.ctrlOpt) + let s2 = ShortcutLogic.displayString(for: ctrlOptA) + precondition(s2.contains("⌃"), "⌃⌥A display must contain ⌃") + precondition(s2.contains("⌥"), "⌃⌥A display must contain ⌥") + precondition(s2.contains("A"), "⌃⌥A display must contain A") + + // No duplicate modifiers in the string + precondition(s1.filter { $0 == "⌘" }.count == 1, "⌘ must appear exactly once") + + // ⌃⌥Space + let ctrlOptSpace = ShortcutSpec(keyCode: 49, nsFlags: ShortcutSpec.ctrlOpt) + let s3 = ShortcutLogic.displayString(for: ctrlOptSpace) + precondition(s3.contains("Space"), "⌃⌥Space display must contain 'Space'") + } + + // MARK: - testDuplicateDetection + + static func testDuplicateDetection() { + // No duplicates in an empty dict + precondition(ShortcutLogic.duplicates(in: [:]).isEmpty, + "empty map must have no duplicates") + + // Single entry — no duplicate + let specA = ShortcutSpec(keyCode: 0, nsFlags: ShortcutSpec.ctrlOpt) + precondition(ShortcutLogic.duplicates(in: [.goToAlert: specA]).isEmpty, + "single entry must have no duplicate") + + // Two different specs — no duplicate + let specB = ShortcutSpec(keyCode: 17, nsFlags: ShortcutSpec.ctrlOpt) + let twoUnique: [ShortcutAction: ShortcutSpec] = [.goToAlert: specA, .jumpToTerminal: specB] + precondition(ShortcutLogic.duplicates(in: twoUnique).isEmpty, + "two distinct specs must have no duplicate") + + // Same spec for two actions → both flagged + let twoSame: [ShortcutAction: ShortcutSpec] = [.goToAlert: specA, .jumpToTerminal: specA] + let dups = ShortcutLogic.duplicates(in: twoSame) + precondition(dups.contains(.goToAlert), "first duped action must be flagged") + precondition(dups.contains(.jumpToTerminal), "second duped action must be flagged") + precondition(dups.count == 2, "exactly 2 actions must be flagged for one duplicate pair") + + // Same key code but different flags → not a duplicate + let specC = ShortcutSpec(keyCode: 0, nsFlags: ShortcutSpec.cmdShift) + let diffFlags: [ShortcutAction: ShortcutSpec] = [.goToAlert: specA, .jumpToTerminal: specC] + precondition(ShortcutLogic.duplicates(in: diffFlags).isEmpty, + "same key code with different modifiers must not be a duplicate") + } + + // MARK: - testLoadSaveRoundTrip + + static func testLoadSaveRoundTrip() { + // Use a test-only suite to avoid polluting the app UserDefaults + let suiteName = "com.coucou.ShortcutTests.\(Int.random(in: 10000...99999))" + guard let ud = UserDefaults(suiteName: suiteName) else { + preconditionFailure("could not create test UserDefaults suite") + } + + // Save a custom spec for .openChat + let custom = ShortcutSpec(keyCode: 5, nsFlags: ShortcutSpec.cmdShift) + ud.set(Int(custom.keyCode), forKey: ShortcutAction.openChat.udKeyCode) + ud.set(Int(custom.nsFlags), forKey: ShortcutAction.openChat.udFlags) + + // Load it back using the raw UserDefaults API (same path ShortcutLogic.hotKey uses) + let loadedKC = ud.object(forKey: ShortcutAction.openChat.udKeyCode) as? Int + let loadedFl = ud.object(forKey: ShortcutAction.openChat.udFlags) as? Int + precondition(loadedKC == Int(custom.keyCode), "saved key code must round-trip") + precondition(loadedFl == Int(custom.nsFlags), "saved flags must round-trip") + + // Cleanup + ud.removePersistentDomain(forName: suiteName) + } + + // MARK: - testLegacyKeysForToggleIsland + + static func testLegacyKeysForToggleIsland() { + precondition(ShortcutAction.toggleIsland.udKeyCode == "hotkeyCode", + "toggleIsland must use legacy 'hotkeyCode' key") + precondition(ShortcutAction.toggleIsland.udFlags == "hotkeyFlags", + "toggleIsland must use legacy 'hotkeyFlags' key") + precondition(ShortcutAction.toggleIsland.udEnabled == "hotkeyEnabled", + "toggleIsland must use legacy 'hotkeyEnabled' key") + + // All other actions must NOT use the legacy keys + for action in ShortcutAction.allCases where action != .toggleIsland { + precondition(action.udKeyCode != "hotkeyCode", + "\(action.rawValue) must not reuse legacy hotkeyCode key") + } + } + + // MARK: - testEnabledByDefault + + static func testEnabledByDefault() { + // toggleIsland is OFF by default (existing behaviour) + precondition(!ShortcutAction.toggleIsland.enabledByDefault, + "toggleIsland must be disabled by default") + + // All other global shortcuts are ON by default + for action in ShortcutAction.allCases where action != .toggleIsland { + precondition(action.enabledByDefault, + "\(action.rawValue) must be enabled by default") + } + } + + // MARK: - testKeyCodeToString + + static func testKeyCodeToString() { + precondition(ShortcutLogic.keyCodeToString(0) == "A", "keyCode 0 = A") + precondition(ShortcutLogic.keyCodeToString(45) == "N", "keyCode 45 = N") + precondition(ShortcutLogic.keyCodeToString(49) == "Space", "keyCode 49 = Space") + precondition(ShortcutLogic.keyCodeToString(30) == "]", "keyCode 30 = ]") + precondition(ShortcutLogic.keyCodeToString(33) == "[", "keyCode 33 = [") + precondition(ShortcutLogic.keyCodeToString(2) == "D", "keyCode 2 = D") + // Unknown key code returns a placeholder + let unknown = ShortcutLogic.keyCodeToString(200) + precondition(!unknown.isEmpty, "unknown key code must return non-empty string") + } + + // MARK: - testCardNavigation + + static func testCardNavigation() { + // nil + down → first item + precondition(ShortcutLogic.navigate(selection: nil, delta: +1, itemCount: 3) == 0, + "nil+down must select first item") + // nil + up → last item + precondition(ShortcutLogic.navigate(selection: nil, delta: -1, itemCount: 3) == 2, + "nil+up must select last item") + // clamp at top + precondition(ShortcutLogic.navigate(selection: 0, delta: -1, itemCount: 3) == 0, + "selection 0 + up must clamp to 0") + // clamp at bottom + precondition(ShortcutLogic.navigate(selection: 2, delta: +1, itemCount: 3) == 2, + "selection last + down must clamp to last") + // normal step down + precondition(ShortcutLogic.navigate(selection: 1, delta: +1, itemCount: 3) == 2, + "1+down in 3-item list must yield 2") + // normal step up + precondition(ShortcutLogic.navigate(selection: 1, delta: -1, itemCount: 3) == 0, + "1+up in 3-item list must yield 0") + // delta 0 — no move + precondition(ShortcutLogic.navigate(selection: 1, delta: 0, itemCount: 3) == 1, + "delta 0 must not move selection") + // empty card → nil (navigateCard guards on cardItemCount > 0, but test the pure function) + precondition(ShortcutLogic.navigate(selection: nil, delta: +1, itemCount: 0) == nil, + "empty card must return nil") + precondition(ShortcutLogic.navigate(selection: 0, delta: +1, itemCount: 0) == nil, + "any selection on empty card must return nil") + } +} diff --git a/tests/fake_local_llm.py b/tests/fake_local_llm.py new file mode 100644 index 0000000..d086849 --- /dev/null +++ b/tests/fake_local_llm.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Minimal fake Ollama / LM Studio server for CI testing. + +Usage: python3 fake_local_llm.py + Writes the bound port to , then serves until killed. + +Routes: + GET /v1/models → two models (one embed, should be filtered out) + POST /v1/chat/completions → SSE stream with block, reasoning-only + deltas (content=null), and Markdown answer + POST /v1/chat/completions → 404 when model == "unknown-model" +""" + +import json +import sys +import socket +import socketserver +import time +from http.server import BaseHTTPRequestHandler, HTTPServer + +# Two models; "nomic-embed-text" must be filtered by the Swift client +MODELS_RESPONSE = { + "data": [ + {"id": "llama3.2", "object": "model"}, + {"id": "nomic-embed-text", "object": "model"}, + ] +} + +# SSE events that make up the streamed reply: +# - events 0-2: a block split over two chunks (should be hidden from UI) +# - event 3: a delta with reasoning_content only (content is null → ignored) +# - events 4-7: real Markdown answer +SSE_EVENTS = [ + {"choices": [{"delta": {"content": "\nstep "}}]}, + {"choices": [{"delta": {"content": "one\n"}}]}, + # reasoning-only delta: content key present but value is null + {"choices": [{"delta": {"content": None, "reasoning_content": "internal"}}]}, + {"choices": [{"delta": {"content": "## Answer\n\n"}}]}, + {"choices": [{"delta": {"content": "Here is the result:\n\n"}}]}, + {"choices": [{"delta": {"content": "- **item 1**\n"}}]}, + {"choices": [{"delta": {"content": "- item 2\n\n"}}]}, + {"choices": [{"delta": {"content": "```python\nprint('hello')\n```"}}]}, +] + +EXPECTED_RESPONSE = "## Answer\n\nHere is the result:\n\n- **item 1**\n- item 2\n\n```python\nprint('hello')\n```" + + +class FakeLLMHandler(BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + pass # suppress request logging + + def do_GET(self): + if self.path == "/v1/models": + body = json.dumps(MODELS_RESPONSE).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + else: + self.send_response(404) + self.end_headers() + + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + raw = self.rfile.read(length) + try: + req_body = json.loads(raw) + except Exception: + req_body = {} + + model = req_body.get("model", "") + + if self.path != "/v1/chat/completions": + self.send_response(404) + self.end_headers() + return + + # Unknown model → 404 with OpenAI-format error + if model == "unknown-model": + err = json.dumps({ + "error": { + "message": f"model '{model}' not found, try pulling it first", + "type": "not_found", + } + }).encode() + self.send_response(404) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(err))) + self.end_headers() + self.wfile.write(err) + return + + # SSE stream + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + + for event in SSE_EVENTS: + line = ("data: " + json.dumps(event) + "\n\n").encode() + self.wfile.write(line) + self.wfile.flush() + time.sleep(0.005) + + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + + +class FastBindHTTPServer(HTTPServer): + """HTTPServer that skips the reverse-DNS lookup in server_bind. + + The default HTTPServer.server_bind calls socket.getfqdn(), which triggers + a reverse-DNS lookup for the bound address. On some CI runners this can + block for several seconds and cause the port-file handshake to time out. + We bypass it by calling TCPServer.server_bind directly and hard-coding + server_name to the loopback address we already know we are binding to. + """ + + def server_bind(self): + socketserver.TCPServer.server_bind(self) + self.server_name = "127.0.0.1" + self.server_port = self.server_address[1] + + +def find_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +if __name__ == "__main__": + port = find_free_port() + server = FastBindHTTPServer(("127.0.0.1", port), FakeLLMHandler) + + # Write port to the file passed as argv[1] so the caller can read it + if len(sys.argv) > 1: + with open(sys.argv[1], "w") as f: + f.write(str(port)) + f.flush() + + server.serve_forever()