diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..c1afac6 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,34 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install bats-core + run: brew install bats-core + + - name: Run shell tests + run: bats tests/ + + - name: Install MCP server dependencies + working-directory: mcp-server + run: npm ci + + - name: Run MCP server tests + working-directory: mcp-server + run: npm test + + - name: Install shellcheck + run: brew install shellcheck + + - name: Run shellcheck + run: shellcheck -x *.sh diff --git a/.gitignore b/.gitignore index 95449da..dfda322 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ .DS_Store mcp-server/node_modules/ mcp-server/dist/ +helpers/get-window-id +helpers/ocr-image +*.xcuserstate +xcuserdata/ diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..65ad6ea --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,13 @@ +# Project SwiftLint config. +# This codebase is coordinate math: x, y, x1, y1, dx, dy, mx (margin-x) etc. +# are the idiomatic names, so the generic short-identifier rule is relaxed. +identifier_name: + min_length: + warning: 1 + error: 1 +line_length: + warning: 300 + error: 320 +cyclomatic_complexity: + warning: 15 + error: 20 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..de936e5 --- /dev/null +++ b/Makefile @@ -0,0 +1,37 @@ +# iphone-control - build & test targets +.PHONY: test test-shell test-mcp lint build build-helpers build-mcp + +# Run all tests +test: test-shell test-mcp + +# Run bats shell tests +test-shell: + @echo "=== Running shell tests (bats) ===" + bats tests/*.bats + +# Run MCP server tests +test-mcp: + @echo "=== Running MCP server tests ===" + cd mcp-server && npx vitest run + +# Lint all shell scripts with shellcheck +# helpers/common.sh is intentionally excluded as a standalone target: it is a +# sourced-only library, so checking it in isolation flags every constant as +# unused. -x pulls it into each script's analysis via source, where real usage +# is visible. +lint: + @echo "=== Linting shell scripts ===" + shellcheck -x *.sh + +# Build everything +build: build-helpers build-mcp + +# Compile Swift helpers +build-helpers: + @echo "=== Compiling Swift helpers ===" + swiftc -O helpers/get-window-id.swift -o helpers/get-window-id + +# Build MCP server TypeScript +build-mcp: + @echo "=== Building MCP server ===" + cd mcp-server && npm run build diff --git a/README.md b/README.md index 8e9aa9e..cd8194b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > Control your iPhone from the Mac terminal using iPhone Mirroring. Built for AI agents. -macOS Sequoia introduced [iPhone Mirroring](https://support.apple.com/en-us/105112) — your iPhone screen rendered as a native Mac window. **iphone-control** turns that window into a programmable interface: find it, screenshot it, tap it, swipe it, type into it. All from bash. +macOS Sequoia introduced [iPhone Mirroring](https://support.apple.com/en-us/105112) - your iPhone screen rendered as a native Mac window. **iphone-control** turns that window into a programmable interface: find it, screenshot it, tap it, swipe it, type into it. All from bash. The killer use case? Hook it up to a multimodal AI (Claude, GPT-4V) and let the AI operate your phone. @@ -42,15 +42,16 @@ The killer use case? Hook it up to a multimodal AI (Claude, GPT-4V) and let the - **macOS 15** (Sequoia) or later - **iPhone Mirroring** open and connected -- [`cliclick`](https://github.com/BlueM/cliclick) — `brew install cliclick` -- **Terminal permissions**: Accessibility + Screen Recording (System Settings → Privacy & Security) +- **Terminal permissions**: Accessibility + Screen Recording (System Settings > Privacy & Security) +- **Xcode Command Line Tools** (`xcode-select --install`): the Swift helpers auto-compile on first run + +No third-party binaries needed: input is delivered natively via JXA/CoreGraphics. ## Install ```bash git clone https://github.com/wmehanna/iphone-control.git cd iphone-control -brew install cliclick ``` ## Usage @@ -58,7 +59,7 @@ brew install cliclick ```bash # Locate the iPhone Mirroring window ./iphone-control.sh find -# → {"id": 1234, "x": 100, "y": 200, "width": 393, "height": 852} +# → {"x":-1080,"y":-49,"width":410,"height":874,"content_x":-1070,"content_y":-40,"content_width":390,"content_height":844,"window_id":1234,"scale":2} # Capture a screenshot ./iphone-control.sh screenshot @@ -84,11 +85,21 @@ brew install cliclick | Command | Args | Description | |---------|------|-------------| -| `find` | — | Returns window position + size as JSON | -| `screenshot` | `[output_path]` | Saves iPhone screen to PNG (default: `/tmp/iphone-screen.png`) | +| `find` | - | Returns window + content bounds, `window_id` and `scale` as JSON | +| `screenshot` | `[--native] [output_path]` | Saves iPhone screen to PNG in point dimensions (default: `/tmp/iphone-screen.png`); `--native` keeps pixel resolution | | `tap` | ` ` | Tap at coordinates | | `swipe` | ` [ms]` | Swipe between two points | | `type` | `"text" [x y]` | Type text, optionally into a tapped field | +| `home` | - | Go to home screen (View menu) | +| `app-switcher` | - | Open app switcher (View menu) | +| `spotlight` | - | Open Spotlight search (View menu) | +| `open-app` | `"Name" [--spotlight]` | Open app by name (registry-first, Spotlight fallback) | +| `status` | - | Check iPhone Mirroring connection state | +| `map-apps` | `[max_pages]` | OCR scan of home screen pages (stdout only) | +| `registry-scan` | `[max_pages]` | Scan home screen and save to persistent registry | +| `registry-lookup` | `` | Look up app position from registry | +| `registry-invalidate` | - | Delete the registry file | +| `registry-list` | - | Show cached registry contents | ## Coordinates @@ -98,6 +109,20 @@ All coordinates are **relative to the iPhone screen**, not the Mac desktop. - Scripts automatically convert to absolute Mac screen position - Run `find` to see the window dimensions for your device +### Multi-display and Retina + +The iPhone Mirroring window can sit on **any display**, including ones left of or +above the primary (where global Mac coordinates are negative) and displays with a +different Retina scale. Everything is handled automatically: + +- Window lookup, taps and swipes use global display coordinates (sign-safe) +- The capture scale is measured from the actual window capture, so mixed 1x/2x + setups and windows straddling two displays resolve correctly +- Screenshots are normalized to **point dimensions**: 1 image pixel = 1 tap + coordinate, regardless of which display the window is on +- Moving the window between displays just works; the 30s window cache + invalidates on any move or resize + ## AI Agent Integration The scripts are designed to be called by an AI agent in a loop: @@ -113,9 +138,81 @@ The scripts are designed to be called by an AI agent in a loop: Works with any tool-using AI that supports image input. The `find` result is cached for 30 seconds, so rapid screenshot→tap→screenshot cycles are fast. +## App Registry + +The app registry scans your home screen once and caches every app's name, page, and tap coordinates. Subsequent `open-app` calls use the cached data to navigate directly - no Spotlight delay. + +### How it works + +``` +open-app "Gmail" + ├─ Registry lookup → found on page 2 + │ ├─ Dimensions match current window → navigate + tap (~0.5s) + │ └─ Dimensions mismatch → invalidate registry → Spotlight fallback + └─ Not found → Spotlight fallback (~1.5s) +``` + +### Quick start + +```bash +# 1. Scan home screen pages (writes ~/.iphone-control/app-registry.json) +./iphone-control.sh registry-scan + +# 2. Open an app (uses registry, falls back to Spotlight) +./iphone-control.sh open-app "Gmail" + +# 3. Force Spotlight (bypass registry) +./iphone-control.sh open-app "Gmail" --spotlight + +# 4. Look up an app's position +./iphone-control.sh registry-lookup "Settings" +# → {"name":"Settings","page":1,"x":100,"y":200,"content_width":402,"content_height":874} + +# 5. List all cached apps +./iphone-control.sh registry-list + +# 6. Force rescan (e.g. after rearranging apps) +./iphone-control.sh registry-invalidate +./iphone-control.sh registry-scan +``` + +### Registry file + +Stored at `~/.iphone-control/app-registry.json`: + +```json +{ + "version": 2, + "scanned_at": "2026-02-20T16:00:00Z", + "total_pages": 4, + "content_width": 402, + "content_height": 874, + "apps": [ + {"name": "Settings", "page": 1, "x": 100, "y": 200}, + {"name": "Gmail", "page": 2, "x": 300, "y": 400} + ] +} +``` + +### Invalidation + +| Trigger | Detection | Action | +|---------|-----------|--------| +| Window resized | `content_width`/`content_height` mismatch | Auto-invalidate, Spotlight fallback | +| Apps rearranged | Manual | Run `registry-scan` to overwrite | +| Registry missing | Lookup exits 1 | Spotlight fallback | +| Registry from old version | `version` != 2 (pre point-space coords) | Lookup fails, Spotlight fallback; rescan to upgrade | + +### Performance + +| Method | Page 1 app | Page 3 app | +|--------|-----------|-----------| +| Spotlight (current) | ~1.5s | ~1.5s | +| Registry | ~0.5s | ~1.1s | + ## MCP Server -An [MCP](https://modelcontextprotocol.io) server wraps the bash scripts so any MCP client (Claude Code, Claude Desktop, etc.) can call them as native tools — including returning screenshots as inline images. +An [MCP](https://modelcontextprotocol.io) server wraps the bash scripts so any MCP client (Claude Code, Claude Desktop, etc.) can call them as native tools - including returning screenshots as inline images. ### Setup @@ -143,13 +240,21 @@ Add to `.claude/settings.json`: | Tool | Params | Returns | |------|--------|---------| -| `find_window` | — | JSON `{id, x, y, width, height}` | -| `screenshot` | — | PNG image (inline, AI-visible) | +| `find_window` | - | JSON: window + content bounds, `window_id`, `scale` (global points, negative on secondary displays) | +| `screenshot` | - | PNG image (inline, AI-visible), point-normalized | | `tap` | `x, y` | Confirmation text | | `swipe` | `x1, y1, x2, y2, duration_ms?` | Confirmation text | | `type_text` | `text, x?, y?` | Confirmation text | +| `open_app` | `name, method?` | Opens app. `method`: `"auto"` (default, registry-first) or `"spotlight"` | +| `home` | - | Go to home screen | +| `app_switcher` | - | Open app switcher | +| `spotlight` | - | Open Spotlight search | +| `status` | - | Check iPhone Mirroring connection | +| `scan_apps` | `max_pages?` | Scans home screen, saves registry, returns full registry JSON | +| `registry_invalidate` | - | Deletes the cached registry, forcing a rescan | +| `list_apps` | - | Returns cached registry (no rescan) | -The `screenshot` tool returns the image directly as base64-encoded PNG content, so multimodal AI models can see and analyze the iPhone screen without any file path juggling. +The `screenshot` tool returns the image directly as base64-encoded PNG content, so multimodal AI models can see and analyze the iPhone screen without any file path juggling. The PNG is point-normalized: its pixel dimensions equal the tap/swipe coordinate space, so a model can tap exactly what it sees on any display or Retina scale. ## Permissions Setup @@ -163,7 +268,6 @@ Your terminal app needs two permissions. macOS will prompt on first use, or set | Problem | Solution | |---------|----------| | `iPhone Mirroring window not found` | Open the iPhone Mirroring app on your Mac | -| `cliclick not found` | `brew install cliclick` | | Taps land in the wrong spot | Run `find` to refresh window position (it may have moved) | | Screenshot is blank or fails | Grant Screen Recording permission to your terminal | | Clicks don't register | Grant Accessibility permission to your terminal | @@ -173,11 +277,14 @@ Your terminal app needs two permissions. macOS will prompt on first use, or set | Script | Mechanism | |--------|-----------| -| `find-window.sh` | JXA + `CGWindowListCopyWindowInfo` to find the "iPhone Mirroring" window | -| `screenshot.sh` | `screencapture -R x,y,w,h` to capture just that region | -| `tap.sh` | Translates relative→absolute coords, then `cliclick c:X,Y` | -| `swipe.sh` | `cliclick dd:` → 10 interpolated `m:` moves → `du:` for smooth gesture | -| `type-text.sh` | Optional `tap.sh` call, then `cliclick t:"text"` | +| `find-window.sh` | Swift helper (`CGWindowListCopyWindowInfo`) + window-ID capture + alpha scan for exact content bounds; measures the Retina scale from the capture itself | +| `screenshot.sh` | `screencapture -x -l ` (window ID, works on any display), crops to content, normalizes to point dimensions (`--native` keeps pixel resolution) | +| `tap.sh` | Translates relative to absolute coords, then native `CGEventPost` mouse down/up | +| `swipe.sh` | `CGEventPost` drag: mouse down, interpolated moves, mouse up | +| `type-text.sh` | Optional `tap.sh` call, then System Events `keystroke` via JXA | +| `open-app.sh` | Registry lookup, page navigation + tap, Spotlight fallback | +| `registry.sh` | Wraps `map-apps.sh`, persists results to `~/.iphone-control/app-registry.json` | +| `map-apps.sh` | Swipes through home pages, OCR via Swift helper (native res, converted to points), outputs JSON to stdout | ## License diff --git a/SKILL.md b/SKILL.md index 3f6d3bf..a1540e3 100644 --- a/SKILL.md +++ b/SKILL.md @@ -11,9 +11,9 @@ Control your iPhone through macOS iPhone Mirroring. The skill captures the mirro | Tool | Install | Purpose | |------|---------|---------| -| `cliclick` | `brew install cliclick` | Mouse clicks, drags, typing | -| `screencapture` | Built-in macOS | Window/region capture | -| `osascript` | Built-in macOS | Window discovery via JXA | +| `screencapture` | Built-in macOS | Window capture by window ID | +| `osascript` | Built-in macOS | JXA: window discovery, CGEvent taps/swipes, typing | +| `swiftc` | Xcode CLT | Auto-compiles the window/OCR helpers on first run | | iPhone Mirroring | macOS 15+ (Sequoia) | Renders iPhone on Mac | ## Permissions Required @@ -47,11 +47,11 @@ All commands go through `iphone-control.sh`: ## Workflow for AI Agent -1. `find` — locate the mirroring window (do once per session) -2. `screenshot` — capture current state, analyze the image +1. `find` - locate the mirroring window (do once per session) +2. `screenshot` - capture current state, analyze the image 3. Decide action based on what's visible -4. `tap`/`swipe`/`type` — execute the action -5. `screenshot` — verify the result +4. `tap`/`swipe`/`type` - execute the action +5. `screenshot` - verify the result 6. Repeat 3-5 ## Coordinate System @@ -60,13 +60,15 @@ All commands go through `iphone-control.sh`: - Coordinates are relative to the iPhone screen, NOT the Mac screen - The scripts handle conversion to absolute Mac screen coordinates - Typical iPhone screen in mirroring: ~375x812 points (varies by model) +- Screenshots are normalized to point dimensions: 1 image pixel = 1 tap coordinate +- Multi-display safe: the window can be on any display, including ones with + negative global coordinates (left of / above primary) or a different Retina scale ## Troubleshooting | Issue | Fix | |-------|-----| | "iPhone Mirroring window not found" | Open iPhone Mirroring app on Mac | -| "cliclick not found" | `brew install cliclick` | -| Tap lands in wrong spot | Run `find` again — window may have moved | +| Tap lands in wrong spot | Run `find` again - window may have moved | | No screen recording permission | System Settings > Privacy & Security > Screen Recording > add Terminal | | No accessibility permission | System Settings > Privacy & Security > Accessibility > add Terminal | diff --git a/companion/Assets.xcassets/AppIcon.appiconset/Contents.json b/companion/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..3f00db4 --- /dev/null +++ b/companion/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,58 @@ +{ + "images" : [ + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/companion/Assets.xcassets/Contents.json b/companion/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/companion/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/companion/iPhoneCompanion.xcodeproj/project.pbxproj b/companion/iPhoneCompanion.xcodeproj/project.pbxproj new file mode 100644 index 0000000..5da8bee --- /dev/null +++ b/companion/iPhoneCompanion.xcodeproj/project.pbxproj @@ -0,0 +1,406 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + AA000001 /* CompanionApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB000001; }; + AA000002 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB000002; }; + AA000003 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB000003; }; + AA000004 /* MessageBubbleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB000004; }; + AA000005 /* SuggestionChipsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB000005; }; + AA000006 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB000006; }; + AA000007 /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB000007; }; + AA000008 /* Conversation.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB000008; }; + AA000009 /* ToolDefinitions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB000009; }; + AA000010 /* ClaudeAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB000010; }; + AA000011 /* OllamaService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB000011; }; + AA000012 /* AIServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB000012; }; + AA000013 /* ScriptRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB000013; }; + AA000014 /* ToolExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB000014; }; + AA000015 /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB000015; }; + AA000016 /* WindowManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB000016; }; + AA000017 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB000017; }; + AA000018 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AB000018; }; + AA000019 /* ModelFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB000021; }; + AA000020 /* OpenRouterService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB000022; }; + AA000021 /* ClaudeCodeAuthHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB000023; }; + AA000022 /* InputController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB000024; }; + AA000023 /* MiniMaxService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB000025; }; + AA000030 /* SSEStreamParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB000030; }; + AA000031 /* AIServiceFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB000031; }; + AA000032 /* ToolOrchestrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB000032; }; + AA000033 /* AccessibilityHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB000033; }; + AA000034 /* ChatInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB000034; }; + AA000035 /* ChatToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB000035; }; + AA000036 /* ProviderSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB000036; }; + AA000037 /* ModelConfigView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB000037; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + AB000001 /* CompanionApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompanionApp.swift; sourceTree = ""; }; + AB000002 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + AB000003 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; + AB000004 /* MessageBubbleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageBubbleView.swift; sourceTree = ""; }; + AB000005 /* SuggestionChipsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionChipsView.swift; sourceTree = ""; }; + AB000006 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + AB000007 /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = ""; }; + AB000008 /* Conversation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Conversation.swift; sourceTree = ""; }; + AB000009 /* ToolDefinitions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolDefinitions.swift; sourceTree = ""; }; + AB000010 /* ClaudeAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeAPIService.swift; sourceTree = ""; }; + AB000011 /* OllamaService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OllamaService.swift; sourceTree = ""; }; + AB000012 /* AIServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIServiceProtocol.swift; sourceTree = ""; }; + AB000013 /* ScriptRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScriptRunner.swift; sourceTree = ""; }; + AB000014 /* ToolExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolExecutor.swift; sourceTree = ""; }; + AB000015 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = ""; }; + AB000016 /* WindowManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowManager.swift; sourceTree = ""; }; + AB000017 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; + AB000018 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + AB000019 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + AB000020 /* iPhoneCompanion.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = iPhoneCompanion.entitlements; sourceTree = ""; }; + AB000021 /* ModelFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelFetcher.swift; sourceTree = ""; }; + AB000022 /* OpenRouterService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenRouterService.swift; sourceTree = ""; }; + AB000023 /* ClaudeCodeAuthHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeCodeAuthHelper.swift; sourceTree = ""; }; + AB000024 /* InputController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputController.swift; sourceTree = ""; }; + AB000025 /* MiniMaxService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiniMaxService.swift; sourceTree = ""; }; + AB000030 /* SSEStreamParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSEStreamParser.swift; sourceTree = ""; }; + AB000031 /* AIServiceFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIServiceFactory.swift; sourceTree = ""; }; + AB000032 /* ToolOrchestrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolOrchestrator.swift; sourceTree = ""; }; + AB000033 /* AccessibilityHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityHelper.swift; sourceTree = ""; }; + AB000034 /* ChatInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInputView.swift; sourceTree = ""; }; + AB000035 /* ChatToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatToolbarView.swift; sourceTree = ""; }; + AB000036 /* ProviderSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderSettingsView.swift; sourceTree = ""; }; + AB000037 /* ModelConfigView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelConfigView.swift; sourceTree = ""; }; + AB000099 /* iPhoneCompanion.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iPhoneCompanion.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + AC000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + AD000001 = { + isa = PBXGroup; + children = ( + AD000002 /* iPhoneCompanion */, + AB000018 /* Assets.xcassets */, + AD000099 /* Products */, + ); + sourceTree = ""; + }; + AD000002 /* iPhoneCompanion */ = { + isa = PBXGroup; + children = ( + AB000019 /* Info.plist */, + AB000020 /* iPhoneCompanion.entitlements */, + AD000003 /* App */, + AD000004 /* Views */, + AD000005 /* Models */, + AD000006 /* Services */, + AD000007 /* Utilities */, + ); + path = iPhoneCompanion; + sourceTree = ""; + }; + AD000003 /* App */ = { + isa = PBXGroup; + children = ( + AB000001 /* CompanionApp.swift */, + AB000002 /* AppDelegate.swift */, + ); + path = App; + sourceTree = ""; + }; + AD000004 /* Views */ = { + isa = PBXGroup; + children = ( + AB000003 /* ChatView.swift */, + AB000034 /* ChatInputView.swift */, + AB000035 /* ChatToolbarView.swift */, + AB000004 /* MessageBubbleView.swift */, + AB000005 /* SuggestionChipsView.swift */, + AB000006 /* SettingsView.swift */, + AB000036 /* ProviderSettingsView.swift */, + AB000037 /* ModelConfigView.swift */, + ); + path = Views; + sourceTree = ""; + }; + AD000005 /* Models */ = { + isa = PBXGroup; + children = ( + AB000007 /* ChatMessage.swift */, + AB000008 /* Conversation.swift */, + AB000009 /* ToolDefinitions.swift */, + ); + path = Models; + sourceTree = ""; + }; + AD000006 /* Services */ = { + isa = PBXGroup; + children = ( + AB000010 /* ClaudeAPIService.swift */, + AB000011 /* OllamaService.swift */, + AB000012 /* AIServiceProtocol.swift */, + AB000013 /* ScriptRunner.swift */, + AB000014 /* ToolExecutor.swift */, + AB000021 /* ModelFetcher.swift */, + AB000022 /* OpenRouterService.swift */, + AB000024 /* InputController.swift */, + AB000025 /* MiniMaxService.swift */, + AB000030 /* SSEStreamParser.swift */, + AB000031 /* AIServiceFactory.swift */, + AB000032 /* ToolOrchestrator.swift */, + ); + path = Services; + sourceTree = ""; + }; + AD000007 /* Utilities */ = { + isa = PBXGroup; + children = ( + AB000015 /* KeychainHelper.swift */, + AB000016 /* WindowManager.swift */, + AB000017 /* Constants.swift */, + AB000023 /* ClaudeCodeAuthHelper.swift */, + AB000033 /* AccessibilityHelper.swift */, + ); + path = Utilities; + sourceTree = ""; + }; + AD000099 /* Products */ = { + isa = PBXGroup; + children = ( + AB000099 /* iPhoneCompanion.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + AE000001 /* iPhoneCompanion */ = { + isa = PBXNativeTarget; + buildConfigurationList = AF000003 /* Build configuration list for PBXNativeTarget "iPhoneCompanion" */; + buildPhases = ( + AE000002 /* Sources */, + AC000001 /* Frameworks */, + AE000003 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = iPhoneCompanion; + productName = iPhoneCompanion; + productReference = AB000099 /* iPhoneCompanion.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + AE000099 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1500; + LastUpgradeCheck = 1500; + }; + buildConfigurationList = AF000001 /* Build configuration list for PBXProject "iPhoneCompanion" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = AD000001; + productRefGroup = AD000099 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + AE000001 /* iPhoneCompanion */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + AE000003 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AA000018 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + AE000002 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AA000001 /* CompanionApp.swift in Sources */, + AA000002 /* AppDelegate.swift in Sources */, + AA000003 /* ChatView.swift in Sources */, + AA000004 /* MessageBubbleView.swift in Sources */, + AA000005 /* SuggestionChipsView.swift in Sources */, + AA000006 /* SettingsView.swift in Sources */, + AA000007 /* ChatMessage.swift in Sources */, + AA000008 /* Conversation.swift in Sources */, + AA000009 /* ToolDefinitions.swift in Sources */, + AA000010 /* ClaudeAPIService.swift in Sources */, + AA000011 /* OllamaService.swift in Sources */, + AA000012 /* AIServiceProtocol.swift in Sources */, + AA000013 /* ScriptRunner.swift in Sources */, + AA000014 /* ToolExecutor.swift in Sources */, + AA000015 /* KeychainHelper.swift in Sources */, + AA000016 /* WindowManager.swift in Sources */, + AA000017 /* Constants.swift in Sources */, + AA000019 /* ModelFetcher.swift in Sources */, + AA000020 /* OpenRouterService.swift in Sources */, + AA000021 /* ClaudeCodeAuthHelper.swift in Sources */, + AA000022 /* InputController.swift in Sources */, + AA000023 /* MiniMaxService.swift in Sources */, + AA000030 /* SSEStreamParser.swift in Sources */, + AA000031 /* AIServiceFactory.swift in Sources */, + AA000032 /* ToolOrchestrator.swift in Sources */, + AA000033 /* AccessibilityHelper.swift in Sources */, + AA000034 /* ChatInputView.swift in Sources */, + AA000035 /* ChatToolbarView.swift in Sources */, + AA000036 /* ProviderSettingsView.swift in Sources */, + AA000037 /* ModelConfigView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + AF000010 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_DYNAMIC_NO_PIC = NO; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + AF000011 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + MACOSX_DEPLOYMENT_TARGET = 14.0; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + AF000020 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = iPhoneCompanion/iPhoneCompanion.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = iPhoneCompanion/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.iphone-companion.app"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + AF000021 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = iPhoneCompanion/iPhoneCompanion.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = iPhoneCompanion/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.iphone-companion.app"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + AF000001 /* Build configuration list for PBXProject "iPhoneCompanion" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AF000010 /* Debug */, + AF000011 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + AF000003 /* Build configuration list for PBXNativeTarget "iPhoneCompanion" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AF000020 /* Debug */, + AF000021 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + + }; + rootObject = AE000099 /* Project object */; +} diff --git a/companion/iPhoneCompanion/App/AppDelegate.swift b/companion/iPhoneCompanion/App/AppDelegate.swift new file mode 100644 index 0000000..f13a19e --- /dev/null +++ b/companion/iPhoneCompanion/App/AppDelegate.swift @@ -0,0 +1,78 @@ +import AppKit +import SwiftUI +import ApplicationServices +import CoreGraphics + +@MainActor +class AppDelegate: NSObject, NSApplicationDelegate { + var panel: NSPanel! + let conversation = Conversation() + private let windowManager = WindowManager() + + func applicationDidFinishLaunching(_ notification: Notification) { + let chatView = ChatView() + .environmentObject(conversation) + + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 380, height: 700), + styleMask: [.titled, .closable, .resizable, .nonactivatingPanel, .utilityWindow], + backing: .buffered, + defer: false + ) + panel.level = .floating + panel.isFloatingPanel = true + panel.hidesOnDeactivate = false + panel.title = "iPhone Companion" + panel.contentView = NSHostingView(rootView: chatView) + panel.contentMinSize = NSSize(width: 320, height: 400) + panel.isReleasedWhenClosed = false + + self.panel = panel + + dockToiPhoneMirroring() + panel.orderFrontRegardless() + + requestPermissions() + } + + private func requestPermissions() { + // Accessibility - prompts if not granted + let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary + let accessibilityGranted = AXIsProcessTrustedWithOptions(options) + NSLog("[Companion] Accessibility granted: %d", accessibilityGranted) + + // Screen Recording - prompts if not granted + let screenRecordingGranted = CGPreflightScreenCaptureAccess() + if !screenRecordingGranted { + CGRequestScreenCaptureAccess() + } + NSLog("[Companion] Screen Recording granted: %d", screenRecordingGranted) + } + + func dockToiPhoneMirroring() { + guard let frame = windowManager.findIPhoneMirroringFrame() else { + panel.center() + return + } + + // CGWindow frames are global top-left coords; the AppKit flip must use the + // PRIMARY screen height (screens.first), not NSScreen.main (the focused screen), + // or the panel lands wrong whenever iPhone Mirroring sits on another display. + guard let screen = NSScreen.screens.first else { return } + let screenHeight = screen.frame.height + + let panelX = frame.origin.x + frame.size.width + 4 + let panelY = screenHeight - frame.origin.y - frame.size.height + let panelHeight = frame.size.height + + panel.setFrame( + NSRect(x: panelX, y: panelY, width: 380, height: panelHeight), + display: true, + animate: false + ) + } + + @objc func redock() { + dockToiPhoneMirroring() + } +} diff --git a/companion/iPhoneCompanion/App/CompanionApp.swift b/companion/iPhoneCompanion/App/CompanionApp.swift new file mode 100644 index 0000000..4b7c755 --- /dev/null +++ b/companion/iPhoneCompanion/App/CompanionApp.swift @@ -0,0 +1,12 @@ +import SwiftUI + +@main +struct CompanionApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + var body: some Scene { + Settings { + EmptyView() + } + } +} diff --git a/companion/iPhoneCompanion/Info.plist b/companion/iPhoneCompanion/Info.plist new file mode 100644 index 0000000..e9efdef --- /dev/null +++ b/companion/iPhoneCompanion/Info.plist @@ -0,0 +1,12 @@ + + + + + NSScreenCaptureUsageDescription + iPhone Companion needs screen recording access to take screenshots of the iPhone Mirroring window. + NSAppleEventsUsageDescription + iPhone Companion uses AppleScript to control iPhone Mirroring (tap, swipe, type). + NSAccessibilityUsageDescription + iPhone Companion needs Accessibility access to send taps, swipes, and keystrokes to iPhone Mirroring. + + diff --git a/companion/iPhoneCompanion/Models/ChatMessage.swift b/companion/iPhoneCompanion/Models/ChatMessage.swift new file mode 100644 index 0000000..7f4a34c --- /dev/null +++ b/companion/iPhoneCompanion/Models/ChatMessage.swift @@ -0,0 +1,50 @@ +import Foundation +import AppKit + +enum MessageRole: String, Codable { + case user + case assistant + case system +} + +enum ContentBlock: Identifiable { + case text(String) + case image(Data) + case toolCall(id: String, name: String, status: ToolStatus) + + var id: String { + switch self { + case .text(let s): return "text-\(s.hashValue)" + case .image(let d): return "img-\(d.hashValue)" + case .toolCall(let id, _, _): return "tool-\(id)" + } + } +} + +enum ToolStatus { + case running + case completed + case failed(String) +} + +struct ChatMessage: Identifiable { + let id: UUID + let role: MessageRole + var content: [ContentBlock] + let timestamp: Date + + init(role: MessageRole, content: [ContentBlock]) { + self.id = UUID() + self.role = role + self.content = content + self.timestamp = Date() + } + + static func user(_ text: String) -> ChatMessage { + ChatMessage(role: .user, content: [.text(text)]) + } + + static func assistant(_ text: String) -> ChatMessage { + ChatMessage(role: .assistant, content: [.text(text)]) + } +} diff --git a/companion/iPhoneCompanion/Models/Conversation.swift b/companion/iPhoneCompanion/Models/Conversation.swift new file mode 100644 index 0000000..01ba822 --- /dev/null +++ b/companion/iPhoneCompanion/Models/Conversation.swift @@ -0,0 +1,290 @@ +import Foundation +import SwiftUI + +enum AIBackend: String, CaseIterable { + case claudeCode = "Claude Code" + case claude = "Claude API" + case openRouter = "OpenRouter" + case minimax = "MiniMax" + case ollama = "Ollama" +} + +@MainActor +class Conversation: ObservableObject { + @Published var messages: [ChatMessage] = [] + @Published var isProcessing = false + private var currentTask: Task? + + @AppStorage("aiBackend") var aiBackend: AIBackend = .claude + @AppStorage("claudeModel") var claudeModel: String = Constants.defaultClaudeModel + @AppStorage("openRouterModel") var openRouterModel: String = Constants.defaultOpenRouterModel + @AppStorage("openRouterVisionModel") var openRouterVisionModel: String = Constants.defaultOpenRouterVisionModel + @AppStorage("minimaxModel") var minimaxModel: String = Constants.defaultMiniMaxModel + @AppStorage("ollamaModel") var ollamaModel: String = Constants.defaultOllamaModel + @AppStorage("ollamaURL") var ollamaURL: String = Constants.defaultOllamaURL + @AppStorage("scriptsPath") var scriptsPath: String = Constants.defaultScriptsPath + + @Published var modelSupportsVision: Bool? = nil // nil = unknown, checked on model change + + private var apiMessages: [APIMessage] = [] + private var toolOrchestrator: ToolOrchestrator? + private var toolExecutor: ToolExecutor? + private var cachedModelCapabilities: [String: ModelFetcher.ModelCapabilities] = [:] + + /// Factory for creating ToolExecutor instances. Injected for testability. + private let toolExecutorFactory: (String) -> ToolExecutor + + init(toolExecutorFactory: ((String) -> ToolExecutor)? = nil) { + self.toolExecutorFactory = toolExecutorFactory ?? { scriptsPath in + ToolExecutor(scriptsPath: scriptsPath) + } + } + + private var currentToolOrchestrator: ToolOrchestrator { + if let existing = toolOrchestrator { + return existing + } + let orchestrator = ToolOrchestrator(toolExecutor: currentToolExecutor) + toolOrchestrator = orchestrator + return orchestrator + } + + private var currentToolExecutor: ToolExecutor { + if let existing = toolExecutor { + return existing + } + let executor = toolExecutorFactory(scriptsPath) + toolExecutor = executor + return executor + } + + func send(_ text: String) { + let userMessage = ChatMessage.user(text) + messages.append(userMessage) + + apiMessages.append(APIMessage(role: "user", content: [.text(text)])) + + isProcessing = true + + currentTask = Task { + // Check model capabilities on first message or if unknown + if modelSupportsVision == nil { + await checkModelCapabilities() + } + + let orchestrator = currentToolOrchestrator + let callbacks = ToolOrchestrator.LoopCallbacks( + createService: { [weak self] in + await self?.createServiceAsync() + }, + getBackend: { [weak self] in + self?.aiBackend ?? .claude + }, + getApiMessages: { [weak self] in + self?.apiMessages ?? [] + }, + appendApiMessage: { [weak self] msg in + self?.apiMessages.append(msg) + }, + appendChatMessage: { [weak self] msg in + self?.messages.append(msg) + }, + updateChatMessage: { [weak self] idx, content in + self?.messages[idx].content = content + }, + chatMessageCount: { [weak self] in + self?.messages.count ?? 0 + }, + needsVisionProxy: { [weak self] in + self?.needsVisionProxy ?? false + }, + describeScreenshot: { [weak self] data in + await self?.describeScreenshot(imageData: data) ?? "[Vision unavailable]" + }, + isCancelled: { + Task.isCancelled + } + ) + + await orchestrator.runAILoop(callbacks: callbacks) + isProcessing = false + } + } + + func cancel() { + currentTask?.cancel() + currentTask = nil + isProcessing = false + messages.append(.assistant("Cancelled.")) + } + + func clearHistory() { + messages.removeAll() + apiMessages.removeAll() + } + + func resetToolExecutor() { + toolExecutor = nil + toolOrchestrator = nil + } + + /// OAuth requires short model names (e.g. "claude-sonnet-4-6" not "claude-sonnet-4-6-20250514") + private var oauthModelName: String { + let model = claudeModel + // Strip trailing -YYYYMMDD date suffix if present + if let range = model.range(of: #"-\d{8}$"#, options: .regularExpression) { + return String(model[model.startIndex.. AIService? { + await AIServiceFactory.createServiceAsync( + backend: aiBackend, + claudeModel: claudeModel, + oauthModelName: oauthModelName, + openRouterModel: openRouterModel, + minimaxModel: minimaxModel, + ollamaModel: ollamaModel, + ollamaURL: ollamaURL + ) + } + + // MARK: - Vision Model Support + + func checkModelCapabilities() async { + guard aiBackend == .openRouter else { + // Claude API, Claude Code, and MiniMax always support vision + modelSupportsVision = aiBackend != .ollama + return + } + + let modelId = openRouterModel + if let cached = cachedModelCapabilities[modelId] { + modelSupportsVision = cached.supportsVision + return + } + + if let caps = await ModelFetcher.fetchModelCapabilities(modelId: modelId) { + cachedModelCapabilities[modelId] = caps + modelSupportsVision = caps.supportsVision + } else { + modelSupportsVision = nil // unknown + } + } + + /// Describe a screenshot using the vision model when the main model doesn't support images + private func describeScreenshot(imageData: Data) async -> String { + let apiKey = loadAPIKey(for: .openRouter) + guard aiBackend == .openRouter, !apiKey.isEmpty, !openRouterVisionModel.isEmpty else { + return "[Screenshot taken — vision model not configured]" + } + + guard let request = buildVisionRequest(imageData: imageData, apiKey: apiKey) else { + return "[Vision model error: failed to build request]" + } + + return await executeVisionRequest(request) + } + + private func buildVisionRequest(imageData: Data, apiKey: String) -> URLRequest? { + let base64 = imageData.base64EncodedString() + let visionMessages: [[String: Any]] = [ + ["role": "user", "content": [ + ["type": "text", "text": "Describe this iPhone screenshot in detail. List all visible UI elements, text, buttons, icons, and their positions. Be thorough but concise."], + ["type": "image_url", "image_url": ["url": "data:image/png;base64,\(base64)"]], + ] as [Any]], + ] + + let body: [String: Any] = [ + "model": openRouterVisionModel, + "max_tokens": Constants.visionMaxTokens, + "messages": visionMessages, + ] + + guard let url = URL(string: Constants.openRouterAPIURL) else { return nil } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "authorization") + + do { + request.httpBody = try JSONSerialization.data(withJSONObject: body) + } catch { + NSLog("[Companion] Failed to serialize vision request body: %@", error.localizedDescription) + return nil + } + + return request + } + + private func executeVisionRequest(_ request: URLRequest) async -> String { + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + let errorBody = String(data: data, encoding: .utf8) ?? "" + return "[Vision model error: HTTP \((response as? HTTPURLResponse)?.statusCode ?? 0) \(errorBody.prefix(100))]" + } + + 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 { + return "[Vision model: failed to parse response]" + } + + return content + } catch { + return "[Vision model error: \(error.localizedDescription)]" + } + } + + /// Whether screenshots need to be described via a separate vision model + private var needsVisionProxy: Bool { + aiBackend == .openRouter && modelSupportsVision == false + } + + // MARK: - API Key Management (facade over KeychainHelper) + + private static let keychainKeys: [AIBackend: String] = [ + .claude: "claude-api-key", + .openRouter: "openrouter-api-key", + .minimax: "minimax-api-key", + ] + + @Published private var cachedAPIKeys: [AIBackend: String] = [:] + + func loadAPIKey(for backend: AIBackend) -> String { + if let cached = cachedAPIKeys[backend] { return cached } + guard let keychainKey = Self.keychainKeys[backend] else { return "" } + let value = KeychainHelper.load(key: keychainKey) ?? "" + cachedAPIKeys[backend] = value + return value + } + + func saveAPIKey(_ value: String, for backend: AIBackend) { + cachedAPIKeys[backend] = value + guard let keychainKey = Self.keychainKeys[backend], !value.isEmpty else { return } + KeychainHelper.save(key: keychainKey, value: value) + } + + var hasAPIKey: Bool { + switch aiBackend { + case .claudeCode: return ClaudeCodeAuthHelper.isAvailable + case .claude: return !loadAPIKey(for: .claude).isEmpty + case .openRouter: return !loadAPIKey(for: .openRouter).isEmpty + case .minimax: return !loadAPIKey(for: .minimax).isEmpty + case .ollama: return true + } + } + + func loadAllAPIKeys() { + for backend in Self.keychainKeys.keys { + _ = loadAPIKey(for: backend) + } + } +} + +extension AIBackend: RawRepresentable {} diff --git a/companion/iPhoneCompanion/Models/ToolDefinitions.swift b/companion/iPhoneCompanion/Models/ToolDefinitions.swift new file mode 100644 index 0000000..b2b4aff --- /dev/null +++ b/companion/iPhoneCompanion/Models/ToolDefinitions.swift @@ -0,0 +1,94 @@ +import Foundation + +struct ToolDefinitions { + static let all: [[String: Any]] = [ + tool("find_window", + description: "Locate the iPhone Mirroring window. Returns JSON with window bounds and content bounds (iOS screen area inside bezel).", + properties: [:]), + + tool("screenshot", + description: "Take a screenshot of the iPhone screen. Returns the image directly.", + properties: [:]), + + tool("tap", + description: "Tap at coordinates relative to the iPhone content area.", + properties: [ + "x": prop("number", "X coordinate"), + "y": prop("number", "Y coordinate"), + ], + required: ["x", "y"]), + + tool("swipe", + description: "Swipe from (x1,y1) to (x2,y2) on the iPhone content area.", + properties: [ + "x1": prop("number", "Start X"), + "y1": prop("number", "Start Y"), + "x2": prop("number", "End X"), + "y2": prop("number", "End Y"), + "duration_ms": prop("number", "Swipe duration in ms (default 300)"), + ], + required: ["x1", "y1", "x2", "y2"]), + + tool("type_text", + description: "Type text into the iPhone. Optionally tap a field first.", + properties: [ + "text": prop("string", "Text to type"), + "x": prop("number", "X coordinate to tap before typing"), + "y": prop("number", "Y coordinate to tap before typing"), + ], + required: ["text"]), + + tool("open_app", + description: "Open an app on the iPhone by name. Uses registry (fast) with Spotlight fallback. Run scan_apps first to populate the registry.", + properties: [ + "name": prop("string", "App name to open (e.g. 'Settings', 'Safari')"), + "method": propEnum("string", "Launch method", ["auto", "spotlight"]), + ], + required: ["name"]), + + tool("home", + description: "Go to the iPhone home screen via the View menu.", + properties: [:]), + + tool("app_switcher", + description: "Open the iPhone app switcher via the View menu.", + properties: [:]), + + tool("status", + description: "Check if iPhone Mirroring is connected and active.", + properties: [:]), + + tool("scan_apps", + description: "Scan all iPhone home screen pages via OCR and save results to persistent registry. Returns full registry JSON.", + properties: [ + "max_pages": prop("number", "Max home screen pages to scan (default 10)"), + ]), + + tool("list_apps", + description: "List all apps from the cached registry. Returns the saved registry data without rescanning.", + properties: [:]), + ] + + private static func prop(_ type: String, _ description: String) -> [String: Any] { + ["type": type, "description": description] + } + + private static func propEnum(_ type: String, _ description: String, _ values: [String]) -> [String: Any] { + ["type": type, "description": description, "enum": values] + } + + private static func tool(_ name: String, description: String, properties: [String: Any], required: [String] = []) -> [String: Any] { + var schema: [String: Any] = [ + "type": "object", + "properties": properties, + ] + if !required.isEmpty { + schema["required"] = required + } + return [ + "name": name, + "description": description, + "input_schema": schema, + ] + } +} diff --git a/companion/iPhoneCompanion/Services/AIServiceFactory.swift b/companion/iPhoneCompanion/Services/AIServiceFactory.swift new file mode 100644 index 0000000..346820a --- /dev/null +++ b/companion/iPhoneCompanion/Services/AIServiceFactory.swift @@ -0,0 +1,65 @@ +import Foundation + +/// Factory responsible for creating AI service instances based on backend configuration. +enum AIServiceFactory { + /// Create an AI service synchronously (for backends that don't need async token refresh). + static func createService( + backend: AIBackend, + claudeModel: String, + oauthModelName: String, + openRouterModel: String, + minimaxModel: String, + ollamaModel: String, + ollamaURL: String + ) -> AIService? { + switch backend { + case .claudeCode: + guard let creds = ClaudeCodeAuthHelper.loadCredentials(), + creds.expiresAt.timeIntervalSinceNow > 0 + else { return nil } + return ClaudeAPIService(apiKey: creds.accessToken, model: oauthModelName, useOAuth: true) + case .claude: + guard let apiKey = KeychainHelper.load(key: "claude-api-key"), !apiKey.isEmpty else { + return nil + } + return ClaudeAPIService(apiKey: apiKey, model: claudeModel) + case .openRouter: + guard let apiKey = KeychainHelper.load(key: "openrouter-api-key"), !apiKey.isEmpty else { + return nil + } + return OpenRouterService(apiKey: apiKey, model: openRouterModel) + case .minimax: + guard let apiKey = KeychainHelper.load(key: "minimax-api-key"), !apiKey.isEmpty else { + return nil + } + return MiniMaxService(apiKey: apiKey, model: minimaxModel) + case .ollama: + return OllamaService(baseURL: ollamaURL, model: ollamaModel) + } + } + + /// Create an AI service with async token refresh support (for Claude Code OAuth). + static func createServiceAsync( + backend: AIBackend, + claudeModel: String, + oauthModelName: String, + openRouterModel: String, + minimaxModel: String, + ollamaModel: String, + ollamaURL: String + ) async -> AIService? { + if case .claudeCode = backend { + guard let token = await ClaudeCodeAuthHelper.getValidToken() else { return nil } + return ClaudeAPIService(apiKey: token, model: oauthModelName, useOAuth: true) + } + return createService( + backend: backend, + claudeModel: claudeModel, + oauthModelName: oauthModelName, + openRouterModel: openRouterModel, + minimaxModel: minimaxModel, + ollamaModel: ollamaModel, + ollamaURL: ollamaURL + ) + } +} diff --git a/companion/iPhoneCompanion/Services/AIServiceProtocol.swift b/companion/iPhoneCompanion/Services/AIServiceProtocol.swift new file mode 100644 index 0000000..3610d23 --- /dev/null +++ b/companion/iPhoneCompanion/Services/AIServiceProtocol.swift @@ -0,0 +1,76 @@ +import Foundation + +enum StreamEvent { + case textDelta(String) + case toolUse(id: String, name: String, input: [String: Any]) + case done(stopReason: String) + case error(String) +} + +/// Base protocol for all AI services — text-only streaming +protocol AIService { + func send(messages: [APIMessage], tools: [[String: Any]], systemPrompt: String) async throws -> AsyncStream +} + +/// AI service that supports tool/function calling. +/// All current services support tools, so this is a semantic marker. +/// If a service doesn't support tools, it should NOT conform to this protocol. +protocol ToolCapableAIService: AIService { + /// Whether the service can handle tool call responses in its message format. + var supportsToolCalling: Bool { get } +} + +extension ToolCapableAIService { + var supportsToolCalling: Bool { true } +} + +/// AI service that supports vision (image inputs). +/// Services conforming to this can receive base64 images directly. +protocol VisionCapableAIService: AIService { + /// Whether the service can process image content blocks. + var supportsVision: Bool { get } +} + +extension VisionCapableAIService { + var supportsVision: Bool { true } +} + +struct APIMessage { + let role: String + let content: [APIContentBlock] +} + +enum APIContentBlock { + case text(String) + case image(base64: String, mediaType: String) + case toolUse(id: String, name: String, input: [String: Any]) + case toolResult(toolUseId: String, content: [APIContentBlock], isError: Bool) + + func toDict() -> [String: Any] { + switch self { + case .text(let text): + return ["type": "text", "text": text] + case .image(let base64, let mediaType): + return [ + "type": "image", + "source": [ + "type": "base64", + "media_type": mediaType, + "data": base64, + ] as [String: Any], + ] + case .toolUse(let id, let name, let input): + return ["type": "tool_use", "id": id, "name": name, "input": input] + case .toolResult(let toolUseId, let content, let isError): + var dict: [String: Any] = [ + "type": "tool_result", + "tool_use_id": toolUseId, + "content": content.map { $0.toDict() }, + ] + if isError { + dict["is_error"] = true + } + return dict + } + } +} diff --git a/companion/iPhoneCompanion/Services/ClaudeAPIService.swift b/companion/iPhoneCompanion/Services/ClaudeAPIService.swift new file mode 100644 index 0000000..bb8170f --- /dev/null +++ b/companion/iPhoneCompanion/Services/ClaudeAPIService.swift @@ -0,0 +1,146 @@ +import Foundation + +class ClaudeAPIService: ToolCapableAIService, VisionCapableAIService { + let apiKey: String + let model: String + let useOAuth: Bool + + init(apiKey: String, model: String = Constants.defaultClaudeModel, useOAuth: Bool = false) { + self.apiKey = apiKey + self.model = model + self.useOAuth = useOAuth + } + + func send(messages: [APIMessage], tools: [[String: Any]], systemPrompt: String) async throws -> AsyncStream { + let body = buildRequestBody(messages: messages, tools: tools, systemPrompt: systemPrompt) + + var request = URLRequest(url: URL(string: Constants.claudeAPIURL)!) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "content-type") + if useOAuth { + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "authorization") + request.setValue("oauth-2025-04-20", forHTTPHeaderField: "anthropic-beta") + } else { + request.setValue(apiKey, forHTTPHeaderField: "x-api-key") + } + request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (bytes, response) = try await URLSession.shared.bytes(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.invalidResponse + } + + if httpResponse.statusCode != 200 { + var errorBody = "" + for try await line in bytes.lines { + errorBody += line + } + throw APIError.httpError(httpResponse.statusCode, errorBody) + } + + return AsyncStream { continuation in + Task { + var currentToolId = "" + var currentToolName = "" + var currentToolInputJSON = "" + + do { + for try await line in bytes.lines { + guard line.hasPrefix("data: ") else { continue } + let jsonStr = String(line.dropFirst(6)) + guard jsonStr != "[DONE]", + let data = jsonStr.data(using: .utf8), + let event = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { continue } + + let eventType = event["type"] as? String ?? "" + + switch eventType { + case "content_block_start": + if let contentBlock = event["content_block"] as? [String: Any], + contentBlock["type"] as? String == "tool_use" { + currentToolId = contentBlock["id"] as? String ?? "" + currentToolName = contentBlock["name"] as? String ?? "" + currentToolInputJSON = "" + } + + case "content_block_delta": + if let delta = event["delta"] as? [String: Any] { + let deltaType = delta["type"] as? String ?? "" + if deltaType == "text_delta", let text = delta["text"] as? String { + continuation.yield(.textDelta(text)) + } else if deltaType == "input_json_delta", let partial = delta["partial_json"] as? String { + currentToolInputJSON += partial + } + } + + case "content_block_stop": + if !currentToolId.isEmpty { + var input: [String: Any] = [:] + if let data = currentToolInputJSON.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + input = parsed + } + continuation.yield(.toolUse(id: currentToolId, name: currentToolName, input: input)) + currentToolId = "" + currentToolName = "" + currentToolInputJSON = "" + } + + case "message_delta": + if let delta = event["delta"] as? [String: Any], + let stopReason = delta["stop_reason"] as? String { + continuation.yield(.done(stopReason: stopReason)) + } + + case "error": + let errorMsg = (event["error"] as? [String: Any])?["message"] as? String ?? "Unknown error" + continuation.yield(.error(errorMsg)) + + default: + break + } + } + } catch { + NSLog("[Companion] Claude SSE stream error: %@", error.localizedDescription) + continuation.yield(.error("Stream error: \(error.localizedDescription)")) + } + + continuation.finish() + } + } + } + + private func buildRequestBody(messages: [APIMessage], tools: [[String: Any]], systemPrompt: String) -> [String: Any] { + let apiMessages = messages.map { msg -> [String: Any] in + [ + "role": msg.role, + "content": msg.content.map { $0.toDict() }, + ] + } + + return [ + "model": model, + "max_tokens": Constants.maxTokens, + "stream": true, + "system": systemPrompt, + "messages": apiMessages, + "tools": tools, + ] + } +} + +enum APIError: LocalizedError { + case invalidResponse + case httpError(Int, String) + + var errorDescription: String? { + switch self { + case .invalidResponse: return "Invalid response from API" + case .httpError(let code, let body): return "HTTP \(code): \(body)" + } + } +} diff --git a/companion/iPhoneCompanion/Services/InputController.swift b/companion/iPhoneCompanion/Services/InputController.swift new file mode 100644 index 0000000..ee200e5 --- /dev/null +++ b/companion/iPhoneCompanion/Services/InputController.swift @@ -0,0 +1,283 @@ +import Foundation +import CoreGraphics +import AppKit + +/// Protocol for input control operations (DIP support). +protocol InputControlling: Sendable { + func tap(x: Int, y: Int) async throws -> String + func swipe(x1: Int, y1: Int, x2: Int, y2: Int, durationMs: Int) async throws -> String + func typeText(_ text: String, tapX: Int?, tapY: Int?) async throws -> String + func clickViewMenuItem(_ itemName: String) async throws -> String + func openAppViaSpotlight(_ appName: String) async throws -> String + func getContentBounds() async throws -> InputController.ContentBounds +} + +/// Posts CG events and AX actions directly from the companion app process. +/// This ensures the app's own Accessibility permission covers all input control, +/// instead of relying on osascript having permission. +actor InputController: InputControlling { + private let scriptRunner: ScriptRunning + + init(scriptsPath: String) { + self.scriptRunner = ScriptRunner(scriptsPath: scriptsPath) + } + + init(scriptRunner: ScriptRunning) { + self.scriptRunner = scriptRunner + } + + struct ContentBounds { + let contentX: Int + let contentY: Int + let contentWidth: Int + let contentHeight: Int + } + + // MARK: - Tap + + func tap(x: Int, y: Int) async throws -> String { + let bounds = try await getContentBounds() + guard x >= 0, x <= bounds.contentWidth, y >= 0, y <= bounds.contentHeight else { + throw InputError.outOfBounds(x, y, bounds.contentWidth, bounds.contentHeight) + } + + let absX = bounds.contentX + x + let absY = bounds.contentY + y + + await activateIPhoneMirroring() + + let saved = saveCursor() + let pt = CGPoint(x: absX, y: absY) + + let down = CGEvent(mouseEventSource: nil, mouseType: .leftMouseDown, mouseCursorPosition: pt, mouseButton: .left) + let up = CGEvent(mouseEventSource: nil, mouseType: .leftMouseUp, mouseCursorPosition: pt, mouseButton: .left) + + down?.post(tap: .cghidEventTap) + Thread.sleep(forTimeInterval: Constants.tapDownUpDelay) + up?.post(tap: .cghidEventTap) + + restoreCursor(saved) + try await Task.sleep(nanoseconds: Constants.tapSettleDelay) + + return "Tapped at (\(x), \(y)) -> screen (\(absX), \(absY))" + } + + // MARK: - Swipe + + func swipe(x1: Int, y1: Int, x2: Int, y2: Int, durationMs: Int = 300) async throws -> String { + let bounds = try await getContentBounds() + for (cx, cy) in [(x1, y1), (x2, y2)] { + guard cx >= 0, cx <= bounds.contentWidth, cy >= 0, cy <= bounds.contentHeight else { + throw InputError.outOfBounds(cx, cy, bounds.contentWidth, bounds.contentHeight) + } + } + + let absX1 = bounds.contentX + x1 + let absY1 = bounds.contentY + y1 + let absX2 = bounds.contentX + x2 + let absY2 = bounds.contentY + y2 + + await activateIPhoneMirroring() + + let saved = saveCursor() + let startPt = CGPoint(x: absX1, y: absY1) + let endPt = CGPoint(x: absX2, y: absY2) + let dx = Double(absX2 - absX1) + let dy = Double(absY2 - absY1) + let steps = 20 + let stepDelay = Double(durationMs) / 1000.0 / Double(steps) + + let down = CGEvent(mouseEventSource: nil, mouseType: .leftMouseDown, mouseCursorPosition: startPt, mouseButton: .left) + down?.post(tap: .cghidEventTap) + + // Immediate kick to 30% - iOS needs early movement to distinguish drag from long-press + let kickPt = CGPoint(x: Double(absX1) + dx * 0.3, y: Double(absY1) + dy * 0.3) + let kick = CGEvent(mouseEventSource: nil, mouseType: .leftMouseDragged, mouseCursorPosition: kickPt, mouseButton: .left) + kick?.post(tap: .cghidEventTap) + + for i in 1...steps { + Thread.sleep(forTimeInterval: stepDelay) + let f = 0.3 + 0.7 * (Double(i) / Double(steps)) + let mid = CGPoint(x: Double(absX1) + dx * f, y: Double(absY1) + dy * f) + let drag = CGEvent(mouseEventSource: nil, mouseType: .leftMouseDragged, mouseCursorPosition: mid, mouseButton: .left) + drag?.post(tap: .cghidEventTap) + } + + let up = CGEvent(mouseEventSource: nil, mouseType: .leftMouseUp, mouseCursorPosition: endPt, mouseButton: .left) + up?.post(tap: .cghidEventTap) + + restoreCursor(saved) + try await Task.sleep(nanoseconds: Constants.swipeSettleDelay) + + return "Swiped (\(x1),\(y1)) -> (\(x2),\(y2)) in \(durationMs)ms" + } + + // MARK: - Type Text (native CGEvent keystrokes) + + func typeText(_ text: String, tapX: Int? = nil, tapY: Int? = nil) async throws -> String { + if let x = tapX, let y = tapY { + _ = try await tap(x: x, y: y) + try await Task.sleep(nanoseconds: Constants.pageSwipeDelay) + } + + await activateIPhoneMirroring() + postKeystrokes(text) + + return "Typed: \(text)" + } + + // MARK: - View Menu Actions (home, app switcher, spotlight) + + func clickViewMenuItem(_ itemName: String) async throws -> String { + await activateIPhoneMirroring() + Thread.sleep(forTimeInterval: Constants.menuOpenDelay) + + guard let pid = await iPhoneMirroringPID() else { + throw InputError.windowNotFound + } + + return try AccessibilityHelper.clickViewMenuItem(itemName, pid: pid) + } + + // MARK: - Open App via Spotlight + + func openAppViaSpotlight(_ appName: String) async throws -> String { + // Open Spotlight via View menu + _ = try await clickViewMenuItem("Spotlight") + try await Task.sleep(nanoseconds: Constants.spotlightOpenDelay) + + // Type the app name + postKeystrokes(appName) + try await Task.sleep(nanoseconds: Constants.spotlightSearchDelay) + + // Press Return to open + postKeyCode(36) // Return + + return "Opened: \(appName) (spotlight)" + } + + // MARK: - Content Bounds + + func getContentBounds() async throws -> ContentBounds { + // Use native CGWindow API - no child process, no TCC inheritance issues + if let bounds = nativeContentBounds() { + return bounds + } + // Fallback: shell script (works when Terminal has Screen Recording permission) + let result = try await scriptRunner.run(script: "find-window.sh") + let json = result.stdout + guard let cx = parseIntField(json, field: "content_x"), + let cy = parseIntField(json, field: "content_y"), + let cw = parseIntField(json, field: "content_width"), + let ch = parseIntField(json, field: "content_height") + else { + throw InputError.windowNotFound + } + return ContentBounds(contentX: cx, contentY: cy, contentWidth: cw, contentHeight: ch) + } + + private func nativeContentBounds() -> ContentBounds? { + guard let list = CGWindowListCopyWindowInfo( + [.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID + ) as? [[String: Any]] else { return nil } + + for window in list { + guard let owner = window[kCGWindowOwnerName as String] as? String, + owner == "iPhone Mirroring", + let boundsDict = window[kCGWindowBounds as String] as? NSDictionary + else { continue } + + var rect = CGRect.zero + CGRectMakeWithDictionaryRepresentation(boundsDict as CFDictionary, &rect) + guard rect.width > 100, rect.height > 100 else { continue } + + // ponytail: rough percentage bezels; port find-window.sh's alpha scan if + // native-path tap accuracy ever matters more than avoiding the child process + let mx = Int(rect.width * 0.06) + let mt = Int(rect.height * 0.09) + let mb = Int(rect.height * 0.04) + + return ContentBounds( + contentX: Int(rect.minX) + mx, + contentY: Int(rect.minY) + mt, + contentWidth: Int(rect.width) - mx * 2, + contentHeight: Int(rect.height) - mt - mb + ) + } + return nil + } + + private func parseIntField(_ json: String, field: String) -> Int? { + guard let range = json.range(of: "\"\(field)\":") else { return nil } + let after = json[range.upperBound...] + let numStr = after.prefix(while: { $0.isNumber || $0 == "-" }) + return Int(numStr) + } + + // MARK: - Native Keystroke Posting + + private func postKeystrokes(_ text: String) { + let src = CGEventSource(stateID: .hidSystemState) + for char in text.unicodeScalars { + let keyDown = CGEvent(keyboardEventSource: src, virtualKey: 0, keyDown: true) + let keyUp = CGEvent(keyboardEventSource: src, virtualKey: 0, keyDown: false) + keyDown?.keyboardSetUnicodeString(stringLength: 1, unicodeString: [UniChar(char.value)]) + keyUp?.keyboardSetUnicodeString(stringLength: 1, unicodeString: [UniChar(char.value)]) + keyDown?.post(tap: .cghidEventTap) + keyUp?.post(tap: .cghidEventTap) + Thread.sleep(forTimeInterval: Constants.keystrokeDelay) + } + } + + private func postKeyCode(_ keyCode: CGKeyCode) { + let src = CGEventSource(stateID: .hidSystemState) + let keyDown = CGEvent(keyboardEventSource: src, virtualKey: keyCode, keyDown: true) + let keyUp = CGEvent(keyboardEventSource: src, virtualKey: keyCode, keyDown: false) + keyDown?.post(tap: .cghidEventTap) + keyUp?.post(tap: .cghidEventTap) + } + + // MARK: - Helpers + + @MainActor + private func activateIPhoneMirroring() { + let apps = NSWorkspace.shared.runningApplications + if let im = apps.first(where: { $0.bundleIdentifier == Constants.iphoneMirroringBundleId }) { + im.activate(options: .activateIgnoringOtherApps) + } + Thread.sleep(forTimeInterval: Constants.activateDelay) + } + + @MainActor + private func iPhoneMirroringPID() -> pid_t? { + NSWorkspace.shared.runningApplications + .first(where: { $0.bundleIdentifier == Constants.iphoneMirroringBundleId })? + .processIdentifier + } + + private func saveCursor() -> CGPoint { + guard let event = CGEvent(source: nil) else { return .zero } + return event.location + } + + private func restoreCursor(_ point: CGPoint) { + CGWarpMouseCursorPosition(point) + } +} + +enum InputError: LocalizedError { + case outOfBounds(Int, Int, Int, Int) + case windowNotFound + case menuNotFound(String) + + var errorDescription: String? { + switch self { + case .outOfBounds(let x, let y, let w, let h): + return "Coordinates (\(x), \(y)) out of bounds (\(w)x\(h))" + case .windowNotFound: + return "iPhone Mirroring window not found. Is iPhone Mirroring open?" + case .menuNotFound(let msg): + return msg + } + } +} diff --git a/companion/iPhoneCompanion/Services/MiniMaxService.swift b/companion/iPhoneCompanion/Services/MiniMaxService.swift new file mode 100644 index 0000000..954674c --- /dev/null +++ b/companion/iPhoneCompanion/Services/MiniMaxService.swift @@ -0,0 +1,256 @@ +import Foundation + +class MiniMaxService: ToolCapableAIService, VisionCapableAIService { + let apiKey: String + let model: String + + init(apiKey: String, model: String = "MiniMax-M2.7") { + self.apiKey = apiKey + self.model = model + } + + func send(messages: [APIMessage], tools: [[String: Any]], systemPrompt: String) async throws -> AsyncStream { + let mmMessages = buildMessages(messages: messages, systemPrompt: systemPrompt) + let mmTools = buildTools(tools: tools) + + var body: [String: Any] = [ + "model": model, + "messages": mmMessages, + "stream": true, + "max_tokens": Constants.maxTokens, + ] + + if !mmTools.isEmpty { + body["tools"] = mmTools + } + + var request = URLRequest(url: URL(string: Constants.minimaxAPIURL)!) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (bytes, response) = try await URLSession.shared.bytes(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.invalidResponse + } + + if httpResponse.statusCode != 200 { + var errorBody = "" + for try await line in bytes.lines { + errorBody += line + "\n" + } + NSLog("[Companion] MiniMax error: status=%d, body=%@", httpResponse.statusCode, errorBody) + throw APIError.httpError(httpResponse.statusCode, errorBody) + } + + return AsyncStream { continuation in + Task { + // Accumulate streaming tool_calls by index (OpenAI v1 streaming format) + var toolCallsById: [Int: (id: String, name: String, arguments: String)] = [:] + + do { + for try await line in bytes.lines { + guard !line.isEmpty, + line.hasPrefix("data: "), + let data = line.dropFirst(6).data(using: .utf8), + let event = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { continue } + + if let error = event["error"] as? [String: Any], + let message = error["message"] as? String { + continuation.yield(.error(message)) + continue + } + + guard let choices = event["choices"] as? [[String: Any]], + let choice = choices.first + else { continue } + + if let delta = choice["delta"] as? [String: Any] { + // Text content + if let content = delta["content"] as? String, !content.isEmpty { + continuation.yield(.textDelta(content)) + } + + // OpenAI v1 tool_calls streaming format + if let toolCallChunks = delta["tool_calls"] as? [[String: Any]] { + for chunk in toolCallChunks { + let index = chunk["index"] as? Int ?? 0 + let chunkId = chunk["id"] as? String ?? "" + let fn = chunk["function"] as? [String: Any] ?? [:] + let chunkName = fn["name"] as? String ?? "" + let chunkArgs = fn["arguments"] as? String ?? "" + + if var existing = toolCallsById[index] { + existing.arguments += chunkArgs + if !chunkId.isEmpty { existing.id = chunkId } + if !chunkName.isEmpty { existing.name = chunkName } + toolCallsById[index] = existing + } else { + toolCallsById[index] = (id: chunkId, name: chunkName, arguments: chunkArgs) + } + } + } + + // Legacy function_call format (older MiniMax models) + if let functionCall = delta["function_call"] as? [String: Any], + let name = functionCall["name"] as? String { + let args = functionCall["arguments"] as? String ?? "{}" + var argsDict: [String: Any] = [:] + if let argsData = args.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: argsData) as? [String: Any] { + argsDict = parsed + } + let id = "mm-\(UUID().uuidString.prefix(8))" + continuation.yield(.toolUse(id: id, name: name, input: argsDict)) + } + } + + let finishReason = choice["finish_reason"] as? String + if let reason = finishReason { + // Flush accumulated tool_calls + let hadToolCalls = !toolCallsById.isEmpty + for index in toolCallsById.keys.sorted() { + let call = toolCallsById[index]! + guard !call.name.isEmpty else { continue } + var argsDict: [String: Any] = [:] + if let argsData = call.arguments.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: argsData) as? [String: Any] { + argsDict = parsed + } + let id = call.id.isEmpty ? "mm-\(UUID().uuidString.prefix(8))" : call.id + continuation.yield(.toolUse(id: id, name: call.name, input: argsDict)) + } + toolCallsById.removeAll() + + let isToolUse = reason == "tool_calls" || hadToolCalls + continuation.yield(.done(stopReason: isToolUse ? "tool_use" : "stop")) + } + } + } catch { + NSLog("[Companion] MiniMax SSE stream error: %@", error.localizedDescription) + continuation.yield(.error("Stream error: \(error.localizedDescription)")) + } + continuation.finish() + } + } + } + + // MARK: - Message Building + + /// Build OpenAI-compatible messages array. + /// Tool calls → assistant message with `tool_calls` field. + /// Tool results → separate `role: "tool"` messages. + private func buildMessages(messages: [APIMessage], systemPrompt: String) -> [[String: Any]] { + var result: [[String: Any]] = [ + ["role": "system", "content": systemPrompt], + ] + + for msg in messages { + // Collect text, tool calls, and tool results separately + var textParts: [String] = [] + var toolCalls: [[String: Any]] = [] + var toolResults: [[String: Any]] = [] + + for block in msg.content { + switch block { + case .text(let text): + textParts.append(text) + + case .image: + break // handled in buildUserContent via msg.content re-scan + + case .toolUse(let id, let name, let input): + let inputJson = (try? JSONSerialization.data(withJSONObject: input)) + .flatMap { String(data: $0, encoding: .utf8) } ?? "{}" + toolCalls.append([ + "id": id, + "type": "function", + "function": ["name": name, "arguments": inputJson], + ]) + + case .toolResult(let toolUseId, let subContent, _): + var resultText = "" + for sub in subContent { + if case .text(let t) = sub { resultText += t } + } + toolResults.append([ + "role": "tool", + "tool_call_id": toolUseId, + "content": resultText, + ]) + } + } + + if msg.role == "user" { + // Tool results become separate role:tool messages + result.append(contentsOf: toolResults) + + // Regular user text (if any) + if !textParts.isEmpty || toolResults.isEmpty { + let content = buildUserContent(textParts: textParts, msg: msg) + result.append(["role": "user", "content": content]) + } + } else { + // Assistant message: text + tool_calls + var assistantMsg: [String: Any] = ["role": "assistant"] + if !textParts.isEmpty { + assistantMsg["content"] = textParts.joined() + } else { + assistantMsg["content"] = NSNull() + } + if !toolCalls.isEmpty { + assistantMsg["tool_calls"] = toolCalls + } + result.append(assistantMsg) + } + } + + return result + } + + private func buildUserContent(textParts: [String], msg: APIMessage) -> Any { + // Check if there are images + let hasImages = msg.content.contains { if case .image = $0 { return true }; return false } + + if hasImages { + var blocks: [[String: Any]] = [] + for part in textParts { + blocks.append(["type": "text", "text": part]) + } + for block in msg.content { + if case .image(let base64, let mediaType) = block { + blocks.append([ + "type": "image_url", + "image_url": ["url": "data:\(mediaType);base64,\(base64)"], + ]) + } + } + return blocks + } + + return textParts.joined() + } + + // MARK: - Tool Building + + private func buildTools(tools: [[String: Any]]) -> [[String: Any]] { + tools.compactMap { tool -> [String: Any]? in + guard let name = tool["name"] as? String, + let description = tool["description"] as? String, + let inputSchema = tool["input_schema"] as? [String: Any] + else { return nil } + + return [ + "type": "function", + "function": [ + "name": name, + "description": description, + "parameters": inputSchema, + ], + ] + } + } +} diff --git a/companion/iPhoneCompanion/Services/ModelFetcher.swift b/companion/iPhoneCompanion/Services/ModelFetcher.swift new file mode 100644 index 0000000..c736c85 --- /dev/null +++ b/companion/iPhoneCompanion/Services/ModelFetcher.swift @@ -0,0 +1,250 @@ +import Foundation + +struct ClaudeModel: Identifiable, Hashable { + let id: String + let name: String +} + +enum ModelFetcher { + static func fetchModels(apiKey: String) async -> (models: [ClaudeModel], error: String?) { + guard !apiKey.isEmpty else { return ([], "No API key") } + + var request = URLRequest(url: URL(string: "\(Constants.claudeModelsURL)?limit=1000")!) + request.setValue(apiKey, forHTTPHeaderField: "x-api-key") + request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + return ([], "No response") + } + + if httpResponse.statusCode == 401 { + return ([], "Invalid API key") + } + + if httpResponse.statusCode == 403 { + // Key valid but no access to /v1/models — validate via messages endpoint + let valid = await validateKey(apiKey: apiKey) + return ([], valid ? nil : "Invalid API key") + } + + if httpResponse.statusCode != 200 { + let body = String(data: data, encoding: .utf8) ?? "" + return ([], "HTTP \(httpResponse.statusCode): \(body.prefix(100))") + } + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let modelsArray = json["data"] as? [[String: Any]] + else { + return ([], "Failed to parse response") + } + + var models: [ClaudeModel] = [] + for model in modelsArray { + guard let id = model["id"] as? String, id.hasPrefix("claude-") else { continue } + let displayName = model["display_name"] as? String ?? formatModelName(id) + models.append(ClaudeModel(id: id, name: displayName)) + } + + // Sort: opus > sonnet > haiku + models.sort { a, b in + let tierA = modelTier(a.id) + let tierB = modelTier(b.id) + if tierA != tierB { return tierA < tierB } + return a.id > b.id + } + + return (models, nil) + } catch { + return ([], error.localizedDescription) + } + } + + static func validateKey(apiKey: String) async -> Bool { + var request = URLRequest(url: URL(string: Constants.claudeAPIURL)!) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.setValue(apiKey, forHTTPHeaderField: "x-api-key") + request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + + // Minimal request — will fail with 400 (bad request) if key is valid but request is incomplete + // Will fail with 401 if key is invalid + let body: [String: Any] = [ + "model": "claude-haiku-3-5-20241022", + "max_tokens": 1, + "messages": [["role": "user", "content": "hi"]], + ] + request.httpBody = try? JSONSerialization.data(withJSONObject: body) + + do { + let (_, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { return false } + // 200 = valid, 400 = valid key but bad request, 401 = invalid key + return httpResponse.statusCode != 401 + } catch { + return false + } + } + + // MARK: - OpenRouter Models + + static func fetchOpenRouterModels(apiKey: String) async -> (models: [ClaudeModel], error: String?) { + guard !apiKey.isEmpty else { return ([], "No API key") } + + var request = URLRequest(url: URL(string: Constants.openRouterModelsURL)!) + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "authorization") + + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + return ([], "No response") + } + + if httpResponse.statusCode == 401 { + return ([], "Invalid API key") + } + + if httpResponse.statusCode != 200 { + return ([], "HTTP \(httpResponse.statusCode)") + } + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let modelsArray = json["data"] as? [[String: Any]] + else { + return ([], "Failed to parse response") + } + + // Filter to models that support tool use (function calling) + var models: [ClaudeModel] = [] + for model in modelsArray { + guard let id = model["id"] as? String, + let name = model["name"] as? String + else { continue } + + // Only include models likely to support tool use + let supported = id.contains("claude") || + id.contains("gpt-4") || + id.contains("gemini") || + id.contains("llama") || + id.contains("mistral") || + id.contains("deepseek") || + id.contains("qwen") + + guard supported else { continue } + + // Skip very old/deprecated models + if id.contains("claude-1") || id.contains("claude-2") { continue } + if id.contains("gpt-3.5") { continue } + + models.append(ClaudeModel(id: id, name: name)) + } + + // Sort: anthropic first, then others + models.sort { a, b in + let aIsAnthropic = a.id.hasPrefix("anthropic/") + let bIsAnthropic = b.id.hasPrefix("anthropic/") + if aIsAnthropic != bIsAnthropic { return aIsAnthropic } + return a.name < b.name + } + + return (models, nil) + } catch { + return ([], error.localizedDescription) + } + } + + // MARK: - MiniMax Models + + /// MiniMax doesn't expose a /v1/models endpoint — validate the key via a minimal + /// chat request, then return the known static model list. + static func fetchMinimaxModels(apiKey: String) async -> (models: [ClaudeModel], error: String?) { + guard !apiKey.isEmpty else { return ([], "No API key") } + + var request = URLRequest(url: URL(string: Constants.minimaxAPIURL)!) + request.httpMethod = "POST" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body: [String: Any] = [ + "model": "MiniMax-Text-01", + "max_tokens": 1, + "messages": [["role": "user", "content": "hi"]], + ] + request.httpBody = try? JSONSerialization.data(withJSONObject: body) + + do { + let (_, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + return ([], "No response") + } + if httpResponse.statusCode == 401 { + return ([], "Invalid API key") + } + // 200 or 400 (bad request) both mean the key is valid + } catch { + return ([], error.localizedDescription) + } + + let models = Constants.fallbackMiniMaxModels.map { ClaudeModel(id: $0.id, name: $0.name) } + return (models, nil) + } + + // MARK: - Model Capabilities + + struct ModelCapabilities { + let supportsVision: Bool + let supportsTools: Bool + } + + static func fetchModelCapabilities(modelId: String) async -> ModelCapabilities? { + guard let url = URL(string: Constants.openRouterModelsURL) else { return nil } + + do { + let (data, response) = try await URLSession.shared.data(from: url) + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + return nil + } + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let modelsArray = json["data"] as? [[String: Any]] + else { return nil } + + // Find our model — handle :free suffix matching + let baseId = modelId.replacingOccurrences(of: ":free", with: "") + guard let model = modelsArray.first(where: { ($0["id"] as? String) == modelId }) + ?? modelsArray.first(where: { ($0["id"] as? String) == baseId }) + else { return nil } + + let arch = model["architecture"] as? [String: Any] ?? [:] + let inputModalities = arch["input_modalities"] as? [String] ?? [] + let supportedParams = model["supported_parameters"] as? [String] ?? [] + + return ModelCapabilities( + supportsVision: inputModalities.contains("image"), + supportsTools: supportedParams.contains("tools") + ) + } catch { + return nil + } + } + + private static func formatModelName(_ id: String) -> String { + var name = id.replacingOccurrences(of: "claude-", with: "") + let parts = name.split(separator: "-") + var filtered = parts.filter { part in + !(part.count >= 8 && part.allSatisfy(\.isNumber)) + } + guard !filtered.isEmpty else { return id } + let family = String(filtered.removeFirst()).capitalized + let version = filtered.joined(separator: ".") + return version.isEmpty ? family : "\(family) \(version)" + } + + private static func modelTier(_ id: String) -> Int { + if id.contains("opus") { return 0 } + if id.contains("sonnet") { return 1 } + if id.contains("haiku") { return 2 } + return 3 + } +} diff --git a/companion/iPhoneCompanion/Services/OllamaService.swift b/companion/iPhoneCompanion/Services/OllamaService.swift new file mode 100644 index 0000000..b155df7 --- /dev/null +++ b/companion/iPhoneCompanion/Services/OllamaService.swift @@ -0,0 +1,118 @@ +import Foundation + +class OllamaService: ToolCapableAIService { + let baseURL: String + let model: String + + init(baseURL: String = Constants.defaultOllamaURL, model: String = Constants.defaultOllamaModel) { + self.baseURL = baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + self.model = model + } + + func send(messages: [APIMessage], tools: [[String: Any]], systemPrompt: String) async throws -> AsyncStream { + let ollamaMessages = buildMessages(messages: messages, systemPrompt: systemPrompt) + let ollamaTools = buildTools(tools: tools) + + let body: [String: Any] = [ + "model": model, + "messages": ollamaMessages, + "tools": ollamaTools, + "stream": true, + ] + + var request = URLRequest(url: URL(string: "\(baseURL)/api/chat")!) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (bytes, response) = try await URLSession.shared.bytes(for: request) + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw APIError.invalidResponse + } + + return AsyncStream { continuation in + Task { + do { + for try await line in bytes.lines { + guard !line.isEmpty, + let data = line.data(using: .utf8), + let event = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { continue } + + if let message = event["message"] as? [String: Any] { + // Text content + if let content = message["content"] as? String, !content.isEmpty { + continuation.yield(.textDelta(content)) + } + + // Tool calls + if let toolCalls = message["tool_calls"] as? [[String: Any]] { + for call in toolCalls { + if let function = call["function"] as? [String: Any], + let name = function["name"] as? String { + let args = function["arguments"] as? [String: Any] ?? [:] + let id = "ollama-\(UUID().uuidString.prefix(8))" + continuation.yield(.toolUse(id: id, name: name, input: args)) + } + } + } + } + + if event["done"] as? Bool == true { + let hasTools = (event["message"] as? [String: Any])?["tool_calls"] != nil + continuation.yield(.done(stopReason: hasTools ? "tool_use" : "end_turn")) + } + } + } catch { + NSLog("[Companion] Ollama SSE stream error: %@", error.localizedDescription) + continuation.yield(.error("Stream error: \(error.localizedDescription)")) + } + continuation.finish() + } + } + } + + private func buildMessages(messages: [APIMessage], systemPrompt: String) -> [[String: Any]] { + var result: [[String: Any]] = [ + ["role": "system", "content": systemPrompt], + ] + + for msg in messages { + var content = "" + for block in msg.content { + switch block { + case .text(let text): content += text + case .toolResult(_, let subContent, _): + for sub in subContent { + if case .text(let t) = sub { content += t } + } + default: break + } + } + if !content.isEmpty { + result.append(["role": msg.role == "assistant" ? "assistant" : "user", "content": content]) + } + } + + return result + } + + private func buildTools(tools: [[String: Any]]) -> [[String: Any]] { + tools.compactMap { tool -> [String: Any]? in + guard let name = tool["name"] as? String, + let description = tool["description"] as? String, + let inputSchema = tool["input_schema"] as? [String: Any] + else { return nil } + + return [ + "type": "function", + "function": [ + "name": name, + "description": description, + "parameters": inputSchema, + ] as [String: Any], + ] + } + } +} diff --git a/companion/iPhoneCompanion/Services/OpenRouterService.swift b/companion/iPhoneCompanion/Services/OpenRouterService.swift new file mode 100644 index 0000000..e529ebe --- /dev/null +++ b/companion/iPhoneCompanion/Services/OpenRouterService.swift @@ -0,0 +1,190 @@ +import Foundation + +class OpenRouterService: ToolCapableAIService { + let apiKey: String + let model: String + + init(apiKey: String, model: String) { + self.apiKey = apiKey + self.model = model + } + + func send(messages: [APIMessage], tools: [[String: Any]], systemPrompt: String) async throws -> AsyncStream { + let body = buildRequestBody(messages: messages, tools: tools, systemPrompt: systemPrompt) + + guard let url = URL(string: Constants.openRouterAPIURL) else { + throw APIError.invalidResponse + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "authorization") + + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.invalidResponse + } + + if httpResponse.statusCode != 200 { + let errorBody = String(data: data, encoding: .utf8) ?? "Unknown error" + throw APIError.httpError(httpResponse.statusCode, errorBody) + } + + let responseStr = String(data: data, encoding: .utf8) ?? "" + NSLog("[Companion] OpenRouter response (%d bytes): %@", data.count, String(responseStr.prefix(500))) + + return AsyncStream { continuation in + var gotAnyContent = false + var toolCallAccum: [Int: SSEStreamParser.AccumulatedToolCall] = [:] + + for line in responseStr.components(separatedBy: "\n") { + guard line.hasPrefix("data: ") else { continue } + let jsonStr = String(line.dropFirst(6)) + + let result = SSEStreamParser.parseLine( + jsonStr, + toolCallAccum: &toolCallAccum, + continuation: continuation, + gotAnyContent: &gotAnyContent + ) + + switch result { + case .done, .error: + continuation.finish() + return + default: + break + } + } + + // Fallback: if no [DONE] and no finish_reason + SSEStreamParser.emitToolCalls(toolCallAccum, continuation: continuation, gotContent: &gotAnyContent) + + if !gotAnyContent { + SSEStreamParser.parseNonStreamingResponse(data: data, continuation: continuation) + } + + continuation.finish() + } + } + + private func buildRequestBody(messages: [APIMessage], tools: [[String: Any]], systemPrompt: String) -> [String: Any] { + var openAIMessages: [[String: Any]] = [ + ["role": "system", "content": systemPrompt], + ] + + for msg in messages { + if msg.role == "user" { + let toolResults = msg.content.compactMap { block -> [String: Any]? in + if case .toolResult(let toolUseId, let content, _) = block { + var resultText = "" + for c in content { + if case .text(let t) = c { resultText += t } + } + for c in content { + if case .image(let base64, _) = c { + resultText += "\n[Screenshot attached as base64 image, \(base64.count / 1024)KB]" + } + } + if resultText.isEmpty { resultText = "Done" } + return [ + "role": "tool", + "tool_call_id": toolUseId, + "content": resultText, + ] + } + return nil + } + + if !toolResults.isEmpty { + openAIMessages.append(contentsOf: toolResults) + } else { + var contentParts: [[String: Any]] = [] + for block in msg.content { + switch block { + case .text(let text): + contentParts.append(["type": "text", "text": text]) + case .image(let base64, let mediaType): + contentParts.append([ + "type": "image_url", + "image_url": ["url": "data:\(mediaType);base64,\(base64)"], + ]) + default: + break + } + } + if contentParts.count == 1, case .text(let t) = msg.content.first { + openAIMessages.append(["role": "user", "content": t]) + } else if !contentParts.isEmpty { + openAIMessages.append(["role": "user", "content": contentParts]) + } + } + } else if msg.role == "assistant" { + var assistantMsg: [String: Any] = ["role": "assistant"] + var textContent = "" + var toolCallsList: [[String: Any]] = [] + + for block in msg.content { + switch block { + case .text(let text): + textContent += text + case .toolUse(let id, let name, let input): + let argsData = try? JSONSerialization.data(withJSONObject: input) + let argsStr = argsData.flatMap { String(data: $0, encoding: .utf8) } ?? "{}" + toolCallsList.append([ + "id": id, + "type": "function", + "function": [ + "name": name, + "arguments": argsStr, + ] as [String: Any], + ]) + default: + break + } + } + + if !textContent.isEmpty { + assistantMsg["content"] = textContent + } + if !toolCallsList.isEmpty { + assistantMsg["tool_calls"] = toolCallsList + } + openAIMessages.append(assistantMsg) + } + } + + let openAITools = tools.compactMap { tool -> [String: Any]? in + guard let name = tool["name"] as? String, + let description = tool["description"] as? String, + let inputSchema = tool["input_schema"] as? [String: Any] + else { return nil } + + return [ + "type": "function", + "function": [ + "name": name, + "description": description, + "parameters": inputSchema, + ] as [String: Any], + ] + } + + var body: [String: Any] = [ + "model": model, + "max_tokens": Constants.maxTokens, + "stream": true, + "messages": openAIMessages, + ] + + if !openAITools.isEmpty { + body["tools"] = openAITools + } + + return body + } +} diff --git a/companion/iPhoneCompanion/Services/SSEStreamParser.swift b/companion/iPhoneCompanion/Services/SSEStreamParser.swift new file mode 100644 index 0000000..53f84f4 --- /dev/null +++ b/companion/iPhoneCompanion/Services/SSEStreamParser.swift @@ -0,0 +1,163 @@ +import Foundation + +/// Reusable SSE line parser and tool call accumulator for OpenAI-compatible streaming APIs. +enum SSEStreamParser { + struct AccumulatedToolCall { + var id: String = "" + var name: String = "" + var args: String = "" + } + + /// Parse a single SSE data line (after stripping "data: " prefix) and accumulate tool calls. + /// Returns the parsed content or nil if the line should be skipped. + static func parseLine( + _ jsonStr: String, + toolCallAccum: inout [Int: AccumulatedToolCall], + continuation: AsyncStream.Continuation, + gotAnyContent: inout Bool + ) -> ParseResult { + if jsonStr == "[DONE]" { + emitToolCalls(toolCallAccum, continuation: continuation, gotContent: &gotAnyContent) + if !gotAnyContent { + continuation.yield(.error("No response from model")) + } + continuation.yield(.done(stopReason: "end_turn")) + return .done + } + + guard let jsonData = jsonStr.data(using: .utf8), + let event = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] + else { return .skip } + + if let error = event["error"] as? [String: Any] { + let msg = error["message"] as? String ?? "Unknown API error" + continuation.yield(.error(msg)) + return .error + } + + guard let choices = event["choices"] as? [[String: Any]], + let choice = choices.first + else { return .skip } + + let finishReason = choice["finish_reason"] as? String + + if let delta = choice["delta"] as? [String: Any] { + if let content = delta["content"] as? String, !content.isEmpty { + gotAnyContent = true + continuation.yield(.textDelta(content)) + } + + // Accumulate tool call chunks + if let toolCalls = delta["tool_calls"] as? [[String: Any]] { + for call in toolCalls { + let index = call["index"] as? Int ?? 0 + var existing = toolCallAccum[index] ?? AccumulatedToolCall() + + if let id = call["id"] as? String, !id.isEmpty { + existing.id = id + } + if let function = call["function"] as? [String: Any] { + if let name = function["name"] as? String, !name.isEmpty { + existing.name = name + } + if let args = function["arguments"] as? String { + existing.args += args + } + } + toolCallAccum[index] = existing + } + } + } + + if let reason = finishReason { + emitToolCalls(toolCallAccum, continuation: continuation, gotContent: &gotAnyContent) + toolCallAccum.removeAll() + + let stopReason = (reason == "tool_calls") ? "tool_use" : "end_turn" + continuation.yield(.done(stopReason: stopReason)) + return .finishReason(reason) + } + + return .continue + } + + /// Emit accumulated tool calls as StreamEvents. + static func emitToolCalls( + _ accum: [Int: AccumulatedToolCall], + continuation: AsyncStream.Continuation, + gotContent: inout Bool + ) { + for index in accum.keys.sorted() { + let call = accum[index] + guard let call, !call.name.isEmpty else { continue } + gotContent = true + + let id = call.id.isEmpty ? "or-\(UUID().uuidString.prefix(8))" : call.id + var input: [String: Any] = [:] + if let argsData = call.args.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: argsData) as? [String: Any] { + input = parsed + } + continuation.yield(.toolUse(id: id, name: call.name, input: input)) + } + } + + /// Parse a non-streaming JSON response and emit events. + static func parseNonStreamingResponse( + data: Data, + continuation: AsyncStream.Continuation + ) { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + continuation.yield(.error("Empty response from model")) + return + } + + if let error = json["error"] as? [String: Any] { + let msg = error["message"] as? String ?? "Unknown error" + continuation.yield(.error(msg)) + return + } + + guard let choices = json["choices"] as? [[String: Any]], + let choice = choices.first, + let message = choice["message"] as? [String: Any] + else { + continuation.yield(.error("Unexpected response format")) + return + } + + if let content = message["content"] as? String { + continuation.yield(.textDelta(content)) + } + + // Check for tool calls in non-streaming response + if let toolCalls = message["tool_calls"] as? [[String: Any]] { + for call in toolCalls { + guard let function = call["function"] as? [String: Any], + let name = function["name"] as? String + else { continue } + let id = call["id"] as? String ?? "or-\(UUID().uuidString.prefix(8))" + let argsStr = function["arguments"] as? String ?? "{}" + var input: [String: Any] = [:] + if let argsData = argsStr.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: argsData) as? [String: Any] { + input = parsed + } + continuation.yield(.toolUse(id: id, name: name, input: input)) + } + let fr = choice["finish_reason"] as? String + let sr = (fr == "tool_calls") ? "tool_use" : "end_turn" + continuation.yield(.done(stopReason: sr)) + } else { + continuation.yield(.done(stopReason: "end_turn")) + } + } + + enum ParseResult { + case done + case error + case skip + case `continue` + case finishReason(String) + } +} diff --git a/companion/iPhoneCompanion/Services/ScriptRunner.swift b/companion/iPhoneCompanion/Services/ScriptRunner.swift new file mode 100644 index 0000000..e174fc1 --- /dev/null +++ b/companion/iPhoneCompanion/Services/ScriptRunner.swift @@ -0,0 +1,104 @@ +import Foundation + +struct ScriptResult { + let stdout: String + let stderr: String + let exitCode: Int32 +} + +/// Protocol for script execution (DIP support). +protocol ScriptRunning: Sendable { + func run(script: String, args: [String], timeout: TimeInterval) async throws -> ScriptResult +} + +extension ScriptRunning { + func run(script: String) async throws -> ScriptResult { + try await run(script: script, args: [], timeout: Constants.defaultScriptTimeout) + } + + func run(script: String, args: [String]) async throws -> ScriptResult { + try await run(script: script, args: args, timeout: Constants.defaultScriptTimeout) + } +} + +actor ScriptRunner: ScriptRunning { + let scriptsPath: String + + init(scriptsPath: String = Constants.defaultScriptsPath) { + self.scriptsPath = scriptsPath + } + + func run(script: String, args: [String] = [], timeout: TimeInterval = Constants.defaultScriptTimeout) async throws -> ScriptResult { + let scriptPath = (scriptsPath as NSString).appendingPathComponent(script) + + guard FileManager.default.fileExists(atPath: scriptPath) else { + throw ScriptError.notFound(scriptPath) + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/bash") + process.arguments = [scriptPath] + args + process.environment = ProcessInfo.processInfo.environment.merging( + ["PATH": Constants.scriptPATH], + uniquingKeysWith: { _, new in new } + ) + process.currentDirectoryURL = URL(fileURLWithPath: scriptsPath) + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + return try await withCheckedThrowingContinuation { continuation in + var timedOut = false + let timer = DispatchSource.makeTimerSource() + timer.schedule(deadline: .now() + timeout) + timer.setEventHandler { + timedOut = true + process.terminate() + } + timer.resume() + + process.terminationHandler = { _ in + timer.cancel() + + let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + + let stdout = String(data: stdoutData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let stderr = String(data: stderrData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + if timedOut { + continuation.resume(throwing: ScriptError.timeout(timeout)) + } else if process.terminationStatus != 0 { + continuation.resume(throwing: ScriptError.failed(stderr.isEmpty ? "Exit code \(process.terminationStatus)" : stderr)) + } else { + continuation.resume(returning: ScriptResult(stdout: stdout, stderr: stderr, exitCode: process.terminationStatus)) + } + } + + do { + try process.run() + } catch { + timer.cancel() + continuation.resume(throwing: ScriptError.launchFailed(error.localizedDescription)) + } + } + } +} + +enum ScriptError: LocalizedError { + case notFound(String) + case failed(String) + case timeout(TimeInterval) + case launchFailed(String) + + var errorDescription: String? { + switch self { + case .notFound(let path): return "Script not found: \(path)" + case .failed(let msg): return msg + case .timeout(let t): return "Script timed out after \(Int(t))s" + case .launchFailed(let msg): return "Failed to launch: \(msg)" + } + } +} diff --git a/companion/iPhoneCompanion/Services/ToolExecutor.swift b/companion/iPhoneCompanion/Services/ToolExecutor.swift new file mode 100644 index 0000000..3233c46 --- /dev/null +++ b/companion/iPhoneCompanion/Services/ToolExecutor.swift @@ -0,0 +1,258 @@ +import Foundation +import CoreGraphics +import AppKit + +struct ToolResult { + let text: String? + let imageData: Data? + let isError: Bool + + static func text(_ text: String) -> ToolResult { + ToolResult(text: text, imageData: nil, isError: false) + } + + static func image(_ data: Data) -> ToolResult { + ToolResult(text: nil, imageData: data, isError: false) + } + + static func error(_ message: String) -> ToolResult { + ToolResult(text: message, imageData: nil, isError: true) + } +} + +actor ToolExecutor { + let scriptRunner: ScriptRunning + let inputController: InputControlling + + init(scriptsPath: String = Constants.defaultScriptsPath) { + let runner = ScriptRunner(scriptsPath: scriptsPath) + self.scriptRunner = runner + self.inputController = InputController(scriptRunner: runner) + } + + init(scriptRunner: ScriptRunning, inputController: InputControlling) { + self.scriptRunner = scriptRunner + self.inputController = inputController + } + + func execute(toolName: String, input: [String: Any]) async -> ToolResult { + do { + switch toolName { + case "find_window": + return try await executeFindWindow() + case "screenshot": + return try await executeScreenshot() + case "tap": + return try await executeTap(input: input) + case "swipe": + return try await executeSwipe(input: input) + case "type_text": + return try await executeTypeText(input: input) + case "open_app": + return try await executeOpenApp(input: input) + case "home": + return try await executeHome() + case "app_switcher": + return try await executeAppSwitcher() + case "status": + return try await executeStatus() + case "scan_apps", "map_apps": + return try await executeScanApps(input: input) + case "list_apps": + return try await executeListApps() + default: + return .error("Unknown tool: \(toolName)") + } + } catch { + return .error(error.localizedDescription) + } + } + + // MARK: - Individual Tool Methods + + private func executeFindWindow() async throws -> ToolResult { + if let result = nativeFindWindow() { return result } + let result = try await scriptRunner.run(script: "find-window.sh") + return .text(result.stdout) + } + + private func executeScreenshot() async throws -> ToolResult { + if let result = nativeScreenshot() { return result } + // Fallback: shell script + let tmpPath = "/tmp/iphone-companion-\(Int(Date().timeIntervalSince1970 * 1000)).png" + let result = try await scriptRunner.run(script: "screenshot.sh", args: [tmpPath]) + let outputPath = result.stdout.isEmpty ? tmpPath : result.stdout + guard let data = try? Data(contentsOf: URL(fileURLWithPath: outputPath)) else { + return .error("Screenshot captured but file not readable") + } + try? FileManager.default.removeItem(atPath: outputPath) + return .image(data) + } + + // MARK: - Native CG Implementations (no child process, no TCC inheritance issues) + + private func iphoneMirroringWindows() -> [[String: Any]] { + guard let list = CGWindowListCopyWindowInfo( + [.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID + ) as? [[String: Any]] else { return [] } + return list.filter { window in + guard let owner = window[kCGWindowOwnerName as String] as? String, + owner == "iPhone Mirroring", + let boundsDict = window[kCGWindowBounds as String] as? NSDictionary + else { return false } + var rect = CGRect.zero + CGRectMakeWithDictionaryRepresentation(boundsDict as CFDictionary, &rect) + return rect.width > 100 && rect.height > 100 + } + } + + private func nativeFindWindow() -> ToolResult? { + guard let window = iphoneMirroringWindows().first, + let boundsDict = window[kCGWindowBounds as String] as? NSDictionary + else { return nil } + + var rect = CGRect.zero + CGRectMakeWithDictionaryRepresentation(boundsDict as CFDictionary, &rect) + + let mx = Int(rect.width * 0.06) + let mt = Int(rect.height * 0.09) + let mb = Int(rect.height * 0.04) + + let json = """ + {"window_x":\(Int(rect.minX)),"window_y":\(Int(rect.minY)),"window_width":\(Int(rect.width)),"window_height":\(Int(rect.height)),"content_x":\(Int(rect.minX) + mx),"content_y":\(Int(rect.minY) + mt),"content_width":\(Int(rect.width) - mx * 2),"content_height":\(Int(rect.height) - mt - mb)} + """ + return .text(json) + } + + private func nativeScreenshot() -> ToolResult? { + guard let window = iphoneMirroringWindows().first, + let windowId = window[kCGWindowNumber as String] as? CGWindowID, + let boundsDict = window[kCGWindowBounds as String] as? NSDictionary + else { return nil } + + var rect = CGRect.zero + CGRectMakeWithDictionaryRepresentation(boundsDict as CFDictionary, &rect) + + guard let cgImage = CGWindowListCreateImage( + rect, + .optionIncludingWindow, + windowId, + [.boundsIgnoreFraming, .shouldBeOpaque] + ) else { return nil } + + // Crop to content area (remove bezels). + // CGWindowListCreateImage returns PIXELS (2x on Retina) while rect is points; + // cropping(to:) works in pixels, so scale the margins by the measured ratio. + let pixelScale = rect.width > 0 ? CGFloat(cgImage.width) / rect.width : 1 + let mx = Int(rect.width * 0.06 * pixelScale) + let mt = Int(rect.height * 0.09 * pixelScale) + let mb = Int(rect.height * 0.04 * pixelScale) + let cropRect = CGRect( + x: mx, y: mt, + width: cgImage.width - mx * 2, + height: cgImage.height - mt - mb + ) + + guard let cropped = cgImage.cropping(to: cropRect) else { return nil } + + let nsImage = NSImage(cgImage: cropped, size: cropRect.size) + guard let tiffData = nsImage.tiffRepresentation, + let bitmapRep = NSBitmapImageRep(data: tiffData), + let pngData = bitmapRep.representation(using: .png, properties: [:]) + else { return nil } + + return .image(pngData) + } + + private func executeTap(input: [String: Any]) async throws -> ToolResult { + guard let x = intValue(input["x"]), + let y = intValue(input["y"]) else { + return .error("tap requires x and y coordinates") + } + _ = try await inputController.tap(x: x, y: y) + return .text("Tapped at (\(x), \(y)). Screenshot to verify.") + } + + private func executeSwipe(input: [String: Any]) async throws -> ToolResult { + guard let x1 = intValue(input["x1"]), + let y1 = intValue(input["y1"]), + let x2 = intValue(input["x2"]), + let y2 = intValue(input["y2"]) else { + return .error("swipe requires x1, y1, x2, y2 coordinates") + } + let duration = intValue(input["duration_ms"]) ?? Constants.defaultSwipeDuration + _ = try await inputController.swipe(x1: x1, y1: y1, x2: x2, y2: y2, durationMs: duration) + return .text("Swiped from (\(x1),\(y1)) to (\(x2),\(y2)). Screenshot to verify.") + } + + private func executeTypeText(input: [String: Any]) async throws -> ToolResult { + guard let text = input["text"] as? String else { + return .error("type_text requires text parameter") + } + let tapX = intValue(input["x"]) + let tapY = intValue(input["y"]) + _ = try await inputController.typeText(text, tapX: tapX, tapY: tapY) + return .text("Typed: \"\(text)\". Screenshot to verify.") + } + + private func executeOpenApp(input: [String: Any]) async throws -> ToolResult { + guard let name = input["name"] as? String else { + return .error("open_app requires name parameter") + } + + var args = [name] + // open-app.sh expects the literal flag "--spotlight", not the raw method value + if let method = input["method"] as? String, method == "spotlight" { + args.append("--spotlight") + } + + let result = try await scriptRunner.run(script: "open-app.sh", args: args, timeout: Constants.defaultScriptTimeout) + let output = result.stdout.isEmpty ? "Opened \(name)" : result.stdout + return .text("\(output). Screenshot needed to check for overlay prompts.") + } + + private func executeHome() async throws -> ToolResult { + _ = try await inputController.clickViewMenuItem("Home Screen") + return .text("Went to Home Screen. Screenshot to verify.") + } + + private func executeAppSwitcher() async throws -> ToolResult { + _ = try await inputController.clickViewMenuItem("App Switcher") + return .text("Opened App Switcher. Screenshot to verify.") + } + + private func executeStatus() async throws -> ToolResult { + let result = try await scriptRunner.run(script: "status.sh") + return .text(result.stdout) + } + + private func executeScanApps(input: [String: Any]) async throws -> ToolResult { + var args = ["scan"] + if let maxPages = numberValue(input["max_pages"]) { + args.append(maxPages) + } + let result = try await scriptRunner.run(script: "registry.sh", args: args, timeout: Constants.scanTimeout) + return .text(result.stdout) + } + + private func executeListApps() async throws -> ToolResult { + let result = try await scriptRunner.run(script: "registry.sh", args: ["list"]) + return .text(result.stdout) + } + + // MARK: - Helpers + + private func intValue(_ value: Any?) -> Int? { + if let n = value as? Int { return n } + if let n = value as? Double { return Int(n) } + if let s = value as? String { return Int(s) } + return nil + } + + private func numberValue(_ value: Any?) -> String? { + if let n = value as? Int { return String(n) } + if let n = value as? Double { return String(Int(n)) } + if let s = value as? String { return s } + return nil + } +} diff --git a/companion/iPhoneCompanion/Services/ToolOrchestrator.swift b/companion/iPhoneCompanion/Services/ToolOrchestrator.swift new file mode 100644 index 0000000..aaaa409 --- /dev/null +++ b/companion/iPhoneCompanion/Services/ToolOrchestrator.swift @@ -0,0 +1,162 @@ +import Foundation + +/// Orchestrates the AI tool-calling loop: sends messages, executes tool calls, and loops. +/// Separated from Conversation to respect single-responsibility principle. +actor ToolOrchestrator { + private let toolExecutor: ToolExecutor + + init(toolExecutor: ToolExecutor) { + self.toolExecutor = toolExecutor + } + + struct LoopCallbacks { + let createService: () async -> AIService? + let getBackend: () -> AIBackend + let getApiMessages: () -> [APIMessage] + let appendApiMessage: (APIMessage) -> Void + let appendChatMessage: (ChatMessage) -> Void + let updateChatMessage: (Int, [ContentBlock]) -> Void + let chatMessageCount: () -> Int + let needsVisionProxy: () -> Bool + let describeScreenshot: (Data) async -> String + let isCancelled: () -> Bool + } + + func runAILoop(callbacks: LoopCallbacks) async { + var iteration = 0 + + while iteration < Constants.maxToolIterations { + guard !callbacks.isCancelled() else { return } + iteration += 1 + + guard let service = await callbacks.createService() else { + let backend = callbacks.getBackend() + if backend == .claudeCode { + callbacks.appendChatMessage(.assistant("Error: Claude Code credentials not found or expired. Run `claude` in terminal to authenticate.")) + } else { + callbacks.appendChatMessage(.assistant("Error: No API key configured. Tap the model bar to add your API key.")) + } + return + } + + do { + let stream = try await service.send( + messages: callbacks.getApiMessages(), + tools: ToolDefinitions.all, + systemPrompt: Constants.systemPrompt + ) + + var assistantText = "" + var toolCalls: [(id: String, name: String, input: [String: Any])] = [] + var stopReason = "end_turn" + var currentAssistantIndex: Int? + + for await event in stream { + guard !callbacks.isCancelled() else { return } + switch event { + case .textDelta(let delta): + assistantText += delta + if let idx = currentAssistantIndex { + callbacks.updateChatMessage(idx, [.text(assistantText)]) + } else { + callbacks.appendChatMessage(ChatMessage(role: .assistant, content: [.text(assistantText)])) + currentAssistantIndex = callbacks.chatMessageCount() - 1 + } + + case .toolUse(let id, let name, let input): + toolCalls.append((id: id, name: name, input: input)) + + case .done(let reason): + stopReason = reason + + case .error(let msg): + callbacks.appendChatMessage(.assistant("Error: \(msg)")) + return + } + } + + // Build assistant API message with all content blocks + var assistantBlocks: [APIContentBlock] = [] + if !assistantText.isEmpty { + assistantBlocks.append(.text(assistantText)) + } + for call in toolCalls { + assistantBlocks.append(.toolUse(id: call.id, name: call.name, input: call.input)) + } + if !assistantBlocks.isEmpty { + callbacks.appendApiMessage(APIMessage(role: "assistant", content: assistantBlocks)) + } + + // If no tool calls, we're done + if stopReason != "tool_use" || toolCalls.isEmpty { + NSLog("[Companion] AI loop done: stopReason=%@, toolCalls=%d, textLen=%d", stopReason, toolCalls.count, assistantText.count) + return + } + NSLog("[Companion] Executing %d tool calls", toolCalls.count) + + // Execute tool calls and collect results + var toolResultBlocks: [APIContentBlock] = [] + + for call in toolCalls { + guard !callbacks.isCancelled() else { return } + // Show tool running status + let toolMsg = ChatMessage(role: .assistant, content: [ + .toolCall(id: call.id, name: call.name, status: .running), + ]) + callbacks.appendChatMessage(toolMsg) + let toolMsgIndex = callbacks.chatMessageCount() - 1 + + let result = await toolExecutor.execute(toolName: call.name, input: call.input) + + // Update tool status + if result.isError { + callbacks.updateChatMessage(toolMsgIndex, [ + .toolCall(id: call.id, name: call.name, status: .failed(result.text ?? "Unknown error")), + ]) + } else { + callbacks.updateChatMessage(toolMsgIndex, [ + .toolCall(id: call.id, name: call.name, status: .completed), + ]) + } + + // Add screenshot to chat if present + if let imageData = result.imageData { + callbacks.appendChatMessage(ChatMessage(role: .assistant, content: [.image(imageData)])) + } + + // Build tool result for API + var resultContent: [APIContentBlock] = [] + if let text = result.text { + resultContent.append(.text(text)) + } + if let imageData = result.imageData { + if callbacks.needsVisionProxy() { + let description = await callbacks.describeScreenshot(imageData) + resultContent.append(.text("[Screenshot description]: \(description)")) + } else { + resultContent.append(.image(base64: imageData.base64EncodedString(), mediaType: "image/png")) + } + } + if resultContent.isEmpty { + resultContent.append(.text("Done")) + } + + toolResultBlocks.append(.toolResult( + toolUseId: call.id, + content: resultContent, + isError: result.isError + )) + } + + // Add tool results as a user message (Claude API requirement) + callbacks.appendApiMessage(APIMessage(role: "user", content: toolResultBlocks)) + + } catch { + callbacks.appendChatMessage(.assistant("Error: \(error.localizedDescription)")) + return + } + } + + callbacks.appendChatMessage(.assistant("Stopped: too many tool call iterations.")) + } +} diff --git a/companion/iPhoneCompanion/Utilities/AccessibilityHelper.swift b/companion/iPhoneCompanion/Utilities/AccessibilityHelper.swift new file mode 100644 index 0000000..e46be16 --- /dev/null +++ b/companion/iPhoneCompanion/Utilities/AccessibilityHelper.swift @@ -0,0 +1,78 @@ +import Foundation +import AppKit + +/// Encapsulates AX API traversal for menu bar interaction. +enum AccessibilityHelper { + /// Click a menu item under the "View" menu of iPhone Mirroring. + static func clickViewMenuItem(_ itemName: String, pid: pid_t) throws -> String { + let appElement = AXUIElementCreateApplication(pid) + + // Get menu bar + var menuBarRef: CFTypeRef? + guard AXUIElementCopyAttributeValue(appElement, kAXMenuBarAttribute as CFString, &menuBarRef) == .success else { + throw InputError.menuNotFound("Cannot access menu bar") + } + let menuBar = unsafeBitCast(menuBarRef, to: AXUIElement.self) + + // Find "View" menu bar item + var menuBarItems: CFTypeRef? + guard AXUIElementCopyAttributeValue(menuBar, kAXChildrenAttribute as CFString, &menuBarItems) == .success, + let items = menuBarItems as? [AXUIElement] + else { + throw InputError.menuNotFound("Cannot access menu bar items") + } + + var viewMenuItem: AXUIElement? + for item in items { + var titleRef: CFTypeRef? + if AXUIElementCopyAttributeValue(item, kAXTitleAttribute as CFString, &titleRef) == .success, + let title = titleRef as? String, title == "View" { + viewMenuItem = item + break + } + } + + guard let viewItem = viewMenuItem else { + throw InputError.menuNotFound("View menu not found") + } + + // Press the View menu to open it + AXUIElementPerformAction(viewItem, kAXPressAction as CFString) + Thread.sleep(forTimeInterval: Constants.menuOpenDelay) + + // Get the menu's children + var menuRef: CFTypeRef? + guard AXUIElementCopyAttributeValue(viewItem, kAXChildrenAttribute as CFString, &menuRef) == .success, + let menus = menuRef as? [AXUIElement], + let menu = menus.first + else { + throw InputError.menuNotFound("View menu has no submenu") + } + + var menuChildren: CFTypeRef? + guard AXUIElementCopyAttributeValue(menu, kAXChildrenAttribute as CFString, &menuChildren) == .success, + let menuItems = menuChildren as? [AXUIElement] + else { + throw InputError.menuNotFound("Cannot access View menu items") + } + + // Find and click the target item + for menuItem in menuItems { + var titleRef: CFTypeRef? + if AXUIElementCopyAttributeValue(menuItem, kAXTitleAttribute as CFString, &titleRef) == .success, + let title = titleRef as? String, title == itemName { + AXUIElementPerformAction(menuItem, kAXPressAction as CFString) + return "\(itemName) activated" + } + } + + // Dismiss menu if item not found + let src = CGEventSource(stateID: .hidSystemState) + let keyDown = CGEvent(keyboardEventSource: src, virtualKey: 53, keyDown: true) + let keyUp = CGEvent(keyboardEventSource: src, virtualKey: 53, keyDown: false) + keyDown?.post(tap: .cghidEventTap) + keyUp?.post(tap: .cghidEventTap) + + throw InputError.menuNotFound("\(itemName) not found in View menu") + } +} diff --git a/companion/iPhoneCompanion/Utilities/ClaudeCodeAuthHelper.swift b/companion/iPhoneCompanion/Utilities/ClaudeCodeAuthHelper.swift new file mode 100644 index 0000000..5e94033 --- /dev/null +++ b/companion/iPhoneCompanion/Utilities/ClaudeCodeAuthHelper.swift @@ -0,0 +1,108 @@ +import Foundation + +/// Reads Claude Code OAuth credentials from ~/.claude/.credentials.json +/// and handles token refresh when expired. +enum ClaudeCodeAuthHelper { + private static var credentialsPath: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".claude") + .appendingPathComponent(".credentials.json") + } + + struct Credentials { + let accessToken: String + let refreshToken: String + let expiresAt: Date + let subscriptionType: String + } + + static var isAvailable: Bool { + FileManager.default.fileExists(atPath: credentialsPath.path) + } + + static func loadCredentials() -> Credentials? { + guard let data = try? Data(contentsOf: credentialsPath), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let oauth = json["claudeAiOauth"] as? [String: Any], + let accessToken = oauth["accessToken"] as? String, + let refreshToken = oauth["refreshToken"] as? String, + let expiresAtMs = oauth["expiresAt"] as? Double + else { return nil } + + return Credentials( + accessToken: accessToken, + refreshToken: refreshToken, + expiresAt: Date(timeIntervalSince1970: expiresAtMs / 1000), + subscriptionType: oauth["subscriptionType"] as? String ?? "unknown" + ) + } + + static func getValidToken() async -> String? { + guard let creds = loadCredentials() else { return nil } + + if creds.expiresAt.timeIntervalSinceNow < 300 { + if let refreshed = await refreshToken(creds.refreshToken) { + return refreshed + } + if creds.expiresAt.timeIntervalSinceNow > 0 { + return creds.accessToken + } + return nil + } + + return creds.accessToken + } + + private static func refreshToken(_ refreshToken: String) async -> String? { + let url = URL(string: Constants.oauthTokenURL)! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "content-type") + + let body: [String: Any] = [ + "grant_type": "refresh_token", + "refresh_token": refreshToken, + "client_id": Constants.oauthClientId, + ] + + guard let bodyData = try? JSONSerialization.data(withJSONObject: body) else { return nil } + request.httpBody = bodyData + + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + NSLog("[Companion] Token refresh failed: HTTP %d", (response as? HTTPURLResponse)?.statusCode ?? 0) + return nil + } + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let newAccessToken = json["access_token"] as? String, + let newRefreshToken = json["refresh_token"] as? String, + let expiresIn = json["expires_in"] as? Double + else { return nil } + + let expiresAtMs = (Date().timeIntervalSince1970 + expiresIn) * 1000 + let updatedOauth: [String: Any] = [ + "accessToken": newAccessToken, + "refreshToken": newRefreshToken, + "expiresAt": expiresAtMs, + "scopes": ["user:inference", "user:mcp_servers", "user:profile", "user:sessions:claude_code"], + "subscriptionType": loadCredentials()?.subscriptionType ?? "unknown", + ] + + let updatedCreds: [String: Any] = ["claudeAiOauth": updatedOauth] + do { + let saveData = try JSONSerialization.data(withJSONObject: updatedCreds) + try saveData.write(to: credentialsPath, options: .atomic) + } catch { + NSLog("[Companion] Failed to save refreshed credentials: %@", error.localizedDescription) + } + + NSLog("[Companion] Claude Code token refreshed") + return newAccessToken + } catch { + NSLog("[Companion] Token refresh error: %@", error.localizedDescription) + return nil + } + } +} diff --git a/companion/iPhoneCompanion/Utilities/Constants.swift b/companion/iPhoneCompanion/Utilities/Constants.swift new file mode 100644 index 0000000..26ffa23 --- /dev/null +++ b/companion/iPhoneCompanion/Utilities/Constants.swift @@ -0,0 +1,157 @@ +import Foundation + +enum Constants { + static let defaultScriptsPath: String = { + // Try to detect from app bundle location (companion/ is inside iphone-control/) + let bundlePath = Bundle.main.bundlePath + let companionDir = (bundlePath as NSString).deletingLastPathComponent + let parentDir = (companionDir as NSString).deletingLastPathComponent + + // Check if parent has iphone-control.sh + let marker = (parentDir as NSString).appendingPathComponent("iphone-control.sh") + if FileManager.default.fileExists(atPath: marker) { + return parentDir + } + + // Fallback: check common locations + let home = NSHomeDirectory() + let candidates = [ + "\(home)/git/iphone-control", + "\(home)/Developer/iphone-control", + "\(home)/Projects/iphone-control", + ] + for path in candidates { + let check = (path as NSString).appendingPathComponent("iphone-control.sh") + if FileManager.default.fileExists(atPath: check) { + return path + } + } + + return "\(home)/git/iphone-control" + }() + + static let scriptPATH = [ + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ].joined(separator: ":") + + // MARK: - API URLs + + static let claudeAPIURL = "https://api.anthropic.com/v1/messages" + static let claudeModelsURL = "https://api.anthropic.com/v1/models" + static let openRouterAPIURL = "https://openrouter.ai/api/v1/chat/completions" + static let openRouterModelsURL = "https://openrouter.ai/api/v1/models" + static let minimaxAPIURL = "https://api.minimax.io/v1/chat/completions" + + // MARK: - Default Models + + static let defaultClaudeModel = "claude-sonnet-4-6-20250514" + static let defaultOpenRouterModel = "anthropic/claude-sonnet-4.6" + static let defaultOpenRouterVisionModel = "google/gemma-3-12b-it:free" + static let defaultOllamaURL = "http://localhost:11434" + static let defaultOllamaModel = "llama3.1" + static let defaultMiniMaxModel = "MiniMax-M2.7" + + // MARK: - AI Request Parameters + + static let maxTokens = 4096 + static let visionMaxTokens = 1024 + + // MARK: - Tool Loop Limits + + static let maxToolIterations = 20 + + // MARK: - Timing Constants (Input Controller) + + static let tapDownUpDelay: TimeInterval = 0.02 + static let activateDelay: TimeInterval = 0.05 + static let tapSettleDelay: UInt64 = 600_000_000 // 600ms — let iOS animation settle after tap + static let swipeSettleDelay: UInt64 = 500_000_000 // 500ms — let iOS settle after swipe + static let keystrokeDelay: TimeInterval = 0.005 + static let menuOpenDelay: TimeInterval = 0.1 + static let spotlightOpenDelay: UInt64 = 400_000_000 // nanoseconds + static let spotlightSearchDelay: UInt64 = 1_000_000_000 // nanoseconds + + // MARK: - App Identifiers + + static let iphoneMirroringBundleId = "com.apple.ScreenContinuity" + + // MARK: - Open App Constants + + static let homeScreenDelay: UInt64 = 500_000_000 // nanoseconds + static let pageSwipeDelay: UInt64 = 300_000_000 // nanoseconds + static let swipeEdgeMargin = 30 + static let defaultSwipeDuration = 300 // milliseconds + + // MARK: - Script Timeouts + + static let defaultScriptTimeout: TimeInterval = 15 + static let scanTimeout: TimeInterval = 120 + + // MARK: - OAuth + + static let oauthClientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" + static let oauthTokenURL = "https://console.anthropic.com/v1/oauth/token" + + // MARK: - System Prompt + + static let systemPrompt = """ + You control an iPhone through iPhone Mirroring on macOS. You have tools to tap, swipe, type, take screenshots, open apps, and navigate. + + Coordinates are relative to the iPhone content area (top-left = 0,0). + + ## MANDATORY VERIFICATION LOOP — follow for EVERY action: + 1. take_screenshot → analyze what's on screen + 2. decide on ONE action + 3. execute that action + 4. take_screenshot → confirm the result before proceeding + 5. If the result is unexpected, STOP and re-assess — never blindly continue + + ## OVERLAY HANDLING: + After any navigation or app launch, always check for and dismiss prompts ("Not Now", "Skip", "Later", "Allow", "Don't Allow", "Continue", "OK"). Tap them before proceeding. Failure to dismiss overlays will cause all subsequent taps to miss their targets. + + ## COORDINATE ACCURACY: + - Tap exactly on the center of interactive elements — buttons, icons, input fields + - If a tap doesn't produce the expected result, do NOT retry in the same spot; re-evaluate coordinates from a fresh screenshot + - Swipe slowly and deliberately; verify the swipe worked before the next action + + ## RESPONSE STYLE: + - Describe what you SEE on screen after each screenshot, then what you're doing next + - Never repeat raw tool output (e.g., don't say "Tapped at (187, 400)") + - Be concise — one sentence per action is enough + - If you're stuck or something unexpected happens, say so clearly + + ## SPEED: + - Never chain multiple actions without a verification screenshot between them + - Quality over speed — one correct action is better than three wrong ones + """ + + // Fallback static list if API fetch fails + static let fallbackClaudeModels: [(id: String, name: String)] = [ + ("claude-opus-4-6-20250514", "Opus 4.6"), + ("claude-sonnet-4-6-20250514", "Sonnet 4.6"), + ("claude-sonnet-4-5-20250514", "Sonnet 4.5"), + ("claude-haiku-4-5-20251001", "Haiku 4.5"), + ] + + static let fallbackOpenRouterModels: [(id: String, name: String)] = [ + ("anthropic/claude-opus-4.6", "Claude Opus 4.6"), + ("anthropic/claude-sonnet-4.6", "Claude Sonnet 4.6"), + ("anthropic/claude-haiku-4.5", "Claude Haiku 4.5"), + ("google/gemini-2.5-pro-preview", "Gemini 2.5 Pro"), + ("openai/gpt-4o", "GPT-4o"), + ] + + static let fallbackMiniMaxModels: [(id: String, name: String)] = [ + ("MiniMax-M2.7", "MiniMax M2.7"), + ("MiniMax-M2.7-highspeed", "MiniMax M2.7 Highspeed"), + ("MiniMax-M2.5", "MiniMax M2.5"), + ("MiniMax-M2.5-highspeed", "MiniMax M2.5 Highspeed"), + ("M2-her", "MiniMax M2-Her (Roleplay)"), + ("MiniMax-Text-01", "MiniMax Text 01 (Legacy)"), + ] +} diff --git a/companion/iPhoneCompanion/Utilities/KeychainHelper.swift b/companion/iPhoneCompanion/Utilities/KeychainHelper.swift new file mode 100644 index 0000000..813ac64 --- /dev/null +++ b/companion/iPhoneCompanion/Utilities/KeychainHelper.swift @@ -0,0 +1,45 @@ +import Foundation + +/// Stores API keys in a local file in Application Support. +/// Avoids macOS Keychain password prompts for unsigned/dev-signed apps. +enum KeychainHelper { + private static var storePath: URL = { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + let dir = appSupport.appendingPathComponent("iPhoneCompanion", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir.appendingPathComponent(".keys") + }() + + private static func loadAll() -> [String: String] { + guard let data = try? Data(contentsOf: storePath), + let dict = try? JSONDecoder().decode([String: String].self, from: data) + else { return [:] } + return dict + } + + private static func saveAll(_ dict: [String: String]) { + do { + let data = try JSONEncoder().encode(dict) + try data.write(to: storePath, options: [.atomic, .completeFileProtection]) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: storePath.path) + } catch { + NSLog("[Companion] Failed to save keychain data: %@", error.localizedDescription) + } + } + + static func save(key: String, value: String) { + var dict = loadAll() + dict[key] = value + saveAll(dict) + } + + static func load(key: String) -> String? { + loadAll()[key] + } + + static func delete(key: String) { + var dict = loadAll() + dict.removeValue(forKey: key) + saveAll(dict) + } +} diff --git a/companion/iPhoneCompanion/Utilities/WindowManager.swift b/companion/iPhoneCompanion/Utilities/WindowManager.swift new file mode 100644 index 0000000..bdd6de9 --- /dev/null +++ b/companion/iPhoneCompanion/Utilities/WindowManager.swift @@ -0,0 +1,125 @@ +import CoreGraphics +import AppKit + +class WindowManager { + struct WindowFrame { + let origin: CGPoint + let size: CGSize + } + + func findIPhoneMirroringFrame() -> WindowFrame? { + if let frame = findViaCGWindowList() { return frame } + if let frame = findViaHelper() { return frame } + if let frame = findViaAccessibility() { return frame } + return nil + } + + private func findViaCGWindowList() -> WindowFrame? { + guard let windowList = CGWindowListCopyWindowInfo([.optionOnScreenOnly], kCGNullWindowID) as? [[String: Any]] else { + return nil + } + + for window in windowList { + guard let owner = window[kCGWindowOwnerName as String] as? String, + owner == "iPhone Mirroring", + let bounds = window[kCGWindowBounds as String] as? [String: Any], + let x = numericValue(bounds["X"]), + let y = numericValue(bounds["Y"]), + let width = numericValue(bounds["Width"]), + let height = numericValue(bounds["Height"]) + else { continue } + + return WindowFrame( + origin: CGPoint(x: x, y: y), + size: CGSize(width: width, height: height) + ) + } + return nil + } + + private func findViaHelper() -> WindowFrame? { + let scriptsPath = UserDefaults.standard.string(forKey: "scriptsPath") ?? Constants.defaultScriptsPath + let helperPath = (scriptsPath as NSString).appendingPathComponent("helpers/get-window-id") + let helperSrc = helperPath + ".swift" + + if !FileManager.default.fileExists(atPath: helperPath), + FileManager.default.fileExists(atPath: helperSrc) { + let compile = Process() + compile.executableURL = URL(fileURLWithPath: "/usr/bin/swiftc") + compile.arguments = ["-O", helperSrc, "-o", helperPath] + do { + try compile.run() + compile.waitUntilExit() + } catch { + NSLog("[Companion] Failed to compile get-window-id helper: %@", error.localizedDescription) + } + } + + guard FileManager.default.fileExists(atPath: helperPath) else { return nil } + + let process = Process() + process.executableURL = URL(fileURLWithPath: helperPath) + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + + do { + try process.run() + process.waitUntilExit() + } catch { return nil } + + guard process.terminationStatus == 0 else { return nil } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) else { return nil } + + let parts = output.split(separator: "|") + guard parts.count >= 5, + let x = Double(parts[1]), + let y = Double(parts[2]), + let width = Double(parts[3]), + let height = Double(parts[4]) + else { return nil } + + return WindowFrame( + origin: CGPoint(x: x, y: y), + size: CGSize(width: width, height: height) + ) + } + + private func findViaAccessibility() -> WindowFrame? { + let apps = NSWorkspace.shared.runningApplications + guard let mirroringApp = apps.first(where: { $0.localizedName == "iPhone Mirroring" }) else { return nil } + + let appElement = AXUIElementCreateApplication(mirroringApp.processIdentifier) + var windowsRef: CFTypeRef? + guard AXUIElementCopyAttributeValue(appElement, kAXWindowsAttribute as CFString, &windowsRef) == .success, + let windows = windowsRef as? [AXUIElement], + let window = windows.first + else { return nil } + + var positionRef: CFTypeRef? + var sizeRef: CFTypeRef? + guard AXUIElementCopyAttributeValue(window, kAXPositionAttribute as CFString, &positionRef) == .success, + AXUIElementCopyAttributeValue(window, kAXSizeAttribute as CFString, &sizeRef) == .success + else { return nil } + + var position = CGPoint.zero + var size = CGSize.zero + let positionValue = unsafeBitCast(positionRef, to: AXValue.self) + let sizeValue = unsafeBitCast(sizeRef, to: AXValue.self) + AXValueGetValue(positionValue, .cgPoint, &position) + AXValueGetValue(sizeValue, .cgSize, &size) + + guard size.width > 0, size.height > 0 else { return nil } + return WindowFrame(origin: position, size: size) + } + + private func numericValue(_ value: Any?) -> CGFloat? { + if let n = value as? CGFloat { return n } + if let n = value as? Double { return CGFloat(n) } + if let n = value as? Int { return CGFloat(n) } + if let n = value as? NSNumber { return CGFloat(n.doubleValue) } + return nil + } +} diff --git a/companion/iPhoneCompanion/Views/ChatInputView.swift b/companion/iPhoneCompanion/Views/ChatInputView.swift new file mode 100644 index 0000000..b636091 --- /dev/null +++ b/companion/iPhoneCompanion/Views/ChatInputView.swift @@ -0,0 +1,106 @@ +import SwiftUI + +/// The input bar at the bottom of the chat view (text field + send/stop button). +struct ChatInputView: View { + @Binding var inputText: String + var isInputFocused: FocusState.Binding + let isProcessing: Bool + let onSend: () -> Void + let onCancel: () -> Void + + private var canSend: Bool { + !inputText.trimmingCharacters(in: .whitespaces).isEmpty && !isProcessing + } + + var body: some View { + HStack(spacing: 10) { + ZStack(alignment: .leading) { + if inputText.isEmpty { + Text("Message...") + .font(.system(size: 13)) + .foregroundStyle(.quaternary) + .padding(.leading, 12) + } + + TextField("", text: $inputText) + .textFieldStyle(.plain) + .font(.system(size: 13)) + .focused(isInputFocused) + .onSubmit { onSend() } + .disabled(isProcessing) + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + .background( + RoundedRectangle(cornerRadius: 20) + .fill(Color(nsColor: .controlBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 20) + .strokeBorder( + isInputFocused.wrappedValue + ? Color.accentColor.opacity(0.4) + : Color.primary.opacity(0.08), + lineWidth: 1 + ) + ) + ) + .animation(.easeOut(duration: 0.2), value: isInputFocused.wrappedValue) + + // Send / Stop button + if isProcessing { + Button(action: onCancel) { + ZStack { + Circle() + .fill( + LinearGradient( + colors: [.red, .red.opacity(0.8)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 30, height: 30) + + Image(systemName: "stop.fill") + .font(.system(size: 11, weight: .bold)) + .foregroundColor(.white) + } + } + .buttonStyle(.plain) + .keyboardShortcut(.escape, modifiers: []) + .transition(.scale.combined(with: .opacity)) + } else { + Button(action: onSend) { + ZStack { + Circle() + .fill( + canSend + ? LinearGradient( + colors: [.accentColor, .accentColor.opacity(0.8)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + : LinearGradient( + colors: [Color.primary.opacity(0.1), Color.primary.opacity(0.08)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 30, height: 30) + + Image(systemName: "arrow.up") + .font(.system(size: 13, weight: .bold)) + .foregroundColor(canSend ? .white : .gray.opacity(0.3)) + } + } + .buttonStyle(.plain) + .disabled(!canSend) + .keyboardShortcut(.return, modifiers: []) + .scaleEffect(canSend ? 1.0 : 0.92) + .animation(.spring(response: 0.25), value: canSend) + .transition(.scale.combined(with: .opacity)) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + } +} diff --git a/companion/iPhoneCompanion/Views/ChatToolbarView.swift b/companion/iPhoneCompanion/Views/ChatToolbarView.swift new file mode 100644 index 0000000..7517ffe --- /dev/null +++ b/companion/iPhoneCompanion/Views/ChatToolbarView.swift @@ -0,0 +1,92 @@ +import SwiftUI + +/// The header/toolbar bar for the chat view. +struct ChatToolbarView: View { + let showSettings: Bool + let onBack: () -> Void + let onClear: () -> Void + let onSettings: () -> Void + let onDock: () -> Void + + var body: some View { + HStack(spacing: 10) { + if showSettings { + Button(action: onBack) { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + .font(.system(size: 12, weight: .semibold)) + Text("Back") + .font(.system(size: 12, weight: .medium)) + } + .foregroundStyle(Color.accentColor) + } + .buttonStyle(.plain) + + Spacer() + + Text("Settings") + .font(.system(size: 13, weight: .semibold)) + + Spacer() + // Balance the back button width + Color.clear.frame(width: 50) + } else { + // App icon + title + ZStack { + RoundedRectangle(cornerRadius: 6) + .fill( + LinearGradient( + colors: [.purple, .blue], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 22, height: 22) + Image(systemName: "iphone") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.white) + } + + Text("iPhone Companion") + .font(.system(size: 13, weight: .semibold)) + + Spacer() + + HStack(spacing: 2) { + toolbarButton(icon: "rectangle.righthalf.inset.filled.arrow.right", help: "Dock (Cmd+D)", action: onDock) + .keyboardShortcut("d", modifiers: .command) + + toolbarButton(icon: "trash", help: "Clear chat", action: onClear) + + toolbarButton(icon: "gearshape.fill", help: "Settings", action: onSettings) + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + ZStack { + Color(nsColor: .windowBackgroundColor).opacity(0.8) + .background(.ultraThinMaterial) + } + ) + .overlay(alignment: .bottom) { + Rectangle() + .fill(Color.primary.opacity(0.06)) + .frame(height: 0.5) + } + } + + @ViewBuilder + private func toolbarButton(icon: String, help: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: icon) + .font(.system(size: 12, weight: .medium)) + .frame(width: 28, height: 28) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .help(help) + } +} diff --git a/companion/iPhoneCompanion/Views/ChatView.swift b/companion/iPhoneCompanion/Views/ChatView.swift new file mode 100644 index 0000000..67c0be8 --- /dev/null +++ b/companion/iPhoneCompanion/Views/ChatView.swift @@ -0,0 +1,216 @@ +import SwiftUI + +struct ChatView: View { + @EnvironmentObject var conversation: Conversation + @State private var inputText = "" + @State private var showSettings = false + @FocusState private var isInputFocused: Bool + + var body: some View { + ZStack { + LinearGradient( + colors: [ + Color(nsColor: .windowBackgroundColor), + Color(nsColor: .windowBackgroundColor).opacity(0.95), + ], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + + VStack(spacing: 0) { + ChatToolbarView( + showSettings: showSettings, + onBack: { showSettings = false }, + onClear: { + withAnimation(.spring(response: 0.3)) { + conversation.clearHistory() + } + }, + onSettings: { showSettings = true }, + onDock: { redock() } + ) + .zIndex(1) + + if showSettings { + SettingsView() + .transition(.move(edge: .trailing).combined(with: .opacity)) + } else { + chatContent + .transition(.move(edge: .leading).combined(with: .opacity)) + } + } + } + .frame(minWidth: 320, minHeight: 400) + .onAppear { + isInputFocused = true + conversation.loadAllAPIKeys() + } + .animation(.spring(response: 0.35, dampingFraction: 0.85), value: showSettings) + } + + // MARK: - Chat Content + + private var chatContent: some View { + VStack(spacing: 0) { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 10) { + if conversation.messages.isEmpty { + emptyStateView + } + + ForEach(conversation.messages) { message in + MessageBubbleView(message: message) + .id(message.id) + } + + if conversation.isProcessing { + thinkingIndicator + .id("loading") + } + } + .padding(.vertical, 12) + } + .onChange(of: conversation.messages.count) { _, _ in + scrollToBottom(proxy) + } + .onChange(of: conversation.isProcessing) { _, _ in + scrollToBottom(proxy) + } + } + + // Bottom area + VStack(spacing: 0) { + if !conversation.messages.isEmpty && !conversation.isProcessing { + SuggestionChipsView { suggestion in + sendMessage(suggestion) + } + } + + ModelConfigView() + + ChatInputView( + inputText: $inputText, + isInputFocused: $isInputFocused, + isProcessing: conversation.isProcessing, + onSend: sendCurrentMessage, + onCancel: { conversation.cancel() } + ) + } + .background( + ZStack { + Color(nsColor: .windowBackgroundColor).opacity(0.8) + .background(.ultraThinMaterial) + } + ) + .overlay(alignment: .top) { + Rectangle() + .fill(Color.primary.opacity(0.06)) + .frame(height: 0.5) + } + } + } + + // MARK: - Empty State + + private var emptyStateView: some View { + VStack(spacing: 16) { + Spacer().frame(height: 40) + + ZStack { + Circle() + .fill( + LinearGradient( + colors: [.purple.opacity(0.15), .blue.opacity(0.1)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 64, height: 64) + + Image(systemName: "iphone.gen3") + .font(.system(size: 28, weight: .light)) + .foregroundStyle( + LinearGradient( + colors: [.purple, .blue], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + } + + VStack(spacing: 6) { + Text("Control Your iPhone") + .font(.system(size: 16, weight: .semibold)) + Text("Type a command or tap a suggestion below") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + + SuggestionChipsView { suggestion in + sendMessage(suggestion) + } + + Spacer() + } + .frame(maxWidth: .infinity) + } + + // MARK: - Thinking Indicator + + private var thinkingIndicator: some View { + HStack(spacing: 8) { + HStack(spacing: 4) { + ForEach(0..<3) { i in + Circle() + .fill(Color.accentColor) + .frame(width: 5, height: 5) + .scaleEffect(conversation.isProcessing ? 1.3 : 0.8) + .opacity(conversation.isProcessing ? 1.0 : 0.3) + .animation( + .easeInOut(duration: 0.6) + .repeatForever(autoreverses: true) + .delay(Double(i) * 0.2), + value: conversation.isProcessing + ) + } + } + Text("Thinking") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background( + Capsule() + .fill(Color.accentColor.opacity(0.06)) + ) + .padding(.horizontal, 14) + } + + // MARK: - Actions + + private func sendCurrentMessage() { + let text = inputText.trimmingCharacters(in: .whitespaces) + guard !text.isEmpty else { return } + sendMessage(text) + } + + private func sendMessage(_ text: String) { + inputText = "" + conversation.send(text) + } + + private func scrollToBottom(_ proxy: ScrollViewProxy) { + if conversation.isProcessing { + withAnimation(.easeOut(duration: 0.25)) { proxy.scrollTo("loading", anchor: .bottom) } + } else if let last = conversation.messages.last { + withAnimation(.easeOut(duration: 0.25)) { proxy.scrollTo(last.id, anchor: .bottom) } + } + } + + private func redock() { + (NSApp.delegate as? AppDelegate)?.redock() + } +} diff --git a/companion/iPhoneCompanion/Views/MessageBubbleView.swift b/companion/iPhoneCompanion/Views/MessageBubbleView.swift new file mode 100644 index 0000000..bf6674a --- /dev/null +++ b/companion/iPhoneCompanion/Views/MessageBubbleView.swift @@ -0,0 +1,342 @@ +import SwiftUI + +struct MessageBubbleView: View { + let message: ChatMessage + @State private var appeared = false + + /// Cleans AI response text by removing internal tags like , tool calls, etc. + private func cleanDisplayText(_ text: String) -> String { + var cleaned = text + + // Remove ... blocks (model's internal reasoning) + let thinkPattern = #"[\s\S]*?"# + if let regex = try? NSRegularExpression(pattern: thinkPattern, options: [.caseInsensitive]) { + cleaned = regex.stringByReplacingMatches(in: cleaned, options: [], range: NSRange(cleaned.startIndex..., in: cleaned), withTemplate: "") + } + + // Remove ... blocks + let toolCallPattern = #"[\s\S]*?"# + if let regex = try? NSRegularExpression(pattern: toolCallPattern, options: [.caseInsensitive]) { + cleaned = regex.stringByReplacingMatches(in: cleaned, options: [], range: NSRange(cleaned.startIndex..., in: cleaned), withTemplate: "") + } + + // Remove any ... blocks + let invokePattern = #""# + if let regex = try? NSRegularExpression(pattern: invokePattern, options: [.caseInsensitive]) { + cleaned = regex.stringByReplacingMatches(in: cleaned, options: [], range: NSRange(cleaned.startIndex..., in: cleaned), withTemplate: "") + } + + // Trim excessive whitespace/newlines + cleaned = cleaned.trimmingCharacters(in: .whitespacesAndNewlines) + + // Collapse multiple newlines into max 2 + while cleaned.contains("\n\n\n") { + cleaned = cleaned.replacingOccurrences(of: "\n\n\n", with: "\n\n") + } + + return cleaned + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(message.content) { block in + switch block { + case .text(let text): + textBubble(text, isUser: message.role == .user) + + case .image(let data): + imageBubble(data) + + case .toolCall(_, let name, let status): + toolStatusView(name: name, status: status) + } + } + } + .padding(.horizontal, 14) + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 8) + .onAppear { + withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { + appeared = true + } + } + } + + // MARK: - Text Bubble + + @ViewBuilder + private func textBubble(_ text: String, isUser: Bool) -> some View { + HStack(alignment: .bottom, spacing: 6) { + if isUser { Spacer(minLength: 50) } + + if !isUser { + // AI avatar + ZStack { + Circle() + .fill( + LinearGradient( + colors: [Color.purple.opacity(0.8), Color.blue.opacity(0.6)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 24, height: 24) + Image(systemName: "brain") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white) + } + .offset(y: -2) + } + + VStack(alignment: isUser ? .trailing : .leading, spacing: 2) { + let displayText = isUser ? text : cleanDisplayText(text) + + // Skip empty messages after cleaning + if !displayText.isEmpty { + Text(displayText) + .textSelection(.enabled) + .font(.system(size: 13)) + .lineSpacing(2) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + isUser + ? AnyShapeStyle( + LinearGradient( + colors: [Color.accentColor, Color.accentColor.opacity(0.85)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + : AnyShapeStyle(Color(nsColor: .controlBackgroundColor)) + ) + .foregroundStyle(isUser ? .white : .primary) + .clipShape(BubbleShape(isUser: isUser)) + .shadow(color: .black.opacity(0.06), radius: 3, y: 1) + + Text(message.timestamp.formatted(.dateTime.hour().minute())) + .font(.system(size: 9)) + .foregroundStyle(.quaternary) + .padding(.horizontal, 4) + } + } + + if !isUser { Spacer(minLength: 50) } + } + } + + // MARK: - Image Bubble + + @ViewBuilder + private func imageBubble(_ data: Data) -> some View { + if let nsImage = NSImage(data: data) { + VStack(alignment: .leading, spacing: 4) { + Image(nsImage: nsImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 260) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder( + LinearGradient( + colors: [.white.opacity(0.2), .clear], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: 1 + ) + ) + .shadow(color: .black.opacity(0.15), radius: 8, y: 4) + + HStack(spacing: 4) { + Image(systemName: "camera.fill") + .font(.system(size: 8)) + Text("Screenshot") + .font(.system(size: 9, weight: .medium)) + } + .foregroundStyle(.secondary) + .padding(.leading, 4) + } + } + } + + // MARK: - Tool Status + + @ViewBuilder + private func toolStatusView(name: String, status: ToolStatus) -> some View { + HStack(spacing: 8) { + // Icon with animated ring for running state + ZStack { + switch status { + case .running: + Circle() + .stroke(Color.accentColor.opacity(0.2), lineWidth: 2) + .frame(width: 20, height: 20) + Circle() + .trim(from: 0, to: 0.3) + .stroke(Color.accentColor, style: StrokeStyle(lineWidth: 2, lineCap: .round)) + .frame(width: 20, height: 20) + .rotationEffect(.degrees(spinnerRotation)) + Image(systemName: toolIcon(name)) + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(Color.accentColor) + case .completed: + Circle() + .fill(Color.green.opacity(0.15)) + .frame(width: 20, height: 20) + Image(systemName: "checkmark") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.green) + case .failed: + Circle() + .fill(Color.red.opacity(0.15)) + .frame(width: 20, height: 20) + Image(systemName: "xmark") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.red) + } + } + + VStack(alignment: .leading, spacing: 1) { + Text(toolDisplayName(name)) + .font(.system(size: 11, weight: .medium, design: .rounded)) + .foregroundStyle(status.color) + + if case .failed(let error) = status { + Text(error) + .font(.system(size: 9)) + .foregroundStyle(.red.opacity(0.8)) + .lineLimit(2) + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(status.backgroundColor) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(status.borderColor, lineWidth: 0.5) + ) + ) + } + + @State private var spinnerRotation: Double = 0 + + private func toolIcon(_ name: String) -> String { + switch name { + case "screenshot": return "camera" + case "tap": return "hand.tap" + case "swipe": return "hand.draw" + case "type_text": return "keyboard" + case "open_app": return "app" + case "home": return "house" + case "app_switcher": return "square.stack.3d.up" + case "find_window": return "macwindow" + case "status": return "info.circle" + case "scan_apps", "map_apps": return "magnifyingglass" + case "list_apps": return "list.bullet" + default: return "gearshape" + } + } + + private func toolDisplayName(_ name: String) -> String { + switch name { + case "screenshot": return "Capturing screen..." + case "tap": return "Tapping..." + case "swipe": return "Swiping..." + case "type_text": return "Typing text..." + case "open_app": return "Opening app..." + case "home": return "Going home..." + case "app_switcher": return "App switcher..." + case "find_window": return "Finding window..." + case "status": return "Checking status..." + case "scan_apps", "map_apps": return "Scanning apps..." + case "list_apps": return "Listing apps..." + default: return "\(name)..." + } + } +} + +// MARK: - Bubble Shape + +struct BubbleShape: Shape { + let isUser: Bool + + func path(in rect: CGRect) -> Path { + let r: CGFloat = 14 + let tail: CGFloat = 4 + + var path = Path() + + if isUser { + // Rounded rect with tail on bottom-right + path.addRoundedRect(in: CGRect( + x: rect.minX, + y: rect.minY, + width: rect.width - tail, + height: rect.height + ), cornerSize: CGSize(width: r, height: r)) + // Small tail + path.move(to: CGPoint(x: rect.maxX - tail, y: rect.maxY - r)) + path.addQuadCurve( + to: CGPoint(x: rect.maxX, y: rect.maxY), + control: CGPoint(x: rect.maxX - tail, y: rect.maxY) + ) + path.addQuadCurve( + to: CGPoint(x: rect.maxX - tail - 6, y: rect.maxY), + control: CGPoint(x: rect.maxX - tail - 2, y: rect.maxY) + ) + } else { + // Rounded rect with tail on bottom-left + path.addRoundedRect(in: CGRect( + x: rect.minX + tail, + y: rect.minY, + width: rect.width - tail, + height: rect.height + ), cornerSize: CGSize(width: r, height: r)) + // Small tail + path.move(to: CGPoint(x: rect.minX + tail, y: rect.maxY - r)) + path.addQuadCurve( + to: CGPoint(x: rect.minX, y: rect.maxY), + control: CGPoint(x: rect.minX + tail, y: rect.maxY) + ) + path.addQuadCurve( + to: CGPoint(x: rect.minX + tail + 6, y: rect.maxY), + control: CGPoint(x: rect.minX + tail + 2, y: rect.maxY) + ) + } + + return path + } +} + +// MARK: - Tool Status Extensions + +extension ToolStatus { + var color: Color { + switch self { + case .running: return .accentColor + case .completed: return .green + case .failed: return .red + } + } + + var backgroundColor: Color { + switch self { + case .running: return Color.accentColor.opacity(0.05) + case .completed: return Color.green.opacity(0.05) + case .failed: return Color.red.opacity(0.05) + } + } + + var borderColor: Color { + switch self { + case .running: return Color.accentColor.opacity(0.15) + case .completed: return Color.green.opacity(0.15) + case .failed: return Color.red.opacity(0.15) + } + } +} diff --git a/companion/iPhoneCompanion/Views/ModelConfigView.swift b/companion/iPhoneCompanion/Views/ModelConfigView.swift new file mode 100644 index 0000000..64fada6 --- /dev/null +++ b/companion/iPhoneCompanion/Views/ModelConfigView.swift @@ -0,0 +1,262 @@ +import SwiftUI + +struct ModelConfigView: View { + @EnvironmentObject var conversation: Conversation + + @State private var showModelConfig = false + + var body: some View { + VStack(spacing: 0) { + // Collapsed bar — tap to expand + Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) { + showModelConfig.toggle() + } + } label: { + HStack(spacing: 8) { + Image(systemName: "cpu") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + + Text(currentModelDisplay) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + + Spacer() + + Text(conversation.aiBackend.rawValue) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background( + Capsule() + .fill(Color.primary.opacity(0.06)) + ) + + Image(systemName: showModelConfig ? "chevron.down" : "chevron.up") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + // Expanded config panel + if showModelConfig { + modelConfigPanel + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + } + + private var currentModelDisplay: String { + switch conversation.aiBackend { + case .claudeCode: return conversation.claudeModel + case .claude: return conversation.claudeModel + case .openRouter: return conversation.openRouterModel + case .minimax: return conversation.minimaxModel + case .ollama: return conversation.ollamaModel + } + } + + private var modelConfigPanel: some View { + VStack(alignment: .leading, spacing: 10) { + // Provider picker + HStack(spacing: 6) { + ForEach(AIBackend.allCases, id: \.self) { backend in + Button { + conversation.aiBackend = backend + } label: { + Text(backend.rawValue) + .font(.system(size: 12, weight: .medium)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + Capsule() + .fill(conversation.aiBackend == backend + ? Color.accentColor.opacity(0.15) + : Color.primary.opacity(0.04)) + ) + .foregroundStyle(conversation.aiBackend == backend + ? Color.accentColor + : .secondary) + } + .buttonStyle(.plain) + } + } + + // Model field + HStack(spacing: 8) { + Image(systemName: "brain") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + + switch conversation.aiBackend { + case .claudeCode: + TextField("e.g. claude-sonnet-4-6", text: $conversation.claudeModel) + .textFieldStyle(.plain) + .font(.system(size: 13, design: .monospaced)) + case .openRouter: + TextField("e.g. anthropic/claude-sonnet-4.6", text: $conversation.openRouterModel) + .textFieldStyle(.plain) + .font(.system(size: 13, design: .monospaced)) + .onChange(of: conversation.openRouterModel) { _, _ in + conversation.modelSupportsVision = nil + } + case .claude: + TextField("e.g. claude-sonnet-4-6-20250514", text: $conversation.claudeModel) + .textFieldStyle(.plain) + .font(.system(size: 13, design: .monospaced)) + case .ollama: + TextField("e.g. llama3.1", text: $conversation.ollamaModel) + .textFieldStyle(.plain) + .font(.system(size: 13, design: .monospaced)) + case .minimax: + TextField("e.g. MiniMax-M2.5", text: $conversation.minimaxModel) + .textFieldStyle(.plain) + .font(.system(size: 13, design: .monospaced)) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color(nsColor: .controlBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.5) + ) + ) + + // Claude Code auth status + if conversation.aiBackend == .claudeCode { + claudeCodeAuthStatus + } + + // API key field (for Claude / OpenRouter / MiniMax) + if conversation.aiBackend == .claude || conversation.aiBackend == .openRouter || conversation.aiBackend == .minimax { + apiKeyRow + } + + // Ollama URL + if conversation.aiBackend == .ollama { + ollamaURLRow + } + } + .padding(.horizontal, 14) + .padding(.bottom, 8) + } + + // MARK: - Subviews + + private var claudeCodeAuthStatus: some View { + HStack(spacing: 8) { + Image(systemName: "terminal.fill") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + + if let creds = ClaudeCodeAuthHelper.loadCredentials() { + let isValid = creds.expiresAt.timeIntervalSinceNow > 0 + HStack(spacing: 4) { + Text(creds.subscriptionType) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.primary) + Spacer() + Image(systemName: isValid ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + .font(.system(size: 12)) + .foregroundStyle(isValid ? .green : .orange) + Text(isValid ? "Connected" : "Expired") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(isValid ? .green : .orange) + } + } else { + Text("Not found — run `claude` in terminal") + .font(.system(size: 12)) + .foregroundStyle(.orange) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color(nsColor: .controlBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.5) + ) + ) + } + + private var apiKeyRow: some View { + HStack(spacing: 8) { + Image(systemName: "key.fill") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + + if conversation.aiBackend == .claude { + SecureField("sk-ant-api03-...", text: Binding( + get: { conversation.loadAPIKey(for: .claude) }, + set: { conversation.saveAPIKey($0, for: .claude) } + )) + .textFieldStyle(.plain) + .font(.system(size: 13, design: .monospaced)) + } else if conversation.aiBackend == .openRouter { + SecureField("sk-or-...", text: Binding( + get: { conversation.loadAPIKey(for: .openRouter) }, + set: { conversation.saveAPIKey($0, for: .openRouter) } + )) + .textFieldStyle(.plain) + .font(.system(size: 13, design: .monospaced)) + } else if conversation.aiBackend == .minimax { + SecureField("sk-cp-...", text: Binding( + get: { conversation.loadAPIKey(for: .minimax) }, + set: { conversation.saveAPIKey($0, for: .minimax) } + )) + .textFieldStyle(.plain) + .font(.system(size: 13, design: .monospaced)) + } + + if conversation.hasAPIKey { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 12)) + .foregroundStyle(.green) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color(nsColor: .controlBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.5) + ) + ) + } + + private var ollamaURLRow: some View { + HStack(spacing: 8) { + Image(systemName: "link") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + + TextField("http://localhost:11434", text: $conversation.ollamaURL) + .textFieldStyle(.plain) + .font(.system(size: 13, design: .monospaced)) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color(nsColor: .controlBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.5) + ) + ) + } +} diff --git a/companion/iPhoneCompanion/Views/ProviderSettingsView.swift b/companion/iPhoneCompanion/Views/ProviderSettingsView.swift new file mode 100644 index 0000000..60ebc43 --- /dev/null +++ b/companion/iPhoneCompanion/Views/ProviderSettingsView.swift @@ -0,0 +1,291 @@ +import SwiftUI + +/// Individual provider configuration sections — legacy view kept for compatibility. +/// The main settings flow now uses SettingsView directly. +struct ProviderSettingsView: View { + @EnvironmentObject var conversation: Conversation + @Binding var showingClaudeKey: Bool + @Binding var showingORKey: Bool + @Binding var showingMinimaxKey: Bool + @Binding var claudeModels: [ClaudeModel] + @Binding var orModels: [ClaudeModel] + @Binding var minimaxModels: [ClaudeModel] + @Binding var claudeStatus: SettingsStatus + @Binding var orStatus: SettingsStatus + @Binding var minimaxStatus: SettingsStatus + + var body: some View { + Group { + if conversation.aiBackend == .claude { + claudeSection + } + + if conversation.aiBackend == .openRouter { + openRouterSection + } + + if conversation.aiBackend == .minimax { + minimaxSection + } + + if conversation.aiBackend == .ollama { + ollamaSection + } + } + } + + // MARK: - Claude Section + + private var claudeSection: some View { + GroupBox("Claude API") { + VStack(alignment: .leading, spacing: 8) { + apiKeyField( + key: Binding( + get: { conversation.loadAPIKey(for: .claude) }, + set: { conversation.saveAPIKey($0, for: .claude) } + ), + showing: $showingClaudeKey, + placeholder: "sk-ant-api03-..." + ) + + HStack { + Button("Save & Detect Models") { + claudeStatus = .validating + fetchClaudeModels() + } + .disabled(conversation.loadAPIKey(for: .claude).isEmpty) + statusIndicator(claudeStatus) + } + + LabeledContent("Model") { + Picker("", selection: $conversation.claudeModel) { + if claudeModels.isEmpty { + ForEach(Constants.fallbackClaudeModels, id: \.id) { m in + Text(m.name).tag(m.id) + } + } else { + ForEach(claudeModels) { m in + Text(m.name).tag(m.id) + } + } + } + .labelsHidden() + .frame(maxWidth: 250) + } + } + .padding(.top, 4) + } + } + + // MARK: - OpenRouter Section + + private var openRouterSection: some View { + GroupBox("OpenRouter") { + VStack(alignment: .leading, spacing: 8) { + apiKeyField( + key: Binding( + get: { conversation.loadAPIKey(for: .openRouter) }, + set: { conversation.saveAPIKey($0, for: .openRouter) } + ), + showing: $showingORKey, + placeholder: "sk-or-..." + ) + + HStack { + Button("Save & Detect Models") { + orStatus = .validating + fetchORModels() + } + .disabled(conversation.loadAPIKey(for: .openRouter).isEmpty) + statusIndicator(orStatus) + } + + LabeledContent("Model") { + Picker("", selection: $conversation.openRouterModel) { + if orModels.isEmpty { + ForEach(Constants.fallbackOpenRouterModels, id: \.id) { m in + Text(m.name).tag(m.id) + } + } else { + ForEach(orModels) { m in + Text(m.name).tag(m.id) + } + } + } + .labelsHidden() + .frame(maxWidth: 250) + } + + Text("Get a key at openrouter.ai/keys — supports Claude, GPT, Gemini, and more.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.top, 4) + } + } + + // MARK: - MiniMax Section + + private var minimaxSection: some View { + GroupBox("MiniMax") { + VStack(alignment: .leading, spacing: 8) { + apiKeyField( + key: Binding( + get: { conversation.loadAPIKey(for: .minimax) }, + set: { conversation.saveAPIKey($0, for: .minimax) } + ), + showing: $showingMinimaxKey, + placeholder: "sk-cp-..." + ) + + HStack { + Button("Save & Detect Models") { + minimaxStatus = .validating + fetchMinimaxModels() + } + .disabled(conversation.loadAPIKey(for: .minimax).isEmpty) + statusIndicator(minimaxStatus) + } + + LabeledContent("Model") { + Picker("", selection: $conversation.minimaxModel) { + if minimaxModels.isEmpty { + ForEach(Constants.fallbackMiniMaxModels, id: \.id) { m in + Text(m.name).tag(m.id) + } + } else { + ForEach(minimaxModels) { m in + Text(m.name).tag(m.id) + } + } + } + .labelsHidden() + .frame(maxWidth: 250) + } + + Text("Get an API key from minimax.io") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.top, 4) + } + } + + // MARK: - Ollama Section + + private var ollamaSection: some View { + GroupBox("Ollama") { + VStack(alignment: .leading, spacing: 8) { + LabeledContent("Model") { + TextField("llama3.1", text: $conversation.ollamaModel) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 250) + } + LabeledContent("URL") { + TextField("http://localhost:11434", text: $conversation.ollamaURL) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 250) + } + } + .padding(.top, 4) + } + } + + // MARK: - Shared Components + + @ViewBuilder + private func apiKeyField(key: Binding, showing: Binding, placeholder: String) -> some View { + HStack { + if showing.wrappedValue { + TextField(placeholder, text: key) + .textFieldStyle(.roundedBorder) + } else { + SecureField(placeholder, text: key) + .textFieldStyle(.roundedBorder) + } + Button(showing.wrappedValue ? "Hide" : "Show") { + showing.wrappedValue.toggle() + } + .buttonStyle(.borderless) + } + } + + @ViewBuilder + private func statusIndicator(_ status: SettingsStatus) -> some View { + switch status { + case .idle: + EmptyView() + case .validating: + ProgressView().scaleEffect(0.7) + Text("Validating...").font(.caption).foregroundStyle(.secondary) + case .valid: + Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) + Text("Key saved").font(.caption).foregroundStyle(.green) + case .invalid(let msg): + Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange) + Text(msg).font(.caption).foregroundStyle(.orange) + } + } + + // MARK: - Model Fetching + + private func fetchClaudeModels() { + Task { + let apiKey = conversation.loadAPIKey(for: .claude) + let result = await ModelFetcher.fetchModels(apiKey: apiKey) + await MainActor.run { + if !result.models.isEmpty { + claudeModels = result.models + claudeStatus = .valid + if !result.models.contains(where: { $0.id == conversation.claudeModel }) { + conversation.claudeModel = result.models.first(where: { $0.id.contains("sonnet") })?.id ?? result.models[0].id + } + } else if let error = result.error { + claudeStatus = .invalid("\(error) — using defaults") + } else { + claudeStatus = .valid + } + } + } + } + + private func fetchMinimaxModels() { + Task { + let apiKey = conversation.loadAPIKey(for: .minimax) + let result = await ModelFetcher.fetchMinimaxModels(apiKey: apiKey) + await MainActor.run { + if !result.models.isEmpty { + minimaxModels = result.models + minimaxStatus = .valid + if !result.models.contains(where: { $0.id == conversation.minimaxModel }) { + conversation.minimaxModel = result.models[0].id + } + } else if let error = result.error { + minimaxStatus = .invalid("\(error) — using defaults") + } else { + minimaxStatus = .valid + } + } + } + } + + private func fetchORModels() { + Task { + let apiKey = conversation.loadAPIKey(for: .openRouter) + let result = await ModelFetcher.fetchOpenRouterModels(apiKey: apiKey) + await MainActor.run { + if !result.models.isEmpty { + orModels = result.models + orStatus = .valid + if !result.models.contains(where: { $0.id == conversation.openRouterModel }) { + conversation.openRouterModel = result.models.first(where: { $0.id.contains("sonnet") })?.id ?? result.models[0].id + } + } else if let error = result.error { + orStatus = .invalid("\(error) — using defaults") + } else { + orStatus = .valid + } + } + } + } +} diff --git a/companion/iPhoneCompanion/Views/SettingsView.swift b/companion/iPhoneCompanion/Views/SettingsView.swift new file mode 100644 index 0000000..65cdbcc --- /dev/null +++ b/companion/iPhoneCompanion/Views/SettingsView.swift @@ -0,0 +1,456 @@ +import SwiftUI + +struct SettingsView: View { + @EnvironmentObject var conversation: Conversation + @State private var showingClaudeKey = false + @State private var showingORKey = false + @State private var showingMinimaxKey = false + @State private var claudeModels: [ClaudeModel] = [] + @State private var orModels: [ClaudeModel] = [] + @State private var minimaxModels: [ClaudeModel] = [] + @State private var claudeStatus: SettingsStatus = .idle + @State private var orStatus: SettingsStatus = .idle + @State private var minimaxStatus: SettingsStatus = .idle + @State private var orVisionStatus: SettingsStatus = .idle + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + // Backend picker + settingsSection("AI Backend") { + Picker("Backend", selection: $conversation.aiBackend) { + ForEach(AIBackend.allCases, id: \.self) { backend in + Text(backend.rawValue).tag(backend) + } + } + .pickerStyle(.segmented) + .labelsHidden() + } + + // Claude Code + if conversation.aiBackend == .claudeCode { + claudeCodeSection + } + + // Claude + if conversation.aiBackend == .claude { + claudeAPISection + } + + // OpenRouter + if conversation.aiBackend == .openRouter { + openRouterSection + } + + // MiniMax + if conversation.aiBackend == .minimax { + minimaxSection + } + + // Ollama + if conversation.aiBackend == .ollama { + ollamaSection + } + + // Scripts path + settingsSection("Scripts") { + VStack(alignment: .leading, spacing: 8) { + HStack { + TextField("Path to iphone-control", text: $conversation.scriptsPath) + .textFieldStyle(.roundedBorder) + .font(.system(size: 12)) + settingsButton("Browse") { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + if panel.runModal() == .OK, let url = panel.url { + conversation.scriptsPath = url.path + conversation.resetToolExecutor() + } + } + } + Text("Path to the iphone-control repo with shell scripts") + .font(.system(size: 10)) + .foregroundStyle(.quaternary) + } + } + } + .padding(18) + } + .onAppear { + conversation.loadAllAPIKeys() + } + } + + // MARK: - Provider Sections + + private var claudeCodeSection: some View { + settingsSection("Claude Code") { + VStack(alignment: .leading, spacing: 10) { + if let creds = ClaudeCodeAuthHelper.loadCredentials() { + let isValid = creds.expiresAt.timeIntervalSinceNow > 0 + HStack(spacing: 8) { + Image(systemName: isValid ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + .font(.system(size: 14)) + .foregroundStyle(isValid ? .green : .orange) + VStack(alignment: .leading, spacing: 2) { + Text(isValid ? "Authenticated" : "Token Expired") + .font(.system(size: 12, weight: .semibold)) + Text("Subscription: \(creds.subscriptionType)") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + + if !isValid { + Text("Run `claude` in terminal to re-authenticate") + .font(.system(size: 10)) + .foregroundStyle(.orange) + } + } else { + HStack(spacing: 8) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 14)) + .foregroundStyle(.red) + Text("Credentials not found") + .font(.system(size: 12, weight: .medium)) + } + Text("Run `claude` in terminal to authenticate") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + + Divider() + + Text("Model") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + TextField("e.g. claude-sonnet-4-6", text: $conversation.claudeModel) + .textFieldStyle(.roundedBorder) + .font(.system(size: 12, design: .monospaced)) + Text("OAuth requires short model names (no date suffix)") + .font(.system(size: 10)) + .foregroundStyle(.quaternary) + } + } + } + + private var claudeAPISection: some View { + settingsSection("Claude API") { + VStack(alignment: .leading, spacing: 10) { + Text("API Key") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + apiKeyField( + key: Binding( + get: { conversation.loadAPIKey(for: .claude) }, + set: { conversation.saveAPIKey($0, for: .claude) } + ), + showing: $showingClaudeKey, + placeholder: "sk-ant-api03-..." + ) + HStack { + settingsButton("Save & Detect Models") { + conversation.saveAPIKey(conversation.loadAPIKey(for: .claude), for: .claude) + claudeStatus = .validating + fetchClaudeModels() + } + .disabled(conversation.loadAPIKey(for: .claude).isEmpty) + statusView(claudeStatus) + } + + Text("Model") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + Picker("", selection: $conversation.claudeModel) { + if claudeModels.isEmpty { + ForEach(Constants.fallbackClaudeModels, id: \.id) { m in + Text(m.name).tag(m.id) + } + } else { + ForEach(claudeModels) { m in + Text(m.name).tag(m.id) + } + } + } + .labelsHidden() + } + } + } + + private var openRouterSection: some View { + settingsSection("OpenRouter") { + VStack(alignment: .leading, spacing: 10) { + Text("API Key") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + apiKeyField( + key: Binding( + get: { conversation.loadAPIKey(for: .openRouter) }, + set: { conversation.saveAPIKey($0, for: .openRouter) } + ), + showing: $showingORKey, + placeholder: "sk-or-..." + ) + HStack { + settingsButton("Save Key") { + orStatus = .valid + } + .disabled(conversation.loadAPIKey(for: .openRouter).isEmpty) + statusView(orStatus) + } + + Text("Model") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + TextField("e.g. anthropic/claude-sonnet-4.6", text: $conversation.openRouterModel) + .textFieldStyle(.roundedBorder) + .font(.system(size: 12, design: .monospaced)) + .onChange(of: conversation.openRouterModel) { _, _ in + conversation.modelSupportsVision = nil + orVisionStatus = .idle + } + + HStack { + settingsButton("Check Model") { + orVisionStatus = .validating + Task { + await conversation.checkModelCapabilities() + if conversation.modelSupportsVision == true { + orVisionStatus = .valid + } else if conversation.modelSupportsVision == false { + orVisionStatus = .invalid("No vision support") + } else { + orVisionStatus = .invalid("Could not detect") + } + } + } + statusView(orVisionStatus) + } + + if conversation.modelSupportsVision == false { + Divider() + Text("Vision Model") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + Text("Screenshots described by this model") + .font(.system(size: 10)) + .foregroundStyle(.quaternary) + TextField("e.g. google/gemma-3-12b-it:free", text: $conversation.openRouterVisionModel) + .textFieldStyle(.roundedBorder) + .font(.system(size: 12, design: .monospaced)) + } + + HStack(spacing: 4) { + Image(systemName: "link") + .font(.system(size: 9)) + Text("openrouter.ai/keys") + .font(.system(size: 10)) + } + .foregroundStyle(.quaternary) + } + } + } + + private var minimaxSection: some View { + settingsSection("MiniMax") { + VStack(alignment: .leading, spacing: 10) { + Text("API Key") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + apiKeyField( + key: Binding( + get: { conversation.loadAPIKey(for: .minimax) }, + set: { conversation.saveAPIKey($0, for: .minimax) } + ), + showing: $showingMinimaxKey, + placeholder: "sk-cp-..." + ) + HStack { + settingsButton("Save & Detect Models") { + conversation.saveAPIKey(conversation.loadAPIKey(for: .minimax), for: .minimax) + minimaxStatus = .validating + fetchMinimaxModels() + } + .disabled(conversation.loadAPIKey(for: .minimax).isEmpty) + statusView(minimaxStatus) + } + + Text("Model") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + Picker("", selection: $conversation.minimaxModel) { + if minimaxModels.isEmpty { + ForEach(Constants.fallbackMiniMaxModels, id: \.id) { m in + Text(m.name).tag(m.id) + } + } else { + ForEach(minimaxModels) { m in + Text(m.name).tag(m.id) + } + } + } + .labelsHidden() + + HStack(spacing: 4) { + Image(systemName: "link") + .font(.system(size: 9)) + Text("minimax.io") + .font(.system(size: 10)) + } + .foregroundStyle(.quaternary) + } + } + } + + private var ollamaSection: some View { + settingsSection("Ollama") { + VStack(alignment: .leading, spacing: 10) { + Text("Model") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + TextField("llama3.1", text: $conversation.ollamaModel) + .textFieldStyle(.roundedBorder) + Text("URL") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + TextField("http://localhost:11434", text: $conversation.ollamaURL) + .textFieldStyle(.roundedBorder) + } + } + } + + // MARK: - Settings Helpers + + @ViewBuilder + private func settingsSection(_ title: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 10) { + Text(title) + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .tracking(0.5) + + content() + .padding(14) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color(nsColor: .controlBackgroundColor).opacity(0.5)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(Color.primary.opacity(0.06), lineWidth: 0.5) + ) + ) + } + } + + @ViewBuilder + private func settingsButton(_ label: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(label) + .font(.system(size: 11, weight: .medium)) + .padding(.horizontal, 12) + .padding(.vertical, 5) + .background( + Capsule() + .fill(Color.accentColor.opacity(0.1)) + ) + .foregroundStyle(Color.accentColor) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private func apiKeyField(key: Binding, showing: Binding, placeholder: String) -> some View { + HStack { + if showing.wrappedValue { + TextField(placeholder, text: key) + .textFieldStyle(.roundedBorder) + .font(.system(size: 12, design: .monospaced)) + } else { + SecureField(placeholder, text: key) + .textFieldStyle(.roundedBorder) + .font(.system(size: 12)) + } + Button(showing.wrappedValue ? "Hide" : "Show") { + showing.wrappedValue.toggle() + } + .buttonStyle(.plain) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(Color.accentColor) + } + } + + @ViewBuilder + private func statusView(_ status: SettingsStatus) -> some View { + switch status { + case .idle: EmptyView() + case .validating: + ProgressView().scaleEffect(0.6) + case .valid: + HStack(spacing: 3) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 10)) + Text("Saved") + .font(.system(size: 10, weight: .medium)) + } + .foregroundStyle(.green) + case .invalid(let msg): + HStack(spacing: 3) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 10)) + Text(msg) + .font(.system(size: 10)) + .lineLimit(1) + } + .foregroundStyle(.orange) + } + } + + // MARK: - Data Fetching + + private func fetchMinimaxModels() { + Task { + let apiKey = conversation.loadAPIKey(for: .minimax) + let result = await ModelFetcher.fetchMinimaxModels(apiKey: apiKey) + await MainActor.run { + if !result.models.isEmpty { + minimaxModels = result.models + minimaxStatus = .valid + if !result.models.contains(where: { $0.id == conversation.minimaxModel }) { + conversation.minimaxModel = result.models[0].id + } + } else if let error = result.error { + minimaxStatus = .invalid("\(error) — using defaults") + } else { + minimaxStatus = .valid + } + } + } + } + + private func fetchClaudeModels() { + Task { + let apiKey = conversation.loadAPIKey(for: .claude) + let result = await ModelFetcher.fetchModels(apiKey: apiKey) + await MainActor.run { + if !result.models.isEmpty { + claudeModels = result.models + claudeStatus = .valid + if !result.models.contains(where: { $0.id == conversation.claudeModel }) { + conversation.claudeModel = result.models.first(where: { $0.id.contains("sonnet") })?.id ?? result.models[0].id + } + } else if let error = result.error { + claudeStatus = .invalid("\(error)") + } else { + claudeStatus = .valid + } + } + } + } +} + +enum SettingsStatus: Equatable { + case idle, validating, valid, invalid(String) +} diff --git a/companion/iPhoneCompanion/Views/SuggestionChipsView.swift b/companion/iPhoneCompanion/Views/SuggestionChipsView.swift new file mode 100644 index 0000000..758879e --- /dev/null +++ b/companion/iPhoneCompanion/Views/SuggestionChipsView.swift @@ -0,0 +1,47 @@ +import SwiftUI + +struct SuggestionChipsView: View { + let onSelect: (String) -> Void + + private let suggestions: [(icon: String, text: String)] = [ + ("camera.fill", "Take a screenshot"), + ("app.badge", "Open Settings"), + ("house.fill", "Go home"), + ("magnifyingglass", "Scan my apps"), + ("info.circle", "Check status"), + ] + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(suggestions, id: \.text) { suggestion in + Button { + onSelect(suggestion.text) + } label: { + HStack(spacing: 5) { + Image(systemName: suggestion.icon) + .font(.system(size: 10, weight: .semibold)) + Text(suggestion.text) + .font(.system(size: 11, weight: .medium)) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + Capsule() + .fill(Color.accentColor.opacity(0.08)) + .overlay( + Capsule() + .strokeBorder(Color.accentColor.opacity(0.2), lineWidth: 0.5) + ) + ) + .foregroundStyle(Color.accentColor) + } + .buttonStyle(.plain) + .contentShape(Capsule()) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 6) + } + } +} diff --git a/companion/iPhoneCompanion/iPhoneCompanion.entitlements b/companion/iPhoneCompanion/iPhoneCompanion.entitlements new file mode 100644 index 0000000..6f9892a --- /dev/null +++ b/companion/iPhoneCompanion/iPhoneCompanion.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + com.apple.security.automation.apple-events + + + diff --git a/find-window.sh b/find-window.sh index 930eae0..be31ed1 100755 --- a/find-window.sh +++ b/find-window.sh @@ -1,56 +1,107 @@ #!/usr/bin/env bash # Find the iPhone Mirroring window position and size. -# Returns JSON: {"id": N, "x": N, "y": N, "width": N, "height": N} +# Uses window ID capture + alpha channel detection for 100% reliable content bounds. +# Returns JSON: {"x":N,"y":N,"width":N,"height":N,"content_x":N,"content_y":N,"content_width":N,"content_height":N,"window_id":N,"scale":N} set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +HELPER="$SCRIPT_DIR/helpers/get-window-id" +HELPER_SRC="$HELPER.swift" CACHE_FILE="/tmp/iphone-mirror-window.json" +CACHE_KEY_FILE="/tmp/iphone-mirror-window.key" CACHE_TTL=30 # seconds -# Use cache if fresh enough -if [[ -f "$CACHE_FILE" ]]; then +# Auto-compile helper if missing or outdated +if [[ ! -x "$HELPER" ]] || [[ "$HELPER_SRC" -nt "$HELPER" ]]; then + swiftc -O "$HELPER_SRC" -o "$HELPER" 2>/dev/null || { + echo '{"error":"Failed to compile get-window-id helper"}' >&2 + exit 1 + } +fi + +# Get window info from compiled helper (fast - <10ms) +win_info=$("$HELPER" 2>/dev/null) || { + echo '{"error":"iPhone Mirroring window not found. Is iPhone Mirroring open?"}' >&2 + exit 1 +} + +IFS='|' read -r window_id wx wy ww wh scale <<< "$win_info" +scale="${scale:-1}" # default to 1 if missing (backward compat) + +# Cache key = window ID + position + size + scale (invalidates on restart, move, resize, or display change) +cache_key="${window_id}|${wx}|${wy}|${ww}|${wh}|${scale}" + +# Use cache if fresh enough and window hasn't moved +if [[ -f "$CACHE_FILE" && -f "$CACHE_KEY_FILE" ]]; then cache_age=$(( $(date +%s) - $(stat -f %m "$CACHE_FILE") )) - if (( cache_age < CACHE_TTL )); then + stored_key=$(cat "$CACHE_KEY_FILE") + if (( cache_age < CACHE_TTL )) && [[ "$stored_key" == "$cache_key" ]]; then cat "$CACHE_FILE" exit 0 fi fi -# Query window info via JXA -result=$(osascript -l JavaScript -e ' -ObjC.import("CoreGraphics"); - -const windows = $.CGWindowListCopyWindowInfo($.kCGWindowListOptionOnScreenOnly, $.kCGNullWindowID); -const count = ObjC.unwrap(windows).length; -let found = null; - -for (let i = 0; i < count; i++) { - const win = ObjC.unwrap(ObjC.unwrap(windows)[i]); - const owner = ObjC.unwrap(win.kCGWindowOwnerName || ""); - const name = ObjC.unwrap(win.kCGWindowName || ""); - - if (owner === "iPhone Mirroring" && name !== "") { - const bounds = win.kCGWindowBounds; - const x = ObjC.unwrap(bounds.X); - const y = ObjC.unwrap(bounds.Y); - const w = ObjC.unwrap(bounds.Width); - const h = ObjC.unwrap(bounds.Height); - const id = ObjC.unwrap(win.kCGWindowNumber); - found = JSON.stringify({id: id, x: x, y: y, width: w, height: h}); - break; - } +# Capture just the window (alpha channel preserves rounded corners) +tmp_png="/tmp/iphone-mirror-bounds-$$.png" +screencapture -x -l "$window_id" -o "$tmp_png" 2>/dev/null || { + echo '{"error":"screencapture failed. Check Screen Recording permission."}' >&2 + exit 1 } -if (found) { - found; -} else { - "null"; +# Scan alpha channel to find content bounds (alpha=255 = content, alpha=0 = rounded corners) +result=$(osascript -l JavaScript -e " +ObjC.import('AppKit'); +const imgData = \$.NSData.dataWithContentsOfFile('$tmp_png'); +const img = \$.NSBitmapImageRep.imageRepWithData(imgData); +const pw = img.pixelsWide; +const ph = img.pixelsHigh; + +function alpha(px, py) { + // colorAtXY is top-left origin, same as the scan loops - no flip + const c = img.colorAtXY(px, py); + // alphaComponent is color-space-independent - avoids null from colorUsingColorSpaceName on P3/wide-gamut displays + return Math.round(ObjC.unwrap(c.alphaComponent) * 255); } -' 2>/dev/null) -if [[ "$result" == "null" || -z "$result" ]]; then - echo '{"error": "iPhone Mirroring window not found. Is iPhone Mirroring open?"}' >&2 +const midX = Math.floor(pw / 2); +const midY = Math.floor(ph / 2); + +// Scan from each edge to find first fully opaque pixel +let left = -1, right = -1, top = -1, bottom = -1; +for (let x = 0; x < pw; x++) { if (alpha(x, midY) === 255) { left = x; break; } } +for (let x = pw - 1; x >= 0; x--) { if (alpha(x, midY) === 255) { right = x; break; } } +for (let y = 0; y < ph; y++) { if (alpha(midX, y) === 255) { top = y; break; } } +for (let y = ph - 1; y >= 0; y--) { if (alpha(midX, y) === 255) { bottom = y; break; } } + +// Validate scan found real content bounds +if (left < 0 || right < 0 || top < 0 || bottom < 0 || left >= right || top >= bottom) { + throw new Error('Alpha scan found no opaque content region - midpoint row/column may be transparent'); +} + +// screencapture -l captures at the window's backing scale (2x on Retina). +// Measure scale from this capture: pixel width / window point width. +// Authoritative even when the window straddles displays or the arrangement has gaps. +// Requires -o (no shadow) on screencapture, or shadow pixels corrupt the ratio. +const m = Math.round(pw / ${ww}); +const scale = (isFinite(m) && m >= 1) ? m : 1; +const cw = Math.round((right - left + 1) / scale); +const ch = Math.round((bottom - top + 1) / scale); +const cx = ${wx} + Math.round(left / scale); +const cy = ${wy} + Math.round(top / scale); + +JSON.stringify({x:${wx},y:${wy},width:${ww},height:${wh},content_x:cx,content_y:cy,content_width:cw,content_height:ch,window_id:${window_id},scale:scale}); +" 2>/dev/null) || true + +rm -f "$tmp_png" + +if [[ -z "$result" ]]; then + echo '{"error":"Failed to detect content bounds from alpha channel"}' >&2 exit 1 fi -echo "$result" > "$CACHE_FILE" +# Write cache atomically - write key first so a reader that sees the key always has the file +tmp_cache="/tmp/iphone-mirror-window-$$.json" +printf '%s\n' "$result" > "$tmp_cache" +mv "$tmp_cache" "$CACHE_FILE" +printf '%s\n' "$cache_key" > "$CACHE_KEY_FILE" echo "$result" diff --git a/helpers/common.sh b/helpers/common.sh new file mode 100755 index 0000000..b40a6a6 --- /dev/null +++ b/helpers/common.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +# Shared functions for iphone-control scripts. +# Source this file: source "$SCRIPT_DIR/helpers/common.sh" +set -euo pipefail + +# --- Constants --- +IPHONE_MIRRORING_BUNDLE_ID="com.apple.ScreenContinuity" +IPHONE_MIRRORING_PROCESS="iPhone Mirroring" + +# Timing constants (seconds) +ACTIVATION_DELAY=0.05 +TAP_DELAY=0.02 +KEYBOARD_APPEAR_DELAY=0.3 +SPOTLIGHT_DELAY=0.4 +SPOTLIGHT_SEARCH_DELAY=1.0 +PAGE_SWIPE_DELAY=0.3 +HOME_SETTLE_DELAY=0.5 + +# Swipe physics +SWIPE_STEPS=20 +SWIPE_KICK_FACTOR=0.3 + +# App grid detection zones (pixels from content edge) +APP_ZONE_TOP=80 +APP_ZONE_BOTTOM_MARGIN=100 +APP_LABEL_MIN_LEN=2 +APP_LABEL_MAX_LEN=25 +APP_ICON_ABOVE_LABEL=20 + +# Page navigation +SWIPE_EDGE_MARGIN=30 +DEFAULT_MAX_PAGES=10 +DEFAULT_SWIPE_DURATION=300 + +# --- JSON Parsing (pure bash, no jq) --- +# Usage: json_field "$json_string" "field_name" +# Returns the value of a top-level JSON field (numbers and simple strings only) +json_field() { + local json="$1" field="$2" + # Absent field must yield empty (not the unstripped remainder) so callers' -z checks work + [[ "$json" == *"\"${field}\":"* ]] || { echo ""; return 0; } + local val="${json#*\"${field}\":}" + # Strip leading whitespace + val="${val#"${val%%[! ]*}"}" + # Handle string vs number + if [[ "$val" == \"* ]]; then + val="${val#\"}" + val="${val%%\"*}" + else + val="${val%%[,\}]*}" + fi + echo "$val" +} + +# Parse all content bounds from find-window.sh JSON output +# Sets: CONTENT_X, CONTENT_Y, CONTENT_W, CONTENT_H, WINDOW_X, WINDOW_Y, WINDOW_ID, SCALE +parse_window_bounds() { + local json="$1" + WINDOW_X=$(json_field "$json" "x") + WINDOW_Y=$(json_field "$json" "y") + CONTENT_X=$(json_field "$json" "content_x") + CONTENT_Y=$(json_field "$json" "content_y") + CONTENT_W=$(json_field "$json" "content_width") + CONTENT_H=$(json_field "$json" "content_height") + WINDOW_ID=$(json_field "$json" "window_id") + SCALE=$(json_field "$json" "scale") + SCALE="${SCALE:-1}" + SCALE="${SCALE%%.*}" # strip decimal - bash arithmetic requires integers + # Guard against 0 or non-integer scale (e.g. empty string from malformed JSON) + if ! [[ "$SCALE" =~ ^[1-9][0-9]*$ ]]; then + SCALE=1 + fi + + # All coordinate fields are required: an empty value would silently become 0 in + # bash arithmetic and taps would land relative to the primary display's origin. + if [[ -z "$CONTENT_X" || -z "$CONTENT_Y" || -z "$CONTENT_W" || -z "$CONTENT_H" || + -z "$WINDOW_X" || -z "$WINDOW_Y" || -z "$WINDOW_ID" ]]; then + echo "Error: Failed to parse required fields from window JSON" >&2 + return 1 + fi +} + +# Validate coordinates are within content bounds +# Usage: validate_coords +validate_coords() { + local x="$1" y="$2" w="$3" h="$4" + if ! [[ "$x" =~ ^-?[0-9]+$ ]] || ! [[ "$y" =~ ^-?[0-9]+$ ]]; then + echo "Error: Coordinates must be integers, got: ($x, $y)" >&2 + return 1 + fi + if (( x < 0 || x >= w || y < 0 || y >= h )); then + echo "Error: Coordinates ($x, $y) out of bounds (${w}x${h})" >&2 + return 1 + fi +} + +# --- Input Sanitization --- +# Sanitize a string for safe embedding in JXA (JavaScript for Automation) string literals. +# Escapes characters that are dangerous in bash double-quoted strings or JXA string literals. +sanitize_jxa() { + local input="$1" + # Escape backslashes first (must be first to avoid double-escaping) + input="${input//\\/\\\\}" + # Escape dollar signs (prevent bash variable expansion in double-quoted embedding) + input="${input//\$/\\\$}" + # Escape backticks (prevent bash command substitution in double-quoted embedding) + input="${input//\`/\\\`}" + # Escape single quotes + input="${input//\'/\\\'}" + # Escape double quotes + input="${input//\"/\\\"}" + # Escape newlines + input="${input//$'\n'/\\n}" + # Escape carriage returns + input="${input//$'\r'/\\r}" + echo "$input" +} + +# Sanitize a string for safe embedding in AppleScript string literals (double-quoted). +# AppleScript uses backslash-escaping for quotes and backslashes. +sanitize_applescript() { + local input="$1" + # Escape backslashes first (must be first to avoid double-escaping) + input="${input//\\/\\\\}" + # Escape dollar signs (prevent bash variable expansion in double-quoted embedding) + input="${input//\$/\\\$}" + # Escape backticks (prevent bash command substitution in double-quoted embedding) + input="${input//\`/\\\`}" + # Escape double quotes + input="${input//\"/\\\"}" + # Escape newlines and carriage returns + input="${input//$'\n'/\\n}" + input="${input//$'\r'/\\r}" + echo "$input" +} + +# --- JXA Boilerplate --- +# Generate JXA code to activate iPhone Mirroring and save/restore cursor. +# Usage: jxa_activate_and_run "" +# Writes script to a temp file to prevent double bash expansion of ${body} contents. +# The body code can use 'saved' (saved cursor position) and assumes ObjC imports are available. +jxa_activate_and_run() { + local body="$1" + local tmp_jxa + tmp_jxa=$(mktemp /tmp/jxa-XXXXXX.js) + # Write preamble - unquoted heredoc expands shell vars; \$ writes literal $ for ObjC bridge + cat > "$tmp_jxa" << JXAEOF +ObjC.import('AppKit'); +ObjC.import('CoreGraphics'); +ObjC.import('Foundation'); + +// Save cursor +const saved = \$.CGEventGetLocation(\$.CGEventCreate(null)); + +// Activate iPhone Mirroring +const apps = \$.NSWorkspace.sharedWorkspace.runningApplications.js; +const im = apps.find(a => ObjC.unwrap(a.bundleIdentifier) === '${IPHONE_MIRRORING_BUNDLE_ID}'); +if (im) im.activateWithOptions(\$.NSApplicationActivateIgnoringOtherApps); +\$.NSThread.sleepForTimeInterval(${ACTIVATION_DELAY}); + +JXAEOF + # Append body with printf - no further shell expansion of body content + printf '%s\n' "$body" >> "$tmp_jxa" + # Append postamble - printf prevents $ expansion + printf '\n$.CGWarpMouseCursorPosition(saved);\n' >> "$tmp_jxa" + osascript -l JavaScript "$tmp_jxa" > /dev/null || { rm -f "$tmp_jxa"; return 1; } + rm -f "$tmp_jxa" +} diff --git a/helpers/get-window-id.swift b/helpers/get-window-id.swift new file mode 100644 index 0000000..94bc635 --- /dev/null +++ b/helpers/get-window-id.swift @@ -0,0 +1,26 @@ +import CoreGraphics +import Foundation + +guard let list = CGWindowListCopyWindowInfo([.optionOnScreenOnly], kCGNullWindowID) as? [[String: Any]] else { + fputs("error: cannot list windows\n", stderr) + exit(1) +} + +for win in list { + guard let owner = win["kCGWindowOwnerName"] as? String, owner == "iPhone Mirroring" else { continue } + let num = win["kCGWindowNumber"] as? Int ?? 0 + let bounds = win["kCGWindowBounds"] as? [String: Any] ?? [:] + let winX = bounds["X"] as? CGFloat ?? 0 + let winY = bounds["Y"] as? CGFloat ?? 0 + let winWidth = bounds["Width"] as? CGFloat ?? 0 + let winHeight = bounds["Height"] as? CGFloat ?? 0 + // Global top-left coordinates; negative on displays left of / above the primary. + // Rounded to integers: bash arithmetic downstream cannot handle fractional origins. + // Scale is NOT detected here: find-window.sh measures it from the actual capture, + // which is correct even for windows straddling displays or gapped arrangements. + print("\(num)|\(Int(winX.rounded()))|\(Int(winY.rounded()))|\(Int(winWidth.rounded()))|\(Int(winHeight.rounded()))") + exit(0) +} + +fputs("error: iPhone Mirroring window not found\n", stderr) +exit(1) diff --git a/helpers/ocr-image.swift b/helpers/ocr-image.swift new file mode 100644 index 0000000..5979a51 --- /dev/null +++ b/helpers/ocr-image.swift @@ -0,0 +1,43 @@ +import Vision +import AppKit + +guard CommandLine.arguments.count > 1 else { + fputs("Usage: ocr-image \n", stderr) + exit(1) +} + +let path = CommandLine.arguments[1] +guard let image = NSImage(contentsOfFile: path), + let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + fputs("error: cannot load image\n", stderr) + exit(1) +} + +let width = cgImage.width +let height = cgImage.height + +let request = VNRecognizeTextRequest() +request.recognitionLevel = .accurate +request.usesLanguageCorrection = true + +let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) +do { + try handler.perform([request]) +} catch { + fputs("error: OCR failed: \(error.localizedDescription)\n", stderr) + exit(1) +} + +guard let results = request.results else { exit(0) } + +// Output: text|x|y|w|h (normalized 0-1 coordinates, origin bottom-left) +for obs in results { + guard let candidate = obs.topCandidates(1).first else { continue } + let box = obs.boundingBox + // Convert from Vision coords (bottom-left origin, normalized) to pixel coords (top-left origin) + let px = Int(box.origin.x * Double(width)) + let py = Int((1.0 - box.origin.y - box.height) * Double(height)) + let pw = Int(box.width * Double(width)) + let ph = Int(box.height * Double(height)) + print("\(candidate.string)|\(px)|\(py)|\(pw)|\(ph)") +} diff --git a/iphone-control.sh b/iphone-control.sh index 98050ed..f2437c0 100755 --- a/iphone-control.sh +++ b/iphone-control.sh @@ -8,13 +8,41 @@ # tap — Tap at coordinates # swipe [duration_ms] — Swipe gesture # type "text" [x y] — Type text (optionally tap field first) +# home — Go to home screen (via View menu) +# app-switcher — Open app switcher (via View menu) +# spotlight — Open Spotlight search (via View menu) +# open-app "App Name" [--spotlight] — Open app (registry→Spotlight fallback) +# status — Check connection state +# map-apps [max_pages] — Map apps on home screen (OCR, stdout only) +# registry-scan [max_pages] — Scan apps and save to persistent registry +# registry-lookup — Look up app position from registry +# registry-invalidate — Delete registry (force rescan) +# registry-list — Show cached registry contents set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/helpers/common.sh" cmd="${1:-help}" shift || true +# Helper: click a View menu item in iPhone Mirroring +_view_menu_click() { + local menu_item="$1" + local safe_item + safe_item=$(sanitize_applescript "$menu_item") + osascript -e " +tell application \"System Events\" + tell process \"${IPHONE_MIRRORING_PROCESS}\" + set frontmost to true + click menu item \"${safe_item}\" of menu 1 of menu bar item \"View\" of menu bar 1 + end tell +end tell" || { + echo "Error: Failed to click View > ${menu_item}. Is iPhone Mirroring running?" >&2 + return 1 + } +} + case "$cmd" in find) "$SCRIPT_DIR/find-window.sh" @@ -31,6 +59,39 @@ case "$cmd" in type) "$SCRIPT_DIR/type-text.sh" "$@" ;; + open-app) + "$SCRIPT_DIR/open-app.sh" "$@" + ;; + home) + _view_menu_click "Home Screen" + echo "Home screen activated" + ;; + app-switcher) + _view_menu_click "App Switcher" + echo "App switcher opened" + ;; + spotlight) + _view_menu_click "Spotlight" + echo "Spotlight opened" + ;; + map-apps) + "$SCRIPT_DIR/map-apps.sh" "$@" + ;; + registry-scan) + "$SCRIPT_DIR/registry.sh" scan "$@" + ;; + registry-lookup) + "$SCRIPT_DIR/registry.sh" lookup "$@" + ;; + registry-invalidate) + "$SCRIPT_DIR/registry.sh" invalidate + ;; + registry-list) + "$SCRIPT_DIR/registry.sh" list + ;; + status) + "$SCRIPT_DIR/status.sh" + ;; help|--help|-h) echo "iPhone Mirroring Control" echo "" @@ -42,8 +103,18 @@ case "$cmd" in echo " tap Tap at coordinates" echo " swipe [ms] Swipe gesture" echo " type \"text\" [x y] Type text" + echo " home Go to home screen" + echo " app-switcher Open app switcher" + echo " spotlight Open Spotlight search" + echo " open-app \"App Name\" [--spotlight] Open app (registry→Spotlight)" + echo " map-apps [max_pages] Map apps on home screen (OCR)" + echo " registry-scan [max_pages] Scan apps, save to registry" + echo " registry-lookup Look up app from registry" + echo " registry-invalidate Delete registry" + echo " registry-list Show registry contents" + echo " status Check connection state" echo "" - echo "Prerequisites: brew install cliclick" + echo "No external dependencies required." ;; *) echo "Unknown command: $cmd" >&2 diff --git a/map-apps.sh b/map-apps.sh new file mode 100755 index 0000000..613efff --- /dev/null +++ b/map-apps.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# Map all apps on the iPhone home screen. +# Navigates through each home screen page, screenshots, and uses OCR to detect app labels. +# Output: JSON array of {name, page, x, y} for each detected app. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/helpers/common.sh" +HELPER="$SCRIPT_DIR/helpers/ocr-image" +HELPER_SRC="$HELPER.swift" +MAX_PAGES="${1:-$DEFAULT_MAX_PAGES}" + +# Auto-compile OCR helper if missing or outdated +if [[ ! -x "$HELPER" ]] || [[ "$HELPER_SRC" -nt "$HELPER" ]]; then + swiftc -O "$HELPER_SRC" -o "$HELPER" 2>/dev/null || { + echo '{"error":"Failed to compile ocr-image helper"}' >&2 + exit 1 + } +fi + +# Get content dimensions +window_json=$("$SCRIPT_DIR/find-window.sh") +parse_window_bounds "$window_json" + +# Go to home screen first +"$SCRIPT_DIR/iphone-control.sh" home > /dev/null +sleep "$HOME_SETTLE_DELAY" + +# Swipe parameters - horizontal swipe across middle of screen +swipe_y=$(( CONTENT_H / 2 )) +if (( CONTENT_W <= SWIPE_EDGE_MARGIN * 2 )); then + echo "Error: Content width ($CONTENT_W) too small for swipe margin ($SWIPE_EDGE_MARGIN)" >&2 + exit 1 +fi +swipe_x_start=$(( CONTENT_W - SWIPE_EDGE_MARGIN )) +swipe_x_end=$SWIPE_EDGE_MARGIN + +# App icon grid zone +app_zone_bottom=$(( CONTENT_H - APP_ZONE_BOTTOM_MARGIN )) + +all_apps="[" +first_entry=true +prev_hash="" + +for (( page=1; page<=MAX_PAGES; page++ )); do + # Screenshot current page + tmp_img="/tmp/iphone-home-page-$$-${page}.png" + "$SCRIPT_DIR/screenshot.sh" --native "$tmp_img" > /dev/null + + # Check if this page is identical to previous (end of pages) + current_hash=$(md5 -q "$tmp_img" 2>/dev/null || md5sum "$tmp_img" | cut -d' ' -f1) + if [[ "$current_hash" == "$prev_hash" ]]; then + rm -f "$tmp_img" + break + fi + prev_hash="$current_hash" + + # OCR the screenshot + ocr_output=$("$HELPER" "$tmp_img" 2>/dev/null) || { + echo "Warning: OCR failed for page $page, skipping" >&2 + rm -f "$tmp_img" + continue + } + rm -f "$tmp_img" + + # Filter for app labels: short text in the app grid zone + while IFS='|' read -r text px py pw _; do + [[ -z "$text" ]] && continue + + # OCR ran on the native-resolution capture; convert pixel coords to points + # so stored tap targets are display- and Retina-scale-independent + px=$(( px / SCALE )); py=$(( py / SCALE )); pw=$(( pw / SCALE )) + + # Skip if outside app grid zone + (( py < APP_ZONE_TOP || py > app_zone_bottom )) && continue + + # App labels are typically 1-2 words, within length bounds, not all-caps system text + text_len=${#text} + (( text_len > APP_LABEL_MAX_LEN || text_len < APP_LABEL_MIN_LEN )) && continue + + # Skip common non-app-label patterns + case "$text" in + [0-9]*:[0-9]*|[0-9]*%|Search*|Suggestions*|Siri*) continue ;; + esac + + # Center of the label = approximate tap target for the app icon above it + tap_x=$(( px + pw / 2 )) + tap_y=$(( py - APP_ICON_ABOVE_LABEL )) + (( tap_y < 0 )) && continue # skip labels too close to top edge + + if $first_entry; then + first_entry=false + else + all_apps+="," + fi + # Escape text for JSON: strip control chars (\\x00-\\x1f), then escape \\ and " + escaped_text=$(printf '%s' "$text" | tr -d '\000-\037') + escaped_text="${escaped_text//\\/\\\\}" + escaped_text="${escaped_text//\"/\\\"}" + all_apps+="{\"name\":\"${escaped_text}\",\"page\":${page},\"x\":${tap_x},\"y\":${tap_y}}" + done <<< "$ocr_output" + + # Swipe to next page (skip on last iteration) + if (( page < MAX_PAGES )); then + "$SCRIPT_DIR/swipe.sh" "$swipe_x_start" "$swipe_y" "$swipe_x_end" "$swipe_y" "$DEFAULT_SWIPE_DURATION" > /dev/null + sleep "$HOME_SETTLE_DELAY" + fi +done + +all_apps+="]" +echo "$all_apps" diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json index 9c06aab..89e7d9f 100644 --- a/mcp-server/package-lock.json +++ b/mcp-server/package-lock.json @@ -12,7 +12,450 @@ }, "devDependencies": { "@types/node": "^22.0.0", - "typescript": "^5.7.0" + "typescript": "^5.7.0", + "vitest": "^3.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, "node_modules/@hono/node-server": { @@ -27,6 +470,13 @@ "hono": "^4" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.26.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", @@ -67,6 +517,381 @@ } } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.11", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", @@ -77,6 +902,121 @@ "undici-types": "~6.21.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -123,6 +1063,16 @@ } } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -156,6 +1106,16 @@ "node": ">= 0.8" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -185,6 +1145,33 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -273,6 +1260,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -329,6 +1326,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -341,12 +1345,64 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -377,6 +1433,16 @@ "node": ">=18.0.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -460,6 +1526,24 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -499,6 +1583,21 @@ "node": ">= 0.8" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -671,6 +1770,13 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -683,6 +1789,23 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -744,6 +1867,25 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -823,6 +1965,43 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -832,6 +2011,35 @@ "node": ">=16.20.0" } }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -893,6 +2101,51 @@ "node": ">=0.10.0" } }, + "node_modules/rollup": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "fsevents": "~2.3.2" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -1059,6 +2312,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -1068,6 +2345,87 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1130,6 +2488,177 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -1145,6 +2674,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/mcp-server/package.json b/mcp-server/package.json index ce3fffd..0784cb0 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -6,13 +6,15 @@ "main": "dist/index.js", "scripts": { "build": "tsc", - "start": "node dist/index.js" + "start": "node dist/index.js", + "test": "vitest run" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1" }, "devDependencies": { + "@types/node": "^22.0.0", "typescript": "^5.7.0", - "@types/node": "^22.0.0" + "vitest": "^3.2.4" } } diff --git a/mcp-server/src/index.test.ts b/mcp-server/src/index.test.ts new file mode 100644 index 0000000..2c56834 --- /dev/null +++ b/mcp-server/src/index.test.ts @@ -0,0 +1,431 @@ +/** + * Tests for the MCP server's runScript helper and tool argument handling. + * + * These tests mock child_process.execFile to avoid calling real shell scripts + * or requiring iPhone Mirroring to be running. + * + * Run with: npx vitest run (or npx jest if jest is configured) + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { execFile } from "node:child_process"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +// We test runScript in isolation by re-implementing its core logic here, +// since the actual module boots an MCP server on import. + +const __filename_test = fileURLToPath(import.meta.url); +const __dirname_test = dirname(__filename_test); +const SCRIPTS_DIR = resolve(__dirname_test, "..", ".."); + +const PATH = [ + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", +].join(":"); + +const TIMEOUT_DEFAULT = 15_000; +const TIMEOUT_APP_LAUNCH = 30_000; +const TIMEOUT_SCAN = 120_000; + +type ExecFileCallback = ( + error: Error | null, + stdout: string, + stderr: string +) => void; + +// Extracted runScript logic for testability +function runScript( + name: string, + args: string[] = [], + timeout = TIMEOUT_DEFAULT, + execFileFn: typeof execFile = execFile +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + (execFileFn as Function)( + `${SCRIPTS_DIR}/${name}`, + args, + { timeout, env: { ...process.env, PATH } }, + (error: Error | null, stdout: string, stderr: string) => { + if (error) { + reject(new Error((stderr || "").trim() || error.message)); + } else { + resolve({ stdout: (stdout || "").trim(), stderr: (stderr || "").trim() }); + } + } + ); + }); +} + +describe("runScript", () => { + it("resolves with stdout on success", async () => { + const mockExecFile = vi.fn( + (cmd: string, args: string[], opts: object, cb: ExecFileCallback) => { + cb(null, "test output\n", ""); + } + ); + + const result = await runScript("test.sh", [], TIMEOUT_DEFAULT, mockExecFile as any); + expect(result.stdout).toBe("test output"); + expect(result.stderr).toBe(""); + }); + + it("rejects with stderr message on error", async () => { + const mockExecFile = vi.fn( + (cmd: string, args: string[], opts: object, cb: ExecFileCallback) => { + cb(new Error("exit code 1"), "", "script failed\n"); + } + ); + + await expect( + runScript("test.sh", [], TIMEOUT_DEFAULT, mockExecFile as any) + ).rejects.toThrow("script failed"); + }); + + it("rejects with error.message when stderr is empty", async () => { + const mockExecFile = vi.fn( + (cmd: string, args: string[], opts: object, cb: ExecFileCallback) => { + cb(new Error("ETIMEOUT"), "", ""); + } + ); + + await expect( + runScript("test.sh", [], TIMEOUT_DEFAULT, mockExecFile as any) + ).rejects.toThrow("ETIMEOUT"); + }); + + it("passes correct script path", async () => { + const mockExecFile = vi.fn( + (cmd: string, args: string[], opts: object, cb: ExecFileCallback) => { + cb(null, cmd, ""); + } + ); + + const result = await runScript("find-window.sh", [], TIMEOUT_DEFAULT, mockExecFile as any); + expect(result.stdout).toContain("find-window.sh"); + expect(result.stdout).toContain(SCRIPTS_DIR); + }); + + it("passes arguments to execFile", async () => { + let capturedArgs: string[] = []; + const mockExecFile = vi.fn( + (cmd: string, args: string[], opts: object, cb: ExecFileCallback) => { + capturedArgs = args; + cb(null, "ok", ""); + } + ); + + await runScript("tap.sh", ["100", "200"], TIMEOUT_DEFAULT, mockExecFile as any); + expect(capturedArgs).toEqual(["100", "200"]); + }); + + it("uses provided timeout", async () => { + let capturedOpts: any = {}; + const mockExecFile = vi.fn( + (cmd: string, args: string[], opts: any, cb: ExecFileCallback) => { + capturedOpts = opts; + cb(null, "ok", ""); + } + ); + + await runScript("registry.sh", ["scan"], TIMEOUT_SCAN, mockExecFile as any); + expect(capturedOpts.timeout).toBe(TIMEOUT_SCAN); + }); + + it("sets PATH in env", async () => { + let capturedOpts: any = {}; + const mockExecFile = vi.fn( + (cmd: string, args: string[], opts: any, cb: ExecFileCallback) => { + capturedOpts = opts; + cb(null, "ok", ""); + } + ); + + await runScript("test.sh", [], TIMEOUT_DEFAULT, mockExecFile as any); + expect(capturedOpts.env.PATH).toBe(PATH); + }); + + it("trims stdout whitespace", async () => { + const mockExecFile = vi.fn( + (cmd: string, args: string[], opts: object, cb: ExecFileCallback) => { + cb(null, " padded output \n", ""); + } + ); + + const result = await runScript("test.sh", [], TIMEOUT_DEFAULT, mockExecFile as any); + expect(result.stdout).toBe("padded output"); + }); +}); + +describe("tool argument construction", () => { + it("tap: passes x and y as string args", () => { + const x = 150; + const y = 300; + const args = [String(x), String(y)]; + expect(args).toEqual(["150", "300"]); + }); + + it("swipe: passes 4 coords without duration", () => { + const x1 = 100, y1 = 200, x2 = 300, y2 = 400; + const args = [String(x1), String(y1), String(x2), String(y2)]; + expect(args).toEqual(["100", "200", "300", "400"]); + expect(args).toHaveLength(4); + }); + + it("swipe: passes 5 args with optional duration", () => { + const x1 = 100, y1 = 200, x2 = 300, y2 = 400; + const duration_ms = 500; + const args = [String(x1), String(y1), String(x2), String(y2)]; + if (duration_ms !== undefined) args.push(String(duration_ms)); + expect(args).toEqual(["100", "200", "300", "400", "500"]); + expect(args).toHaveLength(5); + }); + + it("type_text: passes text only when no coords", () => { + const text = "hello world"; + const args = [text]; + expect(args).toEqual(["hello world"]); + }); + + it("type_text: passes text + coords when both provided", () => { + const text = "hello"; + const x = 50, y = 100; + const args = [text]; + if (x !== undefined && y !== undefined) { + args.push(String(x), String(y)); + } + expect(args).toEqual(["hello", "50", "100"]); + }); + + it("open_app: passes name only for auto method", () => { + const name = "Safari"; + const method: string = "auto"; + const args = [name]; + if (method === "spotlight") args.push("--spotlight"); + expect(args).toEqual(["Safari"]); + }); + + it("open_app: passes --spotlight flag when method is spotlight", () => { + const name = "Settings"; + const method = "spotlight"; + const args = [name]; + if (method === "spotlight") args.push("--spotlight"); + expect(args).toEqual(["Settings", "--spotlight"]); + }); + + it("scan_apps: passes scan command with max_pages", () => { + const max_pages = 5; + const args = ["scan"]; + if (max_pages !== undefined) args.push(String(max_pages)); + expect(args).toEqual(["scan", "5"]); + }); + + it("scan_apps: passes scan command without max_pages", () => { + const max_pages = undefined; + const args: string[] = ["scan"]; + if (max_pages !== undefined) args.push(String(max_pages)); + expect(args).toEqual(["scan"]); + }); +}); + +describe("timeout constants", () => { + it("default timeout is 15 seconds", () => { + expect(TIMEOUT_DEFAULT).toBe(15_000); + }); + + it("app launch timeout is 30 seconds", () => { + expect(TIMEOUT_APP_LAUNCH).toBe(30_000); + }); + + it("scan timeout is 120 seconds", () => { + expect(TIMEOUT_SCAN).toBe(120_000); + }); +}); + +describe("tool handler: screenshot", () => { + it("reads file and returns base64-encoded content", async () => { + const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const base64 = pngBytes.toString("base64"); + + // Simulate what the screenshot tool handler does + const data = Buffer.from(base64, "base64"); + expect(data.toString("base64")).toBe(base64); + expect(data[0]).toBe(0x89); + expect(data[1]).toBe(0x50); // 'P' in PNG + }); + + it("calls screenshot.sh with temp path", async () => { + let capturedArgs: string[] = []; + const mockExecFile = vi.fn( + (cmd: string, args: string[], opts: object, cb: ExecFileCallback) => { + capturedArgs = args; + cb(null, "", ""); + } + ); + + const tmpPath = `/tmp/iphone-mcp-test.png`; + await runScript("screenshot.sh", [tmpPath], TIMEOUT_DEFAULT, mockExecFile as any); + expect(capturedArgs).toEqual([tmpPath]); + }); +}); + +describe("tool handler: tap", () => { + it("passes coordinates as string arguments", async () => { + let capturedArgs: string[] = []; + const mockExecFile = vi.fn( + (cmd: string, args: string[], opts: object, cb: ExecFileCallback) => { + capturedArgs = args; + cb(null, "Tapped at (150, 300)", ""); + } + ); + + const x = 150, y = 300; + const result = await runScript("tap.sh", [String(x), String(y)], TIMEOUT_DEFAULT, mockExecFile as any); + expect(capturedArgs).toEqual(["150", "300"]); + expect(result.stdout).toContain("Tapped"); + }); +}); + +describe("tool handler: swipe", () => { + it("handles optional duration_ms", async () => { + let capturedArgs: string[] = []; + const mockExecFile = vi.fn( + (cmd: string, args: string[], opts: object, cb: ExecFileCallback) => { + capturedArgs = args; + cb(null, "Swiped", ""); + } + ); + + // Without duration + const args4 = [String(10), String(20), String(300), String(400)]; + await runScript("swipe.sh", args4, TIMEOUT_DEFAULT, mockExecFile as any); + expect(capturedArgs).toHaveLength(4); + + // With duration + const duration_ms = 500; + const args5 = [...args4, String(duration_ms)]; + await runScript("swipe.sh", args5, TIMEOUT_DEFAULT, mockExecFile as any); + expect(capturedArgs).toHaveLength(5); + expect(capturedArgs[4]).toBe("500"); + }); +}); + +describe("tool handler: type_text", () => { + it("handles optional x,y coordinates", async () => { + let capturedArgs: string[] = []; + const mockExecFile = vi.fn( + (cmd: string, args: string[], opts: object, cb: ExecFileCallback) => { + capturedArgs = args; + cb(null, "Typed", ""); + } + ); + + // Without coords + const argsNoCoords = ["hello world"]; + await runScript("type-text.sh", argsNoCoords, TIMEOUT_DEFAULT, mockExecFile as any); + expect(capturedArgs).toEqual(["hello world"]); + + // With coords + const x = 50, y = 100; + const argsWithCoords = ["hello", String(x), String(y)]; + await runScript("type-text.sh", argsWithCoords, TIMEOUT_DEFAULT, mockExecFile as any); + expect(capturedArgs).toEqual(["hello", "50", "100"]); + }); +}); + +describe("tool handler: open_app", () => { + it("handles method parameter", async () => { + let capturedArgs: string[] = []; + const mockExecFile = vi.fn( + (cmd: string, args: string[], opts: object, cb: ExecFileCallback) => { + capturedArgs = args; + cb(null, "Opened: Safari (spotlight)", ""); + } + ); + + // auto method - no flag + const autoArgs = ["Safari"]; + await runScript("open-app.sh", autoArgs, TIMEOUT_APP_LAUNCH, mockExecFile as any); + expect(capturedArgs).toEqual(["Safari"]); + + // spotlight method - adds flag + const spotlightArgs = ["Safari", "--spotlight"]; + await runScript("open-app.sh", spotlightArgs, TIMEOUT_APP_LAUNCH, mockExecFile as any); + expect(capturedArgs).toEqual(["Safari", "--spotlight"]); + }); +}); + +describe("tool handler: scan_apps", () => { + it("passes max_pages to registry.sh scan", async () => { + let capturedArgs: string[] = []; + const mockExecFile = vi.fn( + (cmd: string, args: string[], opts: object, cb: ExecFileCallback) => { + capturedArgs = args; + cb(null, '{"version":1}', ""); + } + ); + + const max_pages = 5; + const args = ["scan", String(max_pages)]; + await runScript("registry.sh", args, TIMEOUT_SCAN, mockExecFile as any); + expect(capturedArgs).toEqual(["scan", "5"]); + }); + + it("omits max_pages when undefined", async () => { + let capturedArgs: string[] = []; + const mockExecFile = vi.fn( + (cmd: string, args: string[], opts: object, cb: ExecFileCallback) => { + capturedArgs = args; + cb(null, '{"version":1}', ""); + } + ); + + const args: string[] = ["scan"]; + await runScript("registry.sh", args, TIMEOUT_SCAN, mockExecFile as any); + expect(capturedArgs).toEqual(["scan"]); + }); +}); + +describe("error handling", () => { + it("rejects with stderr when script fails", async () => { + const mockExecFile = vi.fn( + (cmd: string, args: string[], opts: object, cb: ExecFileCallback) => { + cb(new Error("exit code 1"), "", "iPhone Mirroring window not found\n"); + } + ); + + await expect( + runScript("find-window.sh", [], TIMEOUT_DEFAULT, mockExecFile as any) + ).rejects.toThrow("iPhone Mirroring window not found"); + }); + + it("rejects with error.message when script produces no stderr", async () => { + const mockExecFile = vi.fn( + (cmd: string, args: string[], opts: object, cb: ExecFileCallback) => { + cb(new Error("SIGTERM"), "", ""); + } + ); + + await expect( + runScript("screenshot.sh", [], TIMEOUT_DEFAULT, mockExecFile as any) + ).rejects.toThrow("SIGTERM"); + }); + + it("handles timeout errors", async () => { + const mockExecFile = vi.fn( + (cmd: string, args: string[], opts: object, cb: ExecFileCallback) => { + const err = new Error("TIMEOUT") as Error & { killed: boolean }; + err.killed = true; + cb(err, "", ""); + } + ); + + await expect( + runScript("registry.sh", ["scan"], TIMEOUT_SCAN, mockExecFile as any) + ).rejects.toThrow("TIMEOUT"); + }); +}); diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts index 20a2e02..5144d03 100644 --- a/mcp-server/src/index.ts +++ b/mcp-server/src/index.ts @@ -2,6 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { readFile, unlink } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; @@ -22,18 +23,31 @@ const PATH = [ "/sbin", ].join(":"); +// Timeouts (ms) +const TIMEOUT_DEFAULT = 15_000; +const TIMEOUT_APP_LAUNCH = 30_000; +const TIMEOUT_SCAN = 120_000; + function runScript( name: string, - args: string[] = [] + args: string[] = [], + timeout = TIMEOUT_DEFAULT ): Promise<{ stdout: string; stderr: string }> { return new Promise((resolve, reject) => { execFile( `${SCRIPTS_DIR}/${name}`, args, - { timeout: 15_000, env: { ...process.env, PATH } }, + { timeout, env: { ...process.env, PATH } }, (error, stdout, stderr) => { if (error) { - reject(new Error(stderr.trim() || error.message)); + if (error.killed) { + reject(new Error(`${name} timed out after ${timeout}ms`)); + } else { + const detail = stderr.trim() || error.message; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const code = (error as any).code; + reject(new Error(typeof code === "number" ? `${name} exited ${code}: ${detail}` : detail)); + } } else { resolve({ stdout: stdout.trim(), stderr: stderr.trim() }); } @@ -48,14 +62,14 @@ const server = new McpServer({ }); // find_window -server.tool("find_window", "Locate the iPhone Mirroring window. Returns JSON with id, x, y, width, height.", {}, async () => { +server.tool("find_window", "Locate the iPhone Mirroring window. Returns JSON with window bounds and content bounds (iOS screen area inside bezel). Bounds are global Mac display points and can be negative when the window sits on a display left of or above the primary one.", {}, async () => { const { stdout } = await runScript("find-window.sh"); return { content: [{ type: "text", text: stdout }] }; }); // screenshot -server.tool("screenshot", "Take a screenshot of the iPhone screen. Returns the image directly.", {}, async () => { - const tmpPath = `/tmp/iphone-mcp-${Date.now()}.png`; +server.tool("screenshot", "Take a screenshot of the iPhone screen. Returns a PNG whose pixel dimensions equal the tap/swipe coordinate space (1 image pixel = 1 coordinate unit, on any display or Retina scale).", {}, async () => { + const tmpPath = `/tmp/iphone-mcp-${randomUUID()}.png`; try { await runScript("screenshot.sh", [tmpPath]); const data = await readFile(tmpPath); @@ -76,8 +90,8 @@ server.tool("screenshot", "Take a screenshot of the iPhone screen. Returns the i // tap server.tool( "tap", - "Tap at coordinates relative to the iPhone screen.", - { x: z.number().describe("X coordinate"), y: z.number().describe("Y coordinate") }, + "Tap at coordinates relative to the iPhone content area. Coordinates are content points, identical to screenshot pixel coordinates.", + { x: z.number().int().describe("X coordinate"), y: z.number().int().describe("Y coordinate") }, async ({ x, y }) => { const { stdout } = await runScript("tap.sh", [String(x), String(y)]); return { content: [{ type: "text", text: stdout }] }; @@ -87,13 +101,13 @@ server.tool( // swipe server.tool( "swipe", - "Swipe from (x1,y1) to (x2,y2) on the iPhone screen.", + "Swipe from (x1,y1) to (x2,y2) on the iPhone content area. Coordinates are content points, identical to screenshot pixel coordinates.", { - x1: z.number().describe("Start X"), - y1: z.number().describe("Start Y"), - x2: z.number().describe("End X"), - y2: z.number().describe("End Y"), - duration_ms: z.number().optional().describe("Swipe duration in ms (default 300)"), + x1: z.number().int().describe("Start X"), + y1: z.number().int().describe("Start Y"), + x2: z.number().int().describe("End X"), + y2: z.number().int().describe("End Y"), + duration_ms: z.number().int().min(1).optional().describe("Swipe duration in ms (default 300)"), }, async ({ x1, y1, x2, y2, duration_ms }) => { const args = [String(x1), String(y1), String(x2), String(y2)]; @@ -108,11 +122,14 @@ server.tool( "type_text", "Type text into the iPhone. Optionally tap a field first.", { - text: z.string().describe("Text to type"), - x: z.number().optional().describe("X coordinate to tap before typing"), - y: z.number().optional().describe("Y coordinate to tap before typing"), + text: z.string().min(1).describe("Text to type"), + x: z.number().int().optional().describe("X coordinate to tap before typing"), + y: z.number().int().optional().describe("Y coordinate to tap before typing"), }, async ({ text, x, y }) => { + if ((x === undefined) !== (y === undefined)) { + throw new Error("x and y must both be provided together or both omitted"); + } const args = [text]; if (x !== undefined && y !== undefined) { args.push(String(x), String(y)); @@ -122,6 +139,78 @@ server.tool( } ); +// open_app +server.tool( + "open_app", + "Open an app on the iPhone by name. Uses registry (fast, ~0.5s) with Spotlight fallback (~1.5s). Run scan_apps first to populate the registry.", + { + name: z.string().min(1).describe("App name to open (e.g. 'Settings', 'Safari')"), + method: z.enum(["auto", "spotlight"]).optional().describe("Launch method: 'auto' (registry→spotlight fallback, default) or 'spotlight' (force Spotlight)"), + }, + async ({ name, method }) => { + const args = [name]; + if (method === "spotlight") args.push("--spotlight"); + const { stdout } = await runScript("open-app.sh", args, TIMEOUT_APP_LAUNCH); + return { content: [{ type: "text", text: stdout }] }; + } +); + +// home +server.tool("home", "Go to the iPhone home screen via the View menu.", {}, async () => { + const { stdout } = await runScript("iphone-control.sh", ["home"]); + return { content: [{ type: "text", text: stdout }] }; +}); + +// app_switcher +server.tool("app_switcher", "Open the iPhone app switcher via the View menu.", {}, async () => { + const { stdout } = await runScript("iphone-control.sh", ["app-switcher"]); + return { content: [{ type: "text", text: stdout }] }; +}); + +// spotlight +server.tool("spotlight", "Open iPhone Spotlight search via the View menu.", {}, async () => { + const { stdout } = await runScript("iphone-control.sh", ["spotlight"]); + return { content: [{ type: "text", text: stdout }] }; +}); + +// status +server.tool("status", "Check if iPhone Mirroring is connected and active.", {}, async () => { + const { stdout } = await runScript("status.sh"); + return { content: [{ type: "text", text: stdout }] }; +}); + +// scan_apps (replaces both scan_apps and map_apps - they were identical) +server.tool( + "scan_apps", + "Scan all iPhone home screen pages via OCR and save results to persistent registry. This populates the app registry used by open_app for fast launches. Returns full registry JSON with app names, pages, and coordinates.", + { + max_pages: z.number().int().min(1).optional().describe("Max home screen pages to scan (default 10)"), + }, + async ({ max_pages }) => { + const args = ["scan"]; + if (max_pages !== undefined) args.push(String(max_pages)); + const { stdout } = await runScript("registry.sh", args, TIMEOUT_SCAN); + return { content: [{ type: "text", text: stdout }] }; + } +); + +// registry_invalidate +server.tool("registry_invalidate", "Delete the cached app registry, forcing a full rescan on the next scan_apps or open_app call.", {}, async () => { + const { stdout } = await runScript("registry.sh", ["invalidate"]); + return { content: [{ type: "text", text: stdout }] }; +}); + +// list_apps +server.tool( + "list_apps", + "List all apps from the cached registry. Returns the saved registry data without rescanning. Run scan_apps first if no registry exists.", + {}, + async () => { + const { stdout } = await runScript("registry.sh", ["list"]); + return { content: [{ type: "text", text: stdout }] }; + } +); + async function main() { const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/open-app.sh b/open-app.sh new file mode 100755 index 0000000..8362950 --- /dev/null +++ b/open-app.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# Open an app on the iPhone by name. +# Strategy: registry lookup → page navigation + tap (fast), Spotlight fallback (reliable). +# Usage: open-app.sh [--spotlight] +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/helpers/common.sh" + +if [[ $# -lt 1 ]]; then + echo "Usage: open-app.sh [--spotlight]" >&2 + exit 1 +fi + +app_name="$1" +force_spotlight=false +if [[ "${2:-}" == "--spotlight" ]]; then + force_spotlight=true +fi + +# --- Spotlight launcher (fallback) --- +_open_via_spotlight() { + # Write app name to a temp file - avoids embedding user content in any bash string, + # preventing $ expansion and backtick injection (same pattern as type-text.sh). + local tmp_name + tmp_name=$(mktemp /tmp/open-app-XXXXXX.txt) + printf '%s' "$app_name" > "$tmp_name" + + jxa_activate_and_run " +const proc = Application('System Events').processes.byName('${IPHONE_MIRRORING_PROCESS}'); +proc.menuBars[0].menuBarItems.byName('View').menus[0].menuItems.byName('Spotlight').click(); +\$.NSThread.sleepForTimeInterval(${SPOTLIGHT_DELAY}); + +const nameData = \$.NSData.dataWithContentsOfFile('${tmp_name}'); +const appName = \$.NSString.alloc.initWithDataEncoding(nameData, \$.NSUTF8StringEncoding).js; +const se = Application('System Events'); +se.keystroke(appName); +\$.NSThread.sleepForTimeInterval(${SPOTLIGHT_SEARCH_DELAY}); + +se.keyCode(36); +" || { rm -f "$tmp_name"; return 1; } + rm -f "$tmp_name" + echo "Opened: $app_name (spotlight)" +} + +# --- Force Spotlight if requested --- +if $force_spotlight; then + _open_via_spotlight + exit 0 +fi + +# --- Try registry lookup --- +lookup_result=$("$SCRIPT_DIR/registry.sh" lookup "$app_name" 2>/dev/null) || lookup_result="" + +if [[ -z "$lookup_result" ]]; then + _open_via_spotlight + exit 0 +fi + +# Parse lookup result using shared JSON parser +app_page=$(json_field "$lookup_result" "page") +app_x=$(json_field "$lookup_result" "x") +app_y=$(json_field "$lookup_result" "y") +reg_width=$(json_field "$lookup_result" "content_width") +reg_height=$(json_field "$lookup_result" "content_height") + +# Validate we got all values +if [[ -z "$app_page" || -z "$app_x" || -z "$app_y" || -z "$reg_width" || -z "$reg_height" ]]; then + _open_via_spotlight + exit 0 +fi + +# Check content dimensions match current window +window_json=$("$SCRIPT_DIR/find-window.sh") +parse_window_bounds "$window_json" + +if [[ "$CONTENT_W" != "$reg_width" || "$CONTENT_H" != "$reg_height" ]]; then + # Dimensions changed - invalidate registry, fall back to Spotlight + "$SCRIPT_DIR/registry.sh" invalidate > /dev/null + _open_via_spotlight + exit 0 +fi + +# --- Navigate via registry: go home, swipe to page, tap --- + +# Go to home screen (guaranteed page 1) +"$SCRIPT_DIR/iphone-control.sh" home > /dev/null +sleep "$HOME_SETTLE_DELAY" + +# Swipe to target page (each swipe = one page forward) +if (( app_page > 1 )); then + swipe_y=$(( CONTENT_H / 2 )) + swipe_x_start=$(( CONTENT_W - SWIPE_EDGE_MARGIN )) + swipe_x_end=$SWIPE_EDGE_MARGIN + for (( i=1; i /dev/null + sleep "$PAGE_SWIPE_DELAY" + done +fi + +# Tap the app +"$SCRIPT_DIR/tap.sh" "$app_x" "$app_y" > /dev/null + +echo "Opened: $app_name (registry, page $app_page)" diff --git a/registry.sh b/registry.sh new file mode 100755 index 0000000..40ea2fe --- /dev/null +++ b/registry.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +# App Registry Manager - persistent storage for home screen app positions. +# Usage: registry.sh [args...] +# +# Commands: +# scan [max_pages] - Scan home screen, write registry file +# lookup - Find app by name (case-insensitive), output JSON entry +# invalidate - Delete registry file +# list - Output registry file contents +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/helpers/common.sh" +REGISTRY_DIR="$HOME/.iphone-control" +REGISTRY_FILE="$REGISTRY_DIR/app-registry.json" + +cmd="${1:-help}" +shift || true + +case "$cmd" in + scan) + max_pages="${1:-$DEFAULT_MAX_PAGES}" + + # Get current content dimensions + window_json=$("$SCRIPT_DIR/find-window.sh") + parse_window_bounds "$window_json" + + # Run map-apps.sh to get app list + apps_json=$("$SCRIPT_DIR/map-apps.sh" "$max_pages") + + # Build registry JSON - sanitize apps_json for safe JXA embedding + # apps_json comes from our own map-apps.sh output, but we validate it's valid JSON + scanned_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + # Use a temp file to avoid shell interpolation issues with large JSON + tmp_apps="/tmp/iphone-registry-apps-$$.json" + printf '%s\n' "$apps_json" > "$tmp_apps" + + registry=$(osascript -l JavaScript -e " + ObjC.import('Foundation'); + const data = \$.NSData.dataWithContentsOfFile('${tmp_apps}'); + const str = \$.NSString.alloc.initWithDataEncoding(data, \$.NSUTF8StringEncoding).js; + const apps = JSON.parse(str); + let max = 0; + for (const a of apps) { if (a.page > max) max = a.page; } + JSON.stringify({ + version: 2, + scanned_at: '${scanned_at}', + total_pages: max, + content_width: ${CONTENT_W}, + content_height: ${CONTENT_H}, + apps: apps + }, null, 2); + ") || { + rm -f "$tmp_apps" + echo '{"error":"Failed to build registry JSON"}' >&2 + exit 1 + } + rm -f "$tmp_apps" + + if [[ -z "$registry" ]]; then + echo '{"error":"Registry scan returned empty output"}' >&2 + exit 1 + fi + + # Write to file atomically - temp file + mv prevents partial writes on interrupt + mkdir -p "$REGISTRY_DIR" + tmp_reg="/tmp/iphone-registry-$$.json" + printf '%s\n' "$registry" > "$tmp_reg" + mv "$tmp_reg" "$REGISTRY_FILE" + echo "$registry" + ;; + + lookup) + if [[ $# -lt 1 ]]; then + echo '{"error":"Usage: registry.sh lookup "}' >&2 + exit 1 + fi + search_name="$1" + + if [[ ! -f "$REGISTRY_FILE" ]]; then + echo '{"error":"No registry found. Run: registry.sh scan"}' >&2 + exit 1 + fi + + # Write search name to a temp file - avoids embedding user content in any bash string, + # preventing $ expansion and backtick injection in the osascript -e argument. + tmp_search=$(mktemp /tmp/registry-search-XXXXXX.txt) + printf '%s' "$search_name" > "$tmp_search" + + # Case-insensitive lookup: exact match first, then substring + result=$(osascript -l JavaScript -e " + ObjC.import('Foundation'); + const regData = \$.NSData.dataWithContentsOfFile('${REGISTRY_FILE}'); + const str = \$.NSString.alloc.initWithDataEncoding(regData, \$.NSUTF8StringEncoding).js; + const registry = JSON.parse(str); + // v1 registries stored pixel-space coords; v2 stores content points. + // Reject stale versions so open-app falls back to Spotlight instead of mis-tapping. + if (registry.version !== 2) throw new Error('stale registry version: rescan needed'); + const apps = registry.apps; + const searchData = \$.NSData.dataWithContentsOfFile('${tmp_search}'); + const search = \$.NSString.alloc.initWithDataEncoding(searchData, \$.NSUTF8StringEncoding).js; + const lower = search.toLowerCase(); + + // Exact match (case-insensitive) + let match = apps.find(a => a.name.toLowerCase() === lower); + + // Substring fallback + if (!match) { + match = apps.find(a => a.name.toLowerCase().includes(lower)); + } + + if (match) { + JSON.stringify({ + name: match.name, + page: match.page, + x: match.x, + y: match.y, + content_width: registry.content_width, + content_height: registry.content_height + }); + } else { + ''; + } + ") || { + rm -f "$tmp_search" + echo "{\"error\":\"Registry lookup failed\"}" >&2 + exit 1 + } + rm -f "$tmp_search" + + if [[ -z "$result" ]]; then + safe_err="${search_name//\\/\\\\}" + safe_err="${safe_err//\"/\\\"}" + echo "{\"error\":\"App not found: $safe_err\"}" >&2 + exit 1 + fi + echo "$result" + ;; + + invalidate) + if [[ -f "$REGISTRY_FILE" ]]; then + rm -f "$REGISTRY_FILE" + echo "Registry invalidated" + else + echo "No registry to invalidate" + fi + ;; + + list) + if [[ ! -f "$REGISTRY_FILE" ]]; then + echo '{"error":"No registry found. Run: registry.sh scan"}' >&2 + exit 1 + fi + cat "$REGISTRY_FILE" + ;; + + help|--help|-h) + echo "App Registry Manager" + echo "" + echo "Usage: registry.sh [args...]" + echo "" + echo "Commands:" + echo " scan [max_pages] Scan home screen, write registry file" + echo " lookup Find app by name (case-insensitive)" + echo " invalidate Delete registry file" + echo " list Output registry file contents" + ;; + + *) + echo "Unknown command: $cmd" >&2 + echo "Run: registry.sh help" >&2 + exit 1 + ;; +esac diff --git a/screenshot.sh b/screenshot.sh index e66a67f..e3d7402 100755 --- a/screenshot.sh +++ b/screenshot.sh @@ -1,25 +1,56 @@ #!/usr/bin/env bash -# Capture a screenshot of the iPhone Mirroring window. +# Capture a screenshot of the iPhone Mirroring window (content area only). +# Uses window ID capture - no desktop bleed, works with overlapping windows. +# Usage: screenshot.sh [--native] [output_path] +# Default output is normalized to POINT dimensions (content_width x content_height), +# so image pixels map 1:1 onto tap/swipe coordinates on any display, any Retina scale. +# --native keeps the display's native pixel resolution (2x on Retina), for OCR. # Output: path to the saved PNG set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/helpers/common.sh" +NATIVE=0 +if [[ "${1:-}" == "--native" ]]; then + NATIVE=1 + shift +fi OUTPUT="${1:-/tmp/iphone-screen.png}" -# Get window info +# Get window info (includes content bounds and window_id) window_json=$("$SCRIPT_DIR/find-window.sh") +parse_window_bounds "$window_json" -x=$(echo "$window_json" | python3 -c "import sys,json; print(json.load(sys.stdin)['x'])") -y=$(echo "$window_json" | python3 -c "import sys,json; print(json.load(sys.stdin)['y'])") -w=$(echo "$window_json" | python3 -c "import sys,json; print(json.load(sys.stdin)['width'])") -h=$(echo "$window_json" | python3 -c "import sys,json; print(json.load(sys.stdin)['height'])") - -# Capture the region -screencapture -R "${x},${y},${w},${h}" "$OUTPUT" 2>/dev/null +# Offsets for sips must be in pixel space (captured image is at native resolution) +offset_x=$(( (CONTENT_X - WINDOW_X) * SCALE )) +offset_y=$(( (CONTENT_Y - WINDOW_Y) * SCALE )) +crop_w=$(( CONTENT_W * SCALE )) +crop_h=$(( CONTENT_H * SCALE )) -if [[ ! -f "$OUTPUT" ]]; then +# Capture just the window (no desktop, no overlap from other windows) +tmp_full="/tmp/iphone-screenshot-full-$$.png" +screencapture -x -l "$WINDOW_ID" -o "$tmp_full" 2>/dev/null || { echo "Error: Screenshot failed. Check Screen Recording permission." >&2 exit 1 +} + +# Crop to content area (remove bezel/rounded corners) +cp "$tmp_full" "$OUTPUT" +rm -f "$tmp_full" +sips --cropToHeightWidth "$crop_h" "$crop_w" --cropOffset "$offset_y" "$offset_x" "$OUTPUT" >/dev/null || { + echo "Error: Failed to crop screenshot (sips failed)" >&2 + rm -f "$OUTPUT" + exit 1 +} + +# Normalize to point dimensions: one coordinate space for screenshots, OCR, registry, taps. +# Exact 1/SCALE resample (crop dims are CONTENT * SCALE), so no aspect drift. +if (( ! NATIVE && SCALE > 1 )); then + sips --resampleHeightWidth "$CONTENT_H" "$CONTENT_W" "$OUTPUT" >/dev/null || { + echo "Error: Failed to resample screenshot (sips failed)" >&2 + rm -f "$OUTPUT" + exit 1 + } fi echo "$OUTPUT" diff --git a/status.sh b/status.sh new file mode 100755 index 0000000..81e46ac --- /dev/null +++ b/status.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Check iPhone Mirroring connection state. +# Output: JSON with status ("connected", "disconnected", or "not_running") +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/helpers/common.sh" + +status_code=$(osascript -e " +tell application \"System Events\" + if not (exists process \"${IPHONE_MIRRORING_PROCESS}\") then + return \"not_running\" + end if + tell process \"${IPHONE_MIRRORING_PROCESS}\" + if not (exists window 1) then + return \"not_running\" + end if + try + set g to group 1 of window 1 + set childCount to count of UI elements of g + if childCount = 0 then + return \"connected\" + else + return \"disconnected\" + end if + on error + return \"disconnected\" + end try + end tell +end tell" 2>/dev/null) || true + +echo "{\"status\":\"${status_code:-not_running}\"}" diff --git a/swipe.sh b/swipe.sh index bf3ec82..226dc6a 100755 --- a/swipe.sh +++ b/swipe.sh @@ -1,65 +1,65 @@ #!/usr/bin/env bash -# Swipe from (x1,y1) to (x2,y2) on the iPhone Mirroring window. +# Swipe from (x1,y1) to (x2,y2) on the iPhone iOS content area. # Usage: swipe.sh [duration_ms] set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/helpers/common.sh" if [[ $# -lt 4 ]]; then echo "Usage: swipe.sh [duration_ms]" >&2 exit 1 fi -rel_x1="$1" -rel_y1="$2" -rel_x2="$3" -rel_y2="$4" -duration_ms="${5:-300}" +rel_x1="$1"; rel_y1="$2"; rel_x2="$3"; rel_y2="$4" +duration_ms="${5:-$DEFAULT_SWIPE_DURATION}" -if ! command -v cliclick &>/dev/null; then - echo "Error: cliclick not found. Install with: brew install cliclick" >&2 +if ! [[ "$duration_ms" =~ ^[0-9]+$ ]] || (( duration_ms <= 0 )); then + echo "Error: duration_ms must be a positive integer, got: $duration_ms" >&2 exit 1 fi -# Get window position +# Parse content bounds window_json=$("$SCRIPT_DIR/find-window.sh") +parse_window_bounds "$window_json" -win_x=$(echo "$window_json" | python3 -c "import sys,json; print(json.load(sys.stdin)['x'])") -win_y=$(echo "$window_json" | python3 -c "import sys,json; print(json.load(sys.stdin)['y'])") -win_w=$(echo "$window_json" | python3 -c "import sys,json; print(json.load(sys.stdin)['width'])") -win_h=$(echo "$window_json" | python3 -c "import sys,json; print(json.load(sys.stdin)['height'])") +validate_coords "$rel_x1" "$rel_y1" "$CONTENT_W" "$CONTENT_H" || exit 1 +validate_coords "$rel_x2" "$rel_y2" "$CONTENT_W" "$CONTENT_H" || exit 1 -# Validate coordinates -for coord_pair in "$rel_x1,$rel_y1" "$rel_x2,$rel_y2"; do - cx="${coord_pair%,*}" - cy="${coord_pair#*,}" - if (( cx < 0 || cx > win_w || cy < 0 || cy > win_h )); then - echo "Error: Coordinates ($cx, $cy) out of bounds (${win_w}x${win_h})" >&2 - exit 1 - fi -done +abs_x1=$(( CONTENT_X + rel_x1 )); abs_y1=$(( CONTENT_Y + rel_y1 )) +abs_x2=$(( CONTENT_X + rel_x2 )); abs_y2=$(( CONTENT_Y + rel_y2 )) -# Convert to absolute -abs_x1=$(( win_x + rel_x1 )) -abs_y1=$(( win_y + rel_y1 )) -abs_x2=$(( win_x + rel_x2 )) -abs_y2=$(( win_y + rel_y2 )) +jxa_activate_and_run " +const steps = ${SWIPE_STEPS}; +const stepDelay = ${duration_ms} / 1000 / steps; +const startPt = {x: ${abs_x1}, y: ${abs_y1}}; +const endPt = {x: ${abs_x2}, y: ${abs_y2}}; +const dx = ${abs_x2} - ${abs_x1}; +const dy = ${abs_y2} - ${abs_y1}; -# Calculate intermediate steps for smooth swipe -steps=10 -sleep_per_step=$(echo "scale=4; $duration_ms / 1000 / $steps" | bc) +// mouseDown at start point +const dn = \$.CGEventCreateMouseEvent(null, \$.kCGEventLeftMouseDown, startPt, \$.kCGMouseButtonLeft); +\$.CGEventPost(\$.kCGHIDEventTap, dn); -# Mouse down at start, drag through intermediate points, mouse up at end -cliclick dd:"${abs_x1},${abs_y1}" +// IMMEDIATELY send first drag at ${SWIPE_KICK_FACTOR} distance — zero delay after mouseDown. +// iOS decides drag-vs-longpress within ~50ms. If cursor moves >10px in that window, +// it's recognized as a drag. +const kickPt = {x: ${abs_x1} + dx * ${SWIPE_KICK_FACTOR}, y: ${abs_y1} + dy * ${SWIPE_KICK_FACTOR}}; +const kick = \$.CGEventCreateMouseEvent(null, \$.kCGEventLeftMouseDragged, kickPt, \$.kCGMouseButtonLeft); +\$.CGEventPost(\$.kCGHIDEventTap, kick); -for (( i = 1; i <= steps; i++ )); do - frac=$(echo "scale=4; $i / $steps" | bc) - ix=$(echo "$abs_x1 + ($abs_x2 - $abs_x1) * $frac" | bc | cut -d. -f1) - iy=$(echo "$abs_y1 + ($abs_y2 - $abs_y1) * $frac" | bc | cut -d. -f1) - cliclick m:"${ix},${iy}" - sleep "$sleep_per_step" -done +// Interpolate remaining distance over the duration +const remaining = 1.0 - ${SWIPE_KICK_FACTOR}; +for (let i = 1; i <= steps; i++) { + \$.NSThread.sleepForTimeInterval(stepDelay); + const f = ${SWIPE_KICK_FACTOR} + remaining * (i / steps); + const mid = {x: ${abs_x1} + dx * f, y: ${abs_y1} + dy * f}; + const drag = \$.CGEventCreateMouseEvent(null, \$.kCGEventLeftMouseDragged, mid, \$.kCGMouseButtonLeft); + \$.CGEventPost(\$.kCGHIDEventTap, drag); +} -cliclick du:"${abs_x2},${abs_y2}" +const up = \$.CGEventCreateMouseEvent(null, \$.kCGEventLeftMouseUp, endPt, \$.kCGMouseButtonLeft); +\$.CGEventPost(\$.kCGHIDEventTap, up); +" echo "Swiped ($rel_x1,$rel_y1) -> ($rel_x2,$rel_y2) in ${duration_ms}ms" diff --git a/tap.sh b/tap.sh index af1326b..1901148 100755 --- a/tap.sh +++ b/tap.sh @@ -1,9 +1,10 @@ #!/usr/bin/env bash -# Tap at coordinates relative to the iPhone Mirroring window. +# Tap at coordinates relative to the iPhone iOS content area. # Usage: tap.sh set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/helpers/common.sh" if [[ $# -lt 2 ]]; then echo "Usage: tap.sh " >&2 @@ -13,30 +14,23 @@ fi rel_x="$1" rel_y="$2" -# Check cliclick -if ! command -v cliclick &>/dev/null; then - echo "Error: cliclick not found. Install with: brew install cliclick" >&2 - exit 1 -fi - -# Get window position +# Parse content bounds window_json=$("$SCRIPT_DIR/find-window.sh") +parse_window_bounds "$window_json" -win_x=$(echo "$window_json" | python3 -c "import sys,json; print(json.load(sys.stdin)['x'])") -win_y=$(echo "$window_json" | python3 -c "import sys,json; print(json.load(sys.stdin)['y'])") -win_w=$(echo "$window_json" | python3 -c "import sys,json; print(json.load(sys.stdin)['width'])") -win_h=$(echo "$window_json" | python3 -c "import sys,json; print(json.load(sys.stdin)['height'])") +validate_coords "$rel_x" "$rel_y" "$CONTENT_W" "$CONTENT_H" || exit 1 -# Validate coordinates are within bounds -if (( rel_x < 0 || rel_x > win_w || rel_y < 0 || rel_y > win_h )); then - echo "Error: Coordinates ($rel_x, $rel_y) out of bounds (${win_w}x${win_h})" >&2 - exit 1 -fi +abs_x=$(( CONTENT_X + rel_x )) +abs_y=$(( CONTENT_Y + rel_y )) -# Convert to absolute screen coordinates -abs_x=$(( win_x + rel_x )) -abs_y=$(( win_y + rel_y )) +# Activate + click, saving and restoring cursor +jxa_activate_and_run " +const pt = {x: ${abs_x}, y: ${abs_y}}; +const dn = \$.CGEventCreateMouseEvent(null, \$.kCGEventLeftMouseDown, pt, \$.kCGMouseButtonLeft); +const up = \$.CGEventCreateMouseEvent(null, \$.kCGEventLeftMouseUp, pt, \$.kCGMouseButtonLeft); +\$.CGEventPost(\$.kCGHIDEventTap, dn); +\$.NSThread.sleepForTimeInterval(${TAP_DELAY}); +\$.CGEventPost(\$.kCGHIDEventTap, up); +" -# Click -cliclick c:"${abs_x},${abs_y}" echo "Tapped at ($rel_x, $rel_y) -> screen ($abs_x, $abs_y)" diff --git a/tests/test_common.bats b/tests/test_common.bats new file mode 100644 index 0000000..e78ddac --- /dev/null +++ b/tests/test_common.bats @@ -0,0 +1,237 @@ +#!/usr/bin/env bats +# Tests for helpers/common.sh shared functions. + +load test_helper + +setup() { + setup_temp + source "$PROJECT_ROOT/helpers/common.sh" +} + +teardown() { + teardown_temp +} + +# --- json_field --- + +@test "json_field: extracts numeric field" { + local json='{"x":100,"y":200}' + result=$(json_field "$json" "x") + [ "$result" = "100" ] +} + +@test "json_field: extracts second numeric field" { + local json='{"x":100,"y":200}' + result=$(json_field "$json" "y") + [ "$result" = "200" ] +} + +@test "json_field: extracts string field" { + local json='{"status":"connected","other":1}' + result=$(json_field "$json" "status") + [ "$result" = "connected" ] +} + +@test "json_field: extracts last field before closing brace" { + local json='{"a":1,"b":2,"c":3}' + result=$(json_field "$json" "c") + [ "$result" = "3" ] +} + +@test "json_field: handles spaces around colon" { + local json='{"width": 400, "height": 800}' + result=$(json_field "$json" "width") + [ "$result" = "400" ] +} + +@test "json_field: extracts window_id from full JSON" { + result=$(json_field "$MOCK_WINDOW_JSON" "window_id") + [ "$result" = "12345" ] +} + +@test "json_field: extracts scale from full JSON" { + result=$(json_field "$MOCK_WINDOW_JSON" "scale") + [ "$result" = "2" ] +} + +@test "json_field: extracts content_width from full JSON" { + result=$(json_field "$MOCK_WINDOW_JSON" "content_width") + [ "$result" = "380" ] +} + +# --- parse_window_bounds --- + +@test "parse_window_bounds: sets CONTENT_X (negative origin preserved)" { + parse_window_bounds "$MOCK_WINDOW_JSON" + [ "$CONTENT_X" = "-1070" ] +} + +@test "parse_window_bounds: sets CONTENT_Y (negative origin preserved)" { + parse_window_bounds "$MOCK_WINDOW_JSON" + [ "$CONTENT_Y" = "-380" ] +} + +@test "parse_window_bounds: sets CONTENT_W" { + parse_window_bounds "$MOCK_WINDOW_JSON" + [ "$CONTENT_W" = "380" ] +} + +@test "parse_window_bounds: sets CONTENT_H" { + parse_window_bounds "$MOCK_WINDOW_JSON" + [ "$CONTENT_H" = "760" ] +} + +@test "parse_window_bounds: sets WINDOW_X (negative origin preserved)" { + parse_window_bounds "$MOCK_WINDOW_JSON" + [ "$WINDOW_X" = "-1080" ] +} + +@test "parse_window_bounds: sets WINDOW_Y (negative origin preserved)" { + parse_window_bounds "$MOCK_WINDOW_JSON" + [ "$WINDOW_Y" = "-400" ] +} + +@test "parse_window_bounds: sets WINDOW_ID" { + parse_window_bounds "$MOCK_WINDOW_JSON" + [ "$WINDOW_ID" = "12345" ] +} + +@test "parse_window_bounds: sets SCALE" { + parse_window_bounds "$MOCK_WINDOW_JSON" + [ "$SCALE" = "2" ] +} + +@test "parse_window_bounds: SCALE defaults to 1 when set to empty" { + # Simulate what happens when scale field has empty value + SCALE="" + SCALE="${SCALE:-1}" + [ "$SCALE" = "1" ] +} + +@test "parse_window_bounds: SCALE is set from JSON with scale field" { + local json='{"x":0,"y":0,"width":100,"height":200,"content_x":0,"content_y":0,"content_width":100,"content_height":200,"window_id":1,"scale":2}' + parse_window_bounds "$json" + [ "$SCALE" = "2" ] +} + +@test "parse_window_bounds: fails when content_x is missing" { + local json='{"x":0,"y":0,"width":100,"height":200,"content_y":0,"content_width":100,"content_height":200,"window_id":1,"scale":2}' + run parse_window_bounds "$json" + [ "$status" -eq 1 ] + [[ "$output" == *"required fields"* ]] +} + +# --- validate_coords --- + +@test "validate_coords: accepts origin (0,0)" { + run validate_coords 0 0 380 760 + [ "$status" -eq 0 ] +} + +@test "validate_coords: rejects x/y equal to bounds (valid range is 0..w-1)" { + run validate_coords 380 760 380 760 + [ "$status" -eq 1 ] + [[ "$output" == *"out of bounds"* ]] +} + +@test "validate_coords: accepts max valid point (w-1, h-1)" { + run validate_coords 379 759 380 760 + [ "$status" -eq 0 ] +} + +@test "validate_coords: accepts mid-point" { + run validate_coords 190 380 380 760 + [ "$status" -eq 0 ] +} + +@test "validate_coords: rejects negative x" { + run validate_coords -1 100 380 760 + [ "$status" -eq 1 ] + [[ "$output" == *"out of bounds"* ]] +} + +@test "validate_coords: rejects negative y" { + run validate_coords 100 -1 380 760 + [ "$status" -eq 1 ] + [[ "$output" == *"out of bounds"* ]] +} + +@test "validate_coords: rejects x beyond width" { + run validate_coords 381 100 380 760 + [ "$status" -eq 1 ] + [[ "$output" == *"out of bounds"* ]] +} + +@test "validate_coords: rejects y beyond height" { + run validate_coords 100 761 380 760 + [ "$status" -eq 1 ] + [[ "$output" == *"out of bounds"* ]] +} + +# --- sanitize_jxa --- + +@test "sanitize_jxa: escapes backslashes" { + result=$(sanitize_jxa 'hello\world') + [ "$result" = 'hello\\world' ] +} + +@test "sanitize_jxa: escapes double quotes" { + result=$(sanitize_jxa 'say "hello"') + [ "$result" = 'say \"hello\"' ] +} + +@test "sanitize_jxa: escapes single quotes" { + run sanitize_jxa "it's" + [ "$status" -eq 0 ] + # Output should be: it\'s (backslash before the single quote) + [[ "$output" == it* ]] + [[ "$output" == *s ]] + # Must contain a backslash + [[ "$output" == *'\'* ]] +} + +@test "sanitize_jxa: escapes newlines" { + input=$'line1\nline2' + result=$(sanitize_jxa "$input") + [ "$result" = 'line1\nline2' ] +} + +@test "sanitize_jxa: escapes carriage returns" { + input=$'line1\rline2' + result=$(sanitize_jxa "$input") + [ "$result" = 'line1\rline2' ] +} + +@test "sanitize_jxa: handles combined special chars" { + input=$'He said "it\'s a \\ path\n"' + result=$(sanitize_jxa "$input") + [[ "$result" == *'\\"'* ]] + [[ "$result" == *"\\\\'"* ]] || [[ "$result" == *"\\\\"* ]] +} + +@test "sanitize_jxa: passes through plain text unchanged" { + result=$(sanitize_jxa "hello world 123") + [ "$result" = "hello world 123" ] +} + +# --- sanitize_applescript --- + +@test "sanitize_applescript: escapes backslashes" { + result=$(sanitize_applescript 'path\to\file') + [ "$result" = 'path\\to\\file' ] +} + +@test "sanitize_applescript: escapes double quotes" { + result=$(sanitize_applescript 'say "hi"') + [ "$result" = 'say \"hi\"' ] +} + +@test "sanitize_applescript: passes through plain text unchanged" { + result=$(sanitize_applescript "Home Screen") + [ "$result" = "Home Screen" ] +} + +@test "sanitize_applescript: does not escape single quotes" { + result=$(sanitize_applescript "it's fine") + [ "$result" = "it's fine" ] +} diff --git a/tests/test_find_window.bats b/tests/test_find_window.bats new file mode 100644 index 0000000..6c4c6cc --- /dev/null +++ b/tests/test_find_window.bats @@ -0,0 +1,103 @@ +#!/usr/bin/env bats +# Tests for find-window.sh output format. +# These tests use mocked helpers to avoid requiring iPhone Mirroring. + +load test_helper + +setup() { + setup_temp + source "$PROJECT_ROOT/helpers/common.sh" +} + +teardown() { + teardown_temp +} + +@test "find-window: mock returns valid JSON with all expected fields" { + local mock_dir + mock_dir=$(create_mock_scripts_dir) + run "$mock_dir/find-window.sh" + [ "$status" -eq 0 ] + + # Parse and verify all expected fields exist + local json="$output" + [ "$(json_field "$json" "x")" != "" ] + [ "$(json_field "$json" "y")" != "" ] + [ "$(json_field "$json" "width")" != "" ] + [ "$(json_field "$json" "height")" != "" ] + [ "$(json_field "$json" "content_x")" != "" ] + [ "$(json_field "$json" "content_y")" != "" ] + [ "$(json_field "$json" "content_width")" != "" ] + [ "$(json_field "$json" "content_height")" != "" ] + [ "$(json_field "$json" "window_id")" != "" ] + [ "$(json_field "$json" "scale")" != "" ] +} + +@test "find-window: JSON contains numeric x field (negative allowed on secondary displays)" { + local mock_dir + mock_dir=$(create_mock_scripts_dir) + run "$mock_dir/find-window.sh" + local val + val=$(json_field "$output" "x") + [[ "$val" =~ ^-?[0-9]+$ ]] +} + +@test "find-window: JSON contains numeric content_width" { + local mock_dir + mock_dir=$(create_mock_scripts_dir) + run "$mock_dir/find-window.sh" + local val + val=$(json_field "$output" "content_width") + [[ "$val" =~ ^[0-9]+$ ]] +} + +@test "find-window: JSON contains numeric window_id" { + local mock_dir + mock_dir=$(create_mock_scripts_dir) + run "$mock_dir/find-window.sh" + local val + val=$(json_field "$output" "window_id") + [[ "$val" =~ ^[0-9]+$ ]] +} + +@test "find-window: JSON contains numeric scale" { + local mock_dir + mock_dir=$(create_mock_scripts_dir) + run "$mock_dir/find-window.sh" + local val + val=$(json_field "$output" "scale") + [[ "$val" =~ ^[0-9]+$ ]] +} + +@test "find-window: exits with error when iPhone Mirroring not running" { + local mock_dir + mock_dir=$(create_mock_scripts_dir) + create_failing_find_window "$mock_dir" + run "$mock_dir/find-window.sh" + [ "$status" -ne 0 ] + [[ "$output" == *"not found"* ]] || [[ "$output" == *"error"* ]] +} + +@test "find-window: content bounds are within window bounds" { + local mock_dir + mock_dir=$(create_mock_scripts_dir) + run "$mock_dir/find-window.sh" + local json="$output" + + local wx wy ww wh cx cy cw ch + wx=$(json_field "$json" "x") + wy=$(json_field "$json" "y") + ww=$(json_field "$json" "width") + wh=$(json_field "$json" "height") + cx=$(json_field "$json" "content_x") + cy=$(json_field "$json" "content_y") + cw=$(json_field "$json" "content_width") + ch=$(json_field "$json" "content_height") + + # Content origin >= window origin + [ "$cx" -ge "$wx" ] + [ "$cy" -ge "$wy" ] + # Content fits within window + [ "$(( cx + cw ))" -le "$(( wx + ww ))" ] + [ "$(( cy + ch ))" -le "$(( wy + wh ))" ] +} diff --git a/tests/test_helper.bash b/tests/test_helper.bash new file mode 100644 index 0000000..3c6afd7 --- /dev/null +++ b/tests/test_helper.bash @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Shared test helpers for iphone-control bats tests. +# Source this in setup() of each test file. + +# Canonical mock JSON matching find-window.sh output. +# Negative origin on purpose: simulates a window on a 2x display left of / above +# the primary (global coordinates are negative there). Deltas content-window = (10, 20). +MOCK_WINDOW_JSON='{"x":-1080,"y":-400,"width":400,"height":800,"content_x":-1070,"content_y":-380,"content_width":380,"content_height":760,"window_id":12345,"scale":2}' + +# Create a temp directory for each test +setup_temp() { + TEST_TEMP_DIR="$(mktemp -d)" + export TEST_TEMP_DIR +} + +# Clean up temp directory +teardown_temp() { + if [[ -n "${TEST_TEMP_DIR:-}" && -d "$TEST_TEMP_DIR" ]]; then + rm -rf "$TEST_TEMP_DIR" + fi +} + +# Create a mock scripts directory with a fake find-window.sh +# that returns canned JSON without requiring iPhone Mirroring. +create_mock_scripts_dir() { + local mock_dir="$TEST_TEMP_DIR/mock_scripts" + mkdir -p "$mock_dir/helpers" + + # Copy real common.sh so sourced functions work + cp "$PROJECT_ROOT/helpers/common.sh" "$mock_dir/helpers/common.sh" + + # Mock find-window.sh - outputs the canonical fixture (expanded at generation time) + cat > "$mock_dir/find-window.sh" << MOCK +#!/usr/bin/env bash +echo '${MOCK_WINDOW_JSON}' +MOCK + chmod +x "$mock_dir/find-window.sh" + + # Mock screencapture - creates a 1x1 PNG + cat > "$mock_dir/screencapture" << 'MOCK' +#!/usr/bin/env bash +# Create a minimal valid PNG (1x1 pixel, red) +printf '\x89PNG\r\n\x1a\n' > "${@: -1}" 2>/dev/null || true +MOCK + chmod +x "$mock_dir/screencapture" + + echo "$mock_dir" +} + +# Create a mock find-window.sh that fails (simulates iPhone Mirroring not running) +create_failing_find_window() { + local mock_dir="$1" + cat > "$mock_dir/find-window.sh" << 'MOCK' +#!/usr/bin/env bash +echo '{"error":"iPhone Mirroring window not found. Is iPhone Mirroring open?"}' >&2 +exit 1 +MOCK + chmod +x "$mock_dir/find-window.sh" +} + +# Project root (one level up from tests/) +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +export PROJECT_ROOT diff --git a/tests/test_iphone_control.bats b/tests/test_iphone_control.bats new file mode 100644 index 0000000..1ce9755 --- /dev/null +++ b/tests/test_iphone_control.bats @@ -0,0 +1,104 @@ +#!/usr/bin/env bats +# Tests for iphone-control.sh main entry point. + +load test_helper + +setup() { + setup_temp +} + +teardown() { + teardown_temp +} + +@test "iphone-control: help command outputs usage" { + run "$PROJECT_ROOT/iphone-control.sh" help + [ "$status" -eq 0 ] + [[ "$output" == *"iPhone Mirroring Control"* ]] + [[ "$output" == *"Usage"* ]] + [[ "$output" == *"Commands"* ]] +} + +@test "iphone-control: --help flag works" { + run "$PROJECT_ROOT/iphone-control.sh" --help + [ "$status" -eq 0 ] + [[ "$output" == *"iPhone Mirroring Control"* ]] +} + +@test "iphone-control: -h flag works" { + run "$PROJECT_ROOT/iphone-control.sh" -h + [ "$status" -eq 0 ] + [[ "$output" == *"iPhone Mirroring Control"* ]] +} + +@test "iphone-control: no args shows help" { + run "$PROJECT_ROOT/iphone-control.sh" + [ "$status" -eq 0 ] + [[ "$output" == *"iPhone Mirroring Control"* ]] +} + +@test "iphone-control: unknown command exits with error" { + run "$PROJECT_ROOT/iphone-control.sh" nonexistent-cmd + [ "$status" -eq 1 ] + [[ "$output" == *"Unknown command"* ]] +} + +@test "iphone-control: unknown command suggests help" { + run "$PROJECT_ROOT/iphone-control.sh" badcmd + [ "$status" -eq 1 ] + [[ "$output" == *"help"* ]] +} + +@test "iphone-control: help lists all expected commands" { + run "$PROJECT_ROOT/iphone-control.sh" help + [ "$status" -eq 0 ] + [[ "$output" == *"find"* ]] + [[ "$output" == *"screenshot"* ]] + [[ "$output" == *"tap"* ]] + [[ "$output" == *"swipe"* ]] + [[ "$output" == *"type"* ]] + [[ "$output" == *"home"* ]] + [[ "$output" == *"app-switcher"* ]] + [[ "$output" == *"spotlight"* ]] + [[ "$output" == *"open-app"* ]] + [[ "$output" == *"status"* ]] + [[ "$output" == *"registry-scan"* ]] + [[ "$output" == *"registry-lookup"* ]] + [[ "$output" == *"registry-invalidate"* ]] + [[ "$output" == *"registry-list"* ]] +} + +@test "iphone-control: tap without coords shows usage error" { + # tap.sh should fail with usage message when called without args + run "$PROJECT_ROOT/iphone-control.sh" tap + [ "$status" -eq 1 ] + [[ "$output" == *"Usage"* ]] +} + +@test "iphone-control: swipe without coords shows usage error" { + run "$PROJECT_ROOT/iphone-control.sh" swipe + [ "$status" -eq 1 ] + [[ "$output" == *"Usage"* ]] +} + +@test "iphone-control: type without text shows usage error" { + run "$PROJECT_ROOT/iphone-control.sh" type + [ "$status" -eq 1 ] + [[ "$output" == *"Usage"* ]] +} + +@test "iphone-control: routes registry-list to registry.sh" { + # Override HOME to avoid finding a real registry + export HOME="$TEST_TEMP_DIR" + run "$PROJECT_ROOT/iphone-control.sh" registry-list + # Should fail gracefully (no registry file in temp HOME) + [ "$status" -eq 1 ] + [[ "$output" == *"No registry found"* ]] +} + +@test "iphone-control: routes registry-invalidate to registry.sh" { + export HOME="$TEST_TEMP_DIR" + run "$PROJECT_ROOT/iphone-control.sh" registry-invalidate + [ "$status" -eq 0 ] + [[ "$output" == *"No registry to invalidate"* ]] +} diff --git a/tests/test_map_apps.bats b/tests/test_map_apps.bats new file mode 100644 index 0000000..5ec11d7 --- /dev/null +++ b/tests/test_map_apps.bats @@ -0,0 +1,250 @@ +#!/usr/bin/env bats +# Tests for map-apps.sh OCR-based app scanning. + +load test_helper + +setup() { + setup_temp +} + +teardown() { + teardown_temp +} + +# Helper: create a full map-apps mock environment +_create_map_apps_env() { + local mock_dir + mock_dir=$(create_mock_scripts_dir) + + # Mock iphone-control.sh (home command) + cat > "$mock_dir/iphone-control.sh" << 'MOCK' +#!/usr/bin/env bash +echo "home: ok" +MOCK + chmod +x "$mock_dir/iphone-control.sh" + + # Mock swipe.sh + cat > "$mock_dir/swipe.sh" << 'MOCK' +#!/usr/bin/env bash +echo "Swiped" +MOCK + chmod +x "$mock_dir/swipe.sh" + + # Mock screenshot.sh - creates a minimal PNG file (accepts --native like the real one) + cat > "$mock_dir/screenshot.sh" << 'MOCK' +#!/usr/bin/env bash +if [[ "${1:-}" == "--native" ]]; then shift; fi +output="${1:-/tmp/iphone-screen.png}" +printf '\x89PNG\r\n\x1a\n' > "$output" +# Append unique data per call so page hashes differ +echo "$$-$RANDOM" >> "$output" +MOCK + chmod +x "$mock_dir/screenshot.sh" + + echo "$mock_dir" +} + +# Helper: create mock OCR helper that returns canned output +_create_mock_ocr() { + local mock_dir="$1" + local ocr_output="$2" + + mkdir -p "$mock_dir/helpers" + cat > "$mock_dir/helpers/ocr-image" << MOCK +#!/usr/bin/env bash +echo '${ocr_output}' +MOCK + chmod +x "$mock_dir/helpers/ocr-image" + + # Create a dummy swift source so the -nt check works + touch -t 200001010000 "$mock_dir/helpers/ocr-image.swift" +} + +# Helper: create the map-apps test script +_create_map_apps_script() { + local mock_dir="$1" + + cat > "$mock_dir/map-apps.sh" << SCRIPT +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$mock_dir" +source "\$SCRIPT_DIR/helpers/common.sh" +HELPER="\$SCRIPT_DIR/helpers/ocr-image" +HELPER_SRC="\$HELPER.swift" +MAX_PAGES="\${1:-\$DEFAULT_MAX_PAGES}" + +# Skip compilation - use mock OCR helper +if [[ ! -x "\$HELPER" ]]; then + echo '{"error":"OCR helper not found"}' >&2 + exit 1 +fi + +window_json=\$("\$SCRIPT_DIR/find-window.sh") +parse_window_bounds "\$window_json" + +"\$SCRIPT_DIR/iphone-control.sh" home > /dev/null 2>&1 +sleep 0.01 + +swipe_y=\$(( CONTENT_H / 2 )) +swipe_x_start=\$(( CONTENT_W - SWIPE_EDGE_MARGIN )) +swipe_x_end=\$SWIPE_EDGE_MARGIN +app_zone_bottom=\$(( CONTENT_H - APP_ZONE_BOTTOM_MARGIN )) + +all_apps="[" +first_entry=true +prev_hash="" + +for (( page=1; page<=MAX_PAGES; page++ )); do + tmp_img="/tmp/iphone-home-page-\${page}.png" + "\$SCRIPT_DIR/screenshot.sh" --native "\$tmp_img" > /dev/null 2>&1 + + current_hash=\$(md5 -q "\$tmp_img" 2>/dev/null || md5sum "\$tmp_img" | cut -d' ' -f1) + if [[ "\$current_hash" == "\$prev_hash" ]]; then + rm -f "\$tmp_img" + break + fi + prev_hash="\$current_hash" + + ocr_output=\$("\$HELPER" "\$tmp_img" 2>/dev/null) || { + continue + } + + while IFS='|' read -r text px py pw ph; do + [[ -z "\$text" ]] && continue + px=\$(( px / SCALE )); py=\$(( py / SCALE )); pw=\$(( pw / SCALE )) + (( py < APP_ZONE_TOP || py > app_zone_bottom )) && continue + text_len=\${#text} + (( text_len > APP_LABEL_MAX_LEN || text_len < APP_LABEL_MIN_LEN )) && continue + case "\$text" in + [0-9]*:[0-9]*|[0-9]*%|Search*|Suggestions*|Siri*) continue ;; + esac + tap_x=\$(( px + pw / 2 )) + tap_y=\$(( py - APP_ICON_ABOVE_LABEL )) + if \$first_entry; then + first_entry=false + else + all_apps+="," + fi + escaped_text="\${text//\\\\/\\\\\\\\}" + escaped_text="\${escaped_text//\\\"/\\\\\\\"}" + all_apps+="{\"name\":\"\${escaped_text}\",\"page\":\${page},\"x\":\${tap_x},\"y\":\${tap_y}}" + done <<< "\$ocr_output" + + if (( page < MAX_PAGES )); then + "\$SCRIPT_DIR/swipe.sh" "\$swipe_x_start" "\$swipe_y" "\$swipe_x_end" "\$swipe_y" "\$DEFAULT_SWIPE_DURATION" > /dev/null 2>&1 + sleep 0.01 + fi +done + +all_apps+="]" +echo "\$all_apps" +SCRIPT + chmod +x "$mock_dir/map-apps.sh" +} + +@test "map-apps: auto-compiles OCR helper if missing (verify compilation attempt)" { + local mock_dir + mock_dir=$(_create_map_apps_env) + + # Create swift source but no compiled binary + mkdir -p "$mock_dir/helpers" + echo "// swift source" > "$mock_dir/helpers/ocr-image.swift" + # Do NOT create the binary - script should try to compile + + # Create a script that only tests the compilation logic + cat > "$mock_dir/compile_test.sh" << SCRIPT +#!/usr/bin/env bash +set -euo pipefail +HELPER="$mock_dir/helpers/ocr-image" +HELPER_SRC="\$HELPER.swift" + +if [[ ! -x "\$HELPER" ]] || [[ "\$HELPER_SRC" -nt "\$HELPER" ]]; then + echo "compilation_attempted" + exit 0 +fi +echo "no_compilation_needed" +SCRIPT + chmod +x "$mock_dir/compile_test.sh" + + run "$mock_dir/compile_test.sh" + [ "$status" -eq 0 ] + [ "$output" = "compilation_attempted" ] +} + +@test "map-apps: outputs valid JSON array" { + local mock_dir + mock_dir=$(_create_map_apps_env) + # OCR returns app labels in the valid zone (y=200 is within 80..660 for 760-high content) + _create_mock_ocr "$mock_dir" "Safari|50|200|60|15 +Settings|150|200|70|15" + _create_map_apps_script "$mock_dir" + + run "$mock_dir/map-apps.sh" 1 + [ "$status" -eq 0 ] + + # Must start with [ and end with ] + [[ "$output" == "["* ]] + [[ "$output" == *"]" ]] + # Must contain app names + [[ "$output" == *"Safari"* ]] + [[ "$output" == *"Settings"* ]] +} + +@test "map-apps: respects MAX_PAGES argument" { + local mock_dir + mock_dir=$(_create_map_apps_env) + _create_mock_ocr "$mock_dir" "Safari|50|200|60|15" + _create_map_apps_script "$mock_dir" + + # With MAX_PAGES=1, should only scan 1 page and not swipe + run "$mock_dir/map-apps.sh" 1 + [ "$status" -eq 0 ] + [[ "$output" == *"Safari"* ]] + # Only page 1 entries + [[ "$output" == *'"page":1'* ]] +} + +@test "map-apps: converts OCR pixel coords to points (scale 2 fixture)" { + local mock_dir + mock_dir=$(_create_map_apps_env) + # OCR emits native-resolution pixel coords; fixture scale is 2. + # Safari label at px=100,py=400,pw=120 -> points 50,200,60 -> tap at (80, 180) + _create_mock_ocr "$mock_dir" "Safari|100|400|120|30" + _create_map_apps_script "$mock_dir" + + run "$mock_dir/map-apps.sh" 1 + [ "$status" -eq 0 ] + [[ "$output" == *'"x":80'* ]] + [[ "$output" == *'"y":180'* ]] +} + +@test "map-apps: handles empty OCR output gracefully" { + local mock_dir + mock_dir=$(_create_map_apps_env) + _create_mock_ocr "$mock_dir" "" + _create_map_apps_script "$mock_dir" + + run "$mock_dir/map-apps.sh" 1 + [ "$status" -eq 0 ] + [ "$output" = "[]" ] +} + +@test "map-apps: filters app labels by length and zone" { + local mock_dir + mock_dir=$(_create_map_apps_env) + # y=10 is above APP_ZONE_TOP (80) - should be filtered + # "X" is too short (< APP_LABEL_MIN_LEN=2) - should be filtered + # "ThisIsAnExtremelyLongAppNameThatExceedsLimit" is too long (> 25) - should be filtered + # "Safari" at y=200 should pass + _create_mock_ocr "$mock_dir" "X|50|200|10|15 +ThisIsAnExtremelyLongAppNameThatExceedsLimit|50|200|200|15 +TopZone|50|10|60|15 +Safari|50|200|60|15" + _create_map_apps_script "$mock_dir" + + run "$mock_dir/map-apps.sh" 1 + [ "$status" -eq 0 ] + [[ "$output" == *"Safari"* ]] + [[ "$output" != *"TopZone"* ]] + [[ "$output" != *"ThisIsAnExtremely"* ]] +} diff --git a/tests/test_open_app.bats b/tests/test_open_app.bats new file mode 100644 index 0000000..9485caa --- /dev/null +++ b/tests/test_open_app.bats @@ -0,0 +1,217 @@ +#!/usr/bin/env bats +# Tests for open-app.sh argument validation and launch strategies. + +load test_helper + +setup() { + setup_temp + export REAL_HOME="$HOME" + export HOME="$TEST_TEMP_DIR" +} + +teardown() { + export HOME="$REAL_HOME" + teardown_temp +} + +# Helper: create a mock open-app environment with registry, tap, swipe, iphone-control +_create_open_app_env() { + local mock_dir + mock_dir=$(create_mock_scripts_dir) + + # Mock iphone-control.sh (home command) + cat > "$mock_dir/iphone-control.sh" << 'MOCK' +#!/usr/bin/env bash +echo "home: ok" +MOCK + chmod +x "$mock_dir/iphone-control.sh" + + # Mock tap.sh + cat > "$mock_dir/tap.sh" << 'MOCK' +#!/usr/bin/env bash +echo "Tapped at ($1, $2)" +MOCK + chmod +x "$mock_dir/tap.sh" + + # Mock swipe.sh + cat > "$mock_dir/swipe.sh" << 'MOCK' +#!/usr/bin/env bash +echo "Swiped" +MOCK + chmod +x "$mock_dir/swipe.sh" + + echo "$mock_dir" +} + +# Helper: create a mock registry.sh that returns lookup results +_create_mock_registry() { + local mock_dir="$1" + local lookup_result="${2:-}" + + if [[ -n "$lookup_result" ]]; then + cat > "$mock_dir/registry.sh" << MOCK +#!/usr/bin/env bash +cmd="\${1:-help}" +shift || true +case "\$cmd" in + lookup) echo '${lookup_result}' ;; + invalidate) echo "Registry invalidated" ;; + *) echo "Unknown" >&2; exit 1 ;; +esac +MOCK + else + # Registry lookup fails + cat > "$mock_dir/registry.sh" << 'MOCK' +#!/usr/bin/env bash +cmd="${1:-help}" +shift || true +case "$cmd" in + lookup) echo '{"error":"App not found"}' >&2; exit 1 ;; + invalidate) echo "Registry invalidated" ;; + *) echo "Unknown" >&2; exit 1 ;; +esac +MOCK + fi + chmod +x "$mock_dir/registry.sh" +} + +# Helper: create the open-app test script using the mock directory +_create_open_app_script() { + local mock_dir="$1" + + cat > "$mock_dir/open-app.sh" << SCRIPT +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$mock_dir" +source "\$SCRIPT_DIR/helpers/common.sh" + +if [[ \$# -lt 1 ]]; then + echo "Usage: open-app.sh [--spotlight]" >&2 + exit 1 +fi + +app_name="\$1" +force_spotlight=false +if [[ "\${2:-}" == "--spotlight" ]]; then + force_spotlight=true +fi + +_open_via_spotlight() { + echo "Opened: \$app_name (spotlight)" +} + +if \$force_spotlight; then + _open_via_spotlight + exit 0 +fi + +lookup_result=\$("\$SCRIPT_DIR/registry.sh" lookup "\$app_name" 2>/dev/null) || lookup_result="" + +if [[ -z "\$lookup_result" ]]; then + _open_via_spotlight + exit 0 +fi + +app_page=\$(json_field "\$lookup_result" "page") +app_x=\$(json_field "\$lookup_result" "x") +app_y=\$(json_field "\$lookup_result" "y") +reg_width=\$(json_field "\$lookup_result" "content_width") +reg_height=\$(json_field "\$lookup_result" "content_height") + +if [[ -z "\$app_page" || -z "\$app_x" || -z "\$app_y" || -z "\$reg_width" || -z "\$reg_height" ]]; then + _open_via_spotlight + exit 0 +fi + +window_json=\$("\$SCRIPT_DIR/find-window.sh") +parse_window_bounds "\$window_json" + +if [[ "\$CONTENT_W" != "\$reg_width" || "\$CONTENT_H" != "\$reg_height" ]]; then + "\$SCRIPT_DIR/registry.sh" invalidate > /dev/null 2>&1 + _open_via_spotlight + exit 0 +fi + +"\$SCRIPT_DIR/iphone-control.sh" home > /dev/null 2>&1 +sleep 0.01 + +if (( app_page > 1 )); then + swipe_y=\$(( CONTENT_H / 2 )) + swipe_x_start=\$(( CONTENT_W - SWIPE_EDGE_MARGIN )) + swipe_x_end=\$SWIPE_EDGE_MARGIN + for (( i=1; i /dev/null 2>&1 + sleep 0.01 + done +fi + +"\$SCRIPT_DIR/tap.sh" "\$app_x" "\$app_y" > /dev/null 2>&1 + +echo "Opened: \$app_name (registry, page \$app_page)" +SCRIPT + chmod +x "$mock_dir/open-app.sh" +} + +@test "open-app: rejects missing arguments (no app name)" { + run "$PROJECT_ROOT/open-app.sh" + [ "$status" -eq 1 ] + [[ "$output" == *"Usage"* ]] +} + +@test "open-app: falls back to spotlight when registry lookup fails" { + local mock_dir + mock_dir=$(_create_open_app_env) + _create_mock_registry "$mock_dir" "" + _create_open_app_script "$mock_dir" + + run "$mock_dir/open-app.sh" "NonExistentApp" + [ "$status" -eq 0 ] + [[ "$output" == *"spotlight"* ]] +} + +@test "open-app: falls back to spotlight when --spotlight flag used" { + local mock_dir + mock_dir=$(_create_open_app_env) + _create_mock_registry "$mock_dir" '{"name":"Safari","page":1,"x":50,"y":100,"content_width":380,"content_height":760}' + _create_open_app_script "$mock_dir" + + run "$mock_dir/open-app.sh" "Safari" "--spotlight" + [ "$status" -eq 0 ] + [[ "$output" == *"spotlight"* ]] + [[ "$output" != *"registry"* ]] +} + +@test "open-app: falls back to spotlight when dimensions mismatch" { + local mock_dir + mock_dir=$(_create_open_app_env) + # Registry has different dimensions than mock window (380x760) + _create_mock_registry "$mock_dir" '{"name":"Safari","page":1,"x":50,"y":100,"content_width":999,"content_height":999}' + _create_open_app_script "$mock_dir" + + run "$mock_dir/open-app.sh" "Safari" + [ "$status" -eq 0 ] + [[ "$output" == *"spotlight"* ]] +} + +@test "open-app: uses registry path when lookup succeeds and dimensions match" { + local mock_dir + mock_dir=$(_create_open_app_env) + # Dimensions match mock window content: 380x760 + _create_mock_registry "$mock_dir" '{"name":"Safari","page":1,"x":50,"y":100,"content_width":380,"content_height":760}' + _create_open_app_script "$mock_dir" + + run "$mock_dir/open-app.sh" "Safari" + [ "$status" -eq 0 ] + [[ "$output" == *"registry, page 1"* ]] +} + +@test "open-app: handles app names with special characters (quotes, spaces)" { + local mock_dir + mock_dir=$(_create_open_app_env) + _create_mock_registry "$mock_dir" "" + _create_open_app_script "$mock_dir" + + run "$mock_dir/open-app.sh" "My App's \"Test\"" + [ "$status" -eq 0 ] + [[ "$output" == *"spotlight"* ]] +} diff --git a/tests/test_registry.bats b/tests/test_registry.bats new file mode 100644 index 0000000..fd1209e --- /dev/null +++ b/tests/test_registry.bats @@ -0,0 +1,104 @@ +#!/usr/bin/env bats +# Tests for registry.sh command routing and error handling. + +load test_helper + +setup() { + setup_temp + # Override HOME so registry operations don't touch real files + export REAL_HOME="$HOME" + export HOME="$TEST_TEMP_DIR" +} + +teardown() { + export HOME="$REAL_HOME" + teardown_temp +} + +@test "registry: help command works" { + run "$PROJECT_ROOT/registry.sh" help + [ "$status" -eq 0 ] + [[ "$output" == *"App Registry Manager"* ]] + [[ "$output" == *"scan"* ]] + [[ "$output" == *"lookup"* ]] + [[ "$output" == *"invalidate"* ]] + [[ "$output" == *"list"* ]] +} + +@test "registry: --help flag works" { + run "$PROJECT_ROOT/registry.sh" --help + [ "$status" -eq 0 ] + [[ "$output" == *"App Registry Manager"* ]] +} + +@test "registry: unknown command exits with error" { + run "$PROJECT_ROOT/registry.sh" foobar + [ "$status" -eq 1 ] + [[ "$output" == *"Unknown command"* ]] +} + +@test "registry: lookup fails gracefully when no registry exists" { + run "$PROJECT_ROOT/registry.sh" lookup "Safari" + [ "$status" -eq 1 ] + [[ "$output" == *"No registry found"* ]] +} + +@test "registry: lookup requires app name argument" { + run "$PROJECT_ROOT/registry.sh" lookup + [ "$status" -eq 1 ] + [[ "$output" == *"Usage"* ]] || [[ "$output" == *"error"* ]] +} + +@test "registry: invalidate works on non-existent file" { + run "$PROJECT_ROOT/registry.sh" invalidate + [ "$status" -eq 0 ] + [[ "$output" == *"No registry to invalidate"* ]] +} + +@test "registry: invalidate removes existing registry" { + # Create a fake registry + mkdir -p "$HOME/.iphone-control" + echo '{"version":1}' > "$HOME/.iphone-control/app-registry.json" + [ -f "$HOME/.iphone-control/app-registry.json" ] + + run "$PROJECT_ROOT/registry.sh" invalidate + [ "$status" -eq 0 ] + [[ "$output" == *"Registry invalidated"* ]] + [ ! -f "$HOME/.iphone-control/app-registry.json" ] +} + +@test "registry: lookup rejects stale v1 registry (pixel-space coords)" { + mkdir -p "$HOME/.iphone-control" + echo '{"version":1,"content_width":380,"content_height":760,"apps":[{"name":"Safari","page":1,"x":50,"y":100}]}' > "$HOME/.iphone-control/app-registry.json" + + run "$PROJECT_ROOT/registry.sh" lookup "Safari" + [ "$status" -eq 1 ] + [[ "$output" == *"lookup failed"* ]] +} + +@test "registry: lookup finds app in v2 registry" { + mkdir -p "$HOME/.iphone-control" + echo '{"version":2,"content_width":380,"content_height":760,"apps":[{"name":"Safari","page":1,"x":50,"y":100}]}' > "$HOME/.iphone-control/app-registry.json" + + run "$PROJECT_ROOT/registry.sh" lookup "Safari" + [ "$status" -eq 0 ] + [[ "$output" == *'"name":"Safari"'* ]] + [[ "$output" == *'"x":50'* ]] +} + +@test "registry: list fails gracefully when no registry exists" { + run "$PROJECT_ROOT/registry.sh" list + [ "$status" -eq 1 ] + [[ "$output" == *"No registry found"* ]] +} + +@test "registry: list outputs registry contents when file exists" { + mkdir -p "$HOME/.iphone-control" + local registry='{"version":1,"apps":[{"name":"Safari","page":1,"x":50,"y":100}]}' + echo "$registry" > "$HOME/.iphone-control/app-registry.json" + + run "$PROJECT_ROOT/registry.sh" list + [ "$status" -eq 0 ] + [[ "$output" == *"Safari"* ]] + [[ "$output" == *"version"* ]] +} diff --git a/tests/test_registry_scan.bats b/tests/test_registry_scan.bats new file mode 100644 index 0000000..cbbfaa8 --- /dev/null +++ b/tests/test_registry_scan.bats @@ -0,0 +1,146 @@ +#!/usr/bin/env bats +# Tests for registry.sh scan command with mocked dependencies. + +load test_helper + +setup() { + setup_temp + export REAL_HOME="$HOME" + export HOME="$TEST_TEMP_DIR" +} + +teardown() { + export HOME="$REAL_HOME" + teardown_temp +} + +# Helper: create a registry scan environment with mocked map-apps.sh +_create_registry_scan_env() { + local mock_dir + mock_dir=$(create_mock_scripts_dir) + local apps_json="${1:-[{\"name\":\"Safari\",\"page\":1,\"x\":50,\"y\":100},{\"name\":\"Settings\",\"page\":2,\"x\":150,\"y\":200}]}" + + # Mock map-apps.sh - returns canned JSON array + cat > "$mock_dir/map-apps.sh" << MOCK +#!/usr/bin/env bash +echo '${apps_json}' +MOCK + chmod +x "$mock_dir/map-apps.sh" + + echo "$mock_dir" +} + +# Helper: create a registry script that uses the mock directory +_create_registry_script() { + local mock_dir="$1" + + cat > "$mock_dir/registry.sh" << SCRIPT +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$mock_dir" +source "\$SCRIPT_DIR/helpers/common.sh" +REGISTRY_DIR="\$HOME/.iphone-control" +REGISTRY_FILE="\$REGISTRY_DIR/app-registry.json" + +cmd="\${1:-help}" +shift || true + +case "\$cmd" in + scan) + max_pages="\${1:-\$DEFAULT_MAX_PAGES}" + + window_json=\$("\$SCRIPT_DIR/find-window.sh") + parse_window_bounds "\$window_json" + + apps_json=\$("\$SCRIPT_DIR/map-apps.sh" "\$max_pages") + scanned_at=\$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + # Build registry JSON using pure bash (no osascript for tests) + # Parse total_pages from apps_json + max_page=0 + while [[ "\$apps_json" =~ \"page\":([0-9]+) ]]; do + p="\${BASH_REMATCH[1]}" + (( p > max_page )) && max_page=\$p + apps_json="\${apps_json#*\"page\":\$p}" + done + # Re-read apps_json from map-apps + apps_json=\$("\$SCRIPT_DIR/map-apps.sh" "\$max_pages") + + registry="{\"version\":2,\"scanned_at\":\"\${scanned_at}\",\"total_pages\":\${max_page},\"content_width\":\${CONTENT_W},\"content_height\":\${CONTENT_H},\"apps\":\${apps_json}}" + + mkdir -p "\$REGISTRY_DIR" + echo "\$registry" > "\$REGISTRY_FILE" + echo "\$registry" + ;; + *) + echo "Unknown command: \$cmd" >&2 + exit 1 + ;; +esac +SCRIPT + chmod +x "$mock_dir/registry.sh" +} + +@test "registry scan: creates registry directory if missing" { + local mock_dir + mock_dir=$(_create_registry_scan_env) + _create_registry_script "$mock_dir" + + # Ensure directory does not exist + [ ! -d "$HOME/.iphone-control" ] + + run "$mock_dir/registry.sh" scan + [ "$status" -eq 0 ] + [ -d "$HOME/.iphone-control" ] +} + +@test "registry scan: writes valid JSON to registry file" { + local mock_dir + mock_dir=$(_create_registry_scan_env) + _create_registry_script "$mock_dir" + + run "$mock_dir/registry.sh" scan + [ "$status" -eq 0 ] + [ -f "$HOME/.iphone-control/app-registry.json" ] + + # File should contain valid JSON-like structure + local content + content=$(cat "$HOME/.iphone-control/app-registry.json") + [[ "$content" == "{"* ]] + [[ "$content" == *"}" ]] +} + +@test "registry scan: registry contains version, scanned_at, apps fields" { + local mock_dir + mock_dir=$(_create_registry_scan_env) + _create_registry_script "$mock_dir" + + run "$mock_dir/registry.sh" scan + [ "$status" -eq 0 ] + + [[ "$output" == *'"version":2'* ]] + [[ "$output" == *'"scanned_at":'* ]] + [[ "$output" == *'"apps":'* ]] + [[ "$output" == *'"content_width":'* ]] + [[ "$output" == *'"content_height":'* ]] + [[ "$output" == *"Safari"* ]] + [[ "$output" == *"Settings"* ]] +} + +@test "registry scan: respects max_pages argument" { + local mock_dir + mock_dir=$(_create_registry_scan_env) + + # Replace map-apps mock to verify max_pages is passed through + cat > "$mock_dir/map-apps.sh" << 'MOCK' +#!/usr/bin/env bash +max_pages="${1:-10}" +echo "[{\"name\":\"TestApp\",\"page\":1,\"x\":50,\"y\":100,\"max_pages_received\":${max_pages}}]" +MOCK + chmod +x "$mock_dir/map-apps.sh" + _create_registry_script "$mock_dir" + + run "$mock_dir/registry.sh" scan 3 + [ "$status" -eq 0 ] + [[ "$output" == *'"max_pages_received":3'* ]] +} diff --git a/tests/test_screenshot.bats b/tests/test_screenshot.bats new file mode 100644 index 0000000..8f55681 --- /dev/null +++ b/tests/test_screenshot.bats @@ -0,0 +1,101 @@ +#!/usr/bin/env bats +# Tests for screenshot.sh argument handling and error paths. + +load test_helper + +setup() { + setup_temp + source "$PROJECT_ROOT/helpers/common.sh" +} + +teardown() { + teardown_temp +} + +@test "screenshot: uses default output path when none specified" { + # Verify the default path logic in the script + # screenshot.sh line: OUTPUT="${1:-/tmp/iphone-screen.png}" + local default_output="/tmp/iphone-screen.png" + + # Create a minimal mock screenshot script that just tests the default + local mock_dir + mock_dir=$(create_mock_scripts_dir) + + cat > "$mock_dir/screenshot_test.sh" << 'SCRIPT' +#!/usr/bin/env bash +set -euo pipefail +OUTPUT="${1:-/tmp/iphone-screen.png}" +echo "$OUTPUT" +SCRIPT + chmod +x "$mock_dir/screenshot_test.sh" + + run "$mock_dir/screenshot_test.sh" + [ "$status" -eq 0 ] + [ "$output" = "/tmp/iphone-screen.png" ] +} + +@test "screenshot: uses custom output path when specified" { + local mock_dir + mock_dir=$(create_mock_scripts_dir) + + cat > "$mock_dir/screenshot_test.sh" << 'SCRIPT' +#!/usr/bin/env bash +set -euo pipefail +OUTPUT="${1:-/tmp/iphone-screen.png}" +echo "$OUTPUT" +SCRIPT + chmod +x "$mock_dir/screenshot_test.sh" + + run "$mock_dir/screenshot_test.sh" "/tmp/custom-output.png" + [ "$status" -eq 0 ] + [ "$output" = "/tmp/custom-output.png" ] +} + +@test "screenshot: fails when screencapture produces no file" { + local mock_dir + mock_dir=$(create_mock_scripts_dir) + + # Create a mock that simulates screencapture failure (no file created) + cat > "$mock_dir/screenshot_fail.sh" << SCRIPT +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$mock_dir" +source "\$SCRIPT_DIR/helpers/common.sh" +OUTPUT="\${1:-/tmp/iphone-screen.png}" + +window_json=\$("\$SCRIPT_DIR/find-window.sh") +parse_window_bounds "\$window_json" + +tmp_full="/tmp/iphone-screenshot-test-nonexistent-\$\$.png" +# Don't actually create the file — simulate screencapture failure + +if [[ ! -f "\$tmp_full" ]]; then + echo "Error: Screenshot failed. Check Screen Recording permission." >&2 + exit 1 +fi +SCRIPT + chmod +x "$mock_dir/screenshot_fail.sh" + + run "$mock_dir/screenshot_fail.sh" + [ "$status" -eq 1 ] + [[ "$output" == *"Screenshot failed"* ]] +} + +@test "screenshot: crop offset calculation is correct" { + # Verify the math: offset = (CONTENT - WINDOW) * SCALE + parse_window_bounds "$MOCK_WINDOW_JSON" + + local offset_x=$(( (CONTENT_X - WINDOW_X) * SCALE )) + local offset_y=$(( (CONTENT_Y - WINDOW_Y) * SCALE )) + local crop_w=$(( CONTENT_W * SCALE )) + local crop_h=$(( CONTENT_H * SCALE )) + + # CONTENT_X=110, WINDOW_X=100, SCALE=2 => offset_x=20 + [ "$offset_x" -eq 20 ] + # CONTENT_Y=220, WINDOW_Y=200, SCALE=2 => offset_y=40 + [ "$offset_y" -eq 40 ] + # CONTENT_W=380, SCALE=2 => crop_w=760 + [ "$crop_w" -eq 760 ] + # CONTENT_H=760, SCALE=2 => crop_h=1520 + [ "$crop_h" -eq 1520 ] +} diff --git a/tests/test_status.bats b/tests/test_status.bats new file mode 100644 index 0000000..89a431e --- /dev/null +++ b/tests/test_status.bats @@ -0,0 +1,81 @@ +#!/usr/bin/env bats +# Tests for status.sh output format. +# Since status.sh calls osascript directly, we test with a mock. + +load test_helper + +setup() { + setup_temp + source "$PROJECT_ROOT/helpers/common.sh" +} + +teardown() { + teardown_temp +} + +@test "status: mock returns valid JSON" { + local mock_dir + mock_dir=$(create_mock_scripts_dir) + + # Create a mock status.sh that returns JSON without osascript + cat > "$mock_dir/status_test.sh" << 'SCRIPT' +#!/usr/bin/env bash +set -euo pipefail +# Simulate status.sh output +echo '{"status":"not_running"}' +SCRIPT + chmod +x "$mock_dir/status_test.sh" + + run "$mock_dir/status_test.sh" + [ "$status" -eq 0 ] + [[ "$output" == *"status"* ]] +} + +@test "status: contains status field with valid value" { + local mock_dir + mock_dir=$(create_mock_scripts_dir) + + for state in connected disconnected not_running; do + cat > "$mock_dir/status_test.sh" << SCRIPT +#!/usr/bin/env bash +echo '{"status":"${state}"}' +SCRIPT + chmod +x "$mock_dir/status_test.sh" + + run "$mock_dir/status_test.sh" + [ "$status" -eq 0 ] + local val + val=$(json_field "$output" "status") + [ "$val" = "$state" ] + done +} + +@test "status: real script handles empty osascript result" { + # The real status.sh has a fallback: if osascript returns empty, output not_running + # We test this logic in isolation + result="" + if [[ -z "$result" ]]; then + output='{"status":"not_running"}' + else + output="$result" + fi + local val + val=$(json_field "$output" "status") + [ "$val" = "not_running" ] +} + +@test "status: output is single-line JSON" { + local mock_dir + mock_dir=$(create_mock_scripts_dir) + + cat > "$mock_dir/status_test.sh" << 'SCRIPT' +#!/usr/bin/env bash +echo '{"status":"connected"}' +SCRIPT + chmod +x "$mock_dir/status_test.sh" + + run "$mock_dir/status_test.sh" + local lines + lines=$(echo "$output" | wc -l) + [ "$lines" -eq 1 ] +} diff --git a/tests/test_swipe.bats b/tests/test_swipe.bats new file mode 100644 index 0000000..2a38d60 --- /dev/null +++ b/tests/test_swipe.bats @@ -0,0 +1,160 @@ +#!/usr/bin/env bats +# Tests for swipe.sh argument validation and defaults. + +load test_helper + +setup() { + setup_temp +} + +teardown() { + teardown_temp +} + +@test "swipe: rejects missing arguments (no args)" { + run "$PROJECT_ROOT/swipe.sh" + [ "$status" -eq 1 ] + [[ "$output" == *"Usage"* ]] +} + +@test "swipe: rejects missing arguments (1 arg)" { + run "$PROJECT_ROOT/swipe.sh" 10 + [ "$status" -eq 1 ] + [[ "$output" == *"Usage"* ]] +} + +@test "swipe: rejects missing arguments (2 args)" { + run "$PROJECT_ROOT/swipe.sh" 10 20 + [ "$status" -eq 1 ] + [[ "$output" == *"Usage"* ]] +} + +@test "swipe: rejects missing arguments (3 args)" { + run "$PROJECT_ROOT/swipe.sh" 10 20 30 + [ "$status" -eq 1 ] + [[ "$output" == *"Usage"* ]] +} + +@test "swipe: rejects out-of-bounds start coordinates" { + local mock_dir + mock_dir=$(create_mock_scripts_dir) + + cat > "$mock_dir/swipe_test.sh" << SCRIPT +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$mock_dir" +source "\$SCRIPT_DIR/helpers/common.sh" + +if [[ \$# -lt 4 ]]; then + echo "Usage: swipe.sh [duration_ms]" >&2 + exit 1 +fi + +rel_x1="\$1"; rel_y1="\$2"; rel_x2="\$3"; rel_y2="\$4" +duration_ms="\${5:-\$DEFAULT_SWIPE_DURATION}" + +window_json=\$("\$SCRIPT_DIR/find-window.sh") +parse_window_bounds "\$window_json" +validate_coords "\$rel_x1" "\$rel_y1" "\$CONTENT_W" "\$CONTENT_H" || exit 1 +validate_coords "\$rel_x2" "\$rel_y2" "\$CONTENT_W" "\$CONTENT_H" || exit 1 +echo "Swiped (\$rel_x1,\$rel_y1) -> (\$rel_x2,\$rel_y2) in \${duration_ms}ms" +SCRIPT + chmod +x "$mock_dir/swipe_test.sh" + + run "$mock_dir/swipe_test.sh" 500 100 200 300 + [ "$status" -eq 1 ] + [[ "$output" == *"out of bounds"* ]] +} + +@test "swipe: rejects out-of-bounds end coordinates" { + local mock_dir + mock_dir=$(create_mock_scripts_dir) + + cat > "$mock_dir/swipe_test.sh" << SCRIPT +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$mock_dir" +source "\$SCRIPT_DIR/helpers/common.sh" + +if [[ \$# -lt 4 ]]; then + echo "Usage: swipe.sh [duration_ms]" >&2 + exit 1 +fi + +rel_x1="\$1"; rel_y1="\$2"; rel_x2="\$3"; rel_y2="\$4" +duration_ms="\${5:-\$DEFAULT_SWIPE_DURATION}" + +window_json=\$("\$SCRIPT_DIR/find-window.sh") +parse_window_bounds "\$window_json" +validate_coords "\$rel_x1" "\$rel_y1" "\$CONTENT_W" "\$CONTENT_H" || exit 1 +validate_coords "\$rel_x2" "\$rel_y2" "\$CONTENT_W" "\$CONTENT_H" || exit 1 +echo "Swiped" +SCRIPT + chmod +x "$mock_dir/swipe_test.sh" + + run "$mock_dir/swipe_test.sh" 100 100 100 900 + [ "$status" -eq 1 ] + [[ "$output" == *"out of bounds"* ]] +} + +@test "swipe: default duration is 300ms" { + local mock_dir + mock_dir=$(create_mock_scripts_dir) + + cat > "$mock_dir/swipe_test.sh" << SCRIPT +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$mock_dir" +source "\$SCRIPT_DIR/helpers/common.sh" + +if [[ \$# -lt 4 ]]; then + echo "Usage: swipe.sh [duration_ms]" >&2 + exit 1 +fi + +rel_x1="\$1"; rel_y1="\$2"; rel_x2="\$3"; rel_y2="\$4" +duration_ms="\${5:-\$DEFAULT_SWIPE_DURATION}" + +window_json=\$("\$SCRIPT_DIR/find-window.sh") +parse_window_bounds "\$window_json" +validate_coords "\$rel_x1" "\$rel_y1" "\$CONTENT_W" "\$CONTENT_H" || exit 1 +validate_coords "\$rel_x2" "\$rel_y2" "\$CONTENT_W" "\$CONTENT_H" || exit 1 +echo "Swiped (\$rel_x1,\$rel_y1) -> (\$rel_x2,\$rel_y2) in \${duration_ms}ms" +SCRIPT + chmod +x "$mock_dir/swipe_test.sh" + + run "$mock_dir/swipe_test.sh" 100 100 200 200 + [ "$status" -eq 0 ] + [[ "$output" == *"300ms"* ]] +} + +@test "swipe: accepts custom duration" { + local mock_dir + mock_dir=$(create_mock_scripts_dir) + + cat > "$mock_dir/swipe_test.sh" << SCRIPT +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$mock_dir" +source "\$SCRIPT_DIR/helpers/common.sh" + +if [[ \$# -lt 4 ]]; then + echo "Usage: swipe.sh [duration_ms]" >&2 + exit 1 +fi + +rel_x1="\$1"; rel_y1="\$2"; rel_x2="\$3"; rel_y2="\$4" +duration_ms="\${5:-\$DEFAULT_SWIPE_DURATION}" + +window_json=\$("\$SCRIPT_DIR/find-window.sh") +parse_window_bounds "\$window_json" +validate_coords "\$rel_x1" "\$rel_y1" "\$CONTENT_W" "\$CONTENT_H" || exit 1 +validate_coords "\$rel_x2" "\$rel_y2" "\$CONTENT_W" "\$CONTENT_H" || exit 1 +echo "Swiped (\$rel_x1,\$rel_y1) -> (\$rel_x2,\$rel_y2) in \${duration_ms}ms" +SCRIPT + chmod +x "$mock_dir/swipe_test.sh" + + run "$mock_dir/swipe_test.sh" 100 100 200 200 500 + [ "$status" -eq 0 ] + [[ "$output" == *"500ms"* ]] +} diff --git a/tests/test_tap.bats b/tests/test_tap.bats new file mode 100644 index 0000000..0b4db41 --- /dev/null +++ b/tests/test_tap.bats @@ -0,0 +1,109 @@ +#!/usr/bin/env bats +# Tests for tap.sh argument validation and error handling. + +load test_helper + +setup() { + setup_temp +} + +teardown() { + teardown_temp +} + +@test "tap: rejects missing arguments (no args)" { + run "$PROJECT_ROOT/tap.sh" + [ "$status" -eq 1 ] + [[ "$output" == *"Usage"* ]] +} + +@test "tap: rejects missing y argument (one arg)" { + run "$PROJECT_ROOT/tap.sh" 100 + [ "$status" -eq 1 ] + [[ "$output" == *"Usage"* ]] +} + +@test "tap: rejects out-of-bounds coordinates" { + # Create a tap.sh wrapper that uses our mock find-window.sh + local mock_dir + mock_dir=$(create_mock_scripts_dir) + + # Create a tap script that sources common.sh and uses mock find-window + cat > "$mock_dir/tap_test.sh" << SCRIPT +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$mock_dir" +source "\$SCRIPT_DIR/helpers/common.sh" + +if [[ \$# -lt 2 ]]; then + echo "Usage: tap.sh " >&2 + exit 1 +fi + +rel_x="\$1" +rel_y="\$2" + +window_json=\$("\$SCRIPT_DIR/find-window.sh") +parse_window_bounds "\$window_json" +validate_coords "\$rel_x" "\$rel_y" "\$CONTENT_W" "\$CONTENT_H" || exit 1 +echo "Tapped at (\$rel_x, \$rel_y)" +SCRIPT + chmod +x "$mock_dir/tap_test.sh" + + # Out of bounds — content is 380x760 + run "$mock_dir/tap_test.sh" 500 100 + [ "$status" -eq 1 ] + [[ "$output" == *"out of bounds"* ]] +} + +@test "tap: accepts valid coordinates with mock" { + local mock_dir + mock_dir=$(create_mock_scripts_dir) + + cat > "$mock_dir/tap_test.sh" << SCRIPT +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$mock_dir" +source "\$SCRIPT_DIR/helpers/common.sh" + +if [[ \$# -lt 2 ]]; then + echo "Usage: tap.sh " >&2 + exit 1 +fi + +rel_x="\$1" +rel_y="\$2" + +window_json=\$("\$SCRIPT_DIR/find-window.sh") +parse_window_bounds "\$window_json" +validate_coords "\$rel_x" "\$rel_y" "\$CONTENT_W" "\$CONTENT_H" || exit 1 +echo "Tapped at (\$rel_x, \$rel_y)" +SCRIPT + chmod +x "$mock_dir/tap_test.sh" + + run "$mock_dir/tap_test.sh" 190 380 + [ "$status" -eq 0 ] + [[ "$output" == *"Tapped"* ]] +} + +@test "tap: accepts origin coordinates (0,0)" { + local mock_dir + mock_dir=$(create_mock_scripts_dir) + + cat > "$mock_dir/tap_test.sh" << SCRIPT +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$mock_dir" +source "\$SCRIPT_DIR/helpers/common.sh" + +rel_x="\$1"; rel_y="\$2" +window_json=\$("\$SCRIPT_DIR/find-window.sh") +parse_window_bounds "\$window_json" +validate_coords "\$rel_x" "\$rel_y" "\$CONTENT_W" "\$CONTENT_H" || exit 1 +echo "Tapped at (\$rel_x, \$rel_y)" +SCRIPT + chmod +x "$mock_dir/tap_test.sh" + + run "$mock_dir/tap_test.sh" 0 0 + [ "$status" -eq 0 ] +} diff --git a/tests/test_type_text.bats b/tests/test_type_text.bats new file mode 100644 index 0000000..bcf17fe --- /dev/null +++ b/tests/test_type_text.bats @@ -0,0 +1,92 @@ +#!/usr/bin/env bats +# Tests for type-text.sh argument validation and safety. + +load test_helper + +setup() { + setup_temp + source "$PROJECT_ROOT/helpers/common.sh" +} + +teardown() { + teardown_temp +} + +@test "type-text: rejects missing arguments" { + run "$PROJECT_ROOT/type-text.sh" + [ "$status" -eq 1 ] + [[ "$output" == *"Usage"* ]] +} + +@test "type-text: sanitize_applescript prevents double-quote injection" { + # If someone passes text with double quotes, it should be escaped + local malicious='"; tell app "Terminal" to do script "rm -rf /"' + result=$(sanitize_applescript "$malicious") + # The escaped result should not contain unescaped double quotes + # (every " should be preceded by \) + [[ "$result" != *'"; tell app'* ]] || { + # Verify the quotes are escaped + [[ "$result" == *'\"'* ]] + } +} + +@test "type-text: sanitize_applescript escapes backslash in text" { + local text='C:\Users\test' + result=$(sanitize_applescript "$text") + [ "$result" = 'C:\\Users\\test' ] +} + +@test "type-text: sanitize_jxa prevents single-quote injection" { + local malicious="'; process.exit(1); '" + result=$(sanitize_jxa "$malicious") + [[ "$result" == *"\\'"* ]] +} + +@test "type-text: handles unicode text safely" { + # Unicode should pass through sanitization unchanged + result=$(sanitize_applescript "Hello World") + [ "$result" = "Hello World" ] +} + +@test "type-text: handles empty string after required arg check" { + # Empty string is still a valid argument (just empty text) + # The script should accept it since $# >= 1 + # We can't run the full script without osascript, but we verify + # the arg check logic + local mock_dir + mock_dir=$(create_mock_scripts_dir) + + cat > "$mock_dir/type_test.sh" << 'SCRIPT' +#!/usr/bin/env bash +set -euo pipefail +if [[ $# -lt 1 ]]; then + echo "Usage: type-text.sh \"text\" [tap_x tap_y]" >&2 + exit 1 +fi +echo "OK: text='$1'" +SCRIPT + chmod +x "$mock_dir/type_test.sh" + + run "$mock_dir/type_test.sh" "" + [ "$status" -eq 0 ] +} + +@test "type-text: special chars do not cause command injection via sanitize_applescript" { + # Test that backticks, $(), and other shell metacharacters are safe + # after AppleScript sanitization + local dangerous='$(rm -rf /)' + result=$(sanitize_applescript "$dangerous") + # Dollar signs are escaped so double-quoted bash embedding cannot expand them; + # the embedded AppleScript then receives the literal text + [ "$result" = '\$(rm -rf /)' ] +} + +@test "type-text: newlines in text are handled by sanitize_jxa" { + local multiline=$'line1\nline2\nline3' + result=$(sanitize_jxa "$multiline") + [[ "$result" == *'\n'* ]] + # Should not contain literal newlines + local lines + lines=$(echo "$result" | wc -l) + [ "$lines" -eq 1 ] +} diff --git a/type-text.sh b/type-text.sh index 6cfe78f..16be6e3 100755 --- a/type-text.sh +++ b/type-text.sh @@ -5,6 +5,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/helpers/common.sh" if [[ $# -lt 1 ]]; then echo "Usage: type-text.sh \"text\" [tap_x tap_y]" >&2 @@ -15,17 +16,35 @@ text="$1" tap_x="${2:-}" tap_y="${3:-}" -if ! command -v cliclick &>/dev/null; then - echo "Error: cliclick not found. Install with: brew install cliclick" >&2 +if [[ -z "$text" ]]; then + echo "Error: text argument is empty" >&2 + exit 1 +fi + +# Both coordinates must be provided together or not at all +if [[ -n "$tap_x" && -z "$tap_y" ]] || [[ -z "$tap_x" && -n "$tap_y" ]]; then + echo "Usage: type-text.sh \"text\" [tap_x tap_y]" >&2 + echo "Error: tap_x and tap_y must both be provided" >&2 exit 1 fi # Tap field first if coordinates provided if [[ -n "$tap_x" && -n "$tap_y" ]]; then "$SCRIPT_DIR/tap.sh" "$tap_x" "$tap_y" - sleep 0.3 # Wait for keyboard to appear + sleep "$KEYBOARD_APPEAR_DELAY" fi -# Type the text -cliclick t:"$text" +# Write text to a temp file — avoids embedding user content in any bash string, +# eliminating $ expansion and backtick injection entirely. +tmp_text=$(mktemp /tmp/type-text-XXXXXX.txt) +printf '%s' "$text" > "$tmp_text" + +jxa_activate_and_run " +const textData = \$.NSData.dataWithContentsOfFile('${tmp_text}'); +const text = \$.NSString.alloc.initWithDataEncoding(textData, \$.NSUTF8StringEncoding).js; +const se = Application('System Events'); +se.keystroke(text); +" || { rm -f "$tmp_text"; exit 1; } +rm -f "$tmp_text" + echo "Typed: $text"