Import Coucou 0.1.9 (Louis-CFM/coucou@83708fe), Mac app only
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -0,0 +1,2 @@
|
||||
docs/** linguist-documentation
|
||||
design/** linguist-documentation
|
||||
@@ -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/
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 340 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.automation.apple-events</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.bookmarks.app-scope</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.bookmarks.app-scope</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array>
|
||||
<string>iCloud.fr.louisraille.Coucou</string>
|
||||
</array>
|
||||
<key>com.apple.developer.icloud-services</key>
|
||||
<array>
|
||||
<string>CloudKit</string>
|
||||
</array>
|
||||
<key>com.apple.developer.aps-environment</key>
|
||||
<string>development</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.automation.apple-events</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array>
|
||||
<string>iCloud.fr.louisraille.Coucou</string>
|
||||
</array>
|
||||
<key>com.apple.developer.icloud-services</key>
|
||||
<array>
|
||||
<string>CloudKit</string>
|
||||
</array>
|
||||
<key>com.apple.developer.aps-environment</key>
|
||||
<string>development</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array>
|
||||
<string>iCloud.fr.louisraille.Coucou</string>
|
||||
</array>
|
||||
<key>com.apple.developer.icloud-services</key>
|
||||
<array>
|
||||
<string>CloudKit</string>
|
||||
</array>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.fr.louisraille.Coucou</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array>
|
||||
<string>iCloud.fr.louisraille.Coucou</string>
|
||||
</array>
|
||||
<key>com.apple.developer.icloud-services</key>
|
||||
<array>
|
||||
<string>CloudKit</string>
|
||||
</array>
|
||||
<key>com.apple.developer.icloud-container-environment</key>
|
||||
<string>Production</string>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.fr.louisraille.Coucou</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.automation.apple-events</key>
|
||||
<true/>
|
||||
<key>com.apple.application-identifier</key>
|
||||
<string>256AUJ9555.fr.louisraille.NotchBuddy</string>
|
||||
<key>com.apple.developer.team-identifier</key>
|
||||
<string>256AUJ9555</string>
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array>
|
||||
<string>iCloud.fr.louisraille.Coucou</string>
|
||||
</array>
|
||||
<key>com.apple.developer.icloud-services</key>
|
||||
<array>
|
||||
<string>CloudKit</string>
|
||||
</array>
|
||||
<key>com.apple.developer.icloud-container-environment</key>
|
||||
<string>Production</string>
|
||||
<key>com.apple.developer.aps-environment</key>
|
||||
<string>production</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.fr.louisraille.Coucou</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Coucou</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>fr.louisraille.NotchBuddy</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Coucou</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.9</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>10</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSAccessibilityUsageDescription</key>
|
||||
<string>Pour lire le titre de la fenêtre active et l'attacher comme contexte.</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSAppleEventsUsageDescription</key>
|
||||
<string>Pour sauter au terminal, envoyer des mails et piloter Musique.</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSSupportsAutomaticGraphicsSwitching</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Coucou</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>fr.louisraille.Coucou</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Coucou</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>5</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.developer-tools</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSSupportsAutomaticGraphicsSwitching</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Coucou</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>fr.louisraille.Coucou.NotificationContent</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>CoucouNotificationContent</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>3</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>UNNotificationExtensionCategory</key>
|
||||
<string>COUCOU_APPROVAL</string>
|
||||
<key>UNNotificationExtensionDefaultContentHidden</key>
|
||||
<true/>
|
||||
<key>UNNotificationExtensionInitialContentSizeRatio</key>
|
||||
<real>0.45000000000000001</real>
|
||||
</dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.usernotifications.content-extension</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).NotificationViewController</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,67 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Coucou</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>fr.louisraille.Coucou</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Coucou</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>fr.louisraille.Coucou</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>coucou</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>3</string>
|
||||
<key>CoucouAPNsEnvironment</key>
|
||||
<string>$(COUCOU_APNS_ENV)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Coucou asks for Face ID before allowing a command from your iPhone.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Coucou uses the microphone when you dictate an instruction for Claude Code.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>Coucou turns your voice into the text of an instruction for Claude Code.</string>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Coucou</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>fr.louisraille.Coucou.Widgets</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>CoucouWidgets</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>3</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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/<fileName>`.
|
||||
/// - 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)
|
||||
}
|
||||
}
|
||||
@@ -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<ChatProvider> = []
|
||||
|
||||
/// 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<String> = [] {
|
||||
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<String> = [] {
|
||||
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<String> = ["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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<BotState> = [.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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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[..<range.upperBound])
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
.components(separatedBy: ".").first ?? "1"
|
||||
let text = String(stripped[range.upperBound...])
|
||||
blocks.append(.listItem(prefix: "\(number).", text: text, indent: indent))
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Blank line — skip
|
||||
if stripped.isEmpty {
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Paragraph: accumulate consecutive non-special lines
|
||||
var paragraphLines: [String] = [line]
|
||||
i += 1
|
||||
while i < lines.count {
|
||||
let next = lines[i]
|
||||
let nextStripped = next.trimmingCharacters(in: .whitespaces)
|
||||
if nextStripped.isEmpty { break }
|
||||
if next.hasPrefix("#") { break }
|
||||
if next.hasPrefix("```") { break }
|
||||
if nextStripped.hasPrefix("> ") || 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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()))%"
|
||||
}
|
||||
}
|
||||
@@ -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)"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<BotState> = [.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<AnyCancellable> = []
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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...])
|
||||
}
|
||||
}
|
||||
@@ -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<String>()
|
||||
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<String>()
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<EventHotKeyID>.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<ShortcutAction> = []
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<IslandView> = [.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<AnimatablePair<AnimatablePair<CGFloat, CGFloat>, 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 `<think>…</think>` blocks (reasoning models like DeepSeek-R1).
|
||||
static func filterThinkingBlocks(_ text: String) -> String {
|
||||
let pattern = "<think>[\\s\\S]*?</think>"
|
||||
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) `<think>` 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: "<think>") {
|
||||
return String(cleaned[..<range.lowerBound])
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// MARK: Model list
|
||||
|
||||
/// Fetches models, returning `.success([])` when the server responds but has no chat models,
|
||||
/// vs `.failure(.serverUnreachable)` when the server is not reachable.
|
||||
static func fetchModelsResult(baseURL: String) async -> 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)
|
||||
}
|
||||
}
|
||||
@@ -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<AnyCancellable>()
|
||||
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[..<r.lowerBound])
|
||||
}
|
||||
// Strip trailing (...) or [...] groups repeatedly
|
||||
var changed = true
|
||||
while changed {
|
||||
changed = false
|
||||
let t = s.trimmingCharacters(in: .whitespaces)
|
||||
guard let last = t.last, (last == ")" || last == "]") else { break }
|
||||
let open: Character = last == ")" ? "(" : "["
|
||||
if let idx = t.lastIndex(of: open) {
|
||||
let candidate = String(t[..<idx]).trimmingCharacters(in: .whitespaces)
|
||||
if !candidate.isEmpty { s = candidate; changed = true }
|
||||
} else { break }
|
||||
}
|
||||
let result = s.trimmingCharacters(in: .whitespaces)
|
||||
return result.isEmpty ? raw : result
|
||||
}
|
||||
|
||||
private static func shortArtist(_ raw: String) -> 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[..<r.lowerBound]).trimmingCharacters(in: .whitespaces)
|
||||
return result.isEmpty ? raw : result
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
private func handlePlayerInfo(playerState: String?, name: String?, artist inputArtist: String?, album inputAlbum: String?) {
|
||||
guard isPillActive else { return }
|
||||
|
||||
let playing = playerState == "Playing"
|
||||
let wasPlaying = AppState.shared.musicPlaying
|
||||
|
||||
trackTitle = name.map { Self.shortTitle($0) }.flatMap { $0.isEmpty ? nil : $0 }
|
||||
artist = inputArtist.map { Self.shortArtist($0) }.flatMap { $0.isEmpty ? nil : $0 }
|
||||
album = inputAlbum
|
||||
|
||||
AppState.shared.musicPlaying = playing
|
||||
syncTaskName()
|
||||
|
||||
// Reveal only on transition from not-playing → playing
|
||||
if playing && !wasPlaying {
|
||||
NotificationCenter.default.post(name: .musicReveal, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchAndApply() {
|
||||
Task {
|
||||
let result = await runAppleScript("""
|
||||
tell application id "com.apple.Music"
|
||||
set ps to player state as string
|
||||
if ps is "stopped" then return {ps, "", "", ""}
|
||||
try
|
||||
set tr to current track
|
||||
set n to name of tr
|
||||
on error
|
||||
return {ps, "", "", ""}
|
||||
end try
|
||||
set ar to ""
|
||||
set al to ""
|
||||
try
|
||||
set ar to artist of tr
|
||||
end try
|
||||
try
|
||||
set al to album of tr
|
||||
end try
|
||||
return {ps, n, ar, al}
|
||||
end tell
|
||||
""")
|
||||
guard case .success(let values) = result, values.count >= 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
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
@main
|
||||
struct NotchBuddyApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) var delegate
|
||||
|
||||
var body: some Scene {
|
||||
Settings {
|
||||
SettingsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<Void, Never>?
|
||||
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
|
||||
@@ -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<String>()
|
||||
private var fetching = false
|
||||
private var lastPushAt: Date?
|
||||
private var started = false
|
||||
private var pingTask: Task<Void, Never>?
|
||||
private var pollTask: Task<Void, Never>?
|
||||
|
||||
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
|
||||
@@ -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 <text> --resume <session id>`, 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<Void, Never>?
|
||||
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
|
||||
@@ -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 <url>).
|
||||
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<Void, Never>?
|
||||
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<Void, Never>?
|
||||
private var unlockTask: Task<Void, Never>?
|
||||
/// Ends the activity 10 minutes after the agents are done, if nothing restarts.
|
||||
private var doneTask: Task<Void, Never>?
|
||||
|
||||
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
|
||||
@@ -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<Void, Never>?
|
||||
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
|
||||
@@ -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<Void, Never>?
|
||||
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<String> = []
|
||||
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<String> = [
|
||||
"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<String> = ["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
|
||||