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,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
|
||||
@@ -0,0 +1,395 @@
|
||||
#if PHONE_LINK
|
||||
import AppKit
|
||||
import CloudKit
|
||||
import Combine
|
||||
|
||||
// MARK: - Services on the iPhone
|
||||
//
|
||||
// One `Service` record per service Mochi (GitHub, Stripe, Vercel, Resend,
|
||||
// Cal.com, n8n, Notion), built from the data the Mac's pollers already keep in
|
||||
// AppState: no extra API call, no key sent anywhere. The whole snapshot
|
||||
// (amounts, titles, emails, links) is one field encrypted with the user's
|
||||
// iCloud keys; only the pill ID, the state dot and the date are in clear.
|
||||
// Driven by AppState changes (debounced), never by a timer.
|
||||
|
||||
@MainActor
|
||||
final class ServicePublisher {
|
||||
static let shared = ServicePublisher()
|
||||
|
||||
private let container = CKContainer(identifier: CloudProbe.containerID)
|
||||
private var database: CKDatabase { container.privateCloudDatabase }
|
||||
private var cancellable: AnyCancellable?
|
||||
private var published: [String: ServiceSnapshot] = [:]
|
||||
private var cleanedUp = false
|
||||
private var writing = false
|
||||
private var again = false
|
||||
|
||||
func start() {
|
||||
guard cancellable == nil else { return }
|
||||
let s = AppState.shared
|
||||
let changes: [AnyPublisher<Void, Never>] = [
|
||||
s.$stripePayments.map { _ in () }.eraseToAnyPublisher(),
|
||||
s.$stripeBalance.map { _ in () }.eraseToAnyPublisher(),
|
||||
s.$stripeError.map { _ in () }.eraseToAnyPublisher(),
|
||||
s.$githubPulse.map { _ in () }.eraseToAnyPublisher(),
|
||||
s.$githubActivity.map { _ in () }.eraseToAnyPublisher(),
|
||||
s.$vercelDeployments.map { _ in () }.eraseToAnyPublisher(),
|
||||
s.$resendEmails.map { _ in () }.eraseToAnyPublisher(),
|
||||
s.$calcomBookings.map { _ in () }.eraseToAnyPublisher(),
|
||||
s.$calcomError.map { _ in () }.eraseToAnyPublisher(),
|
||||
s.$notionPages.map { _ in () }.eraseToAnyPublisher(),
|
||||
s.$notionError.map { _ in () }.eraseToAnyPublisher(),
|
||||
s.$n8nRuns.map { _ in () }.eraseToAnyPublisher(),
|
||||
]
|
||||
cancellable = Publishers.MergeMany(changes)
|
||||
.debounce(for: .seconds(2), scheduler: RunLoop.main)
|
||||
.sink { [weak self] in
|
||||
MainActor.assumeIsolated { self?.publish() }
|
||||
}
|
||||
log("service publisher on")
|
||||
}
|
||||
|
||||
/// Stops and deletes this Mac's services from iCloud.
|
||||
func stop() {
|
||||
cancellable = nil
|
||||
let ids = PillCatalog.phoneServices.map {
|
||||
CKRecord.ID(recordName: ServiceSnapshot.recordName(for: $0), zoneID: SessionSnapshot.zoneID)
|
||||
}
|
||||
published = [:]
|
||||
cleanedUp = false
|
||||
Task { _ = try? await database.modifyRecords(saving: [], deleting: ids, savePolicy: .changedKeys, atomically: false) }
|
||||
log("service publisher off")
|
||||
}
|
||||
|
||||
private func publish() {
|
||||
guard !writing else { again = true; return }
|
||||
writing = true
|
||||
let snapshots = ServiceSnapshots.all(from: AppState.shared)
|
||||
Task {
|
||||
await write(snapshots)
|
||||
writing = false
|
||||
if again { again = false; publish() }
|
||||
}
|
||||
}
|
||||
|
||||
private func write(_ snapshots: [String: ServiceSnapshot]) async {
|
||||
var deletions: [CKRecord.ID] = []
|
||||
if !cleanedUp {
|
||||
// Services this Mac no longer has (key removed since the last run).
|
||||
deletions = PillCatalog.phoneServices.filter { snapshots[$0] == nil }.map {
|
||||
CKRecord.ID(recordName: ServiceSnapshot.recordName(for: $0), zoneID: SessionSnapshot.zoneID)
|
||||
}
|
||||
cleanedUp = true
|
||||
}
|
||||
let changed = snapshots.values.filter { snapshot in
|
||||
guard let old = published[snapshot.pillId] else { return true }
|
||||
return !old.sameContent(as: snapshot)
|
||||
}
|
||||
let removed = published.keys.filter { snapshots[$0] == nil }
|
||||
deletions += removed.map { CKRecord.ID(recordName: ServiceSnapshot.recordName(for: $0), zoneID: SessionSnapshot.zoneID) }
|
||||
guard !changed.isEmpty || !deletions.isEmpty else { return }
|
||||
|
||||
let records = changed.compactMap { record(for: $0) }
|
||||
do {
|
||||
let result = try await database.modifyRecords(saving: records, deleting: deletions,
|
||||
savePolicy: .changedKeys, atomically: false)
|
||||
for (id, outcome) in result.saveResults {
|
||||
guard let pillId = ServiceSnapshot.pillId(fromRecordName: id.recordName) else { continue }
|
||||
if case .success = outcome { published[pillId] = snapshots[pillId] }
|
||||
}
|
||||
for id in removed { published[id] = nil }
|
||||
let names = changed.map { "\($0.pillId.replacingOccurrences(of: "integration_", with: ""))=\($0.tone.rawValue)" }
|
||||
log("published \(changed.count) service(s) [\(names.sorted().joined(separator: ", "))], removed \(deletions.count)")
|
||||
} catch {
|
||||
log("publish failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func record(for snapshot: ServiceSnapshot) -> CKRecord? {
|
||||
guard let json = try? JSONEncoder().encode(snapshot), let payload = String(data: json, encoding: .utf8) else { return nil }
|
||||
let record = CKRecord(recordType: ServiceSnapshot.recordType,
|
||||
recordID: CKRecord.ID(recordName: ServiceSnapshot.recordName(for: snapshot.pillId),
|
||||
zoneID: SessionSnapshot.zoneID))
|
||||
record["pillId"] = snapshot.pillId
|
||||
record["tone"] = snapshot.tone.rawValue
|
||||
record["updatedAt"] = snapshot.updatedAt
|
||||
record["macName"] = Host.current().localizedName ?? ""
|
||||
record.encryptedValues["payload"] = payload
|
||||
return record
|
||||
}
|
||||
|
||||
private func log(_ message: String) {
|
||||
CloudProbe.shared.log("[services] \(message)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Building the snapshots
|
||||
|
||||
/// One snapshot per service that has data on this Mac. A service without its
|
||||
/// key (nothing loaded) is left out, and the iPhone shows "connect it on your Mac".
|
||||
@MainActor
|
||||
enum ServiceSnapshots {
|
||||
static func all(from s: AppState) -> [String: ServiceSnapshot] {
|
||||
let built = [github(s), stripe(s), vercel(s), resend(s), calcom(s), n8n(s), notion(s)].compactMap { $0 }
|
||||
return Dictionary(uniqueKeysWithValues: built.map { ($0.pillId, $0) })
|
||||
}
|
||||
|
||||
// MARK: GitHub
|
||||
|
||||
static func github(_ s: AppState) -> ServiceSnapshot? {
|
||||
guard let pulse = s.githubPulse else { return nil }
|
||||
func tone(_ ci: CIState) -> ServiceTone {
|
||||
switch ci {
|
||||
case .success: .ok
|
||||
case .failure: .error
|
||||
case .pending: .warning
|
||||
case .unknown: .idle
|
||||
}
|
||||
}
|
||||
func ciLabel(_ ci: CIState) -> String {
|
||||
switch ci {
|
||||
case .success: "checks passed"
|
||||
case .failure: "checks failing"
|
||||
case .pending: "checks running"
|
||||
case .unknown: "no checks"
|
||||
}
|
||||
}
|
||||
func reviewLabel(_ review: ReviewState) -> String {
|
||||
switch review {
|
||||
case .approved: " · approved"
|
||||
case .changesRequested: " · changes requested"
|
||||
case .pending: " · review required"
|
||||
case .unknown: ""
|
||||
}
|
||||
}
|
||||
let failingPR = pulse.myPRs.first { $0.ci == .failure }
|
||||
let failingMain = pulse.mainCI.first { $0.ci == .failure }
|
||||
let pending = pulse.myPRs.contains { $0.ci == .pending } || pulse.mainCI.contains { $0.ci == .pending }
|
||||
let anyGreen = pulse.myPRs.contains { $0.ci == .success } || pulse.mainCI.contains { $0.ci == .success }
|
||||
|
||||
let overall: ServiceTone
|
||||
let reason: String
|
||||
if let main = failingMain {
|
||||
overall = .error
|
||||
reason = "CI failing on \(main.repo) · \(main.branch)"
|
||||
} else if let pr = failingPR {
|
||||
overall = .error
|
||||
reason = "Checks failing on \(pr.repo) #\(pr.number): \(pr.title)"
|
||||
} else if pending {
|
||||
overall = .warning
|
||||
reason = "Checks are running"
|
||||
} else if !pulse.toReview.isEmpty {
|
||||
overall = .warning
|
||||
reason = pulse.toReview.count == 1
|
||||
? "1 pull request waits for your review"
|
||||
: "\(pulse.toReview.count) pull requests wait for your review"
|
||||
} else if anyGreen {
|
||||
overall = .ok
|
||||
reason = "All checks passed"
|
||||
} else {
|
||||
overall = .idle
|
||||
reason = "Nothing going on"
|
||||
}
|
||||
|
||||
var sections: [ServiceSection] = []
|
||||
if !pulse.myPRs.isEmpty {
|
||||
sections.append(ServiceSection(title: "Your pull requests", items: pulse.myPRs.prefix(8).map {
|
||||
ServiceItem(title: "#\($0.number) \($0.title)",
|
||||
detail: "\($0.repo) · \(ciLabel($0.ci))\(reviewLabel($0.review))\($0.isDraft ? " · draft" : "")",
|
||||
tone: tone($0.ci), url: $0.url)
|
||||
}))
|
||||
}
|
||||
if !pulse.toReview.isEmpty {
|
||||
sections.append(ServiceSection(title: "Waiting for your review", items: pulse.toReview.prefix(8).map {
|
||||
ServiceItem(title: "#\($0.number) \($0.title)", detail: $0.repo, tone: .warning, url: $0.url)
|
||||
}))
|
||||
}
|
||||
if !pulse.mainCI.isEmpty {
|
||||
sections.append(ServiceSection(title: "Default branches", items: pulse.mainCI.prefix(8).map {
|
||||
ServiceItem(title: $0.repo, detail: "\($0.branch) · \(ciLabel($0.ci))", tone: tone($0.ci), url: $0.url)
|
||||
}))
|
||||
}
|
||||
var headline = "\(pulse.myPRs.count) open pull request\(pulse.myPRs.count == 1 ? "" : "s")"
|
||||
if let total = s.githubActivity?.total { headline += " · \(total) contributions this year" }
|
||||
return ServiceSnapshot(pillId: "integration_github", tone: overall, headline: headline, reason: reason,
|
||||
sections: sections, updatedAt: pulse.fetchedAt)
|
||||
}
|
||||
|
||||
// MARK: Stripe
|
||||
|
||||
static func stripe(_ s: AppState) -> ServiceSnapshot? {
|
||||
if let error = s.stripeError, !s.stripeLoaded {
|
||||
return ServiceSnapshot(pillId: "integration_stripe", tone: .error, headline: "Can't reach Stripe",
|
||||
reason: error, sections: [], updatedAt: Date())
|
||||
}
|
||||
guard s.stripeLoaded else { return nil }
|
||||
let currency = s.stripeCurrency.uppercased()
|
||||
func money(_ cents: Int, _ code: String) -> String {
|
||||
String(format: "%.2f %@", Double(cents) / 100, code.uppercased())
|
||||
}
|
||||
let latest = s.stripePayments.first
|
||||
let overall: ServiceTone
|
||||
let reason: String
|
||||
if let error = s.stripeError {
|
||||
overall = .error
|
||||
reason = "Stripe answered with an error: \(error)"
|
||||
} else if let latest {
|
||||
let what = latest.description.map { " · \($0)" } ?? ""
|
||||
switch latest.status {
|
||||
case "succeeded":
|
||||
overall = Date().timeIntervalSince(latest.createdAt) < 86_400 ? .ok : .idle
|
||||
reason = "Last payment received: \(money(latest.amount, latest.currency))\(what)"
|
||||
case "failed":
|
||||
overall = .error
|
||||
reason = "Last payment failed: \(money(latest.amount, latest.currency))\(what)"
|
||||
default:
|
||||
overall = .warning
|
||||
reason = "Last payment pending: \(money(latest.amount, latest.currency))\(what)"
|
||||
}
|
||||
} else {
|
||||
overall = .idle
|
||||
reason = "No payment yet"
|
||||
}
|
||||
let items = s.stripePayments.prefix(10).map {
|
||||
ServiceItem(title: money($0.amount, $0.currency),
|
||||
detail: [$0.description, $0.status].compactMap { $0 }.joined(separator: " · "),
|
||||
tone: $0.isSuccess ? .ok : ($0.status == "failed" ? .error : .warning),
|
||||
date: $0.createdAt)
|
||||
}
|
||||
return ServiceSnapshot(pillId: "integration_stripe", tone: overall,
|
||||
headline: "Balance \(money(s.stripeBalance, currency))", reason: reason,
|
||||
sections: items.isEmpty ? [] : [ServiceSection(title: "Recent payments", items: items)],
|
||||
updatedAt: latest?.createdAt ?? Date())
|
||||
}
|
||||
|
||||
// MARK: Vercel
|
||||
|
||||
static func vercel(_ s: AppState) -> ServiceSnapshot? {
|
||||
let filter = s.vercelProjectFilter
|
||||
let deployments = filter.isEmpty ? s.vercelDeployments : s.vercelDeployments.filter { filter.contains($0.projectName) }
|
||||
guard let latest = deployments.first else { return nil }
|
||||
func tone(_ d: VercelDeployment) -> ServiceTone {
|
||||
d.isSuccess ? .ok : (d.state == "CANCELED" ? .warning : .error)
|
||||
}
|
||||
let branch = latest.branch.map { " · \($0)" } ?? ""
|
||||
let reason: String
|
||||
switch latest.state {
|
||||
case "READY": reason = "Last deploy is live: \(latest.projectName)\(branch)"
|
||||
case "CANCELED": reason = "Last deploy was canceled: \(latest.projectName)\(branch)"
|
||||
default: reason = "Last deploy failed: \(latest.projectName)\(branch)"
|
||||
}
|
||||
let items = deployments.prefix(10).map { d in
|
||||
ServiceItem(title: d.projectName,
|
||||
detail: [d.statusLabel, d.branch, d.commitMessage].compactMap { $0 }.joined(separator: " · "),
|
||||
tone: tone(d), date: d.createdAt,
|
||||
url: d.url.isEmpty ? nil : "https://\(d.url)")
|
||||
}
|
||||
return ServiceSnapshot(pillId: "integration_vercel", tone: tone(latest),
|
||||
headline: "\(latest.projectName) · \(latest.statusLabel)", reason: reason,
|
||||
sections: [ServiceSection(title: "Deployments", items: items)],
|
||||
updatedAt: latest.createdAt)
|
||||
}
|
||||
|
||||
// MARK: Resend
|
||||
|
||||
static func resend(_ s: AppState) -> ServiceSnapshot? {
|
||||
guard let latest = s.resendEmails.first else { return nil }
|
||||
func tone(_ event: String) -> ServiceTone {
|
||||
switch event {
|
||||
case "bounced", "complained", "failed": .error
|
||||
case "delivered", "opened", "clicked": .ok
|
||||
case "delivery_delayed": .warning
|
||||
default: .info
|
||||
}
|
||||
}
|
||||
let reason: String
|
||||
switch tone(latest.lastEvent) {
|
||||
case .error: reason = "Last email \(latest.lastEvent): \(latest.subject)"
|
||||
case .warning: reason = "Last email is delayed: \(latest.subject)"
|
||||
case .ok: reason = "Last email \(latest.lastEvent): \(latest.subject)"
|
||||
default: reason = "Last email \(latest.lastEvent): \(latest.subject)"
|
||||
}
|
||||
let items = s.resendEmails.prefix(10).map {
|
||||
ServiceItem(title: $0.subject, detail: "to \($0.to.joined(separator: ", ")) · \($0.lastEvent)",
|
||||
tone: tone($0.lastEvent), date: $0.createdAt)
|
||||
}
|
||||
let total = s.resendTotal.map { "\($0) emails sent" } ?? "Emails"
|
||||
return ServiceSnapshot(pillId: "integration_resend", tone: tone(latest.lastEvent), headline: total,
|
||||
reason: reason, sections: [ServiceSection(title: "Recent emails", items: items)],
|
||||
updatedAt: latest.createdAt)
|
||||
}
|
||||
|
||||
// MARK: Cal.com
|
||||
|
||||
static func calcom(_ s: AppState) -> ServiceSnapshot? {
|
||||
if let error = s.calcomError, !s.calcomLoaded {
|
||||
return ServiceSnapshot(pillId: "integration_calcom", tone: .error, headline: "Can't reach Cal.com",
|
||||
reason: error, sections: [], updatedAt: Date())
|
||||
}
|
||||
guard s.calcomLoaded else { return nil }
|
||||
let upcoming = s.calcomBookings.filter { $0.endTime > Date() }.sorted { $0.startTime < $1.startTime }
|
||||
let next = upcoming.first { $0.isActive }
|
||||
let time = Date.FormatStyle(date: .abbreviated, time: .shortened)
|
||||
let overall: ServiceTone
|
||||
let reason: String
|
||||
if let next {
|
||||
let soon = next.startTime.timeIntervalSinceNow < 3600
|
||||
overall = soon ? .warning : .info
|
||||
let with = next.attendeeName.map { " with \($0)" } ?? ""
|
||||
reason = "\(soon ? "Starting soon" : "Next"): \(next.title)\(with), \(next.startTime.formatted(time))"
|
||||
} else {
|
||||
overall = .idle
|
||||
reason = "No upcoming booking"
|
||||
}
|
||||
let items = upcoming.prefix(10).map {
|
||||
ServiceItem(title: $0.title,
|
||||
detail: [$0.attendeeName, $0.status.lowercased()].compactMap { $0 }.joined(separator: " · "),
|
||||
tone: $0.isActive ? .info : .idle, date: $0.startTime)
|
||||
}
|
||||
let count = upcoming.filter(\.isActive).count
|
||||
return ServiceSnapshot(pillId: "integration_calcom", tone: overall,
|
||||
headline: "\(count) upcoming booking\(count == 1 ? "" : "s")", reason: reason,
|
||||
sections: items.isEmpty ? [] : [ServiceSection(title: "Upcoming", items: items)],
|
||||
updatedAt: Date())
|
||||
}
|
||||
|
||||
// MARK: n8n
|
||||
|
||||
static func n8n(_ s: AppState) -> ServiceSnapshot? {
|
||||
guard let latest = s.n8nRuns.first else { return nil }
|
||||
let reason = latest.success
|
||||
? "Last run succeeded: \(latest.workflow)"
|
||||
: "Last run failed: \(latest.workflow)\(latest.detail.map { " · \($0)" } ?? "")"
|
||||
let items = s.n8nRuns.map {
|
||||
ServiceItem(title: $0.workflow, detail: $0.detail ?? ($0.success ? "succeeded" : "failed"),
|
||||
tone: $0.success ? .ok : .error, date: $0.date)
|
||||
}
|
||||
let failed = s.n8nRuns.filter { !$0.success }.count
|
||||
return ServiceSnapshot(pillId: "integration_n8n", tone: latest.success ? .ok : .error,
|
||||
headline: failed == 0 ? "All recent runs succeeded" : "\(failed) failed run\(failed == 1 ? "" : "s")",
|
||||
reason: reason, sections: [ServiceSection(title: "Recent runs", items: items)],
|
||||
updatedAt: latest.date)
|
||||
}
|
||||
|
||||
// MARK: Notion
|
||||
|
||||
static func notion(_ s: AppState) -> ServiceSnapshot? {
|
||||
if let error = s.notionError, !s.notionLoaded {
|
||||
return ServiceSnapshot(pillId: "integration_notion", tone: .error, headline: "Can't reach Notion",
|
||||
reason: error, sections: [], updatedAt: Date())
|
||||
}
|
||||
guard s.notionLoaded else { return nil }
|
||||
let pages = s.notionPages.sorted { $0.lastEditedAt > $1.lastEditedAt }
|
||||
let items = pages.prefix(10).map {
|
||||
ServiceItem(title: [$0.emoji, $0.title].compactMap { $0 }.joined(separator: " "),
|
||||
tone: .info, date: $0.lastEditedAt, url: $0.url)
|
||||
}
|
||||
let reason = pages.first.map { "Last edited: \($0.title)" } ?? "No recent page"
|
||||
let recent = (pages.first?.lastEditedAt.timeIntervalSinceNow ?? -.infinity) > -3600
|
||||
return ServiceSnapshot(pillId: "integration_notion", tone: recent ? .info : .idle,
|
||||
headline: "\(pages.count) recent page\(pages.count == 1 ? "" : "s")", reason: reason,
|
||||
sections: items.isEmpty ? [] : [ServiceSection(title: "Recently edited", items: items)],
|
||||
updatedAt: pages.first?.lastEditedAt ?? Date())
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,257 @@
|
||||
#if PHONE_LINK
|
||||
import AppKit
|
||||
import CloudKit
|
||||
import Combine
|
||||
|
||||
// MARK: - iPhone plan, step 4: publish agent sessions to iCloud
|
||||
//
|
||||
// One `Session` record per agent pill (VS Code / Claude Code, Cursor, Codex,
|
||||
// Antigravity, Gemini CLI…) in the private zone `Coucou`, so the iPhone can show
|
||||
// them. Driven only by AppState changes (debounced), never by a timer: nothing
|
||||
// runs while no agent changes. Integrations (Stripe, Vercel…) are not sessions.
|
||||
//
|
||||
// In clear (needed for sorting and widgets): pillId, state, step counts, flags,
|
||||
// dates. Encrypted with the user's iCloud keys: project name, steps, cwd,
|
||||
// last message, the pending command and question.
|
||||
|
||||
@MainActor
|
||||
final class SessionPublisher {
|
||||
static let shared = SessionPublisher()
|
||||
|
||||
private let container = CKContainer(identifier: "iCloud.fr.louisraille.Coucou")
|
||||
private var database: CKDatabase { container.privateCloudDatabase }
|
||||
private var cancellable: AnyCancellable?
|
||||
|
||||
/// What was last written to iCloud, by pill ID.
|
||||
private var published: [String: SessionSnapshot] = [:]
|
||||
private var zoneReady = false
|
||||
private var cleanedUp = false
|
||||
private var publishing = false
|
||||
private var pending: [String: SessionSnapshot]?
|
||||
|
||||
/// Stops publishing and deletes this Mac's sessions from iCloud.
|
||||
func stop() {
|
||||
cancellable = nil
|
||||
pending = nil
|
||||
Task {
|
||||
do {
|
||||
let existing = try await existingSessions()
|
||||
let ids = existing.keys.map { SessionSnapshot.recordID(for: $0) }
|
||||
if !ids.isEmpty {
|
||||
_ = try await database.modifyRecords(saving: [], deleting: ids, savePolicy: .changedKeys, atomically: false)
|
||||
}
|
||||
log("publisher off, removed \(ids.count) session(s) from iCloud")
|
||||
} catch let error as CKError where error.code == .zoneNotFound || error.code == .userDeletedZone {
|
||||
log("publisher off, nothing in iCloud")
|
||||
} catch {
|
||||
log("publisher off, cleanup failed: \(error.localizedDescription)")
|
||||
}
|
||||
published = [:]
|
||||
cleanedUp = false
|
||||
}
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard cancellable == nil else { return }
|
||||
let state = AppState.shared
|
||||
cancellable = state.$tasks
|
||||
.combineLatest(state.$pendingApproval, state.$pendingQuestion)
|
||||
.map { tasks, approval, question in
|
||||
SessionSnapshot.all(tasks: tasks, approval: approval, question: question)
|
||||
}
|
||||
.removeDuplicates()
|
||||
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
|
||||
.sink { [weak self] snapshots in
|
||||
MainActor.assumeIsolated { self?.publish(snapshots) }
|
||||
}
|
||||
log("session publisher on")
|
||||
}
|
||||
|
||||
// MARK: Publishing
|
||||
|
||||
private func publish(_ snapshots: [String: SessionSnapshot]) {
|
||||
guard !publishing else { pending = snapshots; return }
|
||||
publishing = true
|
||||
Task {
|
||||
await write(snapshots)
|
||||
publishing = false
|
||||
if let next = pending {
|
||||
pending = nil
|
||||
publish(next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func write(_ snapshots: [String: SessionSnapshot]) async {
|
||||
do {
|
||||
if !zoneReady {
|
||||
_ = try await database.modifyRecordZones(saving: [CKRecordZone(zoneID: SessionSnapshot.zoneID)], deleting: [])
|
||||
zoneReady = true
|
||||
}
|
||||
if !cleanedUp {
|
||||
// Sessions left by a previous run (the Mac quit or crashed).
|
||||
published = try await existingSessions()
|
||||
cleanedUp = true
|
||||
}
|
||||
|
||||
let changed = snapshots.values.filter { published[$0.pillId] != $0 }
|
||||
let removed = published.keys.filter { snapshots[$0] == nil }
|
||||
guard !changed.isEmpty || !removed.isEmpty else { return }
|
||||
|
||||
let records = changed.map { $0.record() }
|
||||
let deletions = removed.map { SessionSnapshot.recordID(for: $0) }
|
||||
let result = try await database.modifyRecords(saving: records, deleting: deletions,
|
||||
savePolicy: .changedKeys, atomically: false)
|
||||
for (id, outcome) in result.saveResults {
|
||||
guard let pillId = SessionSnapshot.pillId(from: id) else { continue }
|
||||
switch outcome {
|
||||
case .success: published[pillId] = snapshots[pillId]
|
||||
case .failure(let error): log("save \(pillId) failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
for (id, outcome) in result.deleteResults {
|
||||
guard let pillId = SessionSnapshot.pillId(from: id) else { continue }
|
||||
switch outcome {
|
||||
case .success: published[pillId] = nil
|
||||
case .failure(let error): log("delete \(pillId) failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
let summary = changed.map { "\($0.pillId)=\($0.state)" }.sorted().joined(separator: ", ")
|
||||
log("published \(changed.count) session(s)\(summary.isEmpty ? "" : " [\(summary)]"), removed \(removed.count)")
|
||||
} catch let error as CKError where error.code == .zoneNotFound || error.code == .userDeletedZone {
|
||||
zoneReady = false
|
||||
log("zone missing, will recreate on the next change")
|
||||
} catch {
|
||||
log("publish failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Session records already in the zone, so the first write can delete stale ones.
|
||||
private func existingSessions() async throws -> [String: SessionSnapshot] {
|
||||
var found: [String: SessionSnapshot] = [:]
|
||||
var token: CKServerChangeToken?
|
||||
var more = true
|
||||
while more {
|
||||
let changes = try await database.recordZoneChanges(inZoneWith: CloudProbe.zoneID, since: token)
|
||||
for (id, result) in changes.modificationResultsByID {
|
||||
guard case .success(let mod) = result, mod.record.recordType == SessionSnapshot.recordType,
|
||||
let pillId = SessionSnapshot.pillId(from: id) else { continue }
|
||||
// Unknown content: a placeholder that never equals a real snapshot,
|
||||
// so it is rewritten if the session still exists, deleted otherwise.
|
||||
found[pillId] = SessionSnapshot.placeholder(pillId: pillId)
|
||||
}
|
||||
token = changes.changeToken
|
||||
more = changes.moreComing
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
private func log(_ message: String) {
|
||||
CloudProbe.shared.log("[sessions] \(message)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Snapshot
|
||||
|
||||
/// The part of an AgentTask the iPhone needs. Equatable so unchanged sessions
|
||||
/// are not rewritten.
|
||||
struct SessionSnapshot: Equatable {
|
||||
static let recordType = "Session"
|
||||
|
||||
let pillId: String
|
||||
let name: String
|
||||
let color: String
|
||||
let state: String
|
||||
let stepIndex: Int
|
||||
let steps: [String]
|
||||
let cwd: String
|
||||
let finalLine: String
|
||||
let approvalTool: String
|
||||
let approvalCommand: String
|
||||
let approvalFingerprint: String
|
||||
let question: String
|
||||
/// The question's choices (QuestionPayload JSON) and its fingerprint, for answering from the iPhone.
|
||||
var questionPayload: String = ""
|
||||
var questionFingerprint: String = ""
|
||||
|
||||
static func all(tasks: [AgentTask], approval: ApprovalInfo?,
|
||||
question: AskQuestion?) -> [String: SessionSnapshot] {
|
||||
var result: [String: SessionSnapshot] = [:]
|
||||
// Services (Stripe, GitHub…) go through ServicePublisher, with their data.
|
||||
for task in tasks where task.source != .n8n && PillCatalog.isSession(task.id) {
|
||||
let hasApproval = approval?.pillId == task.id
|
||||
let questionText = task.state == .question
|
||||
? (question?.questions.map(\.question).joined(separator: "\n") ?? "")
|
||||
: ""
|
||||
let payload = task.state == .question ? question.map(QuestionPayload.init(ask:)) : nil
|
||||
result[task.id] = SessionSnapshot(
|
||||
pillId: task.id,
|
||||
name: task.name,
|
||||
color: task.color,
|
||||
state: task.state.rawValue,
|
||||
stepIndex: task.stepIndex,
|
||||
steps: task.steps,
|
||||
cwd: task.sessionCwd ?? "",
|
||||
finalLine: task.finalLine ?? "",
|
||||
approvalTool: hasApproval ? (approval?.tool ?? "") : "",
|
||||
approvalCommand: hasApproval ? (approval?.command ?? "") : "",
|
||||
approvalFingerprint: hasApproval ? (approval.map(ApprovalRelay.fingerprint) ?? "") : "",
|
||||
question: questionText,
|
||||
questionPayload: payload?.json ?? "",
|
||||
questionFingerprint: payload?.fingerprint ?? "")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
static func placeholder(pillId: String) -> SessionSnapshot {
|
||||
SessionSnapshot(pillId: pillId, name: "", color: "", state: "", stepIndex: -1, steps: [],
|
||||
cwd: "", finalLine: "", approvalTool: "", approvalCommand: "", approvalFingerprint: "", question: "")
|
||||
}
|
||||
|
||||
/// Same zone as CloudProbe.zoneID, rebuilt here because that one is main-actor isolated.
|
||||
static var zoneID: CKRecordZone.ID {
|
||||
CKRecordZone.ID(zoneName: "Coucou", ownerName: CKCurrentUserDefaultName)
|
||||
}
|
||||
|
||||
static func recordID(for pillId: String) -> CKRecord.ID {
|
||||
CKRecord.ID(recordName: "session-\(pillId)", zoneID: zoneID)
|
||||
}
|
||||
|
||||
static func pillId(from id: CKRecord.ID) -> String? {
|
||||
let name = id.recordName
|
||||
guard name.hasPrefix("session-") else { return nil }
|
||||
return String(name.dropFirst("session-".count))
|
||||
}
|
||||
|
||||
func record() -> CKRecord {
|
||||
let record = CKRecord(recordType: Self.recordType, recordID: Self.recordID(for: pillId))
|
||||
record["pillId"] = pillId
|
||||
record["color"] = color
|
||||
record["state"] = state
|
||||
record["stepIndex"] = stepIndex
|
||||
record["stepCount"] = steps.count
|
||||
record["needsApproval"] = !approvalCommand.isEmpty || !approvalTool.isEmpty
|
||||
// Identifies the exact request; the iPhone sends it back with its decision.
|
||||
record["approvalFingerprint"] = approvalFingerprint
|
||||
record["needsAnswer"] = !question.isEmpty
|
||||
record["questionFingerprint"] = questionFingerprint
|
||||
record["updatedAt"] = Date()
|
||||
record["macName"] = Host.current().localizedName ?? ""
|
||||
// Whether this Mac runs instructions sent from the iPhone (GitHub build, switch on).
|
||||
#if APPSTORE
|
||||
record["acceptsInstructions"] = false
|
||||
#else
|
||||
record["acceptsInstructions"] = InstructionRunner.isEnabled && (pillId == "integration_claude" || pillId == "agent_cursor")
|
||||
#endif
|
||||
record.encryptedValues["name"] = name
|
||||
record.encryptedValues["steps"] = steps
|
||||
record.encryptedValues["cwd"] = cwd
|
||||
record.encryptedValues["finalLine"] = finalLine
|
||||
record.encryptedValues["approvalTool"] = approvalTool
|
||||
record.encryptedValues["approvalCommand"] = approvalCommand
|
||||
record.encryptedValues["question"] = question
|
||||
record.encryptedValues["questionPayload"] = questionPayload
|
||||
return record
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,217 @@
|
||||
#if PHONE_LINK
|
||||
import AppKit
|
||||
import CloudKit
|
||||
|
||||
// MARK: - The last turn, for the iPhone
|
||||
//
|
||||
// Built from the hook events HookServer already receives: UserPromptSubmit
|
||||
// starts a turn, Pre/PostToolUse add actions (commands with their output,
|
||||
// edits with their diff), Stop adds the final answer. One encrypted `Turn`
|
||||
// record per session pill, replaced at each turn; nothing else is kept.
|
||||
// Runs only while the iPhone sync is on; no timer.
|
||||
|
||||
@MainActor
|
||||
final class TurnRecorder {
|
||||
static let shared = TurnRecorder()
|
||||
|
||||
private let container = CKContainer(identifier: CloudProbe.containerID)
|
||||
private var database: CKDatabase { container.privateCloudDatabase }
|
||||
private var running = false
|
||||
private var turns: [String: TurnSnapshot] = [:]
|
||||
private var dirty: Set<String> = []
|
||||
private var flushTask: Task<Void, Never>?
|
||||
|
||||
// Size limits: a turn stays well under CloudKit's 1 MB per record.
|
||||
private let maxActions = 200
|
||||
private let maxLinesPerFile = 600
|
||||
private let maxTotalLines = 4000
|
||||
private let maxOutput = 1500
|
||||
private let maxFinal = 12_000
|
||||
|
||||
/// The Claude Code session last seen on a pill and its folder, for
|
||||
/// instructions sent from the iPhone (InstructionRunner).
|
||||
func lastSession(for pillId: String) -> (sessionId: String, cwd: String)? {
|
||||
guard let info = sessions[pillId], !info.sessionId.isEmpty, !info.cwd.isEmpty else { return nil }
|
||||
return info
|
||||
}
|
||||
private var sessions: [String: (sessionId: String, cwd: String)] = [:]
|
||||
|
||||
func start() {
|
||||
running = true
|
||||
log("turn recorder on")
|
||||
}
|
||||
|
||||
func stop() {
|
||||
running = false
|
||||
flushTask?.cancel(); flushTask = nil
|
||||
let ids = turns.keys.map {
|
||||
CKRecord.ID(recordName: TurnSnapshot.recordName(for: $0), zoneID: SessionSnapshot.zoneID)
|
||||
}
|
||||
turns = [:]
|
||||
dirty = []
|
||||
if !ids.isEmpty {
|
||||
Task { _ = try? await database.modifyRecords(saving: [], deleting: ids, savePolicy: .changedKeys, atomically: false) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Called by HookServer for every event of a session pill.
|
||||
func record(event: String, payload: [String: Any], pillId: String) {
|
||||
guard running, PillCatalog.isSession(pillId) else { return }
|
||||
let sessionId = payload["session_id"] as? String ?? payload["conversation_id"] as? String ?? ""
|
||||
let cwd = payload["cwd"] as? String ?? ""
|
||||
let now = Date()
|
||||
if !sessionId.isEmpty, !cwd.isEmpty { sessions[pillId] = (sessionId, cwd) }
|
||||
|
||||
switch event {
|
||||
case "UserPromptSubmit":
|
||||
let prompt = payload["prompt"] as? String ?? ""
|
||||
turns[pillId] = TurnSnapshot(pillId: pillId, sessionId: sessionId,
|
||||
project: URL(fileURLWithPath: cwd).lastPathComponent,
|
||||
prompt: String(prompt.prefix(8000)), actions: [], files: [],
|
||||
finalMessage: "", startedAt: now, endedAt: nil)
|
||||
|
||||
case "PreToolUse":
|
||||
let tool = payload["tool_name"] as? String ?? "Tool"
|
||||
guard tool != "AskUserQuestion" else { return }
|
||||
var turn = current(pillId, sessionId: sessionId, cwd: cwd)
|
||||
guard turn.actions.count < maxActions else { return }
|
||||
let input = payload["tool_input"] as? [String: Any] ?? [:]
|
||||
turn.actions.append(TurnAction(tool: tool, summary: Self.summary(tool: tool, input: input), date: now))
|
||||
turns[pillId] = turn
|
||||
|
||||
case "PostToolUse", "PostToolUseFailure":
|
||||
let tool = payload["tool_name"] as? String ?? "Tool"
|
||||
guard tool != "AskUserQuestion" else { return }
|
||||
var turn = current(pillId, sessionId: sessionId, cwd: cwd)
|
||||
let input = payload["tool_input"] as? [String: Any] ?? [:]
|
||||
let summary = Self.summary(tool: tool, input: input)
|
||||
// The matching PreToolUse action, latest first; one is added if it was missed.
|
||||
var index = turn.actions.lastIndex { $0.tool == tool && $0.summary == summary && $0.output.isEmpty && $0.fileIndex == nil }
|
||||
if index == nil, turn.actions.count < maxActions {
|
||||
turn.actions.append(TurnAction(tool: tool, summary: summary, date: now))
|
||||
index = turn.actions.count - 1
|
||||
}
|
||||
guard let index else { return }
|
||||
turn.actions[index].failed = event == "PostToolUseFailure"
|
||||
turn.actions[index].output = Self.output(payload, limit: maxOutput)
|
||||
if event == "PostToolUse", let diff = Self.fileDiff(tool: tool, input: input) {
|
||||
let used = turn.files.reduce(0) { $0 + $1.lines.count }
|
||||
let budget = max(0, min(maxLinesPerFile, maxTotalLines - used))
|
||||
turn.files.append(TurnFile(diff: diff, maxLines: budget))
|
||||
turn.actions[index].fileIndex = turn.files.count - 1
|
||||
}
|
||||
turns[pillId] = turn
|
||||
|
||||
case "Stop", "StopFailure":
|
||||
var turn = current(pillId, sessionId: sessionId, cwd: cwd)
|
||||
let final = (payload["last_assistant_message"] as? String) ?? (payload["message"] as? String) ?? ""
|
||||
turn.finalMessage = String(final.prefix(maxFinal))
|
||||
turn.endedAt = now
|
||||
turns[pillId] = turn
|
||||
|
||||
default:
|
||||
return
|
||||
}
|
||||
dirty.insert(pillId)
|
||||
scheduleFlush(soon: event == "Stop" || event == "StopFailure" || event == "UserPromptSubmit")
|
||||
}
|
||||
|
||||
/// The turn in progress, or a new one when the prompt wasn't seen (Codex, app started mid-turn).
|
||||
private func current(_ pillId: String, sessionId: String, cwd: String) -> TurnSnapshot {
|
||||
if let turn = turns[pillId], turn.endedAt == nil { return turn }
|
||||
return TurnSnapshot(pillId: pillId, sessionId: sessionId,
|
||||
project: URL(fileURLWithPath: cwd).lastPathComponent,
|
||||
prompt: "", actions: [], files: [], finalMessage: "", startedAt: Date(), endedAt: nil)
|
||||
}
|
||||
|
||||
// MARK: Writing
|
||||
|
||||
private func scheduleFlush(soon: Bool) {
|
||||
if soon { flushTask?.cancel(); flushTask = nil }
|
||||
guard flushTask == nil else { return }
|
||||
flushTask = Task {
|
||||
try? await Task.sleep(for: .seconds(soon ? 0.5 : 2))
|
||||
guard !Task.isCancelled else { return }
|
||||
flushTask = nil
|
||||
await flush()
|
||||
}
|
||||
}
|
||||
|
||||
private func flush() async {
|
||||
let ids = dirty
|
||||
dirty = []
|
||||
let records = ids.compactMap { id -> CKRecord? in
|
||||
guard let turn = turns[id], let json = try? JSONEncoder().encode(turn),
|
||||
let payload = String(data: json, encoding: .utf8) else { return nil }
|
||||
let record = CKRecord(recordType: TurnSnapshot.recordType,
|
||||
recordID: CKRecord.ID(recordName: TurnSnapshot.recordName(for: id),
|
||||
zoneID: SessionSnapshot.zoneID))
|
||||
record["pillId"] = id
|
||||
record["updatedAt"] = Date()
|
||||
record.encryptedValues["payload"] = payload
|
||||
return record
|
||||
}
|
||||
guard !records.isEmpty else { return }
|
||||
do {
|
||||
_ = try await database.modifyRecords(saving: records, deleting: [], savePolicy: .allKeys, atomically: false)
|
||||
} catch {
|
||||
log("turn publish failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Reading the hook payloads
|
||||
|
||||
static func summary(tool: String, input: [String: Any]) -> String {
|
||||
let keys = ["command", "file_path", "path", "pattern", "url", "query", "description", "prompt"]
|
||||
for key in keys {
|
||||
if let value = input[key] as? String, !value.isEmpty { return String(value.prefix(600)) }
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/// What a command printed, or the error.
|
||||
static func output(_ payload: [String: Any], limit: Int) -> String {
|
||||
var text = ""
|
||||
if let error = payload["error"] as? String {
|
||||
text = error
|
||||
} else if let response = payload["tool_response"] as? [String: Any] {
|
||||
let stdout = response["stdout"] as? String ?? ""
|
||||
let stderr = response["stderr"] as? String ?? ""
|
||||
text = [stdout, stderr].filter { !$0.isEmpty }.joined(separator: "\n")
|
||||
} else if let response = payload["tool_response"] as? String {
|
||||
text = response
|
||||
}
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.count > limit ? String(trimmed.prefix(limit)) + "\n…" : trimmed
|
||||
}
|
||||
|
||||
static func fileDiff(tool: String, input: [String: Any]) -> FileDiff? {
|
||||
guard let path = input["file_path"] as? String else { return nil }
|
||||
switch tool {
|
||||
case "Edit":
|
||||
guard let old = input["old_string"] as? String, let new = input["new_string"] as? String else { return nil }
|
||||
return DiffEngine.fromEdit(old: old, new: new, path: path)
|
||||
case "MultiEdit":
|
||||
guard let edits = input["edits"] as? [[String: Any]] else { return nil }
|
||||
var added = 0, removed = 0, hunks: [DiffHunk] = [], tooLarge = false
|
||||
for edit in edits {
|
||||
guard let old = edit["old_string"] as? String, let new = edit["new_string"] as? String else { continue }
|
||||
let d = DiffEngine.fromEdit(old: old, new: new, path: path)
|
||||
added += d.added; removed += d.removed
|
||||
hunks += d.hunks
|
||||
tooLarge = tooLarge || d.tooLarge
|
||||
}
|
||||
return FileDiff(path: path, added: added, removed: removed, hunks: hunks, tooLarge: tooLarge, isNewFile: false)
|
||||
case "Write":
|
||||
guard let content = input["content"] as? String else { return nil }
|
||||
return DiffEngine.fromNew(content: content, path: path)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func log(_ message: String) {
|
||||
CloudProbe.shared.log("[turn] \(message)")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,62 @@
|
||||
import Foundation
|
||||
|
||||
final class ResendPoller: @unchecked Sendable {
|
||||
static let shared = ResendPoller()
|
||||
private var timer: DispatchSourceTimer?
|
||||
private init() {}
|
||||
|
||||
func start() {
|
||||
guard timer == nil else { return }
|
||||
let t = DispatchSource.makeTimerSource(queue: .global(qos: .background))
|
||||
t.schedule(deadline: .now() + 6, repeating: 60)
|
||||
t.setEventHandler { [weak self] in self?.poll() }
|
||||
t.resume()
|
||||
timer = t
|
||||
}
|
||||
|
||||
private func poll() {
|
||||
guard let apiKey = KeychainStore.shared.get("resend-api-key") else { return }
|
||||
guard let url = URL(string: "https://api.resend.com/emails?limit=100") else { return }
|
||||
var req = URLRequest(url: url, timeoutInterval: 10)
|
||||
req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
URLSession.shared.dataTask(with: req) { [weak self] data, response, _ in
|
||||
guard let self else { return }
|
||||
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
guard let data, code == 200 else { return }
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let rawList = json["data"] as? [[String: Any]] else { return }
|
||||
|
||||
let total = (json["total"] as? Int) ?? (json["count"] as? Int)
|
||||
let emails = rawList.compactMap { self.parseEmail($0) }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
AppState.shared.resendEmails = Array(emails.prefix(5))
|
||||
AppState.shared.resendTotal = total ?? (emails.isEmpty ? nil : emails.count)
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
private func parseEmail(_ d: [String: Any]) -> ResendEmail? {
|
||||
guard let id = d["id"] as? String,
|
||||
let createdAt = d["created_at"] as? String else { return nil }
|
||||
|
||||
let to: [String]
|
||||
if let arr = d["to"] as? [String] { to = arr }
|
||||
else if let single = d["to"] as? String { to = [single] }
|
||||
else { to = [] }
|
||||
|
||||
let subject = (d["subject"] as? String) ?? ""
|
||||
let lastEvent = (d["last_event"] as? String) ?? ""
|
||||
|
||||
// Parse ISO8601 date
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
let date = formatter.date(from: createdAt)
|
||||
?? ISO8601DateFormatter().date(from: createdAt)
|
||||
?? Date()
|
||||
|
||||
return ResendEmail(id: id, to: to, subject: subject, createdAt: date, lastEvent: lastEvent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
/// Returns the URL only if it is a plain web link (http/https with a host). Model output
|
||||
/// and API data can carry file://, smb:// or custom app schemes that would launch local apps
|
||||
/// or deep links; those never reach NSWorkspace.open.
|
||||
func safeWebURL(_ string: String?) -> URL? {
|
||||
guard let string,
|
||||
let url = URL(string: string.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||
let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http",
|
||||
let host = url.host, !host.isEmpty else { return nil }
|
||||
return url
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Shortcut Action
|
||||
|
||||
/// Every keyboard shortcut Coucou knows about.
|
||||
/// The first 10 cases are *global* (registered with Carbon and effective from any app).
|
||||
/// Island-local shortcuts are documented in `ShortcutLogic.islandShortcuts` but are
|
||||
/// not managed here — they are handled by a local NSEvent monitor in IslandWindowController.
|
||||
enum ShortcutAction: String, CaseIterable, Sendable {
|
||||
// MARK: Global (Carbon RegisterEventHotKey)
|
||||
case toggleIsland = "toggleIsland" // ⌘⇧N — open / close island (toggle)
|
||||
case openChat = "openChat" // ⌃⌥Space — open island on chat, focus field
|
||||
case goToAlert = "goToAlert" // ⌃⌥A — jump to pending alert
|
||||
case jumpToTerminal = "jumpToTerminal" // ⌃⌥T — focus agent terminal
|
||||
case attachFrontWindow = "attachFrontWindow" // ⌃⌥W — attach front window (non-AppStore)
|
||||
case nextPill = "nextPill" // ⌃⌥] — next pill, open if closed
|
||||
case prevPill = "prevPill" // ⌃⌥[ — previous pill, open if closed
|
||||
case muteToggle = "muteToggle" // ⌃⌥M — mute / unmute sounds
|
||||
case desktopToggle = "desktopToggle" // ⌃⌥D — send Mochi to desktop / bring back
|
||||
case wardrobeToggle = "wardrobeToggle" // ⌃⌥G — open / close wardrobe
|
||||
|
||||
// MARK: UserDefaults keys
|
||||
|
||||
/// Key code storage key. `toggleIsland` reuses the legacy "hotkeyCode" key.
|
||||
var udKeyCode: String {
|
||||
self == .toggleIsland ? "hotkeyCode" : "shortcut.\(rawValue).keyCode"
|
||||
}
|
||||
/// Modifier flags storage key. `toggleIsland` reuses the legacy "hotkeyFlags" key.
|
||||
var udFlags: String {
|
||||
self == .toggleIsland ? "hotkeyFlags" : "shortcut.\(rawValue).flags"
|
||||
}
|
||||
/// Enabled flag storage key. `toggleIsland` reuses the legacy "hotkeyEnabled" key.
|
||||
var udEnabled: String {
|
||||
self == .toggleIsland ? "hotkeyEnabled" : "shortcut.\(rawValue).enabled"
|
||||
}
|
||||
|
||||
// MARK: Metadata
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .toggleIsland: return "Open / close island"
|
||||
case .openChat: return "Open chat"
|
||||
case .goToAlert: return "Go to alert"
|
||||
case .jumpToTerminal: return "Jump to terminal"
|
||||
case .attachFrontWindow: return "Attach front window"
|
||||
case .nextPill: return "Next pill"
|
||||
case .prevPill: return "Previous pill"
|
||||
case .muteToggle: return "Mute / unmute sounds"
|
||||
case .desktopToggle: return "Mochi on / off desktop"
|
||||
case .wardrobeToggle: return "Open / close wardrobe"
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this action should be omitted from App Store builds.
|
||||
var isAppStoreOnly: Bool { false }
|
||||
var isNonAppStore: Bool { self == .attachFrontWindow }
|
||||
|
||||
/// Whether the shortcut is enabled by default (all new global shortcuts are on by default;
|
||||
/// `toggleIsland` is off by default to match the pre-existing behaviour).
|
||||
var enabledByDefault: Bool { self != .toggleIsland }
|
||||
}
|
||||
|
||||
// MARK: - Shortcut Spec
|
||||
|
||||
/// A key + modifier combination stored as plain values (no AppKit dependency).
|
||||
struct ShortcutSpec: Equatable, Hashable, Sendable {
|
||||
/// Virtual key code (matching kVK_* constants and NSEvent.keyCode).
|
||||
var keyCode: UInt16
|
||||
/// NSEvent.ModifierFlags raw value — only the modifier bits (.command / .shift / .option / .control).
|
||||
/// Stored and compared as a plain UInt so this file stays AppKit-free.
|
||||
var nsFlags: UInt
|
||||
|
||||
// MARK: NSEvent modifier raw values (stable since macOS 10.x; see AppKit/NSEvent.h)
|
||||
static let cmdBit: UInt = 1 << 20 // NSEvent.ModifierFlags.command = 1048576
|
||||
static let shiftBit: UInt = 1 << 17 // NSEvent.ModifierFlags.shift = 131072
|
||||
static let optBit: UInt = 1 << 19 // NSEvent.ModifierFlags.option = 524288
|
||||
static let ctrlBit: UInt = 1 << 18 // NSEvent.ModifierFlags.control = 262144
|
||||
static let modMask: UInt = cmdBit | shiftBit | optBit | ctrlBit
|
||||
|
||||
/// True when nsFlags contains at least one of ⌘ ⇧ ⌥ ⌃.
|
||||
var hasModifier: Bool { nsFlags & ShortcutSpec.modMask != 0 }
|
||||
|
||||
// MARK: Convenience values for the default table
|
||||
|
||||
static let cmdShift: UInt = cmdBit | shiftBit // 1179648
|
||||
static let ctrlOpt: UInt = ctrlBit | optBit // 786432
|
||||
}
|
||||
|
||||
// MARK: - Pure logic
|
||||
|
||||
/// Stateless helpers for shortcut management — no AppKit, fully unit-testable standalone.
|
||||
enum ShortcutLogic {
|
||||
|
||||
// MARK: - Default table
|
||||
|
||||
static let defaults: [ShortcutAction: ShortcutSpec] = [
|
||||
.toggleIsland: ShortcutSpec(keyCode: 45, nsFlags: ShortcutSpec.cmdShift), // ⌘⇧N
|
||||
.openChat: ShortcutSpec(keyCode: 49, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥Space
|
||||
.goToAlert: ShortcutSpec(keyCode: 0, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥A
|
||||
.jumpToTerminal: ShortcutSpec(keyCode: 17, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥T
|
||||
.attachFrontWindow: ShortcutSpec(keyCode: 13, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥W
|
||||
.nextPill: ShortcutSpec(keyCode: 30, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥]
|
||||
.prevPill: ShortcutSpec(keyCode: 33, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥[
|
||||
.muteToggle: ShortcutSpec(keyCode: 46, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥M
|
||||
.desktopToggle: ShortcutSpec(keyCode: 2, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥D
|
||||
.wardrobeToggle: ShortcutSpec(keyCode: 5, nsFlags: ShortcutSpec.ctrlOpt), // ⌃⌥G
|
||||
]
|
||||
|
||||
// MARK: - Load / save (UserDefaults)
|
||||
|
||||
/// Current spec for `action`, falling back to the default if not stored.
|
||||
static func hotKey(for action: ShortcutAction) -> ShortcutSpec {
|
||||
let ud = UserDefaults.standard
|
||||
let def = defaults[action]!
|
||||
let kc = ud.object(forKey: action.udKeyCode) as? Int ?? Int(def.keyCode)
|
||||
let fl = ud.object(forKey: action.udFlags) as? Int ?? Int(def.nsFlags)
|
||||
return ShortcutSpec(keyCode: UInt16(kc), nsFlags: UInt(fl))
|
||||
}
|
||||
|
||||
static func save(_ spec: ShortcutSpec, for action: ShortcutAction) {
|
||||
UserDefaults.standard.set(Int(spec.keyCode), forKey: action.udKeyCode)
|
||||
UserDefaults.standard.set(Int(spec.nsFlags), forKey: action.udFlags)
|
||||
}
|
||||
|
||||
static func isEnabled(_ action: ShortcutAction) -> Bool {
|
||||
UserDefaults.standard.object(forKey: action.udEnabled) as? Bool
|
||||
?? action.enabledByDefault
|
||||
}
|
||||
|
||||
static func setEnabled(_ enabled: Bool, for action: ShortcutAction) {
|
||||
UserDefaults.standard.set(enabled, forKey: action.udEnabled)
|
||||
}
|
||||
|
||||
static func resetToDefault(_ action: ShortcutAction) {
|
||||
let def = defaults[action]!
|
||||
save(def, for: action)
|
||||
setEnabled(action.enabledByDefault, for: action)
|
||||
}
|
||||
|
||||
// MARK: - Carbon modifier conversion
|
||||
|
||||
// Carbon modifier bit flags (CarbonCore/Events.h) — no import needed, just constants.
|
||||
private static let cCmdKey: UInt32 = 1 << 8 // cmdKey = 256
|
||||
private static let cShiftKey: UInt32 = 1 << 9 // shiftKey = 512
|
||||
private static let cOptKey: UInt32 = 1 << 11 // optionKey = 2048
|
||||
private static let cCtrlKey: UInt32 = 1 << 12 // controlKey = 4096
|
||||
|
||||
/// Convert NSEvent modifier flags (as raw UInt) to Carbon modifier bit mask.
|
||||
static func carbonModifiers(fromNS nsFlags: UInt) -> UInt32 {
|
||||
let cmd = ShortcutSpec.cmdBit
|
||||
let shift = ShortcutSpec.shiftBit
|
||||
let opt = ShortcutSpec.optBit
|
||||
let ctrl = ShortcutSpec.ctrlBit
|
||||
var c: UInt32 = 0
|
||||
if nsFlags & cmd != 0 { c |= cCmdKey }
|
||||
if nsFlags & shift != 0 { c |= cShiftKey }
|
||||
if nsFlags & opt != 0 { c |= cOptKey }
|
||||
if nsFlags & ctrl != 0 { c |= cCtrlKey }
|
||||
return c
|
||||
}
|
||||
|
||||
// MARK: - Conflict detection (within Coucou's own shortcuts)
|
||||
|
||||
/// Returns the set of actions whose (keyCode, nsFlags) pair is shared with another action.
|
||||
static func duplicates(in specs: [ShortcutAction: ShortcutSpec]) -> Set<ShortcutAction> {
|
||||
var seen: [ShortcutSpec: ShortcutAction] = [:]
|
||||
var dups: Set<ShortcutAction> = []
|
||||
for (action, spec) in specs {
|
||||
if let other = seen[spec] {
|
||||
dups.insert(action)
|
||||
dups.insert(other)
|
||||
} else {
|
||||
seen[spec] = action
|
||||
}
|
||||
}
|
||||
return dups
|
||||
}
|
||||
|
||||
// MARK: - Display string
|
||||
|
||||
/// Human-readable modifier+key string, e.g. "⌃⌥A" or "⌘⇧N".
|
||||
static func displayString(for spec: ShortcutSpec) -> String {
|
||||
let f = spec.nsFlags
|
||||
var s = ""
|
||||
if f & ShortcutSpec.ctrlBit != 0 { s += "⌃" }
|
||||
if f & ShortcutSpec.optBit != 0 { s += "⌥" }
|
||||
if f & ShortcutSpec.shiftBit != 0 { s += "⇧" }
|
||||
if f & ShortcutSpec.cmdBit != 0 { s += "⌘" }
|
||||
s += keyCodeToString(spec.keyCode)
|
||||
return s
|
||||
}
|
||||
|
||||
/// Printable name for the most common virtual key codes.
|
||||
static func keyCodeToString(_ code: UInt16) -> String {
|
||||
let map: [UInt16: String] = [
|
||||
0:"A", 1:"S", 2:"D", 3:"F", 4:"H", 5:"G", 6:"Z", 7:"X",
|
||||
8:"C", 9:"V", 11:"B", 12:"Q", 13:"W", 14:"E", 15:"R", 16:"Y",
|
||||
17:"T", 31:"O", 32:"U", 33:"[", 34:"I", 35:"P", 37:"L", 38:"J",
|
||||
40:"K", 45:"N", 46:"M", 30:"]", 49:"Space",
|
||||
36:"↩", 51:"⌫", 53:"⎋",
|
||||
123:"←", 124:"→", 125:"↓", 126:"↑",
|
||||
18:"1", 19:"2", 20:"3", 21:"4", 23:"5", 22:"6", 26:"7", 28:"8", 25:"9",
|
||||
]
|
||||
return map[code] ?? "·"
|
||||
}
|
||||
|
||||
// MARK: - Card navigation (pure, no AppKit dependency)
|
||||
|
||||
/// Compute the next card-selection index.
|
||||
/// - Returns `nil` when `itemCount == 0`.
|
||||
/// - Clamps to `0 ..< itemCount`.
|
||||
/// - `nil` selection + `delta > 0` → first item; `delta < 0` → last item.
|
||||
static func navigate(selection: Int?, delta: Int, itemCount: Int) -> Int? {
|
||||
guard itemCount > 0 else { return nil }
|
||||
if let cur = selection {
|
||||
return max(0, min(itemCount - 1, cur + delta))
|
||||
}
|
||||
return delta > 0 ? 0 : itemCount - 1
|
||||
}
|
||||
|
||||
// MARK: - Island-local shortcut table (read-only, shown in Settings)
|
||||
|
||||
/// Descriptive table of island-local shortcuts for the Settings view.
|
||||
static let islandShortcuts: [(key: String, description: String)] = [
|
||||
("⌘→ / ⌘←", "Next / previous pill"),
|
||||
("⌘1 – ⌘9", "Switch to pill by number"),
|
||||
("⌘↓ / ⌘↑", "Navigate list items"),
|
||||
("⌘O", "Open selected item"),
|
||||
("⌘E", "Open / close current diff"),
|
||||
("⌘↩", "Send chat message"),
|
||||
("⌘K", "New conversation"),
|
||||
("⌘,", "Open Settings"),
|
||||
("⌘P", "Pin / unpin island"),
|
||||
("⎋", "Close island (if not pinned)"),
|
||||
]
|
||||
}
|
||||