diff --git a/.agents/skills/codemode-evaluations/SKILL.md b/.agents/skills/codemode-evaluations/SKILL.md new file mode 100644 index 0000000..8475b00 --- /dev/null +++ b/.agents/skills/codemode-evaluations/SKILL.md @@ -0,0 +1,117 @@ +--- +name: codemode-evaluations +description: Use when adding, updating, or reviewing CodeMode.swift evaluation scenarios, deterministic eval tests, or Wavelike-backed LLM eval coverage in this repository. +metadata: + short-description: Add CodeMode eval scenarios +--- + +# CodeMode Evaluations + +Use this skill when the user asks to add evaluation coverage for CodeMode.swift tools, bridge behavior, catalog search, tool-call order, capability minimization, validation errors, permission behavior, or LLM agent performance. + +## Source of Truth + +Start with the local harness: + +- `Sources/CodeModeEvaluation/EvalModels.swift`: scenario, seed file, permission, expectation, result models. +- `Sources/CodeModeEvaluation/EvalRunner.swift`: deterministic sandbox runner and transcript validator. +- `Sources/CodeModeEvaluation/EvalScenarios.swift`: built-in scenarios and `CodeModeEvalScenarios.all`. +- `Tests/CodeModeEvalTests/CodeModeEvalRunnerTests.swift`: regression tests for built-in scenarios and validator behavior. +- `Tools/CodeModeEval/Sources/CodeModeEvalCLI`: CLI for deterministic runs, LLM runs, planning, summaries, comparisons, and Markdown reports. + +Apple Evaluations framework guidance maps to this repo as: + +- Evaluation = one `CodeModeEvalScenario` plus its expectations. +- Dataset = categorized scenario set in `CodeModeEvalScenarios.all` and optional LLM suites. +- Subject = `CodeModeAgentTools` for deterministic runs or the Wavelike-backed agent loop for LLM runs. +- Evaluators = `CodeModeEvalExpectation` fields plus `CodeModeEvalRunner.validateTranscript`. +- Aggregation = test pass rate, CLI summaries, scenario summaries, capability pass rate, retry and turn counts. + +Apple's Evaluations framework is useful as design guidance, but do not add `import Evaluations` to the package unless the repository is intentionally moving to a compatible Apple beta toolchain and platform. The current package is SwiftPM-based and already has a portable evaluation harness. + +## Eval Design Checklist + +Treat evaluations as the living specification for the feature under test: + +1. Define the behavior in measurable terms before changing prompts or implementation. +2. Add golden, edge, adversarial, and known-failure cases where relevant. +3. Prefer code-based checks when the result is computable: exact JSON output, error code, diagnostic fragment, tool order, allowed capabilities, forbidden capabilities, and required code fragments. +4. Use LLM repeat runs only for model behavior that cannot be proven deterministically. +5. Keep each scenario narrow enough that a failure points to a specific behavior. +6. Include at least one negative assertion when there is a real risk, such as forbidden capabilities or an expected permission denial. + +## Adding a Deterministic Scenario + +1. Find nearby examples in `EvalScenarios.swift`. +2. Add a `public static let` scenario with a stable dotted id. +3. Add it to `CodeModeEvalScenarios.all` in the appropriate section. +4. Set `task` as the model-facing instruction for LLM runs. Make it precise enough to evaluate. +5. Use `searchCode` when the scenario expects catalog discovery before execution. +6. Use `executeCode` for one-step workflows or `executeSteps` when order matters across multiple tool calls. +7. Set `allowedCapabilities` to the minimum needed by the execute step. +8. Use `seedFiles`, `permissions`, and `catalogPlatform` instead of ad hoc setup in test code. +9. Fill `CodeModeEvalExpectation` with measurable checks: + - `toolOrder` + - `exactAllowedCapabilities` + - `forbiddenCapabilities` + - `requiredSearchResultFragments` + - `requiredExecuteCodeFragments` or `requiredExecuteCodeAlternativeFragments` + - `expectedOutput` + - `expectedErrorCode` + - `requiredErrorSuggestionFragments` + - diagnostic or log fragments when they are part of the contract + +Keep expected JSON stable. If order is not semantically meaningful, assert fragments instead of exact arrays. + +## Adding LLM Eval Coverage + +Use existing deterministic scenarios as the dataset for LLM runs. When adding a scenario that should be part of standard LLM suites: + +1. Inspect `LLMEvalSuite` in `Tools/CodeModeEval/Sources/CodeModeEvalCLI/LLM.swift`. +2. Add the scenario id to `smoke`, `core`, `failures`, or `all` only when it matches that suite's purpose. +3. Run `swift run codemode-eval plan --suite ` from `Tools/CodeModeEval` before live model calls. +4. For live calls, prefer a small repeat count first, then increase only after the scenario is stable. +5. Compare reports with the CLI when evaluating a prompt or model change. + +Use these metrics as the main quality signals: + +- `passRate`: scenario success. +- `exactCapabilityPassRate`: whether the agent used the exact minimal capability set. +- `averageTurns`: whether the task requires too much back-and-forth. +- `averageRetries`: whether transport or model stability regressed. +- failure categories and captured tool calls for root cause. + +## Validation Commands + +From the repo root: + +```sh +swift test --filter CodeModeEvalTests +``` + +From `Tools/CodeModeEval`: + +```sh +swift run codemode-eval list +swift run codemode-eval run --show-code +swift run codemode-eval plan --suite smoke +``` + +Use LLM runs only when credentials and budget are intentionally available: + +```sh +swift run codemode-eval llm --suite smoke --repeat 1 --output /tmp/codemode-llm.json +swift run codemode-eval summarize /tmp/codemode-llm.json --include-failures +swift run codemode-eval report /tmp/codemode-llm.json --output /tmp/codemode-llm.md +``` + +## Review Heuristics + +Reject or revise evals that: + +- Only assert that a command runs without checking behavior. +- Request broader capabilities than the task requires. +- Depend on wall-clock time, network data, or host state when a seeded fixture can express the behavior. +- Combine unrelated bridge behavior in one scenario. +- Add LLM-only coverage for behavior that the deterministic runner can verify. +- Use polished natural-language expectations where a code-based check would be cheaper and more reproducible. diff --git a/.agents/skills/codemode-synthetic-eval-datasets/SKILL.md b/.agents/skills/codemode-synthetic-eval-datasets/SKILL.md new file mode 100644 index 0000000..abe50db --- /dev/null +++ b/.agents/skills/codemode-synthetic-eval-datasets/SKILL.md @@ -0,0 +1,136 @@ +--- +name: codemode-synthetic-eval-datasets +description: Use when designing, generating, validating, or importing synthetic evaluation datasets for CodeMode.swift eval scenarios. +metadata: + short-description: Build CodeMode synthetic eval data +--- + +# CodeMode Synthetic Eval Datasets + +Use this skill when the user asks to generate, expand, validate, rebalance, or import synthetic evaluation data for CodeMode.swift. + +The output should usually become typed Swift scenarios in `Sources/CodeModeEvaluation/EvalScenarios.swift`, plus tests in `Tests/CodeModeEvalTests`, not an unreviewed blob of generated data. + +## Apple-Informed Principles + +Follow these dataset design rules: + +- Start with high-quality human-written seeds. Synthetic data amplifies seed quality and seed gaps. +- Categorize samples by purpose: golden, edge, adversarial, known failures, permission failures, platform-gated catalog discovery, and capability-minimization checks. +- Generate within focused categories instead of asking for one broad "diverse" set. +- Include hard and adversarial seeds. Aim for at least 20 to 30 percent difficult human-authored anchors when building a synthetic set. +- Keep at least 20 to 30 percent human-written samples in any expanded dataset as calibration anchors. +- Validate generated samples programmatically, then manually review a random slice before relying on them. +- Prefer 50 to 200 strong samples per feature or category over thousands of noisy, duplicate, or ambiguous cases. + +## CodeMode Dataset Shape + +Represent synthetic output in CodeMode terms: + +- `id`: stable dotted id, grouped by domain, for example `fs.synthetic.long-path-read`. +- `title`: short human-readable title. +- `task`: model-facing instruction that can be evaluated. +- `searchCode`: catalog lookup the agent should perform, if discovery is part of the behavior. +- `executeCode` or `executeSteps`: expected tool-side behavior for deterministic replay. +- `allowedCapabilities`: exact minimum capability set. +- `seedFiles`: deterministic fixtures under `tmp:`, `documents:`, or `caches:`. +- `permissions`: explicit permission status and request behavior. +- `catalogPlatform`: platform override when testing platform-specific availability. +- `expectation`: measurable checks on tool order, capabilities, output, errors, diagnostics, and code fragments. + +Do not import generated examples directly if expected outputs are ambiguous. Turn them into deterministic scenarios with explicit expected JSON or explicit failure expectations. + +## Generation Workflow + +1. Identify the feature and quality dimensions: + - correctness + - capability minimization + - tool-call order + - argument validation + - permission behavior + - platform pruning + - diagnostic quality + - recovery after invalid arguments +2. Write 5 to 15 seed scenarios by hand. +3. Label each seed with category, difficulty, feature area, required capabilities, and the expected failure mode if any. +4. Generate more cases one category at a time: + - golden filesystem reads + - adversarial path policy escapes + - catalog alias lookups + - permission denied flows + - invalid argument repair cases + - platform-pruned iOS-only or macOS-only helpers +5. Validate generated candidates: + - unique id + - unique or intentionally varied task + - exact minimal capability list + - no impossible host state + - all seeded paths stay inside allowed roots + - expected output is derivable from seeded data + - no leaking the expected answer in a way that invalidates the task +6. Promote accepted samples into `CodeModeEvalScenario` declarations. +7. Add them to `CodeModeEvalScenarios.all` or to a new grouped collection if the set is large. +8. Run deterministic tests before any LLM run. + +## Candidate Review Checklist + +For each generated candidate, answer: + +- What exact behavior does this sample measure? +- Which category does it belong to? +- Is the expected result deterministic? +- Could a model pass by using a broader capability than necessary? +- Does `requiredExecuteCodeAlternativeFragments` allow legitimate API aliases without allowing unrelated implementations? +- Is this a near-duplicate of an existing scenario? +- Would failure produce a useful, localized signal? + +Discard or rewrite candidates that fail these checks. + +## Prompt Pattern for Synthetic Candidates + +When asking a model to draft candidates, keep the prompt constrained: + +```text +Generate CodeMode evaluation scenario candidates for . +Category: . +Each candidate must include: id, title, task, seedFiles, allowedCapabilities, +expectedOutput or expectedErrorCode, and rationale. +Do not include cases that require live network, clock time, user contacts, +real calendars, Photos library contents, or host filesystem state. +Vary phrasing, input length, and difficulty. Keep expected outputs deterministic. +``` + +Then convert accepted candidates to Swift manually or with a small script, preserving the local formatting style. + +## Validation Commands + +From the repo root: + +```sh +swift test --filter CodeModeEvalTests +``` + +From `Tools/CodeModeEval`: + +```sh +swift run codemode-eval run --show-code +swift run codemode-eval plan --suite core +``` + +For LLM-backed validation after deterministic checks pass: + +```sh +swift run codemode-eval llm --repeat 3 --output /tmp/codemode-synthetic-smoke.json +swift run codemode-eval report /tmp/codemode-synthetic-smoke.json --all-runs --include-code +``` + +## When to Use Apple's Evaluations Framework Directly + +Only add a direct `Evaluations` framework target when the user explicitly wants Apple's framework adoption and the local toolchain supports it. In that case: + +- Model each CodeMode scenario as a `ModelSample` with explicit expected values. +- Put the CodeMode tool or agent under test in the `subject(from:)` implementation. +- Use code-based `Evaluator` checks for deterministic behavior. +- Use tool-call trajectory expectations for tool order and arguments. +- Aggregate pass/fail metrics with means and scored metrics with medians or maxima, depending on what the metric represents. +- Keep the existing CodeMode harness running until the new framework produces equivalent or better regression signal. diff --git a/.github/workflows/codemode-evals.yml b/.github/workflows/codemode-evals.yml index a1f4889..9d7679a 100644 --- a/.github/workflows/codemode-evals.yml +++ b/.github/workflows/codemode-evals.yml @@ -36,23 +36,18 @@ jobs: - name: Test package run: swift test - - name: Build deterministic eval CLI - run: swift build --package-path Tools/CodeModeDeterministicEval + - name: Build eval CLI + run: swift build --package-path Tools/CodeModeEval - name: Run deterministic evals - run: swift run --package-path Tools/CodeModeDeterministicEval codemode-deterministic-eval run + run: swift run --package-path Tools/CodeModeEval codemode-eval run - live-llm: - name: Live Wavelike LLM evals + llm-plan: + name: LLM eval planning runs-on: macos-latest needs: deterministic if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' - timeout-minutes: 90 env: - WAVELIKE_MODEL_ID: ${{ secrets.WAVELIKE_MODEL_ID }} - WAVELIKE_APP_ID: ${{ secrets.WAVELIKE_APP_ID }} - WAVELIKE_API_KEY: ${{ secrets.WAVELIKE_API_KEY }} - WAVELIKE_ENV: ${{ secrets.WAVELIKE_ENV }} INPUT_REPEAT_COUNT: ${{ github.event.inputs.repeat_count }} INPUT_REQUEST_DELAY_MS: ${{ github.event.inputs.request_delay_ms }} steps: @@ -62,104 +57,27 @@ jobs: - name: Toolchain run: swift --version - - name: Check Wavelike secrets - id: wavelike - run: | - if [ -n "$WAVELIKE_MODEL_ID" ] && [ -n "$WAVELIKE_APP_ID" ] && [ -n "$WAVELIKE_API_KEY" ]; then - echo "available=true" >> "$GITHUB_OUTPUT" - else - echo "available=false" >> "$GITHUB_OUTPUT" - echo "Wavelike secrets are not configured; skipping live LLM evals." - fi - - name: Build eval CLI - if: steps.wavelike.outputs.available == 'true' run: swift build --package-path Tools/CodeModeEval - - name: Run live LLM eval suites - if: steps.wavelike.outputs.available == 'true' + - name: Preview LLM eval suites run: | - set +e - repeat_count="${INPUT_REPEAT_COUNT:-5}" request_delay_ms="${INPUT_REQUEST_DELAY_MS:-1000}" - reports_dir="Tools/CodeModeEval/.build/reports" - mkdir -p "$reports_dir" - - status=0 - swift run --package-path Tools/CodeModeEval codemode-eval llm \ + swift run --package-path Tools/CodeModeEval codemode-eval plan \ --suite core \ --repeat "$repeat_count" \ - --max-output-tokens 1600 \ - --request-delay-ms "$request_delay_ms" \ - --model-retries 5 \ - --retry-delay-ms 5000 \ - --output "$reports_dir/core-r${repeat_count}.json" || status=$? - - swift run --package-path Tools/CodeModeEval codemode-eval summarize \ - "$reports_dir/core-r${repeat_count}.json" \ - --output "$reports_dir/core-r${repeat_count}-summary.json" || status=$? + --request-delay-ms "$request_delay_ms" - if [ "$repeat_count" = "5" ]; then - swift run --package-path Tools/CodeModeEval codemode-eval report \ - "$reports_dir/core-r${repeat_count}.json" \ - --baseline Tools/CodeModeEval/Baselines/core-r5-summary.json \ - --output "$reports_dir/core-r${repeat_count}.md" || status=$? - else - swift run --package-path Tools/CodeModeEval codemode-eval report \ - "$reports_dir/core-r${repeat_count}.json" \ - --output "$reports_dir/core-r${repeat_count}.md" || status=$? - fi - - swift run --package-path Tools/CodeModeEval codemode-eval llm \ + swift run --package-path Tools/CodeModeEval codemode-eval plan \ --suite failures \ --repeat "$repeat_count" \ - --max-output-tokens 1200 \ - --request-delay-ms "$request_delay_ms" \ - --model-retries 5 \ - --retry-delay-ms 5000 \ - --output "$reports_dir/failures-r${repeat_count}.json" || status=$? - - swift run --package-path Tools/CodeModeEval codemode-eval summarize \ - "$reports_dir/failures-r${repeat_count}.json" \ - --output "$reports_dir/failures-r${repeat_count}-summary.json" || status=$? - - if [ "$repeat_count" = "5" ]; then - swift run --package-path Tools/CodeModeEval codemode-eval report \ - "$reports_dir/failures-r${repeat_count}.json" \ - --baseline Tools/CodeModeEval/Baselines/failures-r5-summary.json \ - --output "$reports_dir/failures-r${repeat_count}.md" || status=$? - else - swift run --package-path Tools/CodeModeEval codemode-eval report \ - "$reports_dir/failures-r${repeat_count}.json" \ - --output "$reports_dir/failures-r${repeat_count}.md" || status=$? - fi + --request-delay-ms "$request_delay_ms" - if [ "$repeat_count" = "5" ]; then - swift run --package-path Tools/CodeModeEval codemode-eval compare \ - Tools/CodeModeEval/Baselines/core-r5-summary.json \ - "$reports_dir/core-r5.json" \ - --retry-tolerance 0.5 \ - --turn-tolerance 0.5 || status=$? - - swift run --package-path Tools/CodeModeEval codemode-eval compare \ - Tools/CodeModeEval/Baselines/failures-r5-summary.json \ - "$reports_dir/failures-r5.json" \ - --retry-tolerance 0.25 \ - --turn-tolerance 0.25 || status=$? - else - echo "Skipping baseline compare because repeat_count=$repeat_count does not match committed r5 baselines." - fi - - exit "$status" + swift run --package-path Tools/CodeModeEval codemode-eval plan \ + --suite catalog \ + --repeat "$repeat_count" \ + --request-delay-ms "$request_delay_ms" - - name: Upload LLM eval reports - if: always() && steps.wavelike.outputs.available == 'true' - uses: actions/upload-artifact@v4 - with: - name: codemode-llm-eval-reports - path: | - Tools/CodeModeEval/.build/reports/*.json - Tools/CodeModeEval/.build/reports/*.md - if-no-files-found: warn + echo "Live Wavelike-backed LLM execution is disabled in this workflow while the default eval package avoids private dependencies." diff --git a/EVALS.md b/EVALS.md index 5b78588..0d82476 100644 --- a/EVALS.md +++ b/EVALS.md @@ -3,39 +3,24 @@ This project has two eval layers: - Deterministic evals run local scenario code through `searchJavaScriptAPI` and `executeJavaScript`. -- Live LLM evals ask the configured Wavelike model to solve the same tasks, capture real tool calls, and grade the transcript. +- Saved live LLM reports capture real model tool calls for the same tasks and can be summarized, reported, and compared by the eval CLI. ## Local Checks -Run deterministic checks before PRs: +Run deterministic checks before PRs with the single supported eval CLI: ```sh swift test swift run --package-path Tools/CodeModeEval codemode-eval run ``` -Run a small live smoke pass while iterating on prompts or tool descriptions: - -```sh -swift run --package-path Tools/CodeModeEval codemode-eval llm --suite smoke --request-delay-ms 1000 -``` - -Live runs print per-scenario progress to stderr so JSON stdout stays parseable. In an interactive terminal this is an in-place progress bar with colored pass/fail states; in CI it falls back to one line per scenario. Use `--quiet` to suppress progress output. - Preview a live run before spending provider calls: ```sh swift run --package-path Tools/CodeModeEval codemode-eval plan --suite core --repeat 5 --request-delay-ms 1000 ``` -Run the repeat baseline suites when model behavior needs a real stability signal: - -```sh -swift run --package-path Tools/CodeModeEval codemode-eval llm --suite core --repeat 5 --request-delay-ms 1000 --output Tools/CodeModeEval/.build/reports/core-r5.json -swift run --package-path Tools/CodeModeEval codemode-eval llm --suite failures --repeat 5 --request-delay-ms 1000 --output Tools/CodeModeEval/.build/reports/failures-r5.json -``` - -Live evals read `WAVELIKE_MODEL_ID`, `WAVELIKE_APP_ID`, `WAVELIKE_API_KEY`, and optional `WAVELIKE_ENV` from the environment or `.env`. +The default eval package intentionally excludes the private Wavelike-backed live runner so `swift build --package-path Tools/CodeModeEval` works in unauthenticated CI. Keep live model execution in a separate private runner or workflow when it is needed; this package keeps the deterministic runner plus `plan`, `summarize`, `report`, and `compare`. ## Baselines @@ -53,6 +38,7 @@ swift run --package-path Tools/CodeModeEval codemode-eval compare \ Default comparison policy allows no pass-rate regression and no exact-capability regression. Retry and turn tolerances should stay small because increases there usually mean the model is recovering from avoidable tool or JavaScript mistakes. When a suite adds or removes scenarios, `compare` still checks overlapping per-scenario metrics but treats overall aggregate metrics as informational until a new baseline is reviewed and committed. +The `catalog` suite covers search-only catalog scenarios. Its scheduled compare is enabled automatically once `Tools/CodeModeEval/Baselines/catalog-r5-summary.json` has been generated from a reviewed live r5 run and committed. Create a human-readable Markdown diagnostics report: @@ -75,14 +61,13 @@ swift run --package-path Tools/CodeModeEval codemode-eval summarize \ ## CI Policy -The GitHub Actions workflow runs deterministic evals on PRs and pushes. Live LLM evals run only on schedule or manual dispatch because they require secrets and provider calls. +The GitHub Actions workflow runs deterministic evals on PRs and pushes. Scheduled and manually dispatched runs build the same public eval CLI and preview `core`, `failures`, and `catalog` LLM suite budgets without making live provider calls or resolving private Wavelike dependencies. -Scheduled/manual live evals: +Scheduled/manual planning runs: -- Run `core` and `failures` with repeat count 5 by default. -- Save raw JSON, summary JSON, and Markdown diagnostics reports as workflow artifacts. -- Compare repeat-5 reports against committed summary baselines. -- Use request pacing and transient model retry/backoff to tolerate provider rate limits. +- Preview `core`, `failures`, and `catalog` with repeat count 5 by default. +- Honor the manual `repeat_count` and `request_delay_ms` inputs for budget estimates. +- Leave baseline comparison to private live-report generation until a reviewed candidate report exists. ## Updating Baselines diff --git a/README.md b/README.md index 7f3b566..7211bb6 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ GitHub: [velos/CodeMode.swift](https://github.com/velos/CodeMode.swift) - Structured failures via `CodeModeToolError` - Hybrid JS surface: - web-style globals: `fetch`, `URL`, `URLSearchParams`, `setTimeout`, `console` - - cross-platform Apple namespaces: `apple.keychain`, `apple.location`, `apple.weather`, `apple.calendar`, `apple.reminders`, `apple.contacts`, `apple.photos`, `apple.vision`, `apple.notifications`, `apple.health`, `apple.home`, `apple.media`, `apple.fs` + - cross-platform Apple namespaces: `apple.keychain`, `apple.location`, `apple.weather`, `apple.calendar`, `apple.reminders`, `apple.contacts`, `apple.photos`, `apple.vision`, `apple.notifications`, `apple.health`, `apple.home`, `apple.media`, `apple.fs`, `apple.cloudkit`, `apple.maps`, `apple.storekit`, `apple.speech`, `apple.appIntents`, `apple.activity`, `apple.foundationModels`, `apple.music`, `apple.wallet` - platform-specific namespaces when needed: `ios.alarm` - iOS/visionOS system UI helpers through an injected presenter, including alerts, calendar editors, photo/contact/document pickers, share sheets, Quick Look previews, web authentication, and iOS-only camera/scan/mail/message compose flows - Node-style aliases for file operations through `globalThis.fs.promises` @@ -52,6 +52,7 @@ Then add the product to your target: - `CodeModeFileSystem` - `LocalCodeModeFileSystem` - `CodeModeAgentToolDescriptions` +- `CodeModeEvaluation` SwiftPM product for deterministic scenario evaluation - `SystemUIPresenter` - `UIKitSystemUIPresenter` on iOS/visionOS @@ -144,6 +145,8 @@ Available in search code: ```ts interface JavaScriptAPIReference { capability: string; + capabilityKey: string; + builtInCapability: string | null; jsNames: string[]; summary: string; tags: string[]; @@ -152,6 +155,7 @@ interface JavaScriptAPIReference { optionalArguments: string[]; argumentTypes: Record; argumentHints: Record; + argumentConstraints: { allowedStringValues: Record }; resultSummary: string; } @@ -294,52 +298,40 @@ swift run --package-path Tools/CodeModeEval codemode-eval run fs.round-trip --sh swift run --package-path Tools/CodeModeEval codemode-eval run --json ``` -The eval harness runs 26 built-in user-style scenarios through the same +The eval harness runs 49 built-in user-style scenarios through the same `searchJavaScriptAPI` and `executeJavaScript` APIs that host apps expose to agents. It validates tool order, discovered catalog output, generated JavaScript fragments, exact `allowedCapabilities`, structured errors, repair suggestions, console logs, diagnostics, and final output. The scenarios cover filesystem workflows, capability minimization, path policy failures, permission failures, catalog search behavior, helper suggestions, API argument-shape confusion, -recovery after structured tool errors, and execution timeouts. +recovery after structured tool errors, execution timeouts, and catalog coverage +for the expanded Apple API families. -- Run Wavelike-backed LLM evals: +- Preview LLM eval suites and work with saved live reports: ```sh -swift run --package-path Tools/CodeModeEval codemode-eval llm fs.round-trip --show-code swift run --package-path Tools/CodeModeEval codemode-eval plan --suite core --repeat 5 --request-delay-ms 1000 -swift run --package-path Tools/CodeModeEval codemode-eval llm --suite smoke --repeat 3 -swift run --package-path Tools/CodeModeEval codemode-eval llm --suite core --repeat 5 --request-delay-ms 1000 --output Tools/CodeModeEval/.build/reports/core-baseline.json swift run --package-path Tools/CodeModeEval codemode-eval summarize Tools/CodeModeEval/.build/reports/core-baseline.json --output Tools/CodeModeEval/.build/reports/core-summary.json swift run --package-path Tools/CodeModeEval codemode-eval report Tools/CodeModeEval/.build/reports/core-baseline.json --output Tools/CodeModeEval/.build/reports/core-baseline.md swift run --package-path Tools/CodeModeEval codemode-eval compare Tools/CodeModeEval/.build/reports/core-baseline.json Tools/CodeModeEval/.build/reports/core-candidate.json ``` -The LLM runner reads `WAVELIKE_MODEL_ID`, `WAVELIKE_APP_ID`, -`WAVELIKE_API_KEY`, and optional `WAVELIKE_ENV` from the process environment or -`.env`. It gives the model the real CodeMode tool descriptions, captures actual -model tool calls, executes those calls against the local CodeMode runtime, and -grades the final repaired transcript with the same deterministic expectations. -When no scenario IDs are supplied, `llm` defaults to `--suite smoke`; available -suites are `smoke`, `core`, `failures`, and `all`. The JSON output is an eval -report envelope with raw run results plus aggregate pass rate, average turns, -retry count, exact/minimal capability success, per-scenario metrics, and failure -categories such as `wrong_tool`, `wrong_js`, `overbroad_capability`, -`failed_recovery`, and `no_final_answer`. -Interactive live runs show an in-place progress bar with colored pass/fail -states; CI output falls back to line-oriented progress. Use `plan` to preview -request budgets, `--output` to save a JSON report, `summarize` to strip raw -transcripts before committing baselines, `report` to generate Markdown -diagnostics with tool-attempt retry traces, then `compare` to fail on pass-rate, -exact-capability, retry, or turn-count regressions. Tolerances are configurable -with `--pass-rate-tolerance`, `--capability-rate-tolerance`, `--retry-tolerance`, -and `--turn-tolerance`. Live LLM evals retry transient transport errors by -default; use `--request-delay-ms`, `--model-retries`, and `--retry-delay-ms` to -pace larger repeated runs against provider rate limits. +The default eval package intentionally avoids private Wavelike dependencies, so +`swift build --package-path Tools/CodeModeEval` works in public or unauthenticated +CI. Use `plan` to preview request budgets, `summarize` to strip raw transcripts +before committing baselines, `report` to generate Markdown diagnostics with +tool-attempt retry traces, then `compare` to fail on pass-rate, exact-capability, +retry, or turn-count regressions. Tolerances are configurable with +`--pass-rate-tolerance`, `--capability-rate-tolerance`, `--retry-tolerance`, and +`--turn-tolerance`. The saved JSON report envelope includes raw run results plus +aggregate pass rate, average turns, retry count, exact/minimal capability +success, per-scenario metrics, and failure categories such as `wrong_tool`, +`wrong_js`, `overbroad_capability`, `failed_recovery`, and `no_final_answer`. See [EVALS.md](EVALS.md) for CI/nightly policy, baseline handling, and recommended commands. The CLI lives in `Tools/CodeModeEval` so library consumers do not resolve -ArgumentParser or Wavelike dependencies when they use the `CodeMode` product. +ArgumentParser when they use the `CodeMode` product. - License: MIT. See [LICENSE](LICENSE) @@ -357,10 +349,19 @@ This repository is an independent implementation and is not affiliated with Clou Development of `CodeMode.swift` was done exclusively with Codex, initiated by an interactively built plan, executed by the model after the plan was finalized. +## Shipped Expanded Families + +These API families are represented in the catalog and local bridge layer on this +branch: + +- APNs / remote-notification token and settings lifecycle under `apple.notifications.*` +- PassKit wallet APIs under `apple.wallet.*` +- Speech, MusicKit, Foundation Models, CloudKit, Maps, StoreKit, App Intents, and ActivityKit namespaces + ## Deferred in v1 The package intentionally defers these to later phases: -- Push notifications / APNs token lifecycle -- PassKit wallet APIs -- Other frameworks such as Speech, MusicKit, Foundation Models +- Production host entitlement provisioning and app-specific UX for newly bridged frameworks +- Broader executable eval coverage for every catalog-only capability family +- Behavior-preserving refactors that generate more JavaScript bindings from registration metadata diff --git a/Sources/CodeMode/API/AppleServiceAdapters.swift b/Sources/CodeMode/API/AppleServiceAdapters.swift new file mode 100644 index 0000000..fd587cd --- /dev/null +++ b/Sources/CodeMode/API/AppleServiceAdapters.swift @@ -0,0 +1,280 @@ +import Foundation + +public struct CodeModeInboxEvent: Sendable, Codable, Equatable { + public var id: String + public var source: String + public var timestamp: Date + public var payload: JSONValue + public var metadata: [String: JSONValue] + + public init( + id: String = UUID().uuidString, + source: String, + timestamp: Date = Date(), + payload: JSONValue, + metadata: [String: JSONValue] = [:] + ) { + self.id = id + self.source = source + self.timestamp = timestamp + self.payload = payload + self.metadata = metadata + } + + var jsonValue: JSONValue { + .object([ + "id": .string(id), + "source": .string(source), + "timestamp": .string(ISO8601DateFormatter().string(from: timestamp)), + "payload": payload, + "metadata": .object(metadata), + ]) + } +} + +public protocol CodeModeEventInbox: Sendable { + func appendEvent(_ event: CodeModeInboxEvent) throws + func readEvents(source: String, arguments: [String: JSONValue]) throws -> JSONValue +} + +public extension CodeModeEventInbox { + func appendEvent(_ event: CodeModeInboxEvent) throws { + throw BridgeError.unsupportedPlatform("\(event.source) event inbox append; configure a host client on CodeModeConfiguration") + } +} + +public final class BoundedCodeModeEventInbox: CodeModeEventInbox, @unchecked Sendable { + private let lock = NSLock() + private let maxEventsPerSource: Int + private var eventsBySource: [String: [CodeModeInboxEvent]] = [:] + + public init(maxEventsPerSource: Int = 100) { + self.maxEventsPerSource = max(1, maxEventsPerSource) + } + + public func appendEvent(_ event: CodeModeInboxEvent) throws { + let source = event.source.trimmingCharacters(in: .whitespacesAndNewlines) + guard source.isEmpty == false else { + throw BridgeError.invalidArguments("event inbox source must not be empty") + } + + var normalizedEvent = event + normalizedEvent.source = source + + lock.lock() + defer { lock.unlock() } + var events = eventsBySource[source] ?? [] + events.insert(normalizedEvent, at: 0) + if events.count > maxEventsPerSource { + events.removeSubrange(maxEventsPerSource.. JSONValue { + let source = source.trimmingCharacters(in: .whitespacesAndNewlines) + guard source.isEmpty == false else { + throw BridgeError.invalidArguments("event inbox source must not be empty") + } + if let limit = arguments.int("limit"), limit <= 0 { + throw BridgeError.invalidArguments("event inbox limit must be greater than 0") + } + let limit = arguments.int("limit") ?? maxEventsPerSource + + lock.lock() + defer { lock.unlock() } + let events = eventsBySource[source] ?? [] + + return .array(events.prefix(limit).map(\.jsonValue)) + } +} + +public struct UnavailableCodeModeEventInbox: CodeModeEventInbox { + public init() {} + + public func readEvents(source: String, arguments: [String: JSONValue]) throws -> JSONValue { + try unavailable("\(source) event inbox") + } +} + +public protocol CloudKitClient: Sendable { + func accountStatus(arguments: [String: JSONValue]) throws -> JSONValue + func queryRecords(arguments: [String: JSONValue]) throws -> JSONValue + func saveRecord(arguments: [String: JSONValue]) throws -> JSONValue + func deleteRecord(arguments: [String: JSONValue]) throws -> JSONValue + func saveSubscription(arguments: [String: JSONValue]) throws -> JSONValue + func readSubscriptionEvents(arguments: [String: JSONValue]) throws -> JSONValue +} + +public protocol RemoteNotificationsClient: Sendable { + func register(arguments: [String: JSONValue]) throws -> JSONValue + func readToken(arguments: [String: JSONValue]) throws -> JSONValue + func readSettings(arguments: [String: JSONValue]) throws -> JSONValue + func setCategories(arguments: [String: JSONValue]) throws -> JSONValue + func readResponses(arguments: [String: JSONValue]) throws -> JSONValue +} + +public protocol SpeechClient: Sendable { + func transcribeFile(arguments: [String: JSONValue]) throws -> JSONValue + func transcribeMicrophone(arguments: [String: JSONValue]) throws -> JSONValue +} + +public protocol AppIntentsClient: Sendable { + func listActions(arguments: [String: JSONValue]) throws -> JSONValue + func runAction(arguments: [String: JSONValue]) throws -> JSONValue + func donateAction(arguments: [String: JSONValue]) throws -> JSONValue + func openAction(arguments: [String: JSONValue]) throws -> JSONValue + func readHandoffs(arguments: [String: JSONValue]) throws -> JSONValue +} + +public protocol FoundationModelsClient: Sendable { + func status(arguments: [String: JSONValue]) throws -> JSONValue + func generateText(arguments: [String: JSONValue]) throws -> JSONValue + func extract(arguments: [String: JSONValue]) throws -> JSONValue +} + +public protocol ActivityClient: Sendable { + func listActivities(arguments: [String: JSONValue]) throws -> JSONValue + func startActivity(arguments: [String: JSONValue]) throws -> JSONValue + func updateActivity(arguments: [String: JSONValue]) throws -> JSONValue + func endActivity(arguments: [String: JSONValue]) throws -> JSONValue + func readPushToken(arguments: [String: JSONValue]) throws -> JSONValue +} + +public protocol MapsClient: Sendable { + func geocode(arguments: [String: JSONValue]) throws -> JSONValue + func reverseGeocode(arguments: [String: JSONValue]) throws -> JSONValue + func search(arguments: [String: JSONValue]) throws -> JSONValue + func routeEstimate(arguments: [String: JSONValue]) throws -> JSONValue + func open(arguments: [String: JSONValue]) throws -> JSONValue +} + +public protocol MusicClient: Sendable { + func requestAuthorization(arguments: [String: JSONValue]) throws -> JSONValue + func subscriptionStatus(arguments: [String: JSONValue]) throws -> JSONValue + func searchCatalog(arguments: [String: JSONValue]) throws -> JSONValue + func catalogDetails(arguments: [String: JSONValue]) throws -> JSONValue + func readLibrary(arguments: [String: JSONValue]) throws -> JSONValue + func writePlaylist(arguments: [String: JSONValue]) throws -> JSONValue + func controlPlayback(arguments: [String: JSONValue]) throws -> JSONValue +} + +public protocol PassKitClient: Sendable { + func walletStatus(arguments: [String: JSONValue]) throws -> JSONValue + func listPasses(arguments: [String: JSONValue]) throws -> JSONValue + func addPass(arguments: [String: JSONValue]) throws -> JSONValue + func presentPass(arguments: [String: JSONValue]) throws -> JSONValue + func applePayStatus(arguments: [String: JSONValue]) throws -> JSONValue + func presentApplePay(arguments: [String: JSONValue]) throws -> JSONValue +} + +public protocol StoreKitClient: Sendable { + func products(arguments: [String: JSONValue]) throws -> JSONValue + func currentEntitlements(arguments: [String: JSONValue]) throws -> JSONValue + func purchase(arguments: [String: JSONValue]) throws -> JSONValue + func restore(arguments: [String: JSONValue]) throws -> JSONValue + func transactionUpdates(arguments: [String: JSONValue]) throws -> JSONValue +} + +public struct UnavailableCloudKitClient: CloudKitClient { + public init() {} + + public func accountStatus(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("CloudKit account status") } + public func queryRecords(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("CloudKit record query") } + public func saveRecord(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("CloudKit record save") } + public func deleteRecord(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("CloudKit record delete") } + public func saveSubscription(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("CloudKit subscription save") } + public func readSubscriptionEvents(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("CloudKit subscription event inbox") } +} + +public struct UnavailableRemoteNotificationsClient: RemoteNotificationsClient { + public init() {} + + public func register(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("remote notification registration") } + public func readToken(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("remote notification token read") } + public func readSettings(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("remote notification settings") } + public func setCategories(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("remote notification categories") } + public func readResponses(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("remote notification response inbox") } +} + +public struct UnavailableSpeechClient: SpeechClient { + public init() {} + + public func transcribeFile(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("Speech file transcription") } + public func transcribeMicrophone(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("Speech microphone transcription") } +} + +public struct UnavailableAppIntentsClient: AppIntentsClient { + public init() {} + + public func listActions(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("App Intents action listing") } + public func runAction(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("App Intents action execution") } + public func donateAction(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("App Intents donation") } + public func openAction(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("App Intents open action") } + public func readHandoffs(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("App Intents handoff inbox") } +} + +public struct UnavailableFoundationModelsClient: FoundationModelsClient { + public init() {} + + public func status(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("Foundation Models status") } + public func generateText(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("Foundation Models text generation") } + public func extract(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("Foundation Models structured extraction") } +} + +public struct UnavailableActivityClient: ActivityClient { + public init() {} + + public func listActivities(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("ActivityKit activity listing") } + public func startActivity(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("ActivityKit activity start") } + public func updateActivity(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("ActivityKit activity update") } + public func endActivity(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("ActivityKit activity end") } + public func readPushToken(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("ActivityKit push token read") } +} + +public struct UnavailableMapsClient: MapsClient { + public init() {} + + public func geocode(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("MapKit geocoding") } + public func reverseGeocode(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("MapKit reverse geocoding") } + public func search(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("MapKit local search") } + public func routeEstimate(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("MapKit route estimates") } + public func open(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("Maps opening") } +} + +public struct UnavailableMusicClient: MusicClient { + public init() {} + + public func requestAuthorization(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("Music authorization") } + public func subscriptionStatus(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("Music subscription status") } + public func searchCatalog(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("Music catalog search") } + public func catalogDetails(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("Music catalog details") } + public func readLibrary(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("Music library read") } + public func writePlaylist(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("Music playlist write") } + public func controlPlayback(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("Music playback control") } +} + +public struct UnavailablePassKitClient: PassKitClient { + public init() {} + + public func walletStatus(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("PassKit Wallet status") } + public func listPasses(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("PassKit pass listing") } + public func addPass(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("PassKit pass add") } + public func presentPass(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("PassKit pass presentation") } + public func applePayStatus(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("Apple Pay status") } + public func presentApplePay(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("Apple Pay presentation") } +} + +public struct UnavailableStoreKitClient: StoreKitClient { + public init() {} + + public func products(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("StoreKit product lookup") } + public func currentEntitlements(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("StoreKit current entitlements") } + public func purchase(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("StoreKit purchase") } + public func restore(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("StoreKit restore") } + public func transactionUpdates(arguments: [String: JSONValue]) throws -> JSONValue { try unavailable("StoreKit transaction inbox") } +} + +private func unavailable(_ feature: String) throws -> JSONValue { + throw BridgeError.unsupportedPlatform("\(feature); configure a host client on CodeModeConfiguration") +} diff --git a/Sources/CodeMode/API/BridgeErrors.swift b/Sources/CodeMode/API/BridgeErrors.swift index dbcd0c6..3fe881f 100644 --- a/Sources/CodeMode/API/BridgeErrors.swift +++ b/Sources/CodeMode/API/BridgeErrors.swift @@ -4,8 +4,10 @@ enum BridgeError: Error, Sendable { case invalidRequest(String) case invalidArguments(String) case capabilityDenied(CapabilityID) + case capabilityKeyDenied(CodeModeCapabilityKey) case capabilityNotFound(String) case permissionDenied(PermissionKind) + case customPermissionDenied(String) case unsupportedPlatform(String) case uiPresenterUnavailable case timeout(milliseconds: Int) @@ -24,10 +26,14 @@ extension BridgeError: LocalizedError { return message case let .capabilityDenied(capability): return "Capability denied: \(capability.rawValue)" + case let .capabilityKeyDenied(capabilityKey): + return "Capability denied: \(capabilityKey.rawValue)" case let .capabilityNotFound(name): return "Capability not found: \(name)" case let .permissionDenied(permission): return "Permission denied: \(permission.rawValue)" + case let .customPermissionDenied(message): + return message case let .unsupportedPlatform(feature): return "Unsupported platform for \(feature)" case .uiPresenterUnavailable: @@ -51,11 +57,11 @@ extension BridgeError: LocalizedError { return "INVALID_REQUEST" case .invalidArguments: return "INVALID_ARGUMENTS" - case .capabilityDenied: + case .capabilityDenied, .capabilityKeyDenied: return "CAPABILITY_DENIED" case .capabilityNotFound: return "CAPABILITY_NOT_FOUND" - case .permissionDenied: + case .permissionDenied, .customPermissionDenied: return "PERMISSION_DENIED" case .unsupportedPlatform: return "UNSUPPORTED_PLATFORM" diff --git a/Sources/CodeMode/API/BridgeModels.swift b/Sources/CodeMode/API/BridgeModels.swift index 6482c5c..0ad3d75 100644 --- a/Sources/CodeMode/API/BridgeModels.swift +++ b/Sources/CodeMode/API/BridgeModels.swift @@ -7,6 +7,18 @@ public struct CodeModeConfiguration: Sendable { public var permissionBroker: any PermissionBroker public var auditLogger: any AuditLogger public var systemUIPresenter: any SystemUIPresenter + public var eventInbox: any CodeModeEventInbox + public var cloudKitClient: any CloudKitClient + public var remoteNotificationsClient: any RemoteNotificationsClient + public var speechClient: any SpeechClient + public var appIntentsClient: any AppIntentsClient + public var foundationModelsClient: any FoundationModelsClient + public var activityClient: any ActivityClient + public var mapsClient: any MapsClient + public var musicClient: any MusicClient + public var passKitClient: any PassKitClient + public var storeKitClient: any StoreKitClient + public var codeModeProviders: [any CodeModeProvider] public var hostPlatform: HostPlatform public init( @@ -16,6 +28,18 @@ public struct CodeModeConfiguration: Sendable { permissionBroker: any PermissionBroker = SystemPermissionBroker(), auditLogger: any AuditLogger = SyncAuditLogger(), systemUIPresenter: any SystemUIPresenter = UnavailableSystemUIPresenter(), + eventInbox: any CodeModeEventInbox = UnavailableCodeModeEventInbox(), + cloudKitClient: any CloudKitClient = UnavailableCloudKitClient(), + remoteNotificationsClient: any RemoteNotificationsClient = UnavailableRemoteNotificationsClient(), + speechClient: any SpeechClient = UnavailableSpeechClient(), + appIntentsClient: any AppIntentsClient = UnavailableAppIntentsClient(), + foundationModelsClient: any FoundationModelsClient = UnavailableFoundationModelsClient(), + activityClient: any ActivityClient = UnavailableActivityClient(), + mapsClient: any MapsClient = UnavailableMapsClient(), + musicClient: any MusicClient = UnavailableMusicClient(), + passKitClient: any PassKitClient = UnavailablePassKitClient(), + storeKitClient: any StoreKitClient = UnavailableStoreKitClient(), + codeModeProviders: [any CodeModeProvider] = [], hostPlatform: HostPlatform = .current ) { self.pathPolicy = pathPolicy @@ -24,6 +48,18 @@ public struct CodeModeConfiguration: Sendable { self.permissionBroker = permissionBroker self.auditLogger = auditLogger self.systemUIPresenter = systemUIPresenter + self.eventInbox = eventInbox + self.cloudKitClient = cloudKitClient + self.remoteNotificationsClient = remoteNotificationsClient + self.speechClient = speechClient + self.appIntentsClient = appIntentsClient + self.foundationModelsClient = foundationModelsClient + self.activityClient = activityClient + self.mapsClient = mapsClient + self.musicClient = musicClient + self.passKitClient = passKitClient + self.storeKitClient = storeKitClient + self.codeModeProviders = codeModeProviders self.hostPlatform = hostPlatform } } @@ -37,7 +73,9 @@ public struct JavaScriptAPISearchRequest: Sendable, Codable, Equatable { } public struct JavaScriptAPIReference: Sendable, Codable, Equatable { - public var capability: CapabilityID + public var capability: String + public var capabilityKey: CodeModeCapabilityKey + public var builtInCapability: CapabilityID? public var jsNames: [String] public var summary: String public var tags: [String] @@ -46,10 +84,13 @@ public struct JavaScriptAPIReference: Sendable, Codable, Equatable { public var optionalArguments: [String] public var argumentTypes: [String: CapabilityArgumentType] public var argumentHints: [String: String] + public var argumentConstraints: CapabilityArgumentConstraints public var resultSummary: String public init( - capability: CapabilityID, + capability: String, + capabilityKey: CodeModeCapabilityKey? = nil, + builtInCapability: CapabilityID? = nil, jsNames: [String], summary: String, tags: [String], @@ -58,9 +99,12 @@ public struct JavaScriptAPIReference: Sendable, Codable, Equatable { optionalArguments: [String], argumentTypes: [String: CapabilityArgumentType], argumentHints: [String: String], + argumentConstraints: CapabilityArgumentConstraints = .none, resultSummary: String ) { self.capability = capability + self.capabilityKey = capabilityKey ?? CodeModeCapabilityKey(rawValue: capability) + self.builtInCapability = builtInCapability self.jsNames = jsNames self.summary = summary self.tags = tags @@ -69,6 +113,7 @@ public struct JavaScriptAPIReference: Sendable, Codable, Equatable { self.optionalArguments = optionalArguments self.argumentTypes = argumentTypes self.argumentHints = argumentHints + self.argumentConstraints = argumentConstraints self.resultSummary = resultSummary } } @@ -86,17 +131,20 @@ public struct JavaScriptAPISearchResponse: Sendable, Codable, Equatable { public struct JavaScriptExecutionRequest: Sendable, Codable, Equatable { public var code: String public var allowedCapabilities: [CapabilityID] + public var allowedCapabilityKeys: [CodeModeCapabilityKey] public var timeoutMs: Int public var context: ExecutionContext public init( code: String, allowedCapabilities: [CapabilityID], + allowedCapabilityKeys: [CodeModeCapabilityKey] = [], timeoutMs: Int = 10_000, context: ExecutionContext = .init() ) { self.code = code self.allowedCapabilities = allowedCapabilities + self.allowedCapabilityKeys = allowedCapabilityKeys self.timeoutMs = timeoutMs self.context = context } @@ -136,6 +184,7 @@ public struct CodeModeToolError: Error, Sendable, Codable, Equatable { public var message: String public var functionName: String? public var capability: CapabilityID? + public var capabilityKey: CodeModeCapabilityKey? public var line: Int? public var column: Int? public var suggestions: [String] @@ -148,6 +197,7 @@ public struct CodeModeToolError: Error, Sendable, Codable, Equatable { message: String, functionName: String? = nil, capability: CapabilityID? = nil, + capabilityKey: CodeModeCapabilityKey? = nil, line: Int? = nil, column: Int? = nil, suggestions: [String] = [], @@ -159,6 +209,7 @@ public struct CodeModeToolError: Error, Sendable, Codable, Equatable { self.message = message self.functionName = functionName self.capability = capability + self.capabilityKey = capabilityKey ?? capability?.codeModeKey self.line = line self.column = column self.suggestions = suggestions @@ -324,10 +375,12 @@ public enum CapabilityID: String, Sendable, Codable, CaseIterable, Hashable { case calendarRead = "calendar.read" case calendarWrite = "calendar.write" + case calendarDelete = "calendar.delete" case calendarUIPresentNewEvent = "calendar.ui.presentNewEvent" case remindersRead = "reminders.read" case remindersWrite = "reminders.write" + case remindersDelete = "reminders.delete" case contactsRead = "contacts.read" case contactsSearch = "contacts.search" @@ -344,6 +397,13 @@ public enum CapabilityID: String, Sendable, Codable, CaseIterable, Hashable { case notificationsSchedule = "notifications.schedule" case notificationsPendingRead = "notifications.pending.read" case notificationsPendingDelete = "notifications.pending.delete" + case notificationsDeliveredRead = "notifications.delivered.read" + case notificationsDeliveredDelete = "notifications.delivered.delete" + case notificationsRemoteRegister = "notifications.remote.register" + case notificationsRemoteTokenRead = "notifications.remote.token.read" + case notificationsSettingsRead = "notifications.settings.read" + case notificationsCategoriesSet = "notifications.categories.set" + case notificationsResponsesRead = "notifications.responses.read" case alarmPermissionRequest = "alarm.permission.request" case alarmRead = "alarm.read" @@ -361,6 +421,61 @@ public enum CapabilityID: String, Sendable, Codable, CaseIterable, Hashable { case mediaFrameExtract = "media.frame.extract" case mediaTranscode = "media.transcode" + case cloudKitAccountStatus = "cloudkit.account.status" + case cloudKitRecordsQuery = "cloudkit.records.query" + case cloudKitRecordSave = "cloudkit.record.save" + case cloudKitRecordDelete = "cloudkit.record.delete" + case cloudKitSubscriptionSave = "cloudkit.subscription.save" + case cloudKitSubscriptionEventsRead = "cloudkit.subscriptionEvents.read" + + case speechPermissionRequest = "speech.permission.request" + case speechStatus = "speech.status" + case speechFileTranscribe = "speech.file.transcribe" + case speechMicrophoneTranscribe = "speech.microphone.transcribe" + + case appIntentsList = "appintents.list" + case appIntentsRun = "appintents.run" + case appIntentsDonate = "appintents.donate" + case appIntentsOpen = "appintents.open" + case appIntentsHandoffsRead = "appintents.handoffs.read" + + case foundationModelsStatus = "foundationModels.status" + case foundationModelsGenerate = "foundationModels.generate" + case foundationModelsExtract = "foundationModels.extract" + + case activityList = "activity.list" + case activityStart = "activity.start" + case activityUpdate = "activity.update" + case activityEnd = "activity.end" + case activityPushTokenRead = "activity.pushToken.read" + + case mapsGeocode = "maps.geocode" + case mapsReverseGeocode = "maps.reverseGeocode" + case mapsSearch = "maps.search" + case mapsRouteEstimate = "maps.route.estimate" + case mapsOpen = "maps.open" + + case musicPermissionRequest = "music.permission.request" + case musicSubscriptionStatus = "music.subscription.status" + case musicCatalogSearch = "music.catalog.search" + case musicCatalogDetails = "music.catalog.details" + case musicLibraryRead = "music.library.read" + case musicPlaylistWrite = "music.playlist.write" + case musicPlaybackControl = "music.playback.control" + + case passKitWalletStatus = "passkit.wallet.status" + case passKitPassesRead = "passkit.passes.read" + case passKitPassAdd = "passkit.pass.add" + case passKitPassPresent = "passkit.pass.present" + case passKitApplePayStatus = "passkit.applePay.status" + case passKitApplePayPresent = "passkit.applePay.present" + + case storeKitProductsRead = "storekit.products.read" + case storeKitEntitlementsRead = "storekit.entitlements.read" + case storeKitPurchase = "storekit.purchase" + case storeKitRestore = "storekit.restore" + case storeKitTransactionsRead = "storekit.transactions.read" + case fsList = "fs.list" case fsRead = "fs.read" case fsWrite = "fs.write" diff --git a/Sources/CodeMode/API/CodeModeProvider.swift b/Sources/CodeMode/API/CodeModeProvider.swift new file mode 100644 index 0000000..ec1f494 --- /dev/null +++ b/Sources/CodeMode/API/CodeModeProvider.swift @@ -0,0 +1,391 @@ +import Foundation + +public struct CodeModeCapabilityKey: RawRepresentable, Sendable, Codable, Hashable, ExpressibleByStringLiteral, CustomStringConvertible { + public var rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + self.rawValue = value + } + + public var description: String { + rawValue + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.rawValue = try container.decode(String.self) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +public extension CapabilityID { + var codeModeKey: CodeModeCapabilityKey { + CodeModeCapabilityKey(rawValue: rawValue) + } +} + +public protocol CodeModeProvider: Sendable { + var codeModePath: String { get } + func codeModeRegistrations() -> [CodeModeRegistration] +} + +public struct CodeModeRegistration: Sendable { + public var capabilityKey: CodeModeCapabilityKey + public var jsPath: String + public var title: String + public var summary: String + public var tags: [String] + public var example: String + public var requiredArguments: [String] + public var optionalArguments: [String] + public var argumentTypes: [String: CapabilityArgumentType] + public var argumentHints: [String: String] + public var argumentConstraints: CapabilityArgumentConstraints + public var resultSummary: String + public var handler: CapabilityHandler + + public init( + capabilityKey: CodeModeCapabilityKey, + jsPath: String, + title: String, + summary: String, + tags: [String] = [], + example: String, + requiredArguments: [String] = [], + optionalArguments: [String] = [], + argumentTypes: [String: CapabilityArgumentType] = [:], + argumentHints: [String: String] = [:], + argumentConstraints: CapabilityArgumentConstraints = .none, + resultSummary: String = "JSON value", + handler: @escaping CapabilityHandler + ) { + self.capabilityKey = capabilityKey + self.jsPath = jsPath + self.title = title + self.summary = summary + self.tags = tags + self.example = example + self.requiredArguments = requiredArguments + self.optionalArguments = optionalArguments + self.argumentTypes = argumentTypes.isEmpty ? Self.inferArgumentTypes(required: requiredArguments, optional: optionalArguments) : argumentTypes + self.argumentHints = argumentHints + self.argumentConstraints = argumentConstraints + self.resultSummary = resultSummary + self.handler = handler + } + + private static func inferArgumentTypes(required: [String], optional: [String]) -> [String: CapabilityArgumentType] { + Dictionary(uniqueKeysWithValues: Array(Set(required + optional)).map { ($0, .any) }) + } +} + +public enum CodeModeFunctionError: Error, Sendable, Equatable { + case invalidArguments(String) + case unsupportedPlatform(String) + case permissionDenied(String) + case nativeFailure(String) +} + +public enum CodeModeArgumentDecoder { + public static func require(_ name: String, as type: T.Type, in arguments: [String: JSONValue]) throws -> T { + guard let value = arguments[name] else { + throw CodeModeFunctionError.invalidArguments("Missing required argument: \(name)") + } + return try T.decodeCodeModeJSON(value, argumentName: name) + } + + public static func optional(_ name: String, as type: T.Type, in arguments: [String: JSONValue]) throws -> T? { + guard let value = arguments[name], value != .null else { + return nil + } + return try T.decodeCodeModeJSON(value, argumentName: name) + } + + public static func requireString(_ name: String, in arguments: [String: JSONValue]) throws -> String { + guard let value = arguments[name] else { + throw CodeModeFunctionError.invalidArguments("Missing required argument: \(name)") + } + guard let string = value.stringValue else { + throw CodeModeFunctionError.invalidArguments("Expected '\(name)' as string") + } + return string + } + + public static func optionalString(_ name: String, in arguments: [String: JSONValue]) throws -> String? { + guard let value = arguments[name], value != .null else { + return nil + } + guard let string = value.stringValue else { + throw CodeModeFunctionError.invalidArguments("Expected '\(name)' as string") + } + return string + } + + public static func requireBool(_ name: String, in arguments: [String: JSONValue]) throws -> Bool { + guard let value = arguments[name] else { + throw CodeModeFunctionError.invalidArguments("Missing required argument: \(name)") + } + guard let bool = value.boolValue else { + throw CodeModeFunctionError.invalidArguments("Expected '\(name)' as bool") + } + return bool + } + + public static func optionalBool(_ name: String, in arguments: [String: JSONValue]) throws -> Bool? { + guard let value = arguments[name], value != .null else { + return nil + } + guard let bool = value.boolValue else { + throw CodeModeFunctionError.invalidArguments("Expected '\(name)' as bool") + } + return bool + } + + public static func requireInt(_ name: String, in arguments: [String: JSONValue]) throws -> Int { + guard let value = arguments[name] else { + throw CodeModeFunctionError.invalidArguments("Missing required argument: \(name)") + } + guard let int = value.intValue else { + throw CodeModeFunctionError.invalidArguments("Expected '\(name)' as number") + } + return int + } + + public static func optionalInt(_ name: String, in arguments: [String: JSONValue]) throws -> Int? { + guard let value = arguments[name], value != .null else { + return nil + } + guard let int = value.intValue else { + throw CodeModeFunctionError.invalidArguments("Expected '\(name)' as number") + } + return int + } + + public static func requireDouble(_ name: String, in arguments: [String: JSONValue]) throws -> Double { + guard let value = arguments[name] else { + throw CodeModeFunctionError.invalidArguments("Missing required argument: \(name)") + } + guard let double = value.doubleValue else { + throw CodeModeFunctionError.invalidArguments("Expected '\(name)' as number") + } + return double + } + + public static func optionalDouble(_ name: String, in arguments: [String: JSONValue]) throws -> Double? { + guard let value = arguments[name], value != .null else { + return nil + } + guard let double = value.doubleValue else { + throw CodeModeFunctionError.invalidArguments("Expected '\(name)' as number") + } + return double + } + + public static func requireFloat(_ name: String, in arguments: [String: JSONValue]) throws -> Float { + Float(try requireDouble(name, in: arguments)) + } + + public static func optionalFloat(_ name: String, in arguments: [String: JSONValue]) throws -> Float? { + try optionalDouble(name, in: arguments).map(Float.init) + } + + public static func requireJSONValue(_ name: String, in arguments: [String: JSONValue]) throws -> JSONValue { + guard let value = arguments[name] else { + throw CodeModeFunctionError.invalidArguments("Missing required argument: \(name)") + } + return value + } + + public static func optionalJSONValue(_ name: String, in arguments: [String: JSONValue]) throws -> JSONValue? { + guard let value = arguments[name], value != .null else { + return nil + } + return value + } +} + +public enum CodeModeValueEncoder { + public static func encode(_ value: Void) -> JSONValue { + .null + } + + public static func encode(_ value: JSONValue) -> JSONValue { + value + } + + public static func encode(_ value: String) -> JSONValue { + .string(value) + } + + public static func encode(_ value: Bool) -> JSONValue { + .bool(value) + } + + public static func encode(_ value: Int) -> JSONValue { + .number(Double(value)) + } + + public static func encode(_ value: Double) -> JSONValue { + .number(value) + } + + public static func encode(_ value: Float) -> JSONValue { + .number(Double(value)) + } + + public static func encode(_ value: T) -> JSONValue where T: CodeModeJSONEncodable { + value.codeModeJSONValue + } + + public static func encode(_ value: T?) -> JSONValue where T: CodeModeJSONEncodable { + value.map { $0.codeModeJSONValue } ?? .null + } +} + +public protocol CodeModeJSONDecodable { + static func decodeCodeModeJSON(_ value: JSONValue, argumentName: String) throws -> Self +} + +public protocol CodeModeJSONEncodable { + var codeModeJSONValue: JSONValue { get } +} + +extension JSONValue: CodeModeJSONEncodable { + public var codeModeJSONValue: JSONValue { self } +} + +extension JSONValue: CodeModeJSONDecodable { + public static func decodeCodeModeJSON(_ value: JSONValue, argumentName: String) throws -> JSONValue { + value + } +} + +extension String: CodeModeJSONEncodable { + public var codeModeJSONValue: JSONValue { .string(self) } +} + +extension String: CodeModeJSONDecodable { + public static func decodeCodeModeJSON(_ value: JSONValue, argumentName: String) throws -> String { + guard let string = value.stringValue else { + throw CodeModeFunctionError.invalidArguments("Expected '\(argumentName)' as string") + } + return string + } +} + +extension Bool: CodeModeJSONEncodable { + public var codeModeJSONValue: JSONValue { .bool(self) } +} + +extension Bool: CodeModeJSONDecodable { + public static func decodeCodeModeJSON(_ value: JSONValue, argumentName: String) throws -> Bool { + guard let bool = value.boolValue else { + throw CodeModeFunctionError.invalidArguments("Expected '\(argumentName)' as bool") + } + return bool + } +} + +extension Int: CodeModeJSONEncodable { + public var codeModeJSONValue: JSONValue { .number(Double(self)) } +} + +extension Int: CodeModeJSONDecodable { + public static func decodeCodeModeJSON(_ value: JSONValue, argumentName: String) throws -> Int { + guard let int = value.intValue else { + throw CodeModeFunctionError.invalidArguments("Expected '\(argumentName)' as number") + } + return int + } +} + +extension Double: CodeModeJSONEncodable { + public var codeModeJSONValue: JSONValue { .number(self) } +} + +extension Double: CodeModeJSONDecodable { + public static func decodeCodeModeJSON(_ value: JSONValue, argumentName: String) throws -> Double { + guard let double = value.doubleValue else { + throw CodeModeFunctionError.invalidArguments("Expected '\(argumentName)' as number") + } + return double + } +} + +extension Float: CodeModeJSONEncodable { + public var codeModeJSONValue: JSONValue { .number(Double(self)) } +} + +extension Float: CodeModeJSONDecodable { + public static func decodeCodeModeJSON(_ value: JSONValue, argumentName: String) throws -> Float { + Float(try Double.decodeCodeModeJSON(value, argumentName: argumentName)) + } +} + +extension Array: CodeModeJSONEncodable where Element: CodeModeJSONEncodable { + public var codeModeJSONValue: JSONValue { + .array(map(\.codeModeJSONValue)) + } +} + +extension Array: CodeModeJSONDecodable where Element: CodeModeJSONDecodable { + public static func decodeCodeModeJSON(_ value: JSONValue, argumentName: String) throws -> [Element] { + guard let array = value.arrayValue else { + throw CodeModeFunctionError.invalidArguments("Expected '\(argumentName)' as array") + } + return try array.enumerated().map { index, value in + try Element.decodeCodeModeJSON(value, argumentName: "\(argumentName)[\(index)]") + } + } +} + +extension Dictionary: CodeModeJSONEncodable where Key == String, Value: CodeModeJSONEncodable { + public var codeModeJSONValue: JSONValue { + .object(mapValues(\.codeModeJSONValue)) + } +} + +extension Dictionary: CodeModeJSONDecodable where Key == String, Value: CodeModeJSONEncodable & CodeModeJSONDecodable { + public static func decodeCodeModeJSON(_ value: JSONValue, argumentName: String) throws -> [String: Value] { + guard let object = value.objectValue else { + throw CodeModeFunctionError.invalidArguments("Expected '\(argumentName)' as object") + } + return try object.reduce(into: [String: Value]()) { partial, pair in + partial[pair.key] = try Value.decodeCodeModeJSON(pair.value, argumentName: "\(argumentName).\(pair.key)") + } + } +} + +public enum CodeModeAsyncBridge { + public static func run( + timeoutMs: Int = 30_000, + operation: @escaping @Sendable () async throws -> JSONValue + ) throws -> JSONValue { + let semaphore = DispatchSemaphore(value: 0) + let result = LockedBox?>(nil) + let task = Task { + do { + result.set(.success(try await operation())) + } catch { + result.set(.failure(error)) + } + semaphore.signal() + } + + let deadline = DispatchTime.now() + .milliseconds(timeoutMs) + if semaphore.wait(timeout: deadline) == .timedOut { + task.cancel() + throw CodeModeFunctionError.nativeFailure("CodeMode function timed out after \(timeoutMs)ms") + } + + return try result.get()!.get() + } +} diff --git a/Sources/CodeMode/API/SystemAppleServiceClients.swift b/Sources/CodeMode/API/SystemAppleServiceClients.swift new file mode 100644 index 0000000..54f744a --- /dev/null +++ b/Sources/CodeMode/API/SystemAppleServiceClients.swift @@ -0,0 +1,831 @@ +import Foundation + +#if canImport(AppKit) +@preconcurrency import AppKit +#endif +#if canImport(CloudKit) +@preconcurrency import CloudKit +#endif +#if canImport(CoreLocation) +@preconcurrency import CoreLocation +#endif +#if canImport(MapKit) +@preconcurrency import MapKit +#endif +#if canImport(UIKit) +@preconcurrency import UIKit +#endif + +public struct SystemCloudKitClient: CloudKitClient { + public var defaultContainerIdentifier: String? + public var timeoutMs: Int + + public init(defaultContainerIdentifier: String? = nil, timeoutMs: Int = 30_000) { + self.defaultContainerIdentifier = defaultContainerIdentifier + self.timeoutMs = timeoutMs + } + + public func accountStatus(arguments: [String: JSONValue]) throws -> JSONValue { + #if canImport(CloudKit) + let status: CKAccountStatus = try waitForSystemClient(timeoutMs: timeoutMs, feature: "CloudKit account status") { completion in + SystemCloudKitMapping.container(arguments: arguments, defaultIdentifier: defaultContainerIdentifier).accountStatus { status, error in + if let error { + completion.complete(.failure(error)) + } else { + completion.complete(.success(status)) + } + } + } + return .object([ + "status": .string(status.codeModeString), + "accountAvailable": .bool(status == .available), + ]) + #else + try unsupportedSystemClient("CloudKit account status") + #endif + } + + public func queryRecords(arguments: [String: JSONValue]) throws -> JSONValue { + #if canImport(CloudKit) + let databaseName = try SystemCloudKitMapping.databaseName(arguments: arguments) + let database = try SystemCloudKitMapping.database(arguments: arguments, defaultIdentifier: defaultContainerIdentifier) + let recordType = try nonEmptySystemString("recordType", in: arguments, capability: "cloudkit.records.query") + let predicate = try SystemCloudKitMapping.predicate(arguments: arguments, capability: "cloudkit.records.query") + let limit = arguments.int("limit") ?? 100 + let query = CKQuery(recordType: recordType, predicate: predicate) + let records: [CKRecord] = try waitForSystemClient(timeoutMs: timeoutMs, feature: "CloudKit record query") { completion in + let operation = CKQueryOperation(query: query) + operation.resultsLimit = limit + let matchedRecords = SynchronizedBox<[CKRecord]>([]) + let matchedError = LockedBox(nil) + operation.recordMatchedBlock = { _, result in + switch result { + case let .success(record): + matchedRecords.mutate { $0.append(record) } + case let .failure(error): + matchedError.set(error) + } + } + operation.queryResultBlock = { result in + switch result { + case .success: + if let error = matchedError.get() { + completion.complete(.failure(error)) + } else { + completion.complete(.success(matchedRecords.get())) + } + case let .failure(error): + completion.complete(.failure(error)) + } + } + database.add(operation) + } + return .object([ + "database": .string(databaseName), + "recordType": .string(recordType), + "records": .array(records.map(SystemCloudKitMapping.recordJSON)), + ]) + #else + try unsupportedSystemClient("CloudKit record query") + #endif + } + + public func saveRecord(arguments: [String: JSONValue]) throws -> JSONValue { + #if canImport(CloudKit) + let databaseName = try SystemCloudKitMapping.databaseName(arguments: arguments) + let database = try SystemCloudKitMapping.database(arguments: arguments, defaultIdentifier: defaultContainerIdentifier) + let recordType = try nonEmptySystemString("recordType", in: arguments, capability: "cloudkit.record.save") + let fields = try SystemCloudKitMapping.recordFields(arguments: arguments, capability: "cloudkit.record.save") + let recordID = arguments.string("recordName") + .flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty } + .map { CKRecord.ID(recordName: $0) } + let record = recordID.map { CKRecord(recordType: recordType, recordID: $0) } ?? CKRecord(recordType: recordType) + for (field, value) in fields { + record[field] = try SystemCloudKitMapping.recordValue(value, field: field) + } + let saved: CKRecord = try waitForSystemClient(timeoutMs: timeoutMs, feature: "CloudKit record save") { completion in + database.save(record) { record, error in + if let error { + completion.complete(.failure(error)) + } else if let record { + completion.complete(.success(record)) + } else { + completion.complete(.failure(BridgeError.nativeFailure("CloudKit save returned no record"))) + } + } + } + return .object([ + "database": .string(databaseName), + "record": SystemCloudKitMapping.recordJSON(saved), + "saved": .bool(true), + ]) + #else + try unsupportedSystemClient("CloudKit record save") + #endif + } + + public func deleteRecord(arguments: [String: JSONValue]) throws -> JSONValue { + #if canImport(CloudKit) + let databaseName = try SystemCloudKitMapping.databaseName(arguments: arguments) + let database = try SystemCloudKitMapping.database(arguments: arguments, defaultIdentifier: defaultContainerIdentifier) + let recordName = try nonEmptySystemString("recordName", in: arguments, capability: "cloudkit.record.delete") + let deletedID: CKRecord.ID = try waitForSystemClient(timeoutMs: timeoutMs, feature: "CloudKit record delete") { completion in + database.delete(withRecordID: CKRecord.ID(recordName: recordName)) { recordID, error in + if let error { + completion.complete(.failure(error)) + } else if let recordID { + completion.complete(.success(recordID)) + } else { + completion.complete(.failure(BridgeError.nativeFailure("CloudKit delete returned no record ID"))) + } + } + } + return .object([ + "database": .string(databaseName), + "recordName": .string(deletedID.recordName), + "deleted": .bool(true), + ]) + #else + try unsupportedSystemClient("CloudKit record delete") + #endif + } + + public func saveSubscription(arguments: [String: JSONValue]) throws -> JSONValue { + #if canImport(CloudKit) + let databaseName = try SystemCloudKitMapping.databaseName(arguments: arguments) + let database = try SystemCloudKitMapping.database(arguments: arguments, defaultIdentifier: defaultContainerIdentifier) + let subscriptionID = try nonEmptySystemString("subscriptionID", in: arguments, capability: "cloudkit.subscription.save") + let recordType = try nonEmptySystemString("recordType", in: arguments, capability: "cloudkit.subscription.save") + let predicate = try SystemCloudKitMapping.predicate(arguments: arguments, capability: "cloudkit.subscription.save") + let subscription = CKQuerySubscription( + recordType: recordType, + predicate: predicate, + subscriptionID: subscriptionID, + options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion] + ) + let notificationInfo = CKSubscription.NotificationInfo() + notificationInfo.shouldSendContentAvailable = true + subscription.notificationInfo = notificationInfo + + let saved: CKSubscription = try waitForSystemClient(timeoutMs: timeoutMs, feature: "CloudKit subscription save") { completion in + database.save(subscription) { subscription, error in + if let error { + completion.complete(.failure(error)) + } else if let subscription { + completion.complete(.success(subscription)) + } else { + completion.complete(.failure(BridgeError.nativeFailure("CloudKit save returned no subscription"))) + } + } + } + return .object([ + "database": .string(databaseName), + "subscriptionID": .string(saved.subscriptionID), + "recordType": .string(recordType), + "saved": .bool(true), + ]) + #else + try unsupportedSystemClient("CloudKit subscription save") + #endif + } + + public func readSubscriptionEvents(arguments: [String: JSONValue]) throws -> JSONValue { + try unsupportedSystemClient("CloudKit subscription event inbox; configure CodeModeConfiguration.eventInbox") + } +} + +public struct SystemMapsClient: MapsClient { + public var timeoutMs: Int + + public init(timeoutMs: Int = 30_000) { + self.timeoutMs = timeoutMs + } + + public func geocode(arguments: [String: JSONValue]) throws -> JSONValue { + #if canImport(CoreLocation) + let address = try nonEmptySystemString("address", in: arguments, capability: "maps.geocode") + let limit = arguments.int("limit") ?? 10 + let geocoder = CLGeocoder() + let placemarks: [CLPlacemark] = try waitForSystemClient(timeoutMs: timeoutMs, feature: "MapKit geocoding") { completion in + geocoder.geocodeAddressString(address, in: SystemMapsMapping.coreLocationRegion(arguments.object("region"))) { placemarks, error in + if let error { + completion.complete(.failure(error)) + } else { + completion.complete(.success(placemarks ?? [])) + } + } + } + return .array(placemarks.prefix(limit).map(SystemMapsMapping.placemarkJSON)) + #else + try unsupportedSystemClient("MapKit geocoding") + #endif + } + + public func reverseGeocode(arguments: [String: JSONValue]) throws -> JSONValue { + #if canImport(CoreLocation) + let coordinate = try SystemMapsMapping.coordinate(arguments: arguments, name: "coordinate", capability: "maps.reverseGeocode") + let location = CLLocation( + latitude: coordinate.latitude, + longitude: coordinate.longitude + ) + let geocoder = CLGeocoder() + let placemarks: [CLPlacemark] = try waitForSystemClient(timeoutMs: timeoutMs, feature: "MapKit reverse geocoding") { completion in + geocoder.reverseGeocodeLocation(location) { placemarks, error in + if let error { + completion.complete(.failure(error)) + } else { + completion.complete(.success(placemarks ?? [])) + } + } + } + return .array(placemarks.map(SystemMapsMapping.placemarkJSON)) + #else + try unsupportedSystemClient("MapKit reverse geocoding") + #endif + } + + public func search(arguments: [String: JSONValue]) throws -> JSONValue { + #if canImport(MapKit) + let query = try nonEmptySystemString("query", in: arguments, capability: "maps.search") + let limit = arguments.int("limit") ?? 10 + let request = MKLocalSearch.Request() + request.naturalLanguageQuery = query + if let region = SystemMapsMapping.mapKitRegion(arguments.object("region")) { + request.region = region + } + let response: MKLocalSearch.Response = try waitForSystemClient(timeoutMs: timeoutMs, feature: "MapKit local search") { completion in + MKLocalSearch(request: request).start { response, error in + if let error { + completion.complete(.failure(error)) + } else if let response { + completion.complete(.success(response)) + } else { + completion.complete(.failure(BridgeError.nativeFailure("MapKit search returned no response"))) + } + } + } + return .object([ + "query": .string(query), + "results": .array(response.mapItems.prefix(limit).map(SystemMapsMapping.mapItemJSON)), + ]) + #else + try unsupportedSystemClient("MapKit local search") + #endif + } + + public func routeEstimate(arguments: [String: JSONValue]) throws -> JSONValue { + #if canImport(MapKit) + guard let origin = arguments.object("origin"), let destination = arguments.object("destination") else { + throw BridgeError.invalidArguments("maps.route.estimate requires origin and destination") + } + _ = try SystemMapsMapping.coordinate(arguments: origin, name: "origin", capability: "maps.route.estimate") + _ = try SystemMapsMapping.coordinate(arguments: destination, name: "destination", capability: "maps.route.estimate") + let transportType = try SystemMapsMapping.transportTypeName(arguments: arguments, defaultValue: "automobile") + let request = MKDirections.Request() + request.source = SystemMapsMapping.mapItem(coordinate: origin) + request.destination = SystemMapsMapping.mapItem(coordinate: destination) + request.transportType = SystemMapsMapping.mapKitTransportType(transportType) + let response: MKDirections.ETAResponse = try waitForSystemClient(timeoutMs: timeoutMs, feature: "MapKit route estimate") { completion in + MKDirections(request: request).calculateETA { response, error in + if let error { + completion.complete(.failure(error)) + } else if let response { + completion.complete(.success(response)) + } else { + completion.complete(.failure(BridgeError.nativeFailure("MapKit route estimate returned no response"))) + } + } + } + return .object([ + "distanceMeters": .number(response.distance), + "expectedTravelTimeSeconds": .number(response.expectedTravelTime), + "transportType": .string(transportType), + ]) + #else + try unsupportedSystemClient("MapKit route estimate") + #endif + } + + public func open(arguments: [String: JSONValue]) throws -> JSONValue { + #if canImport(MapKit) + if let urlString = arguments.string("url") { + guard let url = URL(string: urlString) else { + throw BridgeError.invalidArguments("maps.open url must be valid") + } + return .object(["opened": .bool(openSystemURL(url)), "url": .string(urlString)]) + } + + if let query = arguments.string("query"), query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + let url = try SystemMapsMapping.appleMapsQueryURL(query: query) + return .object(["opened": .bool(openSystemURL(url)), "query": .string(query)]) + } + + let coordinateArguments = arguments.object("destination") ?? arguments + let coordinate = try SystemMapsMapping.coordinate(arguments: coordinateArguments, name: "destination", capability: "maps.open") + let item = SystemMapsMapping.mapItem(coordinate: coordinateArguments) + let launchOptions = try SystemMapsMapping.mapsLaunchOptions(arguments) + return .object([ + "opened": .bool(item.openInMaps(launchOptions: launchOptions)), + "latitude": .number(coordinate.latitude), + "longitude": .number(coordinate.longitude), + ]) + #else + try unsupportedSystemClient("Maps opening") + #endif + } +} + +private func waitForSystemClient( + timeoutMs: Int, + feature: String, + _ start: (SystemClientCallback) -> Void +) throws -> T { + guard timeoutMs > 0 else { + throw BridgeError.invalidArguments("\(feature) timeoutMs must be greater than 0") + } + let callback = SystemClientCallback() + start(callback) + return try callback.wait(timeoutMs: timeoutMs, feature: feature) +} + +private final class SystemClientCallback: @unchecked Sendable { + private let semaphore = DispatchSemaphore(value: 0) + private let result = LockedBox?>(nil) + + func complete(_ value: Result) { + result.set(value) + semaphore.signal() + } + + func wait(timeoutMs: Int, feature: String) throws -> T { + if semaphore.wait(timeout: .now() + .milliseconds(timeoutMs)) == .timedOut { + throw BridgeError.timeout(milliseconds: timeoutMs) + } + switch result.get() { + case let .success(value): + return value + case let .failure(error): + throw BridgeError.nativeFailure("\(feature) failed: \(error.localizedDescription)") + case .none: + throw BridgeError.nativeFailure("\(feature) finished without result") + } + } +} + +private func nonEmptySystemString(_ name: String, in arguments: [String: JSONValue], capability: String) throws -> String { + guard let value = arguments.string(name)?.trimmingCharacters(in: .whitespacesAndNewlines), value.isEmpty == false else { + throw BridgeError.invalidArguments("\(capability) requires \(name)") + } + return value +} + +private func unsupportedSystemClient(_ feature: String) throws -> JSONValue { + throw BridgeError.unsupportedPlatform(feature) +} + +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self + } +} + +enum SystemCloudKitMapping { + static let allowedDatabases = ["private", "shared", "public"] + + static func databaseName(arguments: [String: JSONValue]) throws -> String { + let database = arguments.string("database") ?? "private" + guard allowedDatabases.contains(database) else { + throw BridgeError.invalidArguments("CloudKit database must be one of \(allowedDatabases.joined(separator: ", "))") + } + return database + } + + static func recordFields(arguments: [String: JSONValue], capability: String) throws -> [String: JSONValue] { + guard let fields = arguments.object("fields"), fields.isEmpty == false else { + throw BridgeError.invalidArguments("\(capability) requires non-empty fields") + } + for (field, value) in fields { + guard isRecordFieldValue(value) else { + throw BridgeError.invalidArguments("\(capability) fields.\(field) must be a scalar or array of scalars") + } + } + return fields + } + + static func safePredicate(arguments: [String: JSONValue], capability: String) throws -> (field: String, equals: JSONValue)? { + guard let predicate = arguments["predicate"] else { + return nil + } + guard let object = predicate.objectValue else { + throw BridgeError.invalidArguments("\(capability) predicate must be an object shaped as { field, equals }; raw predicate strings are not supported") + } + guard object.keys.allSatisfy({ ["field", "equals"].contains($0) }) else { + throw BridgeError.invalidArguments("\(capability) predicate only supports field and equals") + } + let field = try nonEmptySystemString("field", in: object, capability: "\(capability) predicate") + guard let equals = object["equals"] else { + throw BridgeError.invalidArguments("\(capability) predicate requires equals") + } + guard isPredicateValue(equals) else { + throw BridgeError.invalidArguments("\(capability) predicate.equals must be a scalar value") + } + return (field, equals) + } + + private static func isRecordFieldValue(_ value: JSONValue) -> Bool { + if isScalar(value) { + return true + } + guard let array = value.arrayValue else { + return false + } + return array.allSatisfy(isNonNullScalar) + } + + private static func isPredicateValue(_ value: JSONValue) -> Bool { + switch value { + case .string, .number, .bool: + return true + case .null, .object, .array: + return false + } + } + + private static func isScalar(_ value: JSONValue) -> Bool { + switch value { + case .string, .number, .bool, .null: + return true + case .object, .array: + return false + } + } + + private static func isNonNullScalar(_ value: JSONValue) -> Bool { + switch value { + case .string, .number, .bool: + return true + case .null, .object, .array: + return false + } + } + + #if canImport(CloudKit) + static func container(arguments: [String: JSONValue], defaultIdentifier: String?) -> CKContainer { + let identifier = arguments.string("containerIdentifier")? + .trimmingCharacters(in: .whitespacesAndNewlines) + .nilIfEmpty ?? defaultIdentifier + if let identifier { + return CKContainer(identifier: identifier) + } + return CKContainer.default() + } + + static func database(arguments: [String: JSONValue], defaultIdentifier: String?) throws -> CKDatabase { + let container = container(arguments: arguments, defaultIdentifier: defaultIdentifier) + switch try databaseName(arguments: arguments) { + case "private": + return container.privateCloudDatabase + case "shared": + return container.sharedCloudDatabase + case "public": + return container.publicCloudDatabase + default: + throw BridgeError.invalidArguments("CloudKit database must be one of \(allowedDatabases.joined(separator: ", "))") + } + } + + static func predicate(arguments: [String: JSONValue], capability: String) throws -> NSPredicate { + guard let shape = try safePredicate(arguments: arguments, capability: capability) else { + return NSPredicate(value: true) + } + let value = try predicateValue(shape.equals) + return NSPredicate(format: "%K == %@", argumentArray: [shape.field, value]) + } + + static func predicateValue(_ value: JSONValue) throws -> Any { + switch value { + case let .string(value): + return value as NSString + case let .number(value): + return NSNumber(value: value) + case let .bool(value): + return NSNumber(value: value) + case .null: + throw BridgeError.invalidArguments("CloudKit predicate.equals must not be null") + case .object, .array: + throw BridgeError.invalidArguments("CloudKit predicate.equals must be a scalar value") + } + } + + static func recordValue(_ value: JSONValue, field: String) throws -> (any CKRecordValue)? { + switch value { + case let .string(value): + return value as NSString + case let .number(value): + return NSNumber(value: value) + case let .bool(value): + return NSNumber(value: value) + case .null: + return nil + case let .array(values): + let array = NSMutableArray() + for element in values { + guard let scalar = try recordScalarValue(element, field: field) else { + throw BridgeError.invalidArguments("cloudkit.record.save fields.\(field) arrays cannot contain null") + } + array.add(scalar) + } + return array + case .object: + throw BridgeError.invalidArguments("cloudkit.record.save fields.\(field) must be a scalar or array of scalars") + } + } + + static func recordScalarValue(_ value: JSONValue, field: String) throws -> (any CKRecordValue)? { + switch value { + case let .string(value): + return value as NSString + case let .number(value): + return NSNumber(value: value) + case let .bool(value): + return NSNumber(value: value) + case .null: + return nil + case .object, .array: + throw BridgeError.invalidArguments("cloudkit.record.save fields.\(field) arrays can only contain scalar values") + } + } + + static func recordJSON(_ record: CKRecord) -> JSONValue { + var fields: [String: JSONValue] = [:] + for key in record.allKeys() { + if let value = record[key] { + fields[key] = jsonValue(value) + } + } + + var object: [String: JSONValue] = [ + "recordName": .string(record.recordID.recordName), + "recordType": .string(record.recordType), + "fields": .object(fields), + ] + if let creationDate = record.creationDate { + object["creationDate"] = .string(ISO8601DateFormatter().string(from: creationDate)) + } + if let modificationDate = record.modificationDate { + object["modificationDate"] = .string(ISO8601DateFormatter().string(from: modificationDate)) + } + return .object(object) + } + + static func jsonValue(_ value: Any) -> JSONValue { + switch value { + case let value as String: + return .string(value) + case let value as NSNumber: + if CFGetTypeID(value) == CFBooleanGetTypeID() { + return .bool(value.boolValue) + } + return .number(value.doubleValue) + case let value as Date: + return .string(ISO8601DateFormatter().string(from: value)) + case let value as [Any]: + return .array(value.map(jsonValue)) + #if canImport(CoreLocation) + case let value as CLLocation: + return .object([ + "latitude": .number(value.coordinate.latitude), + "longitude": .number(value.coordinate.longitude), + ]) + #endif + default: + return .string(String(describing: value)) + } + } + #endif +} + +#if canImport(CloudKit) +private extension CKAccountStatus { + var codeModeString: String { + switch self { + case .available: + return "available" + case .noAccount: + return "noAccount" + case .restricted: + return "restricted" + case .couldNotDetermine: + return "couldNotDetermine" + case .temporarilyUnavailable: + return "temporarilyUnavailable" + @unknown default: + return "unknown" + } + } +} +#endif + +enum SystemMapsMapping { + static let allowedTransportTypes = ["automobile", "walking", "transit", "any"] + + static func coordinate(arguments: [String: JSONValue], name: String, capability: String) throws -> (latitude: Double, longitude: Double) { + guard let latitude = arguments.double("latitude") else { + throw BridgeError.invalidArguments("\(capability) \(name).latitude is required") + } + guard let longitude = arguments.double("longitude") else { + throw BridgeError.invalidArguments("\(capability) \(name).longitude is required") + } + return (latitude, longitude) + } + + static func transportTypeName(arguments: [String: JSONValue], defaultValue: String? = nil) throws -> String { + guard let value = arguments.string("transportType") ?? defaultValue else { + throw BridgeError.invalidArguments("Maps transportType is required") + } + guard allowedTransportTypes.contains(value) else { + throw BridgeError.invalidArguments("Maps transportType must be one of \(allowedTransportTypes.joined(separator: ", "))") + } + return value + } + + static func appleMapsQueryURL(query: String) throws -> URL { + var components = URLComponents(string: "https://maps.apple.com/")! + components.queryItems = [URLQueryItem(name: "q", value: query)] + guard let url = components.url else { + throw BridgeError.invalidArguments("maps.open query could not be encoded") + } + return url + } + + #if canImport(CoreLocation) + static func coreLocationRegion(_ arguments: [String: JSONValue]?) -> CLRegion? { + guard let arguments else { + return nil + } + if let latitude = arguments.double("latitude"), + let longitude = arguments.double("longitude"), + let radius = arguments.double("radiusMeters") { + return CLCircularRegion( + center: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), + radius: radius, + identifier: "codemode.search.region" + ) + } + if let center = arguments.object("center"), + let latitude = center.double("latitude"), + let longitude = center.double("longitude"), + let latitudeDelta = arguments.double("latitudeDelta"), + let longitudeDelta = arguments.double("longitudeDelta") { + let radius = max(latitudeDelta, longitudeDelta) * 111_000 / 2 + return CLCircularRegion( + center: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), + radius: radius, + identifier: "codemode.search.region" + ) + } + return nil + } + + static func placemarkJSON(_ placemark: CLPlacemark) -> JSONValue { + var object: [String: JSONValue] = [:] + if let name = placemark.name { + object["name"] = .string(name) + } + if let thoroughfare = placemark.thoroughfare { + object["thoroughfare"] = .string(thoroughfare) + } + if let locality = placemark.locality { + object["locality"] = .string(locality) + } + if let administrativeArea = placemark.administrativeArea { + object["administrativeArea"] = .string(administrativeArea) + } + if let country = placemark.country { + object["country"] = .string(country) + } + if let postalCode = placemark.postalCode { + object["postalCode"] = .string(postalCode) + } + if let location = placemark.location { + object["latitude"] = .number(location.coordinate.latitude) + object["longitude"] = .number(location.coordinate.longitude) + } + return .object(object) + } + #endif + + #if canImport(MapKit) + static func mapKitRegion(_ arguments: [String: JSONValue]?) -> MKCoordinateRegion? { + guard let arguments else { + return nil + } + if let latitude = arguments.double("latitude"), + let longitude = arguments.double("longitude"), + let radius = arguments.double("radiusMeters") { + return MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), + latitudinalMeters: radius * 2, + longitudinalMeters: radius * 2 + ) + } + if let center = arguments.object("center"), + let latitude = center.double("latitude"), + let longitude = center.double("longitude"), + let latitudeDelta = arguments.double("latitudeDelta"), + let longitudeDelta = arguments.double("longitudeDelta") { + return MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), + span: MKCoordinateSpan(latitudeDelta: latitudeDelta, longitudeDelta: longitudeDelta) + ) + } + return nil + } + + static func mapItem(coordinate arguments: [String: JSONValue]) -> MKMapItem { + let coordinate = CLLocationCoordinate2D( + latitude: arguments.double("latitude") ?? 0, + longitude: arguments.double("longitude") ?? 0 + ) + let item = MKMapItem(placemark: MKPlacemark(coordinate: coordinate)) + if let name = arguments.string("name") { + item.name = name + } + return item + } + + static func mapKitTransportType(_ value: String) -> MKDirectionsTransportType { + switch value { + case "walking": + return .walking + case "transit": + return .transit + case "any": + return .any + default: + return .automobile + } + } + + static func mapsLaunchOptions(_ arguments: [String: JSONValue]) throws -> [String: Any]? { + guard arguments.string("transportType") != nil else { + return nil + } + let transportType = try transportTypeName(arguments: arguments) + let mode: String + switch transportType { + case "walking": + mode = MKLaunchOptionsDirectionsModeWalking + case "transit": + mode = MKLaunchOptionsDirectionsModeTransit + default: + mode = MKLaunchOptionsDirectionsModeDriving + } + return [MKLaunchOptionsDirectionsModeKey: mode] + } + + static func mapItemJSON(_ item: MKMapItem) -> JSONValue { + var object: [String: JSONValue] = [:] + if let name = item.name { + object["name"] = .string(name) + } + if let phoneNumber = item.phoneNumber { + object["phoneNumber"] = .string(phoneNumber) + } + if let url = item.url?.absoluteString { + object["url"] = .string(url) + } + object["latitude"] = .number(item.placemark.coordinate.latitude) + object["longitude"] = .number(item.placemark.coordinate.longitude) + object["placemark"] = placemarkJSON(item.placemark) + return .object(object) + } + #endif +} + +#if canImport(MapKit) +private func openSystemURL(_ url: URL) -> Bool { + #if canImport(UIKit) + if Thread.isMainThread { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + return true + } + let semaphore = DispatchSemaphore(value: 0) + let opened = LockedBox(false) + DispatchQueue.main.async { + UIApplication.shared.open(url) { success in + opened.set(success) + semaphore.signal() + } + } + if semaphore.wait(timeout: .now() + .seconds(10)) == .timedOut { + return false + } + return opened.get() + #elseif canImport(AppKit) + return NSWorkspace.shared.open(url) + #else + return false + #endif +} +#endif diff --git a/Sources/CodeMode/Bridges/AlarmBridge.swift b/Sources/CodeMode/Bridges/AlarmBridge.swift index 84c8fb1..4736f96 100644 --- a/Sources/CodeMode/Bridges/AlarmBridge.swift +++ b/Sources/CodeMode/Bridges/AlarmBridge.swift @@ -9,7 +9,7 @@ import SwiftUI #endif public final class AlarmBridge: @unchecked Sendable { - private static let scheduledAlarms = LockedBox<[String: [String: JSONValue]]>([:]) + private let scheduledAlarms = LockedBox<[String: [String: JSONValue]]>([:]) public init() {} @@ -29,7 +29,7 @@ public final class AlarmBridge: @unchecked Sendable { } let limit = max(1, arguments.int("limit") ?? 50) - let alarms = Array(Self.scheduledAlarms.get().values.prefix(limit)) + let alarms = Array(scheduledAlarms.get().values.prefix(limit)) return .array(alarms.map(JSONValue.object)) } @@ -83,7 +83,7 @@ public final class AlarmBridge: @unchecked Sendable { throw BridgeError.permissionDenied(.alarmKit) } - let known = Self.scheduledAlarms.get() + let known = scheduledAlarms.get() let targets: [String] if let identifier = arguments.string("identifier"), identifier.isEmpty == false { targets = [identifier] @@ -125,17 +125,17 @@ public final class AlarmBridge: @unchecked Sendable { private func upsertScheduledAlarm(_ alarm: [String: JSONValue]) { guard let id = alarm["identifier"]?.stringValue else { return } - var current = Self.scheduledAlarms.get() + var current = scheduledAlarms.get() current[id] = alarm - Self.scheduledAlarms.set(current) + scheduledAlarms.set(current) } private func removeScheduledAlarms(ids: [String]) { - var current = Self.scheduledAlarms.get() + var current = scheduledAlarms.get() for id in ids { current.removeValue(forKey: id) } - Self.scheduledAlarms.set(current) + scheduledAlarms.set(current) } private func isoDate(_ text: String?) -> Date? { diff --git a/Sources/CodeMode/Bridges/BigTicketAppleBridges.swift b/Sources/CodeMode/Bridges/BigTicketAppleBridges.swift new file mode 100644 index 0000000..55090e8 --- /dev/null +++ b/Sources/CodeMode/Bridges/BigTicketAppleBridges.swift @@ -0,0 +1,611 @@ +import Foundation + +public final class CloudKitBridge: @unchecked Sendable { + private let client: any CloudKitClient + private let eventInbox: any CodeModeEventInbox + + public init( + client: any CloudKitClient = UnavailableCloudKitClient(), + eventInbox: any CodeModeEventInbox = UnavailableCodeModeEventInbox() + ) { + self.client = client + self.eventInbox = eventInbox + } + + public func accountStatus(arguments: [String: JSONValue]) throws -> JSONValue { + try client.accountStatus(arguments: arguments) + } + + public func queryRecords(arguments: [String: JSONValue]) throws -> JSONValue { + try requireNonEmptyString("recordType", in: arguments, capability: "cloudkit.records.query") + try validateOptionalCloudKitPredicate(arguments, capability: "cloudkit.records.query") + try validateOptionalLimit(arguments) + return try client.queryRecords(arguments: arguments) + } + + public func saveRecord(arguments: [String: JSONValue]) throws -> JSONValue { + try requireNonEmptyString("recordType", in: arguments, capability: "cloudkit.record.save") + try validateCloudKitFields(arguments, capability: "cloudkit.record.save") + return try client.saveRecord(arguments: arguments) + } + + public func deleteRecord(arguments: [String: JSONValue]) throws -> JSONValue { + try requireNonEmptyString("recordName", in: arguments, capability: "cloudkit.record.delete") + return try client.deleteRecord(arguments: arguments) + } + + public func saveSubscription(arguments: [String: JSONValue]) throws -> JSONValue { + try requireNonEmptyString("subscriptionID", in: arguments, capability: "cloudkit.subscription.save") + try requireNonEmptyString("recordType", in: arguments, capability: "cloudkit.subscription.save") + try validateOptionalCloudKitPredicate(arguments, capability: "cloudkit.subscription.save") + return try client.saveSubscription(arguments: arguments) + } + + public func readSubscriptionEvents(arguments: [String: JSONValue]) throws -> JSONValue { + try validateOptionalLimit(arguments) + if isConfigured(eventInbox) { + return try eventInbox.readEvents(source: "cloudkit.subscription", arguments: arguments) + } + return try client.readSubscriptionEvents(arguments: arguments) + } +} + +public final class RemoteNotificationsBridge: @unchecked Sendable { + private let client: any RemoteNotificationsClient + private let eventInbox: any CodeModeEventInbox + + public init( + client: any RemoteNotificationsClient = UnavailableRemoteNotificationsClient(), + eventInbox: any CodeModeEventInbox = UnavailableCodeModeEventInbox() + ) { + self.client = client + self.eventInbox = eventInbox + } + + public func register(arguments: [String: JSONValue]) throws -> JSONValue { + try client.register(arguments: arguments) + } + + public func readToken(arguments: [String: JSONValue]) throws -> JSONValue { + try client.readToken(arguments: arguments) + } + + public func readSettings(arguments: [String: JSONValue]) throws -> JSONValue { + try client.readSettings(arguments: arguments) + } + + public func setCategories(arguments: [String: JSONValue]) throws -> JSONValue { + try validateNotificationCategories(arguments) + return try client.setCategories(arguments: arguments) + } + + public func readResponses(arguments: [String: JSONValue]) throws -> JSONValue { + try validateOptionalLimit(arguments) + if isConfigured(eventInbox) { + return try eventInbox.readEvents(source: "notifications.response", arguments: arguments) + } + return try client.readResponses(arguments: arguments) + } +} + +public final class SpeechBridge: @unchecked Sendable { + private let client: any SpeechClient + + public init(client: any SpeechClient = UnavailableSpeechClient()) { + self.client = client + } + + public func requestPermission(context: BridgeInvocationContext) throws -> JSONValue { + let status = context.permissionBroker.request(for: .speechRecognition) + context.recordPermission(.speechRecognition, status: status) + return permissionPayload(status) + } + + public func status(context: BridgeInvocationContext) throws -> JSONValue { + let status = context.permissionBroker.status(for: .speechRecognition) + context.recordPermission(.speechRecognition, status: status) + return permissionPayload(status) + } + + public func transcribeFile(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try ensurePermission(.speechRecognition, context: context) + let path = try requireNonEmptyString("path", in: arguments, capability: "speech.file.transcribe") + let resolved = try context.pathPolicy.resolve(path: path) + var clientArguments = arguments + clientArguments["resolvedPath"] = .string(resolved.path) + try validateOptionalPositiveNumber("timeoutMs", in: arguments, capability: "speech.file.transcribe") + return try client.transcribeFile(arguments: clientArguments) + } + + public func transcribeMicrophone(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try ensurePermission(.speechRecognition, context: context) + try ensurePermission(.microphone, context: context) + try validateOptionalPositiveNumber("timeoutMs", in: arguments, capability: "speech.microphone.transcribe") + return try client.transcribeMicrophone(arguments: arguments) + } +} + +public final class AppIntentsBridge: @unchecked Sendable { + private let client: any AppIntentsClient + private let eventInbox: any CodeModeEventInbox + + public init( + client: any AppIntentsClient = UnavailableAppIntentsClient(), + eventInbox: any CodeModeEventInbox = UnavailableCodeModeEventInbox() + ) { + self.client = client + self.eventInbox = eventInbox + } + + public func listActions(arguments: [String: JSONValue]) throws -> JSONValue { + try client.listActions(arguments: arguments) + } + + public func runAction(arguments: [String: JSONValue]) throws -> JSONValue { + try requireNonEmptyString("identifier", in: arguments, capability: "appintents.run") + return try client.runAction(arguments: arguments) + } + + public func donateAction(arguments: [String: JSONValue]) throws -> JSONValue { + try requireNonEmptyString("identifier", in: arguments, capability: "appintents.donate") + return try client.donateAction(arguments: arguments) + } + + public func openAction(arguments: [String: JSONValue]) throws -> JSONValue { + try requireNonEmptyString("identifier", in: arguments, capability: "appintents.open") + return try client.openAction(arguments: arguments) + } + + public func readHandoffs(arguments: [String: JSONValue]) throws -> JSONValue { + try validateOptionalLimit(arguments) + if isConfigured(eventInbox) { + return try eventInbox.readEvents(source: "appintents.handoff", arguments: arguments) + } + return try client.readHandoffs(arguments: arguments) + } +} + +public final class FoundationModelsBridge: @unchecked Sendable { + private let client: any FoundationModelsClient + + public init(client: any FoundationModelsClient = UnavailableFoundationModelsClient()) { + self.client = client + } + + public func status(arguments: [String: JSONValue]) throws -> JSONValue { + try client.status(arguments: arguments) + } + + public func generateText(arguments: [String: JSONValue]) throws -> JSONValue { + try requireNonEmptyString("prompt", in: arguments, capability: "foundationModels.generate") + try validateOptionalPositiveNumber("maxTokens", in: arguments, capability: "foundationModels.generate") + return try client.generateText(arguments: arguments) + } + + public func extract(arguments: [String: JSONValue]) throws -> JSONValue { + try requireNonEmptyString("input", in: arguments, capability: "foundationModels.extract") + guard arguments.object("schema") != nil || arguments.string("schemaIdentifier") != nil else { + throw BridgeError.invalidArguments("foundationModels.extract requires schema or schemaIdentifier") + } + return try client.extract(arguments: arguments) + } +} + +public final class ActivityBridge: @unchecked Sendable { + private let client: any ActivityClient + + public init(client: any ActivityClient = UnavailableActivityClient()) { + self.client = client + } + + public func listActivities(arguments: [String: JSONValue]) throws -> JSONValue { + try client.listActivities(arguments: arguments) + } + + public func startActivity(arguments: [String: JSONValue]) throws -> JSONValue { + try requireNonEmptyString("activityType", in: arguments, capability: "activity.start") + guard arguments.object("attributes") != nil else { + throw BridgeError.invalidArguments("activity.start requires attributes") + } + return try client.startActivity(arguments: arguments) + } + + public func updateActivity(arguments: [String: JSONValue]) throws -> JSONValue { + try requireNonEmptyString("identifier", in: arguments, capability: "activity.update") + guard arguments.object("contentState") != nil else { + throw BridgeError.invalidArguments("activity.update requires contentState") + } + return try client.updateActivity(arguments: arguments) + } + + public func endActivity(arguments: [String: JSONValue]) throws -> JSONValue { + try requireNonEmptyString("identifier", in: arguments, capability: "activity.end") + return try client.endActivity(arguments: arguments) + } + + public func readPushToken(arguments: [String: JSONValue]) throws -> JSONValue { + try requireNonEmptyString("identifier", in: arguments, capability: "activity.pushToken.read") + return try client.readPushToken(arguments: arguments) + } +} + +public final class MapsBridge: @unchecked Sendable { + private let client: any MapsClient + + public init(client: any MapsClient = UnavailableMapsClient()) { + self.client = client + } + + public func geocode(arguments: [String: JSONValue]) throws -> JSONValue { + try requireNonEmptyString("address", in: arguments, capability: "maps.geocode") + try validateOptionalLimit(arguments) + try validateOptionalMapsRegion(arguments, capability: "maps.geocode") + return try client.geocode(arguments: arguments) + } + + public func reverseGeocode(arguments: [String: JSONValue]) throws -> JSONValue { + try requireCoordinateArguments(arguments, capability: "maps.reverseGeocode") + return try client.reverseGeocode(arguments: arguments) + } + + public func search(arguments: [String: JSONValue]) throws -> JSONValue { + try requireNonEmptyString("query", in: arguments, capability: "maps.search") + try validateOptionalLimit(arguments) + try validateOptionalMapsRegion(arguments, capability: "maps.search") + return try client.search(arguments: arguments) + } + + public func routeEstimate(arguments: [String: JSONValue]) throws -> JSONValue { + guard let origin = arguments.object("origin") else { + throw BridgeError.invalidArguments("maps.route.estimate requires origin") + } + guard let destination = arguments.object("destination") else { + throw BridgeError.invalidArguments("maps.route.estimate requires destination") + } + try validateCoordinateObject(origin, name: "origin", capability: "maps.route.estimate") + try validateCoordinateObject(destination, name: "destination", capability: "maps.route.estimate") + return try client.routeEstimate(arguments: arguments) + } + + public func open(arguments: [String: JSONValue]) throws -> JSONValue { + if let destination = arguments.object("destination") { + try validateCoordinateObject(destination, name: "destination", capability: "maps.open") + } + guard arguments.string("query") != nil || + arguments.string("url") != nil || + arguments.object("destination") != nil || + (arguments.double("latitude") != nil && arguments.double("longitude") != nil) + else { + throw BridgeError.invalidArguments("maps.open requires query, url, or latitude/longitude") + } + return try client.open(arguments: arguments) + } +} + +public final class MusicBridge: @unchecked Sendable { + private let client: any MusicClient + + public init(client: any MusicClient = UnavailableMusicClient()) { + self.client = client + } + + public func requestPermission(context: BridgeInvocationContext) throws -> JSONValue { + let status = context.permissionBroker.request(for: .music) + context.recordPermission(.music, status: status) + return permissionPayload(status) + } + + public func subscriptionStatus(arguments: [String: JSONValue]) throws -> JSONValue { + try client.subscriptionStatus(arguments: arguments) + } + + public func searchCatalog(arguments: [String: JSONValue]) throws -> JSONValue { + try requireNonEmptyString("term", in: arguments, capability: "music.catalog.search") + try validateOptionalLimit(arguments) + return try client.searchCatalog(arguments: arguments) + } + + public func catalogDetails(arguments: [String: JSONValue]) throws -> JSONValue { + try requireNonEmptyString("identifier", in: arguments, capability: "music.catalog.details") + return try client.catalogDetails(arguments: arguments) + } + + public func readLibrary(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try ensurePermission(.music, context: context) + try validateOptionalLimit(arguments) + return try client.readLibrary(arguments: arguments) + } + + public func writePlaylist(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try ensurePermission(.music, context: context) + try requireNonEmptyString("name", in: arguments, capability: "music.playlist.write") + return try client.writePlaylist(arguments: arguments) + } + + public func controlPlayback(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try requireNonEmptyString("action", in: arguments, capability: "music.playback.control") + try ensurePermission(.music, context: context) + return try client.controlPlayback(arguments: arguments) + } +} + +public final class PassKitBridge: @unchecked Sendable { + private let client: any PassKitClient + + public init(client: any PassKitClient = UnavailablePassKitClient()) { + self.client = client + } + + public func walletStatus(arguments: [String: JSONValue]) throws -> JSONValue { + try client.walletStatus(arguments: arguments) + } + + public func listPasses(arguments: [String: JSONValue]) throws -> JSONValue { + try validateOptionalLimit(arguments) + return try client.listPasses(arguments: arguments) + } + + public func addPass(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let path = try requireNonEmptyString("path", in: arguments, capability: "passkit.pass.add") + let resolved = try context.pathPolicy.resolve(path: path) + var clientArguments = arguments + clientArguments["resolvedPath"] = .string(resolved.path) + return try client.addPass(arguments: clientArguments) + } + + public func presentPass(arguments: [String: JSONValue]) throws -> JSONValue { + try requireNonEmptyString("identifier", in: arguments, capability: "passkit.pass.present") + return try client.presentPass(arguments: arguments) + } + + public func applePayStatus(arguments: [String: JSONValue]) throws -> JSONValue { + try client.applePayStatus(arguments: arguments) + } + + public func presentApplePay(arguments: [String: JSONValue]) throws -> JSONValue { + try requireNonEmptyString("paymentRequestID", in: arguments, capability: "passkit.applePay.present") + try requireConfirmation(arguments, capability: "passkit.applePay.present") + return try client.presentApplePay(arguments: arguments) + } +} + +public final class StoreKitBridge: @unchecked Sendable { + private let client: any StoreKitClient + private let eventInbox: any CodeModeEventInbox + + public init( + client: any StoreKitClient = UnavailableStoreKitClient(), + eventInbox: any CodeModeEventInbox = UnavailableCodeModeEventInbox() + ) { + self.client = client + self.eventInbox = eventInbox + } + + public func products(arguments: [String: JSONValue]) throws -> JSONValue { + guard let productIDs = arguments.array("productIDs") else { + throw BridgeError.invalidArguments("storekit.products.read requires productIDs") + } + guard productIDs.isEmpty == false else { + throw BridgeError.invalidArguments("storekit.products.read productIDs must not be empty") + } + for productID in productIDs { + guard let value = productID.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines), value.isEmpty == false else { + throw BridgeError.invalidArguments("storekit.products.read productIDs must contain non-empty strings") + } + } + return try client.products(arguments: arguments) + } + + public func currentEntitlements(arguments: [String: JSONValue]) throws -> JSONValue { + try client.currentEntitlements(arguments: arguments) + } + + public func purchase(arguments: [String: JSONValue]) throws -> JSONValue { + try requireNonEmptyString("productID", in: arguments, capability: "storekit.purchase") + try requireConfirmation(arguments, capability: "storekit.purchase") + return try client.purchase(arguments: arguments) + } + + public func restore(arguments: [String: JSONValue]) throws -> JSONValue { + try requireConfirmation(arguments, capability: "storekit.restore") + return try client.restore(arguments: arguments) + } + + public func transactionUpdates(arguments: [String: JSONValue]) throws -> JSONValue { + try validateOptionalLimit(arguments) + if isConfigured(eventInbox) { + return try eventInbox.readEvents(source: "storekit.transaction", arguments: arguments) + } + return try client.transactionUpdates(arguments: arguments) + } +} + +@discardableResult +private func requireNonEmptyString(_ name: String, in arguments: [String: JSONValue], capability: String) throws -> String { + guard let value = arguments.string(name)?.trimmingCharacters(in: .whitespacesAndNewlines), value.isEmpty == false else { + throw BridgeError.invalidArguments("\(capability) requires \(name)") + } + return value +} + +private func requireCoordinateArguments(_ arguments: [String: JSONValue], capability: String) throws { + guard arguments.double("latitude") != nil else { + throw BridgeError.invalidArguments("\(capability) requires latitude") + } + guard arguments.double("longitude") != nil else { + throw BridgeError.invalidArguments("\(capability) requires longitude") + } +} + +private func validateCloudKitFields(_ arguments: [String: JSONValue], capability: String) throws { + guard let fields = arguments.object("fields") else { + throw BridgeError.invalidArguments("\(capability) requires fields") + } + guard fields.isEmpty == false else { + throw BridgeError.invalidArguments("\(capability) fields must not be empty") + } + for (field, value) in fields { + guard isCloudKitScalarOrScalarArray(value) else { + throw BridgeError.invalidArguments("\(capability) fields.\(field) must be a string, number, boolean, null, or array of scalar values") + } + } +} + +private func validateOptionalCloudKitPredicate(_ arguments: [String: JSONValue], capability: String) throws { + guard let predicate = arguments["predicate"] else { + return + } + guard let object = predicate.objectValue else { + throw BridgeError.invalidArguments("\(capability) predicate must be an object shaped as { field, equals }") + } + guard object.keys.allSatisfy({ ["field", "equals"].contains($0) }) else { + throw BridgeError.invalidArguments("\(capability) predicate only supports field and equals") + } + guard let field = object.string("field")?.trimmingCharacters(in: .whitespacesAndNewlines), field.isEmpty == false else { + throw BridgeError.invalidArguments("\(capability) predicate.field must be a non-empty string") + } + guard let equals = object["equals"], isCloudKitScalar(equals) else { + throw BridgeError.invalidArguments("\(capability) predicate.equals must be a scalar value") + } +} + +private func isCloudKitScalarOrScalarArray(_ value: JSONValue) -> Bool { + if isCloudKitScalar(value) { + return true + } + guard let array = value.arrayValue else { + return false + } + return array.allSatisfy(isCloudKitNonNullScalar) +} + +private func isCloudKitScalar(_ value: JSONValue) -> Bool { + switch value { + case .string, .number, .bool, .null: + return true + case .object, .array: + return false + } +} + +private func isCloudKitNonNullScalar(_ value: JSONValue) -> Bool { + switch value { + case .string, .number, .bool: + return true + case .null, .object, .array: + return false + } +} + +private func validateNotificationCategories(_ arguments: [String: JSONValue]) throws { + guard let categories = arguments.array("categories") else { + throw BridgeError.invalidArguments("notifications.categories.set requires categories") + } + for category in categories { + guard let object = category.objectValue else { + throw BridgeError.invalidArguments("notifications.categories.set categories must contain objects") + } + guard let identifier = object.string("identifier")?.trimmingCharacters(in: .whitespacesAndNewlines), identifier.isEmpty == false else { + throw BridgeError.invalidArguments("notifications.categories.set category.identifier must be a non-empty string") + } + if let actions = object.array("actions") { + for action in actions { + guard let actionObject = action.objectValue else { + throw BridgeError.invalidArguments("notifications.categories.set category.actions must contain objects") + } + guard let actionIdentifier = actionObject.string("identifier")?.trimmingCharacters(in: .whitespacesAndNewlines), actionIdentifier.isEmpty == false else { + throw BridgeError.invalidArguments("notifications.categories.set action.identifier must be a non-empty string") + } + guard let title = actionObject.string("title")?.trimmingCharacters(in: .whitespacesAndNewlines), title.isEmpty == false else { + throw BridgeError.invalidArguments("notifications.categories.set action.title must be a non-empty string") + } + try validateOptionalStringArray("options", in: actionObject, capability: "notifications.categories.set") + } + } + try validateOptionalStringArray("intentIdentifiers", in: object, capability: "notifications.categories.set") + try validateOptionalStringArray("options", in: object, capability: "notifications.categories.set") + } +} + +private func validateCoordinateObject(_ object: [String: JSONValue], name: String, capability: String) throws { + guard object.double("latitude") != nil else { + throw BridgeError.invalidArguments("\(capability) \(name).latitude is required") + } + guard object.double("longitude") != nil else { + throw BridgeError.invalidArguments("\(capability) \(name).longitude is required") + } +} + +private func validateOptionalMapsRegion(_ arguments: [String: JSONValue], capability: String) throws { + guard let region = arguments.object("region") else { + return + } + if region.double("latitude") != nil || region.double("longitude") != nil || region.double("radiusMeters") != nil { + guard region.double("latitude") != nil, + region.double("longitude") != nil, + let radius = region.double("radiusMeters"), + radius > 0 + else { + throw BridgeError.invalidArguments("\(capability) region must include latitude, longitude, and positive radiusMeters") + } + return + } + + guard let center = region.object("center") else { + throw BridgeError.invalidArguments("\(capability) region must include either latitude/longitude/radiusMeters or center/latitudeDelta/longitudeDelta") + } + try validateCoordinateObject(center, name: "region.center", capability: capability) + guard let latitudeDelta = region.double("latitudeDelta"), latitudeDelta > 0 else { + throw BridgeError.invalidArguments("\(capability) region.latitudeDelta must be greater than 0") + } + guard let longitudeDelta = region.double("longitudeDelta"), longitudeDelta > 0 else { + throw BridgeError.invalidArguments("\(capability) region.longitudeDelta must be greater than 0") + } +} + +private func validateOptionalStringArray(_ name: String, in arguments: [String: JSONValue], capability: String) throws { + guard let array = arguments.array(name) else { + return + } + for value in array { + guard let string = value.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines), string.isEmpty == false else { + throw BridgeError.invalidArguments("\(capability) \(name) must contain non-empty strings") + } + } +} + +private func isConfigured(_ inbox: any CodeModeEventInbox) -> Bool { + !(inbox is UnavailableCodeModeEventInbox) +} + +private func validateOptionalLimit(_ arguments: [String: JSONValue]) throws { + if let limit = arguments.int("limit"), limit <= 0 { + throw BridgeError.invalidArguments("limit must be greater than 0") + } +} + +private func validateOptionalPositiveNumber(_ name: String, in arguments: [String: JSONValue], capability: String) throws { + if let value = arguments.double(name), value <= 0 { + throw BridgeError.invalidArguments("\(capability) \(name) must be greater than 0") + } +} + +private func requireConfirmation(_ arguments: [String: JSONValue], capability: String) throws { + guard arguments.bool("confirmed") == true else { + throw BridgeError.invalidArguments("\(capability) requires confirmed: true after explicit user-visible confirmation") + } +} + +private func ensurePermission(_ permission: PermissionKind, context: BridgeInvocationContext) throws { + let status = context.resolvedPermission(for: permission) + guard status == .granted else { + throw BridgeError.permissionDenied(permission) + } +} + +private func permissionPayload(_ status: PermissionStatus) -> JSONValue { + .object([ + "status": .string(status.rawValue), + "granted": .bool(status == .granted), + ]) +} diff --git a/Sources/CodeMode/Bridges/BuiltInCapabilityRegistration.swift b/Sources/CodeMode/Bridges/BuiltInCapabilityRegistration.swift new file mode 100644 index 0000000..937f69c --- /dev/null +++ b/Sources/CodeMode/Bridges/BuiltInCapabilityRegistration.swift @@ -0,0 +1,37 @@ +import Foundation + +extension CapabilityRegistration { + init( + _ id: CapabilityID, + jsName: String, + title: String, + summary: String, + tags: [String], + example: String, + requiredPermissions: [PermissionKind] = [], + requiredArguments: [String] = [], + optionalArguments: [String] = [], + argumentTypes: [String: CapabilityArgumentType] = [:], + argumentHints: [String: String] = [:], + resultSummary: String = "JSON value", + handler: @escaping CapabilityHandler + ) { + self.init( + jsNames: [jsName], + descriptor: CapabilityDescriptor( + id: id, + title: title, + summary: summary, + tags: tags, + example: example, + requiredPermissions: requiredPermissions, + requiredArguments: requiredArguments, + optionalArguments: optionalArguments, + argumentTypes: argumentTypes, + argumentHints: argumentHints, + resultSummary: resultSummary + ), + handler: handler + ) + } +} diff --git a/Sources/CodeMode/Bridges/CapabilityRegistrations+CloudPushSpeech.swift b/Sources/CodeMode/Bridges/CapabilityRegistrations+CloudPushSpeech.swift new file mode 100644 index 0000000..ef7661f --- /dev/null +++ b/Sources/CodeMode/Bridges/CapabilityRegistrations+CloudPushSpeech.swift @@ -0,0 +1,287 @@ +import Foundation + +extension DefaultCapabilityRegistrationBuilder { + func bigTicketAppleRegistrations() -> [CapabilityRegistration] { + [ + cloudKitRegistrations(), + remoteNotificationRegistrations(), + speechRegistrations(), + appIntentsRegistrations(), + foundationModelsRegistrations(), + activityRegistrations(), + mapsRegistrations(), + musicRegistrations(), + passKitRegistrations(), + storeKitRegistrations(), + ].flatMap { $0 } + } + + + func cloudKitRegistrations() -> [CapabilityRegistration] { + [ + CapabilityRegistration( + .cloudKitAccountStatus, + jsName: "apple.cloudkit.getAccountStatus", + title: "Read CloudKit account status", + summary: "Read iCloud account availability for CloudKit-backed agent state.", + tags: ["cloudkit", "icloud", "sync", "account"], + example: "await apple.cloudkit.getAccountStatus({ containerIdentifier: 'iCloud.com.example.app' })", + optionalArguments: ["containerIdentifier"], + argumentHints: [ + "containerIdentifier": "Optional iCloud container identifier; defaults to the host client's configured container.", + ], + resultSummary: "Object with status/accountAvailable and containerIdentifier when available." + ) { args, _ in + try cloudKit.accountStatus(arguments: args) + }, + CapabilityRegistration( + .cloudKitRecordsQuery, + jsName: "apple.cloudkit.queryRecords", + title: "Query CloudKit records", + summary: "Query records from a private/shared/public CloudKit database for synced agent state.", + tags: ["cloudkit", "icloud", "database", "query"], + example: "await apple.cloudkit.queryRecords({ database: 'private', recordType: 'Task', limit: 20 })", + requiredArguments: ["recordType"], + optionalArguments: ["database", "containerIdentifier", "zoneID", "predicate", "sortDescriptors", "desiredKeys", "limit"], + argumentHints: [ + "database": "private (default), shared, or public.", + "recordType": "CloudKit record type to query.", + "containerIdentifier": "Optional iCloud container identifier.", + "zoneID": "Optional custom zone identifier.", + "predicate": "Host-supported predicate object or string; keep predicates bounded and repairable.", + "sortDescriptors": "Optional array of sort descriptor objects.", + "desiredKeys": "Optional array of field keys to return.", + "limit": "Max records to return; default is host-defined.", + ], + resultSummary: "Array of records with recordName/recordType/fields/modifiedAt/database." + ) { args, _ in + try cloudKit.queryRecords(arguments: args) + }, + CapabilityRegistration( + .cloudKitRecordSave, + jsName: "apple.cloudkit.saveRecord", + title: "Save CloudKit record", + summary: "Create or update a CloudKit record in a private/shared/public database.", + tags: ["cloudkit", "icloud", "database", "write"], + example: "await apple.cloudkit.saveRecord({ database: 'private', recordType: 'Task', recordName: 'task-1', fields: { title: 'Review' } })", + requiredArguments: ["recordType", "fields"], + optionalArguments: ["recordName", "database", "containerIdentifier", "zoneID", "savePolicy"], + argumentHints: [ + "recordType": "CloudKit record type to create or update.", + "fields": "JSON object mapped by the host client into supported CloudKit field values.", + "recordName": "Optional CloudKit recordName; omitted means create a new record.", + "database": "private (default), shared, or public.", + "containerIdentifier": "Optional iCloud container identifier.", + "zoneID": "Optional custom zone identifier.", + "savePolicy": "Host-supported save policy such as changedKeys or allKeys.", + ], + resultSummary: "Saved record object with recordName/recordType/fields/changeTag/database." + ) { args, _ in + try cloudKit.saveRecord(arguments: args) + }, + CapabilityRegistration( + .cloudKitRecordDelete, + jsName: "apple.cloudkit.deleteRecord", + title: "Delete CloudKit record", + summary: "Delete a CloudKit record by recordName from a configured database.", + tags: ["cloudkit", "icloud", "database", "delete"], + example: "await apple.cloudkit.deleteRecord({ database: 'private', recordName: 'task-1' })", + requiredArguments: ["recordName"], + optionalArguments: ["database", "containerIdentifier", "zoneID"], + argumentHints: [ + "recordName": "CloudKit recordName to delete.", + "database": "private (default), shared, or public.", + "containerIdentifier": "Optional iCloud container identifier.", + "zoneID": "Optional custom zone identifier.", + ], + resultSummary: "Object with recordName/deleted/database." + ) { args, _ in + try cloudKit.deleteRecord(arguments: args) + }, + CapabilityRegistration( + .cloudKitSubscriptionSave, + jsName: "apple.cloudkit.subscribe", + title: "Save CloudKit subscription", + summary: "Register a bounded CloudKit query subscription so the host can enqueue subscription events later.", + tags: ["cloudkit", "icloud", "subscription", "inbox"], + example: "await apple.cloudkit.subscribe({ subscriptionID: 'tasks', database: 'private', recordType: 'Task' })", + requiredArguments: ["subscriptionID", "recordType"], + optionalArguments: ["database", "containerIdentifier", "zoneID", "predicate", "firesOnRecordCreation", "firesOnRecordUpdate", "firesOnRecordDeletion"], + argumentHints: [ + "subscriptionID": "Stable host-visible subscription identifier.", + "recordType": "CloudKit record type to observe.", + "database": "private (default), shared, or public.", + "predicate": "Host-supported predicate object or string.", + "firesOnRecordCreation": "Whether creation events are enqueued; default true.", + "firesOnRecordUpdate": "Whether update events are enqueued; default true.", + "firesOnRecordDeletion": "Whether delete events are enqueued; default true.", + ], + resultSummary: "Object with subscriptionID/saved/database." + ) { args, _ in + try cloudKit.saveSubscription(arguments: args) + }, + CapabilityRegistration( + .cloudKitSubscriptionEventsRead, + jsName: "apple.cloudkit.listEvents", + title: "Read CloudKit subscription inbox", + summary: "Read bounded CloudKit subscription events previously received by the host.", + tags: ["cloudkit", "icloud", "subscription", "inbox", "events"], + example: "await apple.cloudkit.listEvents({ subscriptionID: 'tasks', limit: 20 })", + optionalArguments: ["subscriptionID", "limit", "afterCursor"], + argumentHints: [ + "subscriptionID": "Optional subscription filter.", + "limit": "Maximum number of inbox events to read.", + "afterCursor": "Optional host-provided cursor for incremental reads.", + ], + resultSummary: "Array of subscription event objects with cursor/subscriptionID/recordName/reason/database." + ) { args, _ in + try cloudKit.readSubscriptionEvents(arguments: args) + }, + ] + } + + + func remoteNotificationRegistrations() -> [CapabilityRegistration] { + [ + CapabilityRegistration( + .notificationsRemoteRegister, + jsName: "apple.notifications.registerRemote", + title: "Register for remote notifications", + summary: "Ask the host app to register with APNs and record the client device-token lifecycle.", + tags: ["notifications", "apns", "remote", "registration"], + example: "await apple.notifications.registerRemote()", + optionalArguments: ["types"], + argumentHints: [ + "types": "Optional host-supported notification types; APNs provider sending is intentionally out of scope.", + ], + resultSummary: "Object with registered/status and deviceToken when already available." + ) { args, _ in + try remoteNotifications.register(arguments: args) + }, + CapabilityRegistration( + .notificationsRemoteTokenRead, + jsName: "apple.notifications.getRemoteToken", + title: "Read APNs device token", + summary: "Read the latest APNs device token captured by the host app.", + tags: ["notifications", "apns", "remote", "token"], + example: "await apple.notifications.getRemoteToken()", + resultSummary: "Object with token/environment/updatedAt or null token when registration has not completed." + ) { args, _ in + try remoteNotifications.readToken(arguments: args) + }, + CapabilityRegistration( + .notificationsSettingsRead, + jsName: "apple.notifications.getSettings", + title: "Read notification settings", + summary: "Read UserNotifications/APNs client settings exposed by the host.", + tags: ["notifications", "apns", "settings", "permission"], + example: "await apple.notifications.getSettings()", + resultSummary: "Object with authorizationStatus/alert/badge/sound/criticalAlert/providesAppNotificationSettings." + ) { args, _ in + try remoteNotifications.readSettings(arguments: args) + }, + CapabilityRegistration( + .notificationsCategoriesSet, + jsName: "apple.notifications.setCategories", + title: "Set notification categories", + summary: "Register host-approved notification categories and actions for push/local response handling.", + tags: ["notifications", "apns", "categories", "actions"], + example: "await apple.notifications.setCategories({ categories: [{ identifier: 'task', actions: [{ identifier: 'done', title: 'Done' }] }] })", + requiredArguments: ["categories"], + argumentHints: [ + "categories": "Array of category definitions with identifier/actions/options approved by the host.", + ], + resultSummary: "Object with registered category identifiers." + ) { args, _ in + try remoteNotifications.setCategories(arguments: args) + }, + CapabilityRegistration( + .notificationsResponsesRead, + jsName: "apple.notifications.listResponses", + title: "Read notification response inbox", + summary: "Read bounded notification action/open responses captured by the host for later agent handling.", + tags: ["notifications", "apns", "response", "inbox", "events"], + example: "await apple.notifications.listResponses({ limit: 20 })", + optionalArguments: ["limit", "categoryIdentifier", "actionIdentifier", "afterCursor"], + argumentHints: [ + "limit": "Maximum number of response events to read.", + "categoryIdentifier": "Optional category filter.", + "actionIdentifier": "Optional action filter.", + "afterCursor": "Optional host-provided cursor for incremental reads.", + ], + resultSummary: "Array of response events with cursor/identifier/actionIdentifier/categoryIdentifier/userText/userInfo/date." + ) { args, _ in + try remoteNotifications.readResponses(arguments: args) + }, + ] + } + + + func speechRegistrations() -> [CapabilityRegistration] { + [ + CapabilityRegistration( + .speechPermissionRequest, + jsName: "apple.speech.requestPermission", + title: "Request speech recognition permission", + summary: "Trigger the Speech recognition permission prompt.", + tags: ["speech", "permission", "transcription"], + example: "await apple.speech.requestPermission()", + resultSummary: "Object with status/granted." + ) { _, context in + try speech.requestPermission(context: context) + }, + CapabilityRegistration( + .speechStatus, + jsName: "apple.speech.getStatus", + title: "Read speech recognition permission status", + summary: "Read Speech recognition permission status without starting capture.", + tags: ["speech", "permission", "transcription"], + example: "await apple.speech.getStatus()", + resultSummary: "Object with status/granted." + ) { _, context in + try speech.status(context: context) + }, + CapabilityRegistration( + .speechFileTranscribe, + jsName: "apple.speech.transcribeFile", + title: "Transcribe audio file", + summary: "Transcribe a sandbox audio file through the host Speech adapter.", + tags: ["speech", "transcription", "audio"], + example: "await apple.speech.transcribeFile({ path: 'tmp:meeting.m4a', locale: 'en-US', timeoutMs: 60000 })", + requiredPermissions: [.speechRecognition], + requiredArguments: ["path"], + optionalArguments: ["locale", "timeoutMs", "requiresOnDeviceRecognition", "taskHint"], + argumentHints: [ + "path": "Sandbox audio file path.", + "locale": "BCP-47 locale identifier such as en-US.", + "timeoutMs": "Maximum transcription wait time in milliseconds.", + "requiresOnDeviceRecognition": "Whether to require on-device recognition when the host supports it.", + "taskHint": "Speech task hint such as dictation, search, or confirmation.", + ], + resultSummary: "Object with transcript/segments/locale/isFinal/durationSeconds." + ) { args, context in + try speech.transcribeFile(arguments: args, context: context) + }, + CapabilityRegistration( + .speechMicrophoneTranscribe, + jsName: "apple.speech.transcribeMicrophone", + title: "Transcribe microphone audio", + summary: "Run live microphone transcription through a host-mediated session with an explicit timeout.", + tags: ["speech", "transcription", "audio", "microphone"], + example: "await apple.speech.transcribeMicrophone({ locale: 'en-US', timeoutMs: 15000 })", + requiredPermissions: [.speechRecognition, .microphone], + optionalArguments: ["locale", "timeoutMs", "requiresOnDeviceRecognition", "taskHint", "partialResults"], + argumentHints: [ + "locale": "BCP-47 locale identifier such as en-US.", + "timeoutMs": "Maximum microphone capture/transcription time in milliseconds.", + "requiresOnDeviceRecognition": "Whether to require on-device recognition when the host supports it.", + "taskHint": "Speech task hint such as dictation, search, or confirmation.", + "partialResults": "Whether partial transcripts may be returned.", + ], + resultSummary: "Object with transcript/segments/locale/isFinal/timedOut." + ) { args, context in + try speech.transcribeMicrophone(arguments: args, context: context) + }, + ] + } +} diff --git a/Sources/CodeMode/Bridges/CapabilityRegistrations+Commerce.swift b/Sources/CodeMode/Bridges/CapabilityRegistrations+Commerce.swift new file mode 100644 index 0000000..31255d4 --- /dev/null +++ b/Sources/CodeMode/Bridges/CapabilityRegistrations+Commerce.swift @@ -0,0 +1,301 @@ +import Foundation + +extension DefaultCapabilityRegistrationBuilder { + func musicRegistrations() -> [CapabilityRegistration] { + [ + CapabilityRegistration( + .musicPermissionRequest, + jsName: "apple.music.requestPermission", + title: "Request Music permission", + summary: "Request Apple Music/media-library permission before library or playback actions.", + tags: ["music", "musickit", "permission"], + example: "await apple.music.requestPermission()", + resultSummary: "Object with status/granted." + ) { _, context in + try music.requestPermission(context: context) + }, + CapabilityRegistration( + .musicSubscriptionStatus, + jsName: "apple.music.getSubscriptionStatus", + title: "Read Music subscription status", + summary: "Read MusicKit subscription/capability status exposed by the host.", + tags: ["music", "musickit", "subscription", "catalog"], + example: "await apple.music.getSubscriptionStatus()", + resultSummary: "Object with canPlayCatalogContent/hasCloudLibraryEnabled/status." + ) { args, _ in + try music.subscriptionStatus(arguments: args) + }, + CapabilityRegistration( + .musicCatalogSearch, + jsName: "apple.music.search", + title: "Search Music catalog", + summary: "Search the Apple Music catalog for songs, albums, artists, playlists, or stations.", + tags: ["music", "musickit", "catalog", "search"], + example: "await apple.music.search({ term: 'Miles Davis', types: ['artists', 'albums'], limit: 5 })", + requiredArguments: ["term"], + optionalArguments: ["types", "limit", "countryCode"], + argumentHints: [ + "term": "Catalog search term.", + "types": "Optional array such as songs, albums, artists, playlists, stations.", + "limit": "Maximum results.", + "countryCode": "Optional storefront country code.", + ], + resultSummary: "Object grouped by result type with catalog identifiers and metadata." + ) { args, _ in + try music.searchCatalog(arguments: args) + }, + CapabilityRegistration( + .musicCatalogDetails, + jsName: "apple.music.getDetails", + title: "Read Music catalog details", + summary: "Read details for a catalog item identifier through MusicKit.", + tags: ["music", "musickit", "catalog", "details"], + example: "await apple.music.getDetails({ identifier: '123', type: 'albums' })", + requiredArguments: ["identifier"], + optionalArguments: ["type", "countryCode"], + argumentHints: [ + "identifier": "Music catalog item identifier.", + "type": "Catalog type such as songs, albums, artists, playlists, or stations.", + ], + resultSummary: "Catalog item details object." + ) { args, _ in + try music.catalogDetails(arguments: args) + }, + CapabilityRegistration( + .musicLibraryRead, + jsName: "apple.music.readLibrary", + title: "Read Music library", + summary: "Read the user's Music library playlists or items after Music permission is granted.", + tags: ["music", "musickit", "library"], + example: "await apple.music.readLibrary({ type: 'playlists', limit: 20 })", + requiredPermissions: [.music], + optionalArguments: ["type", "limit"], + argumentHints: [ + "type": "Library type such as playlists, songs, albums, or artists.", + "limit": "Maximum library items.", + ], + resultSummary: "Array of library items with identifiers and metadata." + ) { args, context in + try music.readLibrary(arguments: args, context: context) + }, + CapabilityRegistration( + .musicPlaylistWrite, + jsName: "apple.music.writePlaylist", + title: "Write Music playlist", + summary: "Create or update a user-library playlist through a host MusicKit adapter.", + tags: ["music", "musickit", "library", "playlist", "write"], + example: "await apple.music.writePlaylist({ name: 'Focus', catalogIDs: ['song-id'] })", + requiredPermissions: [.music], + requiredArguments: ["name"], + optionalArguments: ["playlistID", "catalogIDs", "libraryIDs", "description"], + argumentHints: [ + "name": "Playlist name.", + "playlistID": "Optional existing playlist identifier to update.", + "catalogIDs": "Optional catalog song identifiers to add.", + "libraryIDs": "Optional library song identifiers to add.", + ], + resultSummary: "Object with playlistID/name/written." + ) { args, context in + try music.writePlaylist(arguments: args, context: context) + }, + CapabilityRegistration( + .musicPlaybackControl, + jsName: "apple.music.play", + title: "Control Music playback", + summary: "Control Music playback queue through a user-authorized host adapter.", + tags: ["music", "musickit", "playback", "queue"], + example: "await apple.music.play({ action: 'playCatalog', catalogID: 'song-id' })", + requiredPermissions: [.music], + requiredArguments: ["action"], + optionalArguments: ["catalogID", "libraryID", "queue", "startPlaying"], + argumentHints: [ + "action": "Host-supported action such as play, pause, stop, skipToNext, playCatalog, or playLibrary.", + "catalogID": "Optional catalog item identifier.", + "libraryID": "Optional library item identifier.", + "queue": "Optional queue definition approved by the host adapter.", + ], + resultSummary: "Object with action/status/currentItem when available." + ) { args, context in + try music.controlPlayback(arguments: args, context: context) + }, + ] + } + + + func passKitRegistrations() -> [CapabilityRegistration] { + [ + CapabilityRegistration( + .passKitWalletStatus, + jsName: "apple.wallet.getStatus", + title: "Read Wallet status", + summary: "Read PassKit Wallet availability and pass-library access status.", + tags: ["passkit", "wallet", "passes"], + example: "await apple.wallet.getStatus()", + resultSummary: "Object with available/canAddPasses/canPresentPasses." + ) { args, _ in + try passKit.walletStatus(arguments: args) + }, + CapabilityRegistration( + .passKitPassesRead, + jsName: "apple.wallet.listPasses", + title: "List Wallet passes", + summary: "List Wallet passes accessible to the host app through PassKit.", + tags: ["passkit", "wallet", "passes", "read"], + example: "await apple.wallet.listPasses({ limit: 20 })", + optionalArguments: ["passTypeIdentifier", "serialNumber", "limit"], + argumentHints: [ + "passTypeIdentifier": "Optional pass type filter.", + "serialNumber": "Optional serial number filter.", + "limit": "Maximum accessible passes.", + ], + resultSummary: "Array of passes with passTypeIdentifier/serialNumber/organizationName/description." + ) { args, _ in + try passKit.listPasses(arguments: args) + }, + CapabilityRegistration( + .passKitPassAdd, + jsName: "apple.wallet.addPass", + title: "Add Wallet pass", + summary: "Present user-mediated UI to add a sandbox .pkpass file to Wallet.", + tags: ["passkit", "wallet", "passes", "add", "system-ui"], + example: "await apple.wallet.addPass({ path: 'tmp:ticket.pkpass' })", + requiredArguments: ["path"], + optionalArguments: ["timeoutMs"], + argumentHints: [ + "path": "Sandbox path to a .pkpass file.", + "timeoutMs": "Optional timeout for user-mediated add-pass UI.", + ], + resultSummary: "Object with action/added/passTypeIdentifier/serialNumber." + ) { args, context in + try passKit.addPass(arguments: args, context: context) + }, + CapabilityRegistration( + .passKitPassPresent, + jsName: "apple.wallet.presentPass", + title: "Present Wallet pass", + summary: "Present user-mediated details for an accessible Wallet pass.", + tags: ["passkit", "wallet", "passes", "system-ui"], + example: "await apple.wallet.presentPass({ identifier: 'pass-id' })", + requiredArguments: ["identifier"], + optionalArguments: ["timeoutMs"], + resultSummary: "Object with action/presented/identifier." + ) { args, _ in + try passKit.presentPass(arguments: args) + }, + CapabilityRegistration( + .passKitApplePayStatus, + jsName: "apple.wallet.canMakePayments", + title: "Read Apple Pay status", + summary: "Read Apple Pay capability using host merchant configuration; no merchant setup is accepted from JavaScript.", + tags: ["passkit", "apple-pay", "payments", "safety"], + example: "await apple.wallet.canMakePayments()", + optionalArguments: ["networks"], + argumentHints: [ + "networks": "Optional supported payment networks to test against host merchant configuration.", + ], + resultSummary: "Object with canMakePayments/canMakePaymentsUsingNetworks." + ) { args, _ in + try passKit.applePayStatus(arguments: args) + }, + CapabilityRegistration( + .passKitApplePayPresent, + jsName: "apple.wallet.presentPayment", + title: "Present Apple Pay request", + summary: "Present a host-defined Apple Pay payment request using host merchant configuration after explicit user-visible confirmation.", + tags: ["passkit", "apple-pay", "payments", "system-ui", "safety"], + example: "await apple.wallet.presentPayment({ paymentRequestID: 'checkout-123', confirmed: true })", + requiredArguments: ["paymentRequestID", "confirmed"], + optionalArguments: ["timeoutMs"], + argumentHints: [ + "paymentRequestID": "Host-defined payment request identifier using host merchant configuration; arbitrary merchant setup is not accepted from JavaScript.", + "confirmed": "Must be true after explicit user-visible confirmation before presenting Apple Pay.", + ], + resultSummary: "Object with action/authorized/paymentRequestID/status." + ) { args, _ in + try passKit.presentApplePay(arguments: args) + }, + ] + } + + + func storeKitRegistrations() -> [CapabilityRegistration] { + [ + CapabilityRegistration( + .storeKitProductsRead, + jsName: "apple.storekit.listProducts", + title: "Read StoreKit products", + summary: "Read StoreKit product metadata for host-configured product identifiers.", + tags: ["storekit", "commerce", "products"], + example: "await apple.storekit.listProducts({ productIDs: ['pro.monthly'] })", + requiredArguments: ["productIDs"], + argumentHints: [ + "productIDs": "Array of host-configured StoreKit product identifiers.", + ], + resultSummary: "Array of products with id/displayName/description/price/type." + ) { args, _ in + try storeKit.products(arguments: args) + }, + CapabilityRegistration( + .storeKitEntitlementsRead, + jsName: "apple.storekit.listEntitlements", + title: "Read StoreKit entitlements", + summary: "Read current StoreKit entitlements and verified transaction state.", + tags: ["storekit", "commerce", "entitlements"], + example: "await apple.storekit.listEntitlements()", + resultSummary: "Array of current entitlements with productID/transactionID/expirationDate." + ) { args, _ in + try storeKit.currentEntitlements(arguments: args) + }, + CapabilityRegistration( + .storeKitPurchase, + jsName: "apple.storekit.purchase", + title: "Purchase StoreKit product", + summary: "Present StoreKit purchase UI for a host-configured product after explicit user-visible confirmation.", + tags: ["storekit", "commerce", "purchase", "safety"], + example: "await apple.storekit.purchase({ productID: 'pro.monthly', confirmed: true })", + requiredArguments: ["productID", "confirmed"], + optionalArguments: ["appAccountToken"], + argumentHints: [ + "productID": "Host-configured StoreKit product identifier.", + "confirmed": "Must be true after explicit user-visible confirmation before purchase UI is presented.", + "appAccountToken": "Optional UUID string for StoreKit appAccountToken.", + ], + resultSummary: "Object with status/productID/transactionID when completed." + ) { args, _ in + try storeKit.purchase(arguments: args) + }, + CapabilityRegistration( + .storeKitRestore, + jsName: "apple.storekit.restore", + title: "Restore StoreKit purchases", + summary: "Trigger StoreKit restore/sync after explicit user-visible confirmation.", + tags: ["storekit", "commerce", "restore", "safety"], + example: "await apple.storekit.restore({ confirmed: true })", + requiredArguments: ["confirmed"], + argumentHints: [ + "confirmed": "Must be true after explicit user-visible confirmation before restore starts.", + ], + resultSummary: "Object with restored/status." + ) { args, _ in + try storeKit.restore(arguments: args) + }, + CapabilityRegistration( + .storeKitTransactionsRead, + jsName: "apple.storekit.listTransactions", + title: "Read StoreKit transaction inbox", + summary: "Read bounded transaction updates captured by the host StoreKit adapter.", + tags: ["storekit", "commerce", "transactions", "inbox", "events"], + example: "await apple.storekit.listTransactions({ limit: 20 })", + optionalArguments: ["limit", "productID", "afterCursor"], + argumentHints: [ + "limit": "Maximum transaction updates to return.", + "productID": "Optional product filter.", + "afterCursor": "Optional host-provided cursor for incremental reads.", + ], + resultSummary: "Array of transaction events with cursor/productID/transactionID/status/date." + ) { args, _ in + try storeKit.transactionUpdates(arguments: args) + }, + ] + } +} diff --git a/Sources/CodeMode/Bridges/CapabilityRegistrations+Core.swift b/Sources/CodeMode/Bridges/CapabilityRegistrations+Core.swift new file mode 100644 index 0000000..e151a66 --- /dev/null +++ b/Sources/CodeMode/Bridges/CapabilityRegistrations+Core.swift @@ -0,0 +1,242 @@ +import Foundation + +extension DefaultCapabilityRegistrationBuilder { + func networkRegistrations() -> [CapabilityRegistration] { + [ + CapabilityRegistration( + jsNames: ["fetch"], + descriptor: .init( + id: .networkFetch, + title: "Fetch HTTP resource", + summary: "Perform HTTP(S) requests through URLSession via a fetch-compatible API.", + tags: ["network", "http", "fetch"], + example: "await fetch('https://api.example.com/data').then(r => r.json())", + requiredArguments: ["url"], + optionalArguments: [ + "options.method", + "options.headers", + "options.body", + "options.bodyBase64", + "options.timeoutMs", + "options.responseEncoding", + ], + argumentHints: [ + "url": "Absolute HTTP(S) URL string.", + "options.method": "HTTP method; defaults to GET.", + "options.headers": "Object of header key/value string pairs.", + "options.body": "UTF-8 request body string.", + "options.bodyBase64": "Base64-encoded request body. Mutually exclusive with options.body.", + "options.timeoutMs": "Request and bridge wait timeout in milliseconds; defaults to 30000.", + "options.responseEncoding": "text (default) or base64.", + ], + resultSummary: "Object with ok/status/statusText/headers/bodyText or bodyBase64." + ), + handler: { args, context in + try network.fetch(arguments: args, context: context) + } + ), + ] + } + + + func keychainRegistrations() -> [CapabilityRegistration] { + KeychainCodeModeBuiltIns(keychain: keychain).capabilityRegistrations() + } + + + func filesystemRegistrations() -> [CapabilityRegistration] { + [ + CapabilityRegistration( + jsNames: ["apple.fs.list", "fs.promises.readdir"], + descriptor: .init( + id: .fsList, + title: "List directory", + summary: "List files/directories within allowed sandbox roots as entry objects.", + tags: ["filesystem", "io", "fs"], + example: "await apple.fs.list({ path: 'tmp:' })", + requiredArguments: ["path"], + argumentHints: [ + "path": "Directory path using allowed root prefix (tmp:, caches:, documents:).", + ], + resultSummary: "Array of entry objects with name/path/isDirectory/size. Use entry.name for filenames; fs.promises.readdir returns the same entry objects, not strings." + ), + handler: { args, context in + try fs.list(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.fs.read", "fs.promises.readFile"], + descriptor: .init( + id: .fsRead, + title: "Read file", + summary: "Read text/base64 file data within allowed sandbox roots.", + tags: ["filesystem", "io", "fs"], + example: "await apple.fs.read({ path: 'tmp:data.json', encoding: 'utf8' })", + requiredArguments: ["path"], + optionalArguments: ["encoding"], + argumentHints: [ + "path": "File path using allowed root prefix.", + "encoding": "utf8 (default) or base64.", + ], + resultSummary: "Object with path plus text or base64 field." + ), + handler: { args, context in + try fs.read(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.fs.write", "fs.promises.writeFile"], + descriptor: .init( + id: .fsWrite, + title: "Write file", + summary: "Write text/base64 file data within allowed sandbox roots.", + tags: ["filesystem", "io", "fs"], + example: "await apple.fs.write({ path: 'tmp:data.json', data: '{\"ok\":true}' })", + requiredArguments: ["path"], + optionalArguments: ["data", "encoding"], + argumentHints: [ + "path": "File path using allowed root prefix.", + "data": "UTF-8 text or base64 string depending on encoding.", + "encoding": "utf8 (default) or base64.", + ], + resultSummary: "Object with path and bytesWritten." + ), + handler: { args, context in + try fs.write(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.fs.move", "fs.promises.rename"], + descriptor: .init( + id: .fsMove, + title: "Move file", + summary: "Move file/directory within allowed sandbox roots.", + tags: ["filesystem", "io", "fs"], + example: "await apple.fs.move({ from: 'tmp:a.txt', to: 'tmp:b.txt' })", + requiredArguments: ["from", "to"], + argumentHints: [ + "from": "Source sandbox path.", + "to": "Destination sandbox path.", + ], + resultSummary: "Object with from/to resolved paths." + ), + handler: { args, context in + try fs.move(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.fs.copy", "fs.promises.copyFile"], + descriptor: .init( + id: .fsCopy, + title: "Copy file", + summary: "Copy file/directory within allowed sandbox roots.", + tags: ["filesystem", "io", "fs"], + example: "await apple.fs.copy({ from: 'tmp:a.txt', to: 'tmp:b.txt' })", + requiredArguments: ["from", "to"], + argumentHints: [ + "from": "Source sandbox path.", + "to": "Destination sandbox path.", + ], + resultSummary: "Object with from/to resolved paths." + ), + handler: { args, context in + try fs.copy(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.fs.delete", "fs.promises.rm"], + descriptor: .init( + id: .fsDelete, + title: "Delete file", + summary: "Delete file/directory within allowed sandbox roots.", + tags: ["filesystem", "io", "fs"], + example: "await apple.fs.delete({ path: 'tmp:data.json' })", + requiredArguments: ["path"], + optionalArguments: ["recursive"], + argumentHints: [ + "path": "Path to file or directory.", + "recursive": "Required as true when deleting a directory.", + ], + resultSummary: "Object with deleted flag and path." + ), + handler: { args, context in + try fs.delete(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.fs.stat", "fs.promises.stat"], + descriptor: .init( + id: .fsStat, + title: "Stat path", + summary: "Read file metadata within allowed sandbox roots.", + tags: ["filesystem", "io", "fs"], + example: "await apple.fs.stat({ path: 'tmp:data.json' })", + requiredArguments: ["path"], + argumentHints: [ + "path": "File or directory path.", + ], + resultSummary: "Object with path/isDirectory/size/createdAt/modifiedAt." + ), + handler: { args, context in + try fs.stat(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.fs.mkdir", "fs.promises.mkdir"], + descriptor: .init( + id: .fsMkdir, + title: "Create directory", + summary: "Create directories within allowed sandbox roots.", + tags: ["filesystem", "io", "fs"], + example: "await apple.fs.mkdir({ path: 'tmp:artifacts', recursive: true })", + requiredArguments: ["path"], + optionalArguments: ["recursive"], + argumentHints: [ + "path": "Directory path to create.", + "recursive": "Boolean; default true.", + ], + resultSummary: "Object with created flag and path." + ), + handler: { args, context in + try fs.mkdir(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.fs.exists"], + descriptor: .init( + id: .fsExists, + title: "Check path exists", + summary: "Check if file/directory exists within allowed sandbox roots.", + tags: ["filesystem", "io", "fs"], + example: "await apple.fs.exists({ path: 'tmp:data.json' })", + requiredArguments: ["path"], + argumentHints: [ + "path": "File or directory path to check.", + ], + resultSummary: "Boolean." + ), + handler: { args, context in + try fs.exists(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.fs.access", "fs.promises.access"], + descriptor: .init( + id: .fsAccess, + title: "Check path access", + summary: "Check read/write access for path within allowed sandbox roots.", + tags: ["filesystem", "io", "fs"], + example: "await apple.fs.access({ path: 'tmp:data.json' })", + requiredArguments: ["path"], + argumentHints: [ + "path": "File or directory path to inspect.", + ], + resultSummary: "Object with readable/writable/path." + ), + handler: { args, context in + try fs.access(arguments: args, context: context) + } + ), + ] + } +} diff --git a/Sources/CodeMode/Bridges/CapabilityRegistrations+EventKit.swift b/Sources/CodeMode/Bridges/CapabilityRegistrations+EventKit.swift new file mode 100644 index 0000000..ad5b4e9 --- /dev/null +++ b/Sources/CodeMode/Bridges/CapabilityRegistrations+EventKit.swift @@ -0,0 +1,247 @@ +import Foundation + +extension DefaultCapabilityRegistrationBuilder { + func calendarAndReminderRegistrations() -> [CapabilityRegistration] { + [ + CapabilityRegistration( + jsNames: ["apple.calendar.listEvents"], + descriptor: .init( + id: .calendarRead, + title: "Read calendar events", + summary: "List events in a date range from EventKit.", + tags: ["calendar", "eventkit", "schedule"], + example: "await apple.calendar.listEvents({ start: '2026-02-21T00:00:00Z', end: '2026-03-01T00:00:00Z' })", + requiredPermissions: [.calendar], + optionalArguments: ["start", "end", "limit", "calendarIdentifier", "calendarIdentifiers"], + argumentHints: [ + "start": "ISO8601 timestamp; defaults to now.", + "end": "ISO8601 timestamp; defaults to start + 14 days.", + "limit": "Max number of items, default 50.", + "calendarIdentifier": "Optional EventKit calendarIdentifier to restrict results.", + "calendarIdentifiers": "Optional array of EventKit calendarIdentifier strings to restrict results.", + ], + resultSummary: "Array of events with identifier/title/startDate/endDate/notes/calendarIdentifier/calendarTitle/location/url/isAllDay." + ), + handler: { args, context in + try eventKit.readEvents(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.calendar.createEvent", "apple.calendar.updateEvent"], + descriptor: .init( + id: .calendarWrite, + title: "Create or update calendar event", + summary: "Create a calendar event or patch an existing event by identifier.", + tags: ["calendar", "eventkit", "schedule"], + example: "await apple.calendar.createEvent({ title: 'Standup', start: '2026-02-22T16:00:00Z', end: '2026-02-22T16:15:00Z' })", + requiredPermissions: [], + optionalArguments: [ + "operation", + "identifier", + "title", + "start", + "end", + "notes", + "location", + "url", + "isAllDay", + "calendarIdentifier", + ], + argumentHints: [ + "operation": "create (default without identifier) or update (default with identifier).", + "identifier": "EventKit eventIdentifier to update. Updates require full calendar access at runtime.", + "title": "Event title string.", + "start": "ISO8601 start timestamp.", + "end": "ISO8601 end timestamp.", + "notes": "Optional notes/body string.", + "location": "Optional location string.", + "url": "Optional absolute URL string attached to the event.", + "isAllDay": "Whether the event is all-day.", + "calendarIdentifier": "Optional destination EventKit calendarIdentifier.", + ], + resultSummary: "Object with identifier/title/startDate/endDate/notes/calendarIdentifier/calendarTitle/location/url/isAllDay." + ), + handler: { args, context in + try eventKit.writeEvent(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.calendar.deleteEvent"], + descriptor: .init( + id: .calendarDelete, + title: "Delete calendar event", + summary: "Delete an existing EventKit event by identifier.", + tags: ["calendar", "eventkit", "schedule", "delete"], + example: "await apple.calendar.deleteEvent({ identifier: 'EVENT_ID', span: 'thisEvent' })", + requiredPermissions: [.calendar], + requiredArguments: ["identifier"], + optionalArguments: ["span"], + argumentHints: [ + "identifier": "EventKit eventIdentifier from apple.calendar.listEvents.", + "span": "thisEvent (default) or futureEvents for recurring events.", + ], + resultSummary: "Object with identifier/deleted." + ), + handler: { args, context in + try eventKit.deleteEvent(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.calendar.pickCalendar"], + descriptor: .init( + id: .calendarUIPickCalendar, + title: "Pick calendar with system UI", + summary: "Present EventKit calendar chooser UI and return the user-selected writable calendars.", + tags: ["calendar", "eventkit", "system-ui", "picker"], + example: "await apple.calendar.pickCalendar({ selectionStyle: 'single' })", + requiredPermissions: [.calendarWriteOnly], + optionalArguments: ["selectionStyle", "displayStyle", "timeoutMs"], + argumentTypes: [ + "selectionStyle": .string, + "displayStyle": .string, + "timeoutMs": .number, + ], + argumentHints: [ + "selectionStyle": "single (default) or multiple.", + "displayStyle": "writable (default) or all.", + "timeoutMs": "Optional timeout for waiting on user selection.", + ], + resultSummary: "Array of selected calendars with identifier/title/type/allowsContentModifications." + ), + handler: { args, context in + try systemUI.pickCalendar(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.calendar.presentEvent"], + descriptor: .init( + id: .calendarUIPresentEvent, + title: "Present calendar event details", + summary: "Present system UI for an existing calendar event identifier.", + tags: ["calendar", "eventkit", "system-ui", "details"], + example: "await apple.calendar.presentEvent({ identifier: 'EVENT_ID', allowsEditing: false })", + requiredPermissions: [.calendar], + requiredArguments: ["identifier"], + optionalArguments: ["allowsEditing", "allowsCalendarPreview", "timeoutMs"], + argumentTypes: [ + "identifier": .string, + "allowsEditing": .bool, + "allowsCalendarPreview": .bool, + "timeoutMs": .number, + ], + argumentHints: [ + "identifier": "EventKit eventIdentifier from apple.calendar.listEvents.", + "allowsEditing": "Whether the user can edit from the detail UI; default false.", + "allowsCalendarPreview": "Whether the UI may show calendar day previews; default true.", + "timeoutMs": "Optional timeout for waiting on dismissal.", + ], + resultSummary: "Object with action dismissed." + ), + handler: { args, context in + try systemUI.presentCalendarEvent(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.calendar.presentNewEvent"], + descriptor: .init( + id: .calendarUIPresentNewEvent, + title: "Present calendar event editor", + summary: "Present system UI to let the user create or edit a new calendar event draft.", + tags: ["calendar", "eventkit", "system-ui", "picker"], + example: "await apple.calendar.presentNewEvent({ title: 'Standup', start: '2026-02-22T16:00:00Z', end: '2026-02-22T16:15:00Z' })", + requiredPermissions: [.calendarWriteOnly], + optionalArguments: ["title", "start", "end", "notes", "location", "timeoutMs"], + argumentTypes: [ + "title": .string, + "start": .string, + "end": .string, + "notes": .string, + "location": .string, + "timeoutMs": .number, + ], + argumentHints: [ + "title": "Optional event title shown in the editor.", + "start": "Optional ISO8601 start timestamp.", + "end": "Optional ISO8601 end timestamp.", + "notes": "Optional event notes/body text.", + "location": "Optional location string.", + "timeoutMs": "Optional timeout for waiting on user save/cancel.", + ], + resultSummary: "Object with action plus identifier/title when the user saves." + ), + handler: { args, context in + try systemUI.presentNewCalendarEvent(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.reminders.listReminders"], + descriptor: .init( + id: .remindersRead, + title: "Read reminders", + summary: "Read incomplete reminders from EventKit.", + tags: ["reminders", "eventkit", "task"], + example: "await apple.reminders.listReminders({ limit: 20 })", + requiredPermissions: [.reminders], + optionalArguments: ["start", "end", "includeCompleted", "calendarIdentifier", "calendarIdentifiers", "limit"], + argumentHints: [ + "start": "Optional ISO8601 due-date lower bound.", + "end": "Optional ISO8601 due-date upper bound.", + "includeCompleted": "Whether completed reminders are included; default false.", + "calendarIdentifier": "Optional EventKit reminder calendarIdentifier to restrict results.", + "calendarIdentifiers": "Optional array of EventKit reminder calendarIdentifier strings to restrict results.", + "limit": "Max number of reminder items, default 50.", + ], + resultSummary: "Array of reminders with identifier/title/isCompleted/dueDate/completionDate/notes/priority/calendarIdentifier/calendarTitle." + ), + handler: { args, context in + try eventKit.readReminders(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.reminders.createReminder", "apple.reminders.updateReminder", "apple.reminders.completeReminder"], + descriptor: .init( + id: .remindersWrite, + title: "Create or update reminder", + summary: "Create a reminder or patch an existing reminder by identifier.", + tags: ["reminders", "eventkit", "task"], + example: "await apple.reminders.createReminder({ title: 'Buy batteries', dueDate: '2026-02-22T18:00:00Z' })", + requiredPermissions: [.reminders], + optionalArguments: ["operation", "identifier", "title", "dueDate", "notes", "isCompleted", "priority", "calendarIdentifier"], + argumentHints: [ + "operation": "create (default without identifier), update, or complete.", + "identifier": "EventKit calendarItemIdentifier to update or complete.", + "title": "Reminder title string.", + "dueDate": "Optional ISO8601 due date timestamp.", + "notes": "Optional reminder notes.", + "isCompleted": "Completion state for update or completeReminder; default true for completeReminder.", + "priority": "EventKit reminder priority integer.", + "calendarIdentifier": "Optional destination EventKit reminders calendarIdentifier.", + ], + resultSummary: "Object with identifier/title/isCompleted/dueDate/completionDate/notes/priority/calendarIdentifier/calendarTitle." + ), + handler: { args, context in + try eventKit.writeReminder(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.reminders.deleteReminder"], + descriptor: .init( + id: .remindersDelete, + title: "Delete reminder", + summary: "Delete an existing EventKit reminder by identifier.", + tags: ["reminders", "eventkit", "task", "delete"], + example: "await apple.reminders.deleteReminder({ identifier: 'REMINDER_ID' })", + requiredPermissions: [.reminders], + requiredArguments: ["identifier"], + argumentHints: [ + "identifier": "EventKit calendarItemIdentifier from apple.reminders.listReminders.", + ], + resultSummary: "Object with identifier/deleted." + ), + handler: { args, context in + try eventKit.deleteReminder(arguments: args, context: context) + } + ), + ] + } +} diff --git a/Sources/CodeMode/Bridges/CapabilityRegistrations+IntentsModelsActivityMaps.swift b/Sources/CodeMode/Bridges/CapabilityRegistrations+IntentsModelsActivityMaps.swift new file mode 100644 index 0000000..6e261f2 --- /dev/null +++ b/Sources/CodeMode/Bridges/CapabilityRegistrations+IntentsModelsActivityMaps.swift @@ -0,0 +1,320 @@ +import Foundation + +extension DefaultCapabilityRegistrationBuilder { + func appIntentsRegistrations() -> [CapabilityRegistration] { + [ + CapabilityRegistration( + .appIntentsList, + jsName: "apple.appIntents.list", + title: "List host App Intent adapters", + summary: "List host-registered App Intents or Shortcuts actions exposed to CodeMode.", + tags: ["appintents", "shortcuts", "actions", "host-adapter"], + example: "await apple.appIntents.list()", + optionalArguments: ["domain", "limit"], + argumentHints: [ + "domain": "Optional host-defined action domain filter.", + "limit": "Maximum actions to return.", + ], + resultSummary: "Array of actions with identifier/title/summary/requiredParameters." + ) { args, _ in + try appIntents.listActions(arguments: args) + }, + CapabilityRegistration( + .appIntentsRun, + jsName: "apple.appIntents.run", + title: "Run host App Intent adapter", + summary: "Run a host-registered App Intent adapter with structured parameters.", + tags: ["appintents", "shortcuts", "actions", "host-adapter"], + example: "await apple.appIntents.run({ identifier: 'createNote', parameters: { title: 'Draft' } })", + requiredArguments: ["identifier"], + optionalArguments: ["parameters", "timeoutMs"], + argumentHints: [ + "identifier": "Host-registered action identifier from apple.appIntents.list.", + "parameters": "Structured parameters validated by the host adapter.", + "timeoutMs": "Maximum wait time in milliseconds.", + ], + resultSummary: "Adapter-defined structured result." + ) { args, _ in + try appIntents.runAction(arguments: args) + }, + CapabilityRegistration( + .appIntentsDonate, + jsName: "apple.appIntents.donate", + title: "Donate host App Intent action", + summary: "Ask the host to donate a supported action to Shortcuts/Siri suggestions where feasible.", + tags: ["appintents", "shortcuts", "donation", "host-adapter"], + example: "await apple.appIntents.donate({ identifier: 'createNote', parameters: { title: 'Draft' } })", + requiredArguments: ["identifier"], + optionalArguments: ["parameters"], + argumentHints: [ + "identifier": "Host-registered action identifier.", + "parameters": "Structured action parameters used for the donation.", + ], + resultSummary: "Object with donated/status." + ) { args, _ in + try appIntents.donateAction(arguments: args) + }, + CapabilityRegistration( + .appIntentsOpen, + jsName: "apple.appIntents.open", + title: "Open host App Intent surface", + summary: "Open a host-provided App Intent, Shortcuts, or app surface for user-mediated continuation.", + tags: ["appintents", "shortcuts", "open", "host-adapter"], + example: "await apple.appIntents.open({ identifier: 'showTask', parameters: { id: 'task-1' } })", + requiredArguments: ["identifier"], + optionalArguments: ["parameters"], + argumentHints: [ + "identifier": "Host-registered open action identifier.", + "parameters": "Structured parameters for the host open action.", + ], + resultSummary: "Object with opened/status." + ) { args, _ in + try appIntents.openAction(arguments: args) + }, + CapabilityRegistration( + .appIntentsHandoffsRead, + jsName: "apple.appIntents.listHandoffs", + title: "Read App Intent handoff inbox", + summary: "Read bounded App Intent handoff events captured by the host app.", + tags: ["appintents", "shortcuts", "handoff", "inbox", "events"], + example: "await apple.appIntents.listHandoffs({ limit: 20 })", + optionalArguments: ["identifier", "limit", "afterCursor"], + argumentHints: [ + "identifier": "Optional action identifier filter.", + "limit": "Maximum handoff events to return.", + "afterCursor": "Optional host-provided cursor for incremental reads.", + ], + resultSummary: "Array of handoff events with cursor/identifier/parameters/date/source." + ) { args, _ in + try appIntents.readHandoffs(arguments: args) + }, + ] + } + + + func foundationModelsRegistrations() -> [CapabilityRegistration] { + [ + CapabilityRegistration( + .foundationModelsStatus, + jsName: "apple.foundationModels.getStatus", + title: "Read Foundation Models availability", + summary: "Read host Foundation Models availability/status before attempting local generation.", + tags: ["foundationmodels", "apple-intelligence", "llm", "availability"], + example: "await apple.foundationModels.getStatus()", + resultSummary: "Object with available/status/reason/modelIdentifier when available." + ) { args, _ in + try foundationModels.status(arguments: args) + }, + CapabilityRegistration( + .foundationModelsGenerate, + jsName: "apple.foundationModels.generate", + title: "Generate text with Foundation Models", + summary: "Generate text locally through the host Foundation Models adapter when available.", + tags: ["foundationmodels", "apple-intelligence", "llm", "generation"], + example: "await apple.foundationModels.generate({ prompt: 'Summarize this note', maxTokens: 200 })", + requiredArguments: ["prompt"], + optionalArguments: ["instructions", "temperature", "maxTokens", "schemaIdentifier", "timeoutMs"], + argumentHints: [ + "prompt": "Prompt text sent to the host Foundation Models session.", + "instructions": "Optional host-approved system instructions.", + "temperature": "Optional sampling temperature when supported.", + "maxTokens": "Optional maximum output tokens.", + "schemaIdentifier": "Optional host-defined output schema identifier.", + "timeoutMs": "Maximum generation wait time in milliseconds.", + ], + resultSummary: "Object with text/finishReason/usage when available." + ) { args, _ in + try foundationModels.generateText(arguments: args) + }, + CapabilityRegistration( + .foundationModelsExtract, + jsName: "apple.foundationModels.extract", + title: "Extract structured data with Foundation Models", + summary: "Run host-defined structured extraction or classification with Foundation Models.", + tags: ["foundationmodels", "apple-intelligence", "llm", "structured-output"], + example: "await apple.foundationModels.extract({ input: text, schemaIdentifier: 'todo' })", + requiredArguments: ["input"], + optionalArguments: ["schema", "schemaIdentifier", "instructions", "timeoutMs"], + argumentHints: [ + "input": "Input text to classify or extract from.", + "schema": "Optional JSON schema object accepted by the host adapter.", + "schemaIdentifier": "Preferred host-defined schema identifier.", + "instructions": "Optional task-specific instructions.", + "timeoutMs": "Maximum extraction wait time in milliseconds.", + ], + resultSummary: "Object with values/classification/confidence/schemaIdentifier." + ) { args, _ in + try foundationModels.extract(arguments: args) + }, + ] + } + + + func activityRegistrations() -> [CapabilityRegistration] { + [ + CapabilityRegistration( + .activityList, + jsName: "apple.activity.list", + title: "List Live Activities", + summary: "List host-registered ActivityKit Live Activities visible to CodeMode.", + tags: ["activitykit", "live-activities", "host-adapter"], + example: "await apple.activity.list()", + optionalArguments: ["activityType", "limit"], + resultSummary: "Array of activities with identifier/activityType/state/contentState/attributes." + ) { args, _ in + try activity.listActivities(arguments: args) + }, + CapabilityRegistration( + .activityStart, + jsName: "apple.activity.start", + title: "Start Live Activity", + summary: "Start a host-registered ActivityKit Live Activity adapter.", + tags: ["activitykit", "live-activities", "host-adapter"], + example: "await apple.activity.start({ activityType: 'delivery', attributes: {}, contentState: {} })", + requiredArguments: ["activityType", "attributes"], + optionalArguments: ["contentState", "pushType", "staleDate"], + argumentHints: [ + "activityType": "Host-registered activity adapter type.", + "attributes": "Adapter-defined immutable attributes.", + "contentState": "Adapter-defined mutable content state.", + "pushType": "Optional push token request mode when the adapter supports remote updates.", + "staleDate": "Optional ISO8601 stale date.", + ], + resultSummary: "Object with identifier/activityType/state/pushToken when available." + ) { args, _ in + try activity.startActivity(arguments: args) + }, + CapabilityRegistration( + .activityUpdate, + jsName: "apple.activity.update", + title: "Update Live Activity", + summary: "Update an existing host-registered Live Activity content state.", + tags: ["activitykit", "live-activities", "host-adapter"], + example: "await apple.activity.update({ identifier: 'activity-1', contentState: { progress: 0.7 } })", + requiredArguments: ["identifier", "contentState"], + optionalArguments: ["alert", "staleDate"], + resultSummary: "Object with identifier/updated/state." + ) { args, _ in + try activity.updateActivity(arguments: args) + }, + CapabilityRegistration( + .activityEnd, + jsName: "apple.activity.end", + title: "End Live Activity", + summary: "End a host-registered Live Activity, optionally with final content state.", + tags: ["activitykit", "live-activities", "host-adapter"], + example: "await apple.activity.end({ identifier: 'activity-1', dismissalPolicy: 'immediate' })", + requiredArguments: ["identifier"], + optionalArguments: ["contentState", "dismissalPolicy"], + resultSummary: "Object with identifier/ended/state." + ) { args, _ in + try activity.endActivity(arguments: args) + }, + CapabilityRegistration( + .activityPushTokenRead, + jsName: "apple.activity.getPushToken", + title: "Read Live Activity push token", + summary: "Read a Live Activity push token when the host adapter supports push updates.", + tags: ["activitykit", "live-activities", "push-token"], + example: "await apple.activity.getPushToken({ identifier: 'activity-1' })", + requiredArguments: ["identifier"], + resultSummary: "Object with identifier/pushToken/environment." + ) { args, _ in + try activity.readPushToken(arguments: args) + }, + ] + } + + + func mapsRegistrations() -> [CapabilityRegistration] { + [ + CapabilityRegistration( + .mapsGeocode, + jsName: "apple.maps.geocode", + title: "Geocode address", + summary: "Resolve an address or place text to coordinate candidates through MapKit.", + tags: ["maps", "mapkit", "geocode", "location"], + example: "await apple.maps.geocode({ address: '1 Infinite Loop, Cupertino', limit: 3 })", + requiredArguments: ["address"], + optionalArguments: ["region", "limit"], + argumentHints: [ + "address": "Address or place text to geocode.", + "region": "Optional search bias region object.", + "limit": "Maximum coordinate candidates.", + ], + resultSummary: "Array of placemarks with name/address/latitude/longitude." + ) { args, _ in + try maps.geocode(arguments: args) + }, + CapabilityRegistration( + .mapsReverseGeocode, + jsName: "apple.maps.reverseGeocode", + title: "Reverse geocode coordinates", + summary: "Resolve latitude/longitude into address candidates through MapKit.", + tags: ["maps", "mapkit", "reverse-geocode", "location"], + example: "await apple.maps.reverseGeocode({ latitude: 37.3318, longitude: -122.0312 })", + requiredArguments: ["latitude", "longitude"], + optionalArguments: ["locale"], + resultSummary: "Array of placemarks with name/address/latitude/longitude." + ) { args, _ in + try maps.reverseGeocode(arguments: args) + }, + CapabilityRegistration( + .mapsSearch, + jsName: "apple.maps.search", + title: "Search local map items", + summary: "Search for local businesses, addresses, or points of interest through MapKit.", + tags: ["maps", "mapkit", "local-search", "places"], + example: "await apple.maps.search({ query: 'coffee near me', limit: 5 })", + requiredArguments: ["query"], + optionalArguments: ["region", "resultTypes", "limit"], + argumentHints: [ + "query": "Search query string.", + "region": "Optional coordinate region object for local bias.", + "resultTypes": "Optional host-supported result type filters.", + "limit": "Maximum map items.", + ], + resultSummary: "Array of map items with name/address/category/latitude/longitude/url." + ) { args, _ in + try maps.search(arguments: args) + }, + CapabilityRegistration( + .mapsRouteEstimate, + jsName: "apple.maps.routeEstimate", + title: "Estimate route", + summary: "Estimate route distance and travel time between two coordinates or map items.", + tags: ["maps", "mapkit", "directions", "route"], + example: "await apple.maps.routeEstimate({ origin: { latitude: 37.33, longitude: -122.03 }, destination: { latitude: 37.77, longitude: -122.42 }, transportType: 'automobile' })", + requiredArguments: ["origin", "destination"], + optionalArguments: ["transportType", "departureDate", "arrivalDate"], + argumentHints: [ + "origin": "Coordinate or map item object.", + "destination": "Coordinate or map item object.", + "transportType": "automobile (default), walking, or transit when supported.", + ], + resultSummary: "Object with distanceMeters/expectedTravelTimeSeconds/transportType." + ) { args, _ in + try maps.routeEstimate(arguments: args) + }, + CapabilityRegistration( + .mapsOpen, + jsName: "apple.maps.open", + title: "Open Maps", + summary: "Open Apple Maps with coordinates, a query, URL, or directions in a user-mediated handoff.", + tags: ["maps", "mapkit", "open", "directions"], + example: "await apple.maps.open({ query: 'Apple Park' })", + optionalArguments: ["query", "url", "latitude", "longitude", "destination", "transportType"], + argumentHints: [ + "query": "Place/search query to open.", + "url": "Optional maps URL to open.", + "latitude": "Latitude when opening coordinates.", + "longitude": "Longitude when opening coordinates.", + "destination": "Optional destination object for directions.", + ], + resultSummary: "Object with opened/target." + ) { args, _ in + try maps.open(arguments: args) + }, + ] + } +} diff --git a/Sources/CodeMode/Bridges/CapabilityRegistrations+LocationWeather.swift b/Sources/CodeMode/Bridges/CapabilityRegistrations+LocationWeather.swift new file mode 100644 index 0000000..d6d9719 --- /dev/null +++ b/Sources/CodeMode/Bridges/CapabilityRegistrations+LocationWeather.swift @@ -0,0 +1,7 @@ +import Foundation + +extension DefaultCapabilityRegistrationBuilder { + func locationAndWeatherRegistrations() -> [CapabilityRegistration] { + LocationWeatherCodeModeBuiltIns(location: location, weather: weather).capabilityRegistrations() + } +} diff --git a/Sources/CodeMode/Bridges/CapabilityRegistrations+PeoplePhotosDocuments.swift b/Sources/CodeMode/Bridges/CapabilityRegistrations+PeoplePhotosDocuments.swift new file mode 100644 index 0000000..d38daed --- /dev/null +++ b/Sources/CodeMode/Bridges/CapabilityRegistrations+PeoplePhotosDocuments.swift @@ -0,0 +1,337 @@ +import Foundation + +extension DefaultCapabilityRegistrationBuilder { + func contactRegistrations() -> [CapabilityRegistration] { + [ + CapabilityRegistration( + jsNames: ["apple.contacts.list"], + descriptor: .init( + id: .contactsRead, + title: "Read contacts", + summary: "Read contacts with bounded fields.", + tags: ["contacts", "address-book", "people"], + example: "await apple.contacts.list({ limit: 25 })", + requiredPermissions: [.contacts], + optionalArguments: ["limit", "identifiers"], + argumentHints: [ + "limit": "Max number of contacts, default 50.", + "identifiers": "Optional array of contact identifiers for targeted read.", + ], + resultSummary: "Array of contacts with identifier/name/organization/phones/emails." + ), + handler: { args, context in + try contacts.read(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.contacts.search"], + descriptor: .init( + id: .contactsSearch, + title: "Search contacts", + summary: "Search contacts by name.", + tags: ["contacts", "search", "people"], + example: "await apple.contacts.search({ query: 'Alex', limit: 10 })", + requiredPermissions: [.contacts], + requiredArguments: ["query"], + optionalArguments: ["limit"], + argumentHints: [ + "query": "Name text to match.", + "limit": "Max number of contacts, default 20.", + ], + resultSummary: "Array of contact objects." + ), + handler: { args, context in + try contacts.search(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.contacts.pick"], + descriptor: .init( + id: .contactsUIPick, + title: "Pick contacts with system UI", + summary: "Present system contact picker UI and return selected contacts without requiring full Contacts permission.", + tags: ["contacts", "people", "system-ui", "picker"], + example: "await apple.contacts.pick({ mode: 'single', displayedPropertyKeys: ['phoneNumbers', 'emailAddresses'] })", + optionalArguments: ["mode", "displayedPropertyKeys", "timeoutMs"], + argumentTypes: [ + "mode": .string, + "displayedPropertyKeys": .array, + "timeoutMs": .number, + ], + argumentHints: [ + "mode": "single (default) or multiple.", + "displayedPropertyKeys": "Optional array of CNContact property key strings to display.", + "timeoutMs": "Optional timeout for waiting on user selection.", + ], + resultSummary: "Array of selected contacts with identifier/name/organization/phones/emails." + ), + handler: { args, context in + try systemUI.pickContacts(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.contacts.presentContact"], + descriptor: .init( + id: .contactsUIPresentContact, + title: "Present contact card", + summary: "Present system contact card UI for a contact identifier.", + tags: ["contacts", "people", "system-ui", "details"], + example: "await apple.contacts.presentContact({ identifier: 'CONTACT_ID', allowsEditing: false })", + requiredPermissions: [.contacts], + requiredArguments: ["identifier"], + optionalArguments: ["allowsEditing", "allowsActions", "displayedPropertyKeys", "timeoutMs"], + argumentTypes: [ + "identifier": .string, + "allowsEditing": .bool, + "allowsActions": .bool, + "displayedPropertyKeys": .array, + "timeoutMs": .number, + ], + argumentHints: [ + "identifier": "Contact identifier from apple.contacts.list/search/pick.", + "allowsEditing": "Whether the user can edit the contact; default false.", + "allowsActions": "Whether built-in actions like call/message are shown; default true.", + "displayedPropertyKeys": "Optional array of CNContact property key strings to display.", + "timeoutMs": "Optional timeout for waiting on dismissal.", + ], + resultSummary: "Object with action and contact when available." + ), + handler: { args, context in + try systemUI.presentContact(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.contacts.presentNewContact"], + descriptor: .init( + id: .contactsUIPresentNewContact, + title: "Present new contact editor", + summary: "Present system UI for creating a new contact draft.", + tags: ["contacts", "people", "system-ui", "create"], + example: "await apple.contacts.presentNewContact({ givenName: 'Alex', familyName: 'Lee', emailAddresses: ['alex@example.com'] })", + requiredPermissions: [.contacts], + optionalArguments: ["givenName", "familyName", "organization", "phoneNumbers", "emailAddresses", "timeoutMs"], + argumentTypes: [ + "givenName": .string, + "familyName": .string, + "organization": .string, + "phoneNumbers": .array, + "emailAddresses": .array, + "timeoutMs": .number, + ], + argumentHints: [ + "givenName": "Optional given name to prefill.", + "familyName": "Optional family name to prefill.", + "organization": "Optional organization to prefill.", + "phoneNumbers": "Optional array of phone number strings.", + "emailAddresses": "Optional array of email address strings.", + "timeoutMs": "Optional timeout for waiting on user completion.", + ], + resultSummary: "Object with action and contact when the user saves." + ), + handler: { args, context in + try systemUI.presentNewContact(arguments: args, context: context) + } + ), + ] + } + + + func photoAndDocumentRegistrations() -> [CapabilityRegistration] { + [ + CapabilityRegistration( + jsNames: ["apple.photos.list"], + descriptor: .init( + id: .photosRead, + title: "List photo library assets", + summary: "List photos/videos from the user photo library.", + tags: ["photos", "photo-library", "media"], + example: "await apple.photos.list({ mediaType: 'image', limit: 20 })", + requiredPermissions: [.photoLibrary], + optionalArguments: ["mediaType", "limit"], + argumentHints: [ + "mediaType": "any (default), image, or video.", + "limit": "Max number of results, default 50.", + ], + resultSummary: "Array of assets with localIdentifier/mediaType/dimensions/date metadata." + ), + handler: { args, context in + try photos.read(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.photos.export"], + descriptor: .init( + id: .photosExport, + title: "Export photo library asset", + summary: "Export a photo/video asset to sandbox file path and register artifact handle.", + tags: ["photos", "photo-library", "artifact"], + example: "await apple.photos.export({ localIdentifier: 'ABC/L0/001', outputPath: 'tmp:exported.jpg' })", + requiredPermissions: [.photoLibrary], + requiredArguments: ["localIdentifier"], + optionalArguments: ["outputPath"], + argumentHints: [ + "localIdentifier": "PHAsset localIdentifier from photos.read result.", + "outputPath": "Optional sandbox output path; defaults to tmp-generated file.", + ], + resultSummary: "Object with path/artifactID/localIdentifier/mediaType/bytes." + ), + handler: { args, context in + try photos.export(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.photos.pick"], + descriptor: .init( + id: .photosUIPick, + title: "Pick photos with system UI", + summary: "Present system photo picker UI and export selected assets into the sandbox artifact store.", + tags: ["photos", "photo-library", "system-ui", "picker", "artifact"], + example: "await apple.photos.pick({ mediaType: 'image', limit: 3, outputDirectory: 'tmp:picks' })", + optionalArguments: ["mediaType", "limit", "outputDirectory", "timeoutMs"], + argumentTypes: [ + "mediaType": .string, + "limit": .number, + "outputDirectory": .string, + "timeoutMs": .number, + ], + argumentHints: [ + "mediaType": "any (default), image/photo, or video.", + "limit": "Maximum number of selectable items; default 1.", + "outputDirectory": "Optional sandbox directory for exported picker files; defaults to tmp:.", + "timeoutMs": "Optional timeout for waiting on user selection/export.", + ], + resultSummary: "Array of selected assets with path/artifactID/mediaType/uniformTypeIdentifier/bytes." + ), + handler: { args, context in + try systemUI.pickPhotos(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.photos.presentLimitedLibraryPicker"], + descriptor: .init( + id: .photosUIPresentLimitedLibraryPicker, + title: "Present limited Photos picker", + summary: "Present the Photos limited-library management UI so the user can update the app's selected photo set.", + tags: ["photos", "photo-library", "system-ui", "permission", "picker"], + example: "await apple.photos.presentLimitedLibraryPicker()", + optionalArguments: ["timeoutMs"], + argumentTypes: [ + "timeoutMs": .number, + ], + argumentHints: [ + "timeoutMs": "Optional timeout for waiting on the limited-library picker completion.", + ], + resultSummary: "Object with action/status and selectedIdentifiers when the limited selection changes." + ), + handler: { args, context in + try systemUI.presentLimitedPhotoLibraryPicker(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.documents.pick"], + descriptor: .init( + id: .documentsUIPick, + title: "Pick documents with system UI", + summary: "Present Files document picker UI and copy selected documents into the sandbox artifact store.", + tags: ["documents", "files", "system-ui", "picker", "artifact"], + example: "await apple.documents.pick({ contentTypes: ['public.item'], allowMultiple: true, outputDirectory: 'tmp:imports' })", + optionalArguments: ["contentTypes", "allowMultiple", "outputDirectory", "timeoutMs"], + argumentTypes: [ + "contentTypes": .array, + "allowMultiple": .bool, + "outputDirectory": .string, + "timeoutMs": .number, + ], + argumentHints: [ + "contentTypes": "Optional array of UTType identifiers; defaults to public.item.", + "allowMultiple": "Whether multiple files may be selected; default false.", + "outputDirectory": "Optional sandbox directory for copied files; defaults to tmp:.", + "timeoutMs": "Optional timeout for waiting on user selection/copy.", + ], + resultSummary: "Array of selected documents with path/artifactID/filename/uniformTypeIdentifier/bytes." + ), + handler: { args, context in + try systemUI.pickDocuments(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.documents.export", "apple.documents.save"], + descriptor: .init( + id: .documentsUIExport, + title: "Export documents with system UI", + summary: "Present Files export UI to save one or more sandbox files to a user-selected destination.", + tags: ["documents", "files", "system-ui", "export", "save"], + example: "await apple.documents.export({ path: 'tmp:report.pdf' })", + optionalArguments: ["path", "paths", "asCopy", "timeoutMs"], + argumentTypes: [ + "path": .string, + "paths": .array, + "asCopy": .bool, + "timeoutMs": .number, + ], + argumentHints: [ + "path": "Single sandbox file path to export.", + "paths": "Optional array of sandbox file paths to export.", + "asCopy": "Whether to export as a copy; default true.", + "timeoutMs": "Optional timeout for waiting on export completion.", + ], + resultSummary: "Object with action/count and destination URLs when the provider returns them." + ), + handler: { args, context in + try systemUI.exportDocuments(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.documents.openIn"], + descriptor: .init( + id: .documentsUIOpenIn, + title: "Open document in another app", + summary: "Present the system Open In menu for a sandbox file.", + tags: ["documents", "files", "system-ui", "open-in", "handoff"], + example: "await apple.documents.openIn({ path: 'tmp:report.pdf' })", + requiredArguments: ["path"], + optionalArguments: ["name", "uti", "timeoutMs"], + argumentTypes: [ + "path": .string, + "name": .string, + "uti": .string, + "timeoutMs": .number, + ], + argumentHints: [ + "path": "Sandbox file path to hand off.", + "name": "Optional display name for the document interaction controller.", + "uti": "Optional uniform type identifier override.", + "timeoutMs": "Optional timeout for waiting on the Open In menu dismissal.", + ], + resultSummary: "Object with action and application bundle identifier when the user hands off the file." + ), + handler: { args, context in + try systemUI.openDocument(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.documents.scan"], + descriptor: .init( + id: .documentsUIScan, + title: "Scan documents with system UI", + summary: "Present VisionKit document scanner UI and export scanned pages into the sandbox artifact store.", + tags: ["documents", "scan", "camera", "system-ui", "artifact"], + example: "await apple.documents.scan({ outputDirectory: 'tmp:scans' })", + optionalArguments: ["outputDirectory", "timeoutMs"], + argumentTypes: [ + "outputDirectory": .string, + "timeoutMs": .number, + ], + argumentHints: [ + "outputDirectory": "Optional sandbox directory for scanned page images; defaults to tmp:.", + "timeoutMs": "Optional timeout for waiting on user scanning/export.", + ], + resultSummary: "Array of scanned page artifacts with path/artifactID/pageIndex/mediaType/bytes." + ), + handler: { args, context in + try systemUI.scanDocuments(arguments: args, context: context) + } + ), + ] + } +} diff --git a/Sources/CodeMode/Bridges/CapabilityRegistrations+SystemServices.swift b/Sources/CodeMode/Bridges/CapabilityRegistrations+SystemServices.swift new file mode 100644 index 0000000..4a7fe72 --- /dev/null +++ b/Sources/CodeMode/Bridges/CapabilityRegistrations+SystemServices.swift @@ -0,0 +1,405 @@ +import Foundation + +extension DefaultCapabilityRegistrationBuilder { + func visionRegistrations() -> [CapabilityRegistration] { + [ + CapabilityRegistration( + .visionImageAnalyze, + jsName: "apple.vision.analyzeImage", + title: "Analyze image with Vision", + summary: "Run on-device image analysis for labels/text/barcodes on sandbox image paths.", + tags: ["vision", "image-analysis", "ml"], + example: "await apple.vision.analyzeImage({ path: 'tmp:receipt.jpg', features: ['text'], maxResults: 10 })", + requiredArguments: ["path"], + optionalArguments: ["features", "maxResults"], + argumentHints: [ + "path": "Sandbox image path to analyze.", + "features": "Optional array including labels/text/barcodes.", + "maxResults": "Max observations returned per feature, default 5.", + ], + resultSummary: "Object containing requested analysis sections such as labels/text/barcodes." + ) { args, context in + try vision.analyzeImage(arguments: args, context: context) + }, + ] + } + + + func notificationRegistrations() -> [CapabilityRegistration] { + [ + CapabilityRegistration( + .notificationsPermissionRequest, + jsName: "apple.notifications.requestPermission", + title: "Request notification permission", + summary: "Request local notification authorization from the user.", + tags: ["notifications", "permission"], + example: "await apple.notifications.requestPermission()", + resultSummary: "Object with status/granted fields." + ) { _, context in + try notifications.requestPermission(context: context) + }, + CapabilityRegistration( + .notificationsSchedule, + jsName: "apple.notifications.schedule", + title: "Schedule local notification", + summary: "Schedule a local notification using time interval or fireDate trigger.", + tags: ["notifications", "local", "schedule"], + example: "await apple.notifications.schedule({ title: 'Stand up', body: 'Stretch break', secondsFromNow: 900 })", + requiredPermissions: [.notifications], + requiredArguments: ["title"], + optionalArguments: [ + "identifier", + "subtitle", + "body", + "secondsFromNow", + "fireDate", + "repeats", + "sound", + "badge", + "userInfo", + "threadIdentifier", + "categoryIdentifier", + ], + argumentHints: [ + "title": "Notification title text.", + "identifier": "Optional request identifier; defaults to codemode UUID.", + "subtitle": "Optional subtitle text.", + "body": "Optional body text.", + "secondsFromNow": "Delay in seconds for time interval trigger (default 5).", + "fireDate": "Optional ISO8601 timestamp for calendar trigger.", + "repeats": "Boolean repeat flag (time interval requires >= 60 seconds).", + "sound": "default (default), none, or a bundled custom sound name.", + "badge": "Optional app icon badge number.", + "userInfo": "Optional property-list-safe userInfo object.", + "threadIdentifier": "Optional thread identifier for notification grouping.", + "categoryIdentifier": "Optional category identifier for notification actions.", + ], + resultSummary: "Object with identifier/scheduled/repeats." + ) { args, context in + try notifications.schedule(arguments: args, context: context) + }, + CapabilityRegistration( + .notificationsPendingRead, + jsName: "apple.notifications.listPending", + title: "List pending local notifications", + summary: "List pending local notification requests.", + tags: ["notifications", "local", "schedule"], + example: "await apple.notifications.listPending({ limit: 20 })", + requiredPermissions: [.notifications], + optionalArguments: ["limit"], + argumentHints: [ + "limit": "Max number of pending requests to return, default 50.", + ], + resultSummary: "Array of pending requests with identifiers/content/trigger metadata." + ) { args, context in + try notifications.readPending(arguments: args, context: context) + }, + CapabilityRegistration( + .notificationsPendingDelete, + jsName: "apple.notifications.cancelPending", + title: "Delete pending local notifications", + summary: "Delete pending local notifications by identifier list or clear all.", + tags: ["notifications", "local", "schedule"], + example: "await apple.notifications.cancelPending({ identifiers: ['codemode.1', 'codemode.2'] })", + requiredPermissions: [.notifications], + optionalArguments: ["identifier", "identifiers"], + argumentHints: [ + "identifier": "Single pending request identifier to remove.", + "identifiers": "Array of pending request identifiers to remove. Omit both to clear all pending requests.", + ], + resultSummary: "Object with deleted/count fields." + ) { args, context in + try notifications.deletePending(arguments: args, context: context) + }, + CapabilityRegistration( + .notificationsDeliveredRead, + jsName: "apple.notifications.listDelivered", + title: "List delivered local notifications", + summary: "List notifications currently delivered in Notification Center.", + tags: ["notifications", "local", "delivered"], + example: "await apple.notifications.listDelivered({ limit: 20 })", + requiredPermissions: [.notifications], + optionalArguments: ["limit"], + argumentHints: [ + "limit": "Max number of delivered notifications to return, default 50.", + ], + resultSummary: "Array of delivered notifications with identifiers/content/date metadata." + ) { args, context in + try notifications.readDelivered(arguments: args, context: context) + }, + CapabilityRegistration( + .notificationsDeliveredDelete, + jsName: "apple.notifications.removeDelivered", + title: "Delete delivered local notifications", + summary: "Delete delivered notifications by identifier list or clear all.", + tags: ["notifications", "local", "delivered", "delete"], + example: "await apple.notifications.removeDelivered({ identifiers: ['codemode.1', 'codemode.2'] })", + requiredPermissions: [.notifications], + optionalArguments: ["identifier", "identifiers"], + argumentHints: [ + "identifier": "Single delivered notification identifier to remove.", + "identifiers": "Array of delivered notification identifiers to remove. Omit both to clear all delivered notifications.", + ], + resultSummary: "Object with deleted/count fields." + ) { args, context in + try notifications.deleteDelivered(arguments: args, context: context) + }, + ] + } + + + func alarmRegistrations() -> [CapabilityRegistration] { + [ + CapabilityRegistration( + .alarmPermissionRequest, + jsName: "ios.alarm.requestPermission", + title: "Request AlarmKit permission", + summary: "Request AlarmKit authorization from the user.", + tags: ["alarmkit", "permission", "alarms"], + example: "await ios.alarm.requestPermission()", + resultSummary: "Object with status/granted fields." + ) { _, context in + try alarm.requestPermission(context: context) + }, + CapabilityRegistration( + .alarmRead, + jsName: "ios.alarm.list", + title: "List scheduled alarms", + summary: "List scheduled alarms known to the bridge runtime.", + tags: ["alarmkit", "alarms", "schedule"], + example: "await ios.alarm.list({ limit: 20 })", + requiredPermissions: [.alarmKit], + optionalArguments: ["limit"], + argumentHints: [ + "limit": "Max number of scheduled alarms returned, default 50.", + ], + resultSummary: "Array of scheduled alarms with identifier/title/timing fields." + ) { args, context in + try alarm.read(arguments: args, context: context) + }, + CapabilityRegistration( + .alarmSchedule, + jsName: "ios.alarm.schedule", + title: "Schedule AlarmKit alarm", + summary: "Schedule an AlarmKit alarm using secondsFromNow or fireDate.", + tags: ["alarmkit", "alarms", "schedule"], + example: "await ios.alarm.schedule({ title: 'Wake up', secondsFromNow: 1800 })", + requiredPermissions: [.alarmKit], + requiredArguments: ["title"], + optionalArguments: ["identifier", "secondsFromNow", "fireDate"], + argumentHints: [ + "title": "Alarm title shown in presentation.", + "identifier": "Optional UUID string; generated when omitted.", + "secondsFromNow": "Fallback relative delay in seconds, default 60.", + "fireDate": "Optional absolute ISO8601 date; used when provided.", + ], + resultSummary: "Object with identifier/scheduled/title." + ) { args, context in + try alarm.schedule(arguments: args, context: context) + }, + CapabilityRegistration( + .alarmCancel, + jsName: "ios.alarm.cancel", + title: "Cancel scheduled alarms", + summary: "Cancel one or more scheduled alarms by identifier, or clear all known alarms.", + tags: ["alarmkit", "alarms", "schedule"], + example: "await ios.alarm.cancel({ identifiers: ['8F11679B-92E8-4D2F-84B4-4D0A7C95E3C3'] })", + requiredPermissions: [.alarmKit], + optionalArguments: ["identifier", "identifiers"], + argumentHints: [ + "identifier": "Single alarm identifier UUID string.", + "identifiers": "Array of alarm identifier UUID strings. Omit both to cancel all known alarms.", + ], + resultSummary: "Object with deleted/count fields." + ) { args, context in + try alarm.cancel(arguments: args, context: context) + }, + ] + } + + + func healthRegistrations() -> [CapabilityRegistration] { + [ + CapabilityRegistration( + .healthPermissionRequest, + jsName: "apple.health.requestPermission", + title: "Request HealthKit permission", + summary: "Request HealthKit authorization for requested read/write types.", + tags: ["healthkit", "health", "permission"], + example: "await apple.health.requestPermission({ readTypes: ['stepCount', 'heartRate'], writeTypes: ['stepCount'] })", + optionalArguments: ["readTypes", "writeTypes"], + argumentTypes: [ + "readTypes": .array, + "writeTypes": .array, + ], + argumentHints: [ + "readTypes": "Optional array of type names to read, e.g. stepCount, heartRate, activeEnergyBurned.", + "writeTypes": "Optional array of type names to write (quantity types only).", + ], + resultSummary: "Object with status/granted fields and requested type arrays." + ) { args, context in + try health.requestPermission(arguments: args, context: context) + }, + CapabilityRegistration( + .healthRead, + jsName: "apple.health.read", + title: "Read HealthKit samples", + summary: "Read HealthKit samples for a supported type and date range.", + tags: ["healthkit", "health", "query"], + example: "await apple.health.read({ type: 'stepCount', start: '2026-03-03T00:00:00Z', end: '2026-03-04T00:00:00Z', limit: 25, unit: 'count' })", + requiredArguments: ["type"], + optionalArguments: ["start", "end", "limit", "unit"], + argumentTypes: [ + "type": .string, + "start": .string, + "end": .string, + "limit": .number, + "unit": .string, + ], + argumentHints: [ + "type": "Supported: stepCount, heartRate, activeEnergyBurned, bodyMass, distanceWalkingRunning, sleepAnalysis, workout.", + "start": "Optional ISO8601 start timestamp; defaults to last 24h.", + "end": "Optional ISO8601 end timestamp; defaults to now.", + "limit": "Max number of samples, default 50.", + "unit": "Optional unit override for quantity types (count, bpm, kcal, kg, m).", + ], + resultSummary: "Array of samples with identifier/type/value and timing metadata." + ) { args, context in + try health.read(arguments: args, context: context) + }, + CapabilityRegistration( + .healthWrite, + jsName: "apple.health.write", + title: "Write HealthKit quantity sample", + summary: "Write a HealthKit quantity sample for supported writable quantity types.", + tags: ["healthkit", "health", "write"], + example: "await apple.health.write({ type: 'stepCount', value: 1200, unit: 'count', start: '2026-03-04T08:00:00Z', end: '2026-03-04T08:30:00Z' })", + requiredArguments: ["type", "value"], + optionalArguments: ["unit", "start", "end"], + argumentTypes: [ + "type": .string, + "value": .number, + "unit": .string, + "start": .string, + "end": .string, + ], + argumentHints: [ + "type": "Writable types: stepCount, heartRate, activeEnergyBurned, bodyMass, distanceWalkingRunning.", + "value": "Numeric sample value.", + "unit": "Optional unit (count, bpm, kcal, kg, m).", + "start": "Optional ISO8601 start timestamp; defaults to now.", + "end": "Optional ISO8601 end timestamp; defaults to start.", + ], + resultSummary: "Object with identifier/type/value/unit and written=true." + ) { args, context in + try health.write(arguments: args, context: context) + }, + ] + } + + + func homeRegistrations() -> [CapabilityRegistration] { + [ + CapabilityRegistration( + .homeRead, + jsName: "apple.home.list", + title: "Read HomeKit graph", + summary: "Read homes/accessories/services (and optional characteristics) from HomeKit.", + tags: ["homekit", "iot", "devices"], + example: "await apple.home.list({ includeCharacteristics: true, limit: 5 })", + requiredPermissions: [.homeKit], + optionalArguments: ["includeCharacteristics", "limit"], + argumentHints: [ + "includeCharacteristics": "Boolean; include characteristic details when true.", + "limit": "Max number of homes to return, default 10.", + ], + resultSummary: "Array of homes with accessories/services snapshot." + ) { args, context in + try home.read(arguments: args, context: context) + }, + CapabilityRegistration( + .homeWrite, + jsName: "apple.home.writeCharacteristic", + title: "Write HomeKit characteristic", + summary: "Write a value to a writable HomeKit characteristic for a target accessory.", + tags: ["homekit", "iot", "devices", "control"], + example: "await apple.home.writeCharacteristic({ accessoryIdentifier: 'UUID', characteristicType: 'HMCharacteristicTypePowerState', value: true })", + requiredPermissions: [.homeKit], + requiredArguments: ["accessoryIdentifier", "characteristicType", "value"], + optionalArguments: ["serviceType"], + argumentTypes: [ + "accessoryIdentifier": .string, + "characteristicType": .string, + "value": .any, + "serviceType": .string, + ], + argumentHints: [ + "accessoryIdentifier": "Accessory UUID string from home.read output.", + "characteristicType": "Characteristic type identifier (e.g. HMCharacteristicTypePowerState).", + "value": "Target value; string/number/bool/null.", + "serviceType": "Optional service type filter for characteristic lookup.", + ], + resultSummary: "Object with accessoryIdentifier/characteristicType/written." + ) { args, context in + try home.write(arguments: args, context: context) + }, + ] + } + + + func mediaRegistrations() -> [CapabilityRegistration] { + [ + CapabilityRegistration( + .mediaMetadataRead, + jsName: "apple.media.metadata", + title: "Read media metadata", + summary: "Read duration and track metadata from media files.", + tags: ["media", "avfoundation", "metadata"], + example: "await apple.media.metadata({ path: 'tmp:video.mov' })", + requiredArguments: ["path"], + argumentHints: [ + "path": "Sandbox path like tmp:clip.mov.", + ], + resultSummary: "Object with path/durationSeconds/tracks." + ) { args, context in + try media.metadata(arguments: args, context: context) + }, + CapabilityRegistration( + .mediaFrameExtract, + jsName: "apple.media.extractFrame", + title: "Extract video frame", + summary: "Extract frame at time offset and persist JPEG output.", + tags: ["media", "avfoundation", "thumbnail"], + example: "await apple.media.extractFrame({ path: 'tmp:video.mov', timeMs: 1500 })", + requiredArguments: ["path"], + optionalArguments: ["timeMs", "outputPath"], + argumentHints: [ + "path": "Input video sandbox path.", + "timeMs": "Frame timestamp in milliseconds; default 0.", + "outputPath": "Optional output sandbox path; defaults to tmp-generated JPEG.", + ], + resultSummary: "Object with output path and artifactID." + ) { args, context in + try media.extractFrame(arguments: args, context: context) + }, + CapabilityRegistration( + .mediaTranscode, + jsName: "apple.media.transcode", + title: "Transcode media", + summary: "Transcode media into MP4 with preset quality.", + tags: ["media", "avfoundation", "transcode"], + example: "await apple.media.transcode({ path: 'tmp:input.mov', preset: 'AVAssetExportPresetMediumQuality' })", + requiredArguments: ["path"], + optionalArguments: ["outputPath", "preset"], + argumentHints: [ + "path": "Input media sandbox path.", + "outputPath": "Optional output sandbox path; defaults to tmp-generated mp4.", + "preset": "AVAssetExportSession preset string; default AVAssetExportPresetMediumQuality.", + ], + resultSummary: "Object with output path/artifactID/preset." + ) { args, context in + try media.transcode(arguments: args, context: context) + }, + ] + } +} diff --git a/Sources/CodeMode/Bridges/CapabilityRegistrations+SystemUI.swift b/Sources/CodeMode/Bridges/CapabilityRegistrations+SystemUI.swift new file mode 100644 index 0000000..d8bfdc1 --- /dev/null +++ b/Sources/CodeMode/Bridges/CapabilityRegistrations+SystemUI.swift @@ -0,0 +1,393 @@ +import Foundation + +extension DefaultCapabilityRegistrationBuilder { + func interactionUIRegistrations() -> [CapabilityRegistration] { + [ + CapabilityRegistration( + jsNames: ["apple.share.present"], + descriptor: .init( + id: .shareUIPresent, + title: "Present share sheet", + summary: "Present system share sheet for text, URLs, and sandbox file artifacts.", + tags: ["share", "export", "system-ui"], + example: "await apple.share.present({ text: 'Report ready', paths: ['tmp:report.pdf'] })", + optionalArguments: ["text", "url", "path", "paths", "subject", "excludedActivityTypes", "timeoutMs"], + argumentTypes: [ + "text": .string, + "url": .string, + "path": .string, + "paths": .array, + "subject": .string, + "excludedActivityTypes": .array, + "timeoutMs": .number, + ], + argumentHints: [ + "text": "Optional text item to share.", + "url": "Optional absolute HTTP(S) URL to share.", + "path": "Optional single sandbox file path to share.", + "paths": "Optional array of sandbox file paths to share.", + "subject": "Optional subject for services that support it.", + "excludedActivityTypes": "Optional array of UIActivity.ActivityType raw value strings to hide.", + "timeoutMs": "Optional timeout for waiting on share completion.", + ], + resultSummary: "Object with completed/activityType/action." + ), + handler: { args, context in + try systemUI.presentShareSheet(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.quicklook.preview"], + descriptor: .init( + id: .quickLookUIPreview, + title: "Preview files with Quick Look", + summary: "Present Quick Look preview UI for one or more sandbox file artifacts.", + tags: ["quicklook", "preview", "documents", "system-ui"], + example: "await apple.quicklook.preview({ path: 'tmp:report.pdf' })", + optionalArguments: ["path", "paths", "timeoutMs"], + argumentTypes: [ + "path": .string, + "paths": .array, + "timeoutMs": .number, + ], + argumentHints: [ + "path": "Single sandbox file path to preview.", + "paths": "Optional array of sandbox file paths to preview.", + "timeoutMs": "Optional timeout for waiting on dismissal.", + ], + resultSummary: "Object with action dismissed and count." + ), + handler: { args, context in + try systemUI.previewQuickLook(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.camera.capture"], + descriptor: .init( + id: .cameraUICapture, + title: "Capture photo or video with camera UI", + summary: "Present system camera UI and export captured media into the sandbox artifact store.", + tags: ["camera", "capture", "photos", "system-ui", "artifact"], + example: "await apple.camera.capture({ mediaType: 'image', outputDirectory: 'tmp:camera' })", + optionalArguments: [ + "mediaType", + "outputDirectory", + "timeoutMs", + "allowsEditing", + "cameraDevice", + "flashMode", + "videoQuality", + "maximumDurationSeconds", + ], + argumentTypes: [ + "mediaType": .string, + "outputDirectory": .string, + "timeoutMs": .number, + "allowsEditing": .bool, + "cameraDevice": .string, + "flashMode": .string, + "videoQuality": .string, + "maximumDurationSeconds": .number, + ], + argumentHints: [ + "mediaType": "any (default), image/photo, or video.", + "outputDirectory": "Optional sandbox directory for captured media; defaults to tmp:.", + "timeoutMs": "Optional timeout for waiting on capture/export.", + "allowsEditing": "Whether the system editor is shown before returning media; default false.", + "cameraDevice": "rear (default) or front.", + "flashMode": "auto (default), on, or off.", + "videoQuality": "UIImagePickerController quality name such as high, medium, low, 640x480, iFrame1280x720, or iFrame960x540.", + "maximumDurationSeconds": "Optional maximum duration for video capture.", + ], + resultSummary: "Object with path/artifactID/mediaType/uniformTypeIdentifier/bytes." + ), + handler: { args, context in + try systemUI.captureCamera(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.camera.scanData"], + descriptor: .init( + id: .cameraUIScanData, + title: "Scan text or barcodes with camera UI", + summary: "Present VisionKit live data scanner UI and return recognized text or barcode payloads.", + tags: ["camera", "scan", "barcode", "text", "visionkit", "system-ui"], + example: "await apple.camera.scanData({ mode: 'barcode', returnsOnFirstResult: true })", + optionalArguments: [ + "mode", + "recognizedDataTypes", + "languages", + "qualityLevel", + "recognizesMultipleItems", + "returnsOnFirstResult", + "isGuidanceEnabled", + "isHighlightingEnabled", + "isPinchToZoomEnabled", + "isHighFrameRateTrackingEnabled", + "timeoutMs", + ], + argumentTypes: [ + "mode": .string, + "recognizedDataTypes": .array, + "languages": .array, + "qualityLevel": .string, + "recognizesMultipleItems": .bool, + "returnsOnFirstResult": .bool, + "isGuidanceEnabled": .bool, + "isHighlightingEnabled": .bool, + "isPinchToZoomEnabled": .bool, + "isHighFrameRateTrackingEnabled": .bool, + "timeoutMs": .number, + ], + argumentHints: [ + "mode": "any (default), text, or barcode.", + "recognizedDataTypes": "Optional array containing text and/or barcode; overrides mode.", + "languages": "Optional text recognition language identifiers.", + "qualityLevel": "balanced (default), fast, or accurate.", + "recognizesMultipleItems": "Whether the scanner tracks multiple items at once; default false.", + "returnsOnFirstResult": "Whether to dismiss as soon as data is recognized; default true.", + "isGuidanceEnabled": "Whether VisionKit guidance UI is shown; default true.", + "isHighlightingEnabled": "Whether recognized items are highlighted; default true.", + "isPinchToZoomEnabled": "Whether pinch-to-zoom is enabled; default true.", + "isHighFrameRateTrackingEnabled": "Whether high-frame-rate tracking is enabled; default true.", + "timeoutMs": "Optional timeout for waiting on a scan result or cancellation.", + ], + resultSummary: "Object with action and items containing text transcripts or barcode payloads." + ), + handler: { args, context in + try systemUI.scanData(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.mail.compose"], + descriptor: .init( + id: .mailUICompose, + title: "Compose mail with system UI", + summary: "Present system mail compose UI with optional recipients, body, and sandbox file attachments.", + tags: ["mail", "compose", "system-ui", "share"], + example: "await apple.mail.compose({ to: ['alex@example.com'], subject: 'Report', body: 'Attached.', attachments: [{ path: 'tmp:report.pdf' }] })", + optionalArguments: ["to", "cc", "bcc", "subject", "body", "isHTML", "attachments", "timeoutMs"], + argumentTypes: [ + "to": .array, + "cc": .array, + "bcc": .array, + "subject": .string, + "body": .string, + "isHTML": .bool, + "attachments": .array, + "timeoutMs": .number, + ], + argumentHints: [ + "to": "Optional array of recipient email strings.", + "cc": "Optional array of CC email strings.", + "bcc": "Optional array of BCC email strings.", + "subject": "Optional subject.", + "body": "Optional message body.", + "isHTML": "Whether body should be treated as HTML; default false.", + "attachments": "Optional array of { path, mimeType?, filename? } sandbox file attachments.", + "timeoutMs": "Optional timeout for waiting on user completion.", + ], + resultSummary: "Object with action sent/saved/cancelled/failed." + ), + handler: { args, context in + try systemUI.composeMail(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.messages.compose"], + descriptor: .init( + id: .messagesUICompose, + title: "Compose message with system UI", + summary: "Present system Messages compose UI with optional recipients, body, and sandbox file attachments.", + tags: ["messages", "sms", "compose", "system-ui", "share"], + example: "await apple.messages.compose({ recipients: ['4085551212'], body: 'Report ready' })", + optionalArguments: ["recipients", "subject", "body", "attachments", "timeoutMs"], + argumentTypes: [ + "recipients": .array, + "subject": .string, + "body": .string, + "attachments": .array, + "timeoutMs": .number, + ], + argumentHints: [ + "recipients": "Optional array of phone number or address strings.", + "subject": "Optional subject on devices/accounts that support it.", + "body": "Optional message body.", + "attachments": "Optional array of { path, filename? } sandbox file attachments.", + "timeoutMs": "Optional timeout for waiting on user completion.", + ], + resultSummary: "Object with action sent/cancelled/failed." + ), + handler: { args, context in + try systemUI.composeMessage(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.print.present"], + descriptor: .init( + id: .printUIPresent, + title: "Present print UI", + summary: "Present the system print sheet for one or more sandbox files.", + tags: ["print", "documents", "system-ui", "export"], + example: "await apple.print.present({ path: 'tmp:report.pdf', jobName: 'Report' })", + optionalArguments: ["path", "paths", "jobName", "outputType", "showsNumberOfCopies", "timeoutMs"], + argumentTypes: [ + "path": .string, + "paths": .array, + "jobName": .string, + "outputType": .string, + "showsNumberOfCopies": .bool, + "timeoutMs": .number, + ], + argumentHints: [ + "path": "Single sandbox file path to print.", + "paths": "Optional array of sandbox file paths to print.", + "jobName": "Optional print job name.", + "outputType": "general (default), photo, or grayscale.", + "showsNumberOfCopies": "Whether copy count controls are shown; default true.", + "timeoutMs": "Optional timeout for waiting on print completion/cancellation.", + ], + resultSummary: "Object with action/completed for the print interaction." + ), + handler: { args, context in + try systemUI.presentPrint(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.web.present"], + descriptor: .init( + id: .webUIPresent, + title: "Present web page with system UI", + summary: "Present an HTTP(S) URL with the system Safari view controller.", + tags: ["web", "safari", "browser", "system-ui"], + example: "await apple.web.present({ url: 'https://example.com' })", + requiredArguments: ["url"], + optionalArguments: ["entersReaderIfAvailable", "timeoutMs"], + argumentTypes: [ + "url": .string, + "entersReaderIfAvailable": .bool, + "timeoutMs": .number, + ], + argumentHints: [ + "url": "Absolute HTTP(S) URL to present.", + "entersReaderIfAvailable": "Whether Safari may enter Reader automatically; default false.", + "timeoutMs": "Optional timeout for waiting on dismissal.", + ], + resultSummary: "Object with action dismissed." + ), + handler: { args, context in + try systemUI.presentWeb(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.auth.webAuthenticate"], + descriptor: .init( + id: .authUIWebAuthenticate, + title: "Authenticate with system web UI", + summary: "Start an ASWebAuthenticationSession for OAuth-style browser authentication.", + tags: ["auth", "oauth", "web", "browser", "system-ui"], + example: "await apple.auth.webAuthenticate({ url: 'https://example.com/oauth', callbackURLScheme: 'myapp' })", + requiredArguments: ["url"], + optionalArguments: ["callbackURLScheme", "prefersEphemeralSession", "timeoutMs"], + argumentTypes: [ + "url": .string, + "callbackURLScheme": .string, + "prefersEphemeralSession": .bool, + "timeoutMs": .number, + ], + argumentHints: [ + "url": "Absolute HTTP(S) authentication URL.", + "callbackURLScheme": "Optional custom URL scheme that completes the session.", + "prefersEphemeralSession": "Whether to prefer a private browser session; default false.", + "timeoutMs": "Optional timeout for waiting on callback/cancellation.", + ], + resultSummary: "Object with action callback/cancelled and callbackURL when available." + ), + handler: { args, context in + try systemUI.authenticateWeb(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.ui.presentAlert"], + descriptor: .init( + id: .uiAlertPresent, + title: "Present alert with custom buttons", + summary: "Present a system alert or action sheet and return the button the user selects.", + tags: ["ui", "alert", "dialog", "system-ui"], + example: "await apple.ui.presentAlert({ title: 'Delete draft?', message: 'This cannot be undone.', buttons: [{ id: 'cancel', title: 'Cancel', style: 'cancel' }, { id: 'delete', title: 'Delete', style: 'destructive' }] })", + requiredArguments: ["buttons"], + optionalArguments: ["title", "message", "preferredStyle", "sourceRect", "timeoutMs"], + argumentTypes: [ + "title": .string, + "message": .string, + "preferredStyle": .string, + "buttons": .array, + "sourceRect": .object, + "timeoutMs": .number, + ], + argumentHints: [ + "title": "Optional alert title.", + "message": "Optional alert message.", + "preferredStyle": "alert (default) or actionSheet.", + "sourceRect": "Optional { x, y, width, height } anchor for action sheets.", + "buttons": "Array of { id?, title, style? }; style is default, cancel, or destructive. At most one cancel button.", + "timeoutMs": "Optional timeout for waiting on user selection.", + ], + resultSummary: "Object with action/buttonID/buttonTitle/buttonIndex/style for the selected button." + ), + handler: { args, context in + try systemUI.presentAlert(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.ui.presentPrompt"], + descriptor: .init( + id: .uiPromptPresent, + title: "Present prompt with text fields", + summary: "Present a system alert with one or more text fields and custom buttons.", + tags: ["ui", "alert", "prompt", "input", "system-ui"], + example: "await apple.ui.presentPrompt({ title: 'Name', fields: [{ id: 'name', placeholder: 'Name' }], buttons: [{ id: 'cancel', title: 'Cancel', style: 'cancel' }, { id: 'ok', title: 'OK' }] })", + requiredArguments: ["fields", "buttons"], + optionalArguments: ["title", "message", "timeoutMs"], + argumentTypes: [ + "title": .string, + "message": .string, + "fields": .array, + "buttons": .array, + "timeoutMs": .number, + ], + argumentHints: [ + "fields": "Array of { id?, placeholder?, text?/defaultValue?, secure?, keyboardType? }. keyboardType is default, email, number, phone, or url.", + "buttons": "Array of { id?, title, style? }; style is default, cancel, or destructive. At most one cancel button.", + "timeoutMs": "Optional timeout for waiting on user selection.", + ], + resultSummary: "Object with selected button metadata and values keyed by field id." + ), + handler: { args, context in + try systemUI.presentPrompt(arguments: args, context: context) + } + ), + CapabilityRegistration( + jsNames: ["apple.settings.open"], + descriptor: .init( + id: .settingsUIOpen, + title: "Open app settings", + summary: "Open the host app's Settings page so the user can recover denied permissions.", + tags: ["settings", "permissions", "system-ui"], + example: "await apple.settings.open()", + optionalArguments: ["timeoutMs"], + argumentTypes: [ + "timeoutMs": .number, + ], + argumentHints: [ + "timeoutMs": "Optional timeout for waiting on UIApplication.open completion.", + ], + resultSummary: "Object with action opened/failed and opened boolean." + ), + handler: { args, context in + try systemUI.openSettings(arguments: args, context: context) + } + ), + ] + } +} diff --git a/Sources/CodeMode/Bridges/DefaultCapabilityLoader.swift b/Sources/CodeMode/Bridges/DefaultCapabilityLoader.swift index 32aa0c6..2ff5130 100644 --- a/Sources/CodeMode/Bridges/DefaultCapabilityLoader.swift +++ b/Sources/CodeMode/Bridges/DefaultCapabilityLoader.swift @@ -2,1482 +2,123 @@ import Foundation public enum DefaultCapabilityLoader { public static func loadAllRegistrations( - fileSystem: any CodeModeFileSystem = LocalCodeModeFileSystem() + fileSystem: any CodeModeFileSystem = LocalCodeModeFileSystem(), + eventInbox: any CodeModeEventInbox = UnavailableCodeModeEventInbox(), + cloudKitClient: any CloudKitClient = UnavailableCloudKitClient(), + remoteNotificationsClient: any RemoteNotificationsClient = UnavailableRemoteNotificationsClient(), + speechClient: any SpeechClient = UnavailableSpeechClient(), + appIntentsClient: any AppIntentsClient = UnavailableAppIntentsClient(), + foundationModelsClient: any FoundationModelsClient = UnavailableFoundationModelsClient(), + activityClient: any ActivityClient = UnavailableActivityClient(), + mapsClient: any MapsClient = UnavailableMapsClient(), + musicClient: any MusicClient = UnavailableMusicClient(), + passKitClient: any PassKitClient = UnavailablePassKitClient(), + storeKitClient: any StoreKitClient = UnavailableStoreKitClient() ) -> [CapabilityRegistration] { - let fs = FileSystemBridge(fileSystem: fileSystem) - let network = NetworkBridge() - let keychain = KeychainBridge() - let location = LocationBridge() - let weather = WeatherBridge() - let eventKit = EventKitBridge() - let contacts = ContactsBridge() - let photos = PhotosBridge() - let vision = VisionBridge() - let notifications = NotificationsBridge() - let alarm = AlarmBridge() - let health = HealthBridge() - let home = HomeBridge() - let media = MediaBridge() - let systemUI = SystemUIBridge() + DefaultCapabilityRegistrationBuilder( + fileSystem: fileSystem, + eventInbox: eventInbox, + cloudKitClient: cloudKitClient, + remoteNotificationsClient: remoteNotificationsClient, + speechClient: speechClient, + appIntentsClient: appIntentsClient, + foundationModelsClient: foundationModelsClient, + activityClient: activityClient, + mapsClient: mapsClient, + musicClient: musicClient, + passKitClient: passKitClient, + storeKitClient: storeKitClient + ).loadAll() + } +} + +struct DefaultCapabilityRegistrationBuilder { + let fs: FileSystemBridge + let network: NetworkBridge + let keychain: KeychainBridge + let location: LocationBridge + let weather: WeatherBridge + let eventKit: EventKitBridge + let contacts: ContactsBridge + let photos: PhotosBridge + let vision: VisionBridge + let notifications: NotificationsBridge + let alarm: AlarmBridge + let health: HealthBridge + let home: HomeBridge + let media: MediaBridge + let systemUI: SystemUIBridge + let eventInbox: any CodeModeEventInbox + let cloudKit: CloudKitBridge + let remoteNotifications: RemoteNotificationsBridge + let speech: SpeechBridge + let appIntents: AppIntentsBridge + let foundationModels: FoundationModelsBridge + let activity: ActivityBridge + let maps: MapsBridge + let music: MusicBridge + let passKit: PassKitBridge + let storeKit: StoreKitBridge + + init( + fileSystem: any CodeModeFileSystem, + eventInbox: any CodeModeEventInbox, + cloudKitClient: any CloudKitClient, + remoteNotificationsClient: any RemoteNotificationsClient, + speechClient: any SpeechClient, + appIntentsClient: any AppIntentsClient, + foundationModelsClient: any FoundationModelsClient, + activityClient: any ActivityClient, + mapsClient: any MapsClient, + musicClient: any MusicClient, + passKitClient: any PassKitClient, + storeKitClient: any StoreKitClient + ) { + self.fs = FileSystemBridge(fileSystem: fileSystem) + self.network = NetworkBridge() + self.keychain = KeychainBridge() + self.location = LocationBridge() + self.weather = WeatherBridge() + self.eventKit = EventKitBridge() + self.contacts = ContactsBridge() + self.photos = PhotosBridge() + self.vision = VisionBridge() + self.notifications = NotificationsBridge() + self.alarm = AlarmBridge() + self.health = HealthBridge() + self.home = HomeBridge() + self.media = MediaBridge() + self.systemUI = SystemUIBridge() + self.eventInbox = eventInbox + self.cloudKit = CloudKitBridge(client: cloudKitClient, eventInbox: eventInbox) + self.remoteNotifications = RemoteNotificationsBridge(client: remoteNotificationsClient, eventInbox: eventInbox) + self.speech = SpeechBridge(client: speechClient) + self.appIntents = AppIntentsBridge(client: appIntentsClient, eventInbox: eventInbox) + self.foundationModels = FoundationModelsBridge(client: foundationModelsClient) + self.activity = ActivityBridge(client: activityClient) + self.maps = MapsBridge(client: mapsClient) + self.music = MusicBridge(client: musicClient) + self.passKit = PassKitBridge(client: passKitClient) + self.storeKit = StoreKitBridge(client: storeKitClient, eventInbox: eventInbox) + } - return [ - CapabilityRegistration( - descriptor: .init( - id: .networkFetch, - title: "Fetch HTTP resource", - summary: "Perform HTTP(S) requests through URLSession via a fetch-compatible API.", - tags: ["network", "http", "fetch"], - example: "await fetch('https://api.example.com/data').then(r => r.json())", - requiredArguments: ["url"], - optionalArguments: ["options.method", "options.headers", "options.body"], - argumentHints: [ - "url": "Absolute HTTP(S) URL string.", - "options.method": "HTTP method; defaults to GET.", - "options.headers": "Object of header key/value string pairs.", - "options.body": "UTF-8 request body string.", - ], - resultSummary: "Object with ok/status/statusText/headers/bodyText." - ), - handler: { args, context in - try network.fetch(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .keychainRead, - title: "Read Keychain value", - summary: "Read a string value from app-scoped Keychain storage.", - tags: ["security", "token", "keychain"], - example: "await apple.keychain.get('auth_token')", - requiredArguments: ["key"], - argumentHints: [ - "key": "Logical key for this secret value.", - ], - resultSummary: "Object { key, value } or null when the key does not exist." - ), - handler: { args, _ in - try keychain.read(arguments: args) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .keychainWrite, - title: "Write Keychain value", - summary: "Store or update a string value in app-scoped Keychain storage.", - tags: ["security", "token", "keychain"], - example: "await apple.keychain.set('auth_token', token)", - requiredArguments: ["key"], - optionalArguments: ["value"], - argumentHints: [ - "key": "Logical key for this secret value.", - "value": "Secret string value. Defaults to empty string when omitted.", - ], - resultSummary: "Object { key, written: true }." - ), - handler: { args, _ in - try keychain.write(arguments: args) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .keychainDelete, - title: "Delete Keychain value", - summary: "Delete an app-scoped Keychain value.", - tags: ["security", "token", "keychain"], - example: "await apple.keychain.delete('auth_token')", - requiredArguments: ["key"], - argumentHints: [ - "key": "Logical key for value removal.", - ], - resultSummary: "Object { key, deleted: true }." - ), - handler: { args, _ in - try keychain.delete(arguments: args) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .locationRead, - title: "Read location state or coordinates", - summary: "Read location permission status or current coordinates.", - tags: ["location", "permission", "geospatial"], - example: "await apple.location.getCurrentPosition()", - requiredPermissions: [], - optionalArguments: ["mode"], - argumentHints: [ - "mode": "permissionStatus or current (default current).", - ], - resultSummary: "Permission status string or coordinates object." - ), - handler: { args, context in - try location.read(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .locationPermissionRequest, - title: "Request location permission", - summary: "Trigger location when-in-use permission request flow.", - tags: ["location", "permission"], - example: "await apple.location.requestPermission()", - resultSummary: "Permission status string." - ), - handler: { _, context in - location.requestPermission(context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .weatherRead, - title: "Read WeatherKit weather", - summary: "Fetch current weather for a latitude/longitude pair.", - tags: ["weather", "forecast", "weatherkit"], - example: "await apple.weather.getCurrentWeather({ latitude: 37.77, longitude: -122.41 })", - requiredArguments: ["latitude", "longitude"], - argumentHints: [ - "latitude": "Latitude in decimal degrees.", - "longitude": "Longitude in decimal degrees.", - ], - resultSummary: "Object with temperatureCelsius/condition/symbolName/date." - ), - handler: { args, _ in - try weather.read(arguments: args) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .calendarRead, - title: "Read calendar events", - summary: "List events in a date range from EventKit.", - tags: ["calendar", "eventkit", "schedule"], - example: "await apple.calendar.listEvents({ start: '2026-02-21T00:00:00Z', end: '2026-03-01T00:00:00Z' })", - requiredPermissions: [.calendar], - optionalArguments: ["start", "end", "limit"], - argumentHints: [ - "start": "ISO8601 timestamp; defaults to now.", - "end": "ISO8601 timestamp; defaults to start + 14 days.", - "limit": "Max number of items, default 50.", - ], - resultSummary: "Array of events with identifier/title/startDate/endDate/notes/calendarTitle." - ), - handler: { args, context in - try eventKit.readEvents(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .calendarWrite, - title: "Create calendar event", - summary: "Create a calendar event in the default calendar.", - tags: ["calendar", "eventkit", "schedule"], - example: "await apple.calendar.createEvent({ title: 'Standup', start: '2026-02-22T16:00:00Z', end: '2026-02-22T16:15:00Z' })", - requiredPermissions: [.calendarWriteOnly], - requiredArguments: ["title", "start", "end"], - optionalArguments: ["notes"], - argumentHints: [ - "title": "Event title string.", - "start": "ISO8601 start timestamp.", - "end": "ISO8601 end timestamp.", - "notes": "Optional notes/body string.", - ], - resultSummary: "Object with identifier/title." - ), - handler: { args, context in - try eventKit.writeEvent(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .calendarUIPickCalendar, - title: "Pick calendar with system UI", - summary: "Present EventKit calendar chooser UI and return the user-selected writable calendars.", - tags: ["calendar", "eventkit", "system-ui", "picker"], - example: "await apple.calendar.pickCalendar({ selectionStyle: 'single' })", - requiredPermissions: [.calendarWriteOnly], - optionalArguments: ["selectionStyle", "displayStyle", "timeoutMs"], - argumentTypes: [ - "selectionStyle": .string, - "displayStyle": .string, - "timeoutMs": .number, - ], - argumentHints: [ - "selectionStyle": "single (default) or multiple.", - "displayStyle": "writable (default) or all.", - "timeoutMs": "Optional timeout for waiting on user selection.", - ], - resultSummary: "Array of selected calendars with identifier/title/type/allowsContentModifications." - ), - handler: { args, context in - try systemUI.pickCalendar(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .calendarUIPresentEvent, - title: "Present calendar event details", - summary: "Present system UI for an existing calendar event identifier.", - tags: ["calendar", "eventkit", "system-ui", "details"], - example: "await apple.calendar.presentEvent({ identifier: 'EVENT_ID', allowsEditing: false })", - requiredPermissions: [.calendar], - requiredArguments: ["identifier"], - optionalArguments: ["allowsEditing", "allowsCalendarPreview", "timeoutMs"], - argumentTypes: [ - "identifier": .string, - "allowsEditing": .bool, - "allowsCalendarPreview": .bool, - "timeoutMs": .number, - ], - argumentHints: [ - "identifier": "EventKit eventIdentifier from apple.calendar.listEvents.", - "allowsEditing": "Whether the user can edit from the detail UI; default false.", - "allowsCalendarPreview": "Whether the UI may show calendar day previews; default true.", - "timeoutMs": "Optional timeout for waiting on dismissal.", - ], - resultSummary: "Object with action dismissed." - ), - handler: { args, context in - try systemUI.presentCalendarEvent(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .calendarUIPresentNewEvent, - title: "Present calendar event editor", - summary: "Present system UI to let the user create or edit a new calendar event draft.", - tags: ["calendar", "eventkit", "system-ui", "picker"], - example: "await apple.calendar.presentNewEvent({ title: 'Standup', start: '2026-02-22T16:00:00Z', end: '2026-02-22T16:15:00Z' })", - requiredPermissions: [.calendarWriteOnly], - optionalArguments: ["title", "start", "end", "notes", "location"], - argumentTypes: [ - "title": .string, - "start": .string, - "end": .string, - "notes": .string, - "location": .string, - ], - argumentHints: [ - "title": "Optional event title shown in the editor.", - "start": "Optional ISO8601 start timestamp.", - "end": "Optional ISO8601 end timestamp.", - "notes": "Optional event notes/body text.", - "location": "Optional location string.", - ], - resultSummary: "Object with action plus identifier/title when the user saves." - ), - handler: { args, context in - try systemUI.presentNewCalendarEvent(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .remindersRead, - title: "Read reminders", - summary: "Read incomplete reminders from EventKit.", - tags: ["reminders", "eventkit", "task"], - example: "await apple.reminders.listReminders({ limit: 20 })", - requiredPermissions: [.reminders], - optionalArguments: ["limit"], - argumentHints: [ - "limit": "Max number of reminder items, default 50.", - ], - resultSummary: "Array of reminders with identifier/title/isCompleted/dueDate." - ), - handler: { args, context in - try eventKit.readReminders(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .remindersWrite, - title: "Create reminder", - summary: "Create a reminder in default reminders list.", - tags: ["reminders", "eventkit", "task"], - example: "await apple.reminders.createReminder({ title: 'Buy batteries', dueDate: '2026-02-22T18:00:00Z' })", - requiredPermissions: [.reminders], - requiredArguments: ["title"], - optionalArguments: ["dueDate"], - argumentHints: [ - "title": "Reminder title string.", - "dueDate": "Optional ISO8601 due date timestamp.", - ], - resultSummary: "Object with identifier/title." - ), - handler: { args, context in - try eventKit.writeReminder(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .contactsRead, - title: "Read contacts", - summary: "Read contacts with bounded fields.", - tags: ["contacts", "address-book", "people"], - example: "await apple.contacts.list({ limit: 25 })", - requiredPermissions: [.contacts], - optionalArguments: ["limit", "identifiers"], - argumentHints: [ - "limit": "Max number of contacts, default 50.", - "identifiers": "Optional array of contact identifiers for targeted read.", - ], - resultSummary: "Array of contacts with identifier/name/organization/phones/emails." - ), - handler: { args, context in - try contacts.read(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .contactsSearch, - title: "Search contacts", - summary: "Search contacts by name.", - tags: ["contacts", "search", "people"], - example: "await apple.contacts.search({ query: 'Alex', limit: 10 })", - requiredPermissions: [.contacts], - requiredArguments: ["query"], - optionalArguments: ["limit"], - argumentHints: [ - "query": "Name text to match.", - "limit": "Max number of contacts, default 20.", - ], - resultSummary: "Array of contact objects." - ), - handler: { args, context in - try contacts.search(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .contactsUIPick, - title: "Pick contacts with system UI", - summary: "Present system contact picker UI and return selected contacts without requiring full Contacts permission.", - tags: ["contacts", "people", "system-ui", "picker"], - example: "await apple.contacts.pick({ mode: 'single', displayedPropertyKeys: ['phoneNumbers', 'emailAddresses'] })", - optionalArguments: ["mode", "displayedPropertyKeys", "timeoutMs"], - argumentTypes: [ - "mode": .string, - "displayedPropertyKeys": .array, - "timeoutMs": .number, - ], - argumentHints: [ - "mode": "single (default) or multiple.", - "displayedPropertyKeys": "Optional array of CNContact property key strings to display.", - "timeoutMs": "Optional timeout for waiting on user selection.", - ], - resultSummary: "Array of selected contacts with identifier/name/organization/phones/emails." - ), - handler: { args, context in - try systemUI.pickContacts(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .contactsUIPresentContact, - title: "Present contact card", - summary: "Present system contact card UI for a contact identifier.", - tags: ["contacts", "people", "system-ui", "details"], - example: "await apple.contacts.presentContact({ identifier: 'CONTACT_ID', allowsEditing: false })", - requiredPermissions: [.contacts], - requiredArguments: ["identifier"], - optionalArguments: ["allowsEditing", "allowsActions", "displayedPropertyKeys", "timeoutMs"], - argumentTypes: [ - "identifier": .string, - "allowsEditing": .bool, - "allowsActions": .bool, - "displayedPropertyKeys": .array, - "timeoutMs": .number, - ], - argumentHints: [ - "identifier": "Contact identifier from apple.contacts.list/search/pick.", - "allowsEditing": "Whether the user can edit the contact; default false.", - "allowsActions": "Whether built-in actions like call/message are shown; default true.", - "displayedPropertyKeys": "Optional array of CNContact property key strings to display.", - "timeoutMs": "Optional timeout for waiting on dismissal.", - ], - resultSummary: "Object with action and contact when available." - ), - handler: { args, context in - try systemUI.presentContact(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .contactsUIPresentNewContact, - title: "Present new contact editor", - summary: "Present system UI for creating a new contact draft.", - tags: ["contacts", "people", "system-ui", "create"], - example: "await apple.contacts.presentNewContact({ givenName: 'Alex', familyName: 'Lee', emailAddresses: ['alex@example.com'] })", - requiredPermissions: [.contacts], - optionalArguments: ["givenName", "familyName", "organization", "phoneNumbers", "emailAddresses", "timeoutMs"], - argumentTypes: [ - "givenName": .string, - "familyName": .string, - "organization": .string, - "phoneNumbers": .array, - "emailAddresses": .array, - "timeoutMs": .number, - ], - argumentHints: [ - "givenName": "Optional given name to prefill.", - "familyName": "Optional family name to prefill.", - "organization": "Optional organization to prefill.", - "phoneNumbers": "Optional array of phone number strings.", - "emailAddresses": "Optional array of email address strings.", - "timeoutMs": "Optional timeout for waiting on user completion.", - ], - resultSummary: "Object with action and contact when the user saves." - ), - handler: { args, context in - try systemUI.presentNewContact(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .photosRead, - title: "List photo library assets", - summary: "List photos/videos from the user photo library.", - tags: ["photos", "photo-library", "media"], - example: "await apple.photos.list({ mediaType: 'image', limit: 20 })", - requiredPermissions: [.photoLibrary], - optionalArguments: ["mediaType", "limit"], - argumentHints: [ - "mediaType": "any (default), image, or video.", - "limit": "Max number of results, default 50.", - ], - resultSummary: "Array of assets with localIdentifier/mediaType/dimensions/date metadata." - ), - handler: { args, context in - try photos.read(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .photosExport, - title: "Export photo library asset", - summary: "Export a photo/video asset to sandbox file path and register artifact handle.", - tags: ["photos", "photo-library", "artifact"], - example: "await apple.photos.export({ localIdentifier: 'ABC/L0/001', outputPath: 'tmp:exported.jpg' })", - requiredPermissions: [.photoLibrary], - requiredArguments: ["localIdentifier"], - optionalArguments: ["outputPath"], - argumentHints: [ - "localIdentifier": "PHAsset localIdentifier from photos.read result.", - "outputPath": "Optional sandbox output path; defaults to tmp-generated file.", - ], - resultSummary: "Object with path/artifactID/localIdentifier/mediaType/bytes." - ), - handler: { args, context in - try photos.export(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .photosUIPick, - title: "Pick photos with system UI", - summary: "Present system photo picker UI and export selected assets into the sandbox artifact store.", - tags: ["photos", "photo-library", "system-ui", "picker", "artifact"], - example: "await apple.photos.pick({ mediaType: 'image', limit: 3, outputDirectory: 'tmp:picks' })", - optionalArguments: ["mediaType", "limit", "outputDirectory", "timeoutMs"], - argumentTypes: [ - "mediaType": .string, - "limit": .number, - "outputDirectory": .string, - "timeoutMs": .number, - ], - argumentHints: [ - "mediaType": "any (default), image/photo, or video.", - "limit": "Maximum number of selectable items; default 1.", - "outputDirectory": "Optional sandbox directory for exported picker files; defaults to tmp:.", - "timeoutMs": "Optional timeout for waiting on user selection/export.", - ], - resultSummary: "Array of selected assets with path/artifactID/mediaType/uniformTypeIdentifier/bytes." - ), - handler: { args, context in - try systemUI.pickPhotos(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .photosUIPresentLimitedLibraryPicker, - title: "Present limited Photos picker", - summary: "Present the Photos limited-library management UI so the user can update the app's selected photo set.", - tags: ["photos", "photo-library", "system-ui", "permission", "picker"], - example: "await apple.photos.presentLimitedLibraryPicker()", - optionalArguments: ["timeoutMs"], - argumentTypes: [ - "timeoutMs": .number, - ], - argumentHints: [ - "timeoutMs": "Optional timeout for waiting on the limited-library picker completion.", - ], - resultSummary: "Object with action/status and selectedIdentifiers when the limited selection changes." - ), - handler: { args, context in - try systemUI.presentLimitedPhotoLibraryPicker(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .documentsUIPick, - title: "Pick documents with system UI", - summary: "Present Files document picker UI and copy selected documents into the sandbox artifact store.", - tags: ["documents", "files", "system-ui", "picker", "artifact"], - example: "await apple.documents.pick({ contentTypes: ['public.item'], allowMultiple: true, outputDirectory: 'tmp:imports' })", - optionalArguments: ["contentTypes", "allowMultiple", "outputDirectory", "timeoutMs"], - argumentTypes: [ - "contentTypes": .array, - "allowMultiple": .bool, - "outputDirectory": .string, - "timeoutMs": .number, - ], - argumentHints: [ - "contentTypes": "Optional array of UTType identifiers; defaults to public.item.", - "allowMultiple": "Whether multiple files may be selected; default false.", - "outputDirectory": "Optional sandbox directory for copied files; defaults to tmp:.", - "timeoutMs": "Optional timeout for waiting on user selection/copy.", - ], - resultSummary: "Array of selected documents with path/artifactID/filename/uniformTypeIdentifier/bytes." - ), - handler: { args, context in - try systemUI.pickDocuments(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .documentsUIExport, - title: "Export documents with system UI", - summary: "Present Files export UI to save one or more sandbox files to a user-selected destination.", - tags: ["documents", "files", "system-ui", "export", "save"], - example: "await apple.documents.export({ path: 'tmp:report.pdf' })", - optionalArguments: ["path", "paths", "asCopy", "timeoutMs"], - argumentTypes: [ - "path": .string, - "paths": .array, - "asCopy": .bool, - "timeoutMs": .number, - ], - argumentHints: [ - "path": "Single sandbox file path to export.", - "paths": "Optional array of sandbox file paths to export.", - "asCopy": "Whether to export as a copy; default true.", - "timeoutMs": "Optional timeout for waiting on export completion.", - ], - resultSummary: "Object with action/count and destination URLs when the provider returns them." - ), - handler: { args, context in - try systemUI.exportDocuments(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .documentsUIOpenIn, - title: "Open document in another app", - summary: "Present the system Open In menu for a sandbox file.", - tags: ["documents", "files", "system-ui", "open-in", "handoff"], - example: "await apple.documents.openIn({ path: 'tmp:report.pdf' })", - requiredArguments: ["path"], - optionalArguments: ["name", "uti", "timeoutMs"], - argumentTypes: [ - "path": .string, - "name": .string, - "uti": .string, - "timeoutMs": .number, - ], - argumentHints: [ - "path": "Sandbox file path to hand off.", - "name": "Optional display name for the document interaction controller.", - "uti": "Optional uniform type identifier override.", - "timeoutMs": "Optional timeout for waiting on the Open In menu dismissal.", - ], - resultSummary: "Object with action and application bundle identifier when the user hands off the file." - ), - handler: { args, context in - try systemUI.openDocument(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .documentsUIScan, - title: "Scan documents with system UI", - summary: "Present VisionKit document scanner UI and export scanned pages into the sandbox artifact store.", - tags: ["documents", "scan", "camera", "system-ui", "artifact"], - example: "await apple.documents.scan({ outputDirectory: 'tmp:scans' })", - optionalArguments: ["outputDirectory", "timeoutMs"], - argumentTypes: [ - "outputDirectory": .string, - "timeoutMs": .number, - ], - argumentHints: [ - "outputDirectory": "Optional sandbox directory for scanned page images; defaults to tmp:.", - "timeoutMs": "Optional timeout for waiting on user scanning/export.", - ], - resultSummary: "Array of scanned page artifacts with path/artifactID/pageIndex/mediaType/bytes." - ), - handler: { args, context in - try systemUI.scanDocuments(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .shareUIPresent, - title: "Present share sheet", - summary: "Present system share sheet for text, URLs, and sandbox file artifacts.", - tags: ["share", "export", "system-ui"], - example: "await apple.share.present({ text: 'Report ready', paths: ['tmp:report.pdf'] })", - optionalArguments: ["text", "url", "path", "paths", "subject", "excludedActivityTypes", "timeoutMs"], - argumentTypes: [ - "text": .string, - "url": .string, - "path": .string, - "paths": .array, - "subject": .string, - "excludedActivityTypes": .array, - "timeoutMs": .number, - ], - argumentHints: [ - "text": "Optional text item to share.", - "url": "Optional absolute HTTP(S) URL to share.", - "path": "Optional single sandbox file path to share.", - "paths": "Optional array of sandbox file paths to share.", - "subject": "Optional subject for services that support it.", - "excludedActivityTypes": "Optional array of UIActivity.ActivityType raw value strings to hide.", - "timeoutMs": "Optional timeout for waiting on share completion.", - ], - resultSummary: "Object with completed/activityType/action." - ), - handler: { args, context in - try systemUI.presentShareSheet(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .quickLookUIPreview, - title: "Preview files with Quick Look", - summary: "Present Quick Look preview UI for one or more sandbox file artifacts.", - tags: ["quicklook", "preview", "documents", "system-ui"], - example: "await apple.quicklook.preview({ path: 'tmp:report.pdf' })", - optionalArguments: ["path", "paths", "timeoutMs"], - argumentTypes: [ - "path": .string, - "paths": .array, - "timeoutMs": .number, - ], - argumentHints: [ - "path": "Single sandbox file path to preview.", - "paths": "Optional array of sandbox file paths to preview.", - "timeoutMs": "Optional timeout for waiting on dismissal.", - ], - resultSummary: "Object with action dismissed and count." - ), - handler: { args, context in - try systemUI.previewQuickLook(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .cameraUICapture, - title: "Capture photo or video with camera UI", - summary: "Present system camera UI and export captured media into the sandbox artifact store.", - tags: ["camera", "capture", "photos", "system-ui", "artifact"], - example: "await apple.camera.capture({ mediaType: 'image', outputDirectory: 'tmp:camera' })", - optionalArguments: ["mediaType", "outputDirectory", "timeoutMs"], - argumentTypes: [ - "mediaType": .string, - "outputDirectory": .string, - "timeoutMs": .number, - ], - argumentHints: [ - "mediaType": "any (default), image/photo, or video.", - "outputDirectory": "Optional sandbox directory for captured media; defaults to tmp:.", - "timeoutMs": "Optional timeout for waiting on capture/export.", - ], - resultSummary: "Object with path/artifactID/mediaType/uniformTypeIdentifier/bytes." - ), - handler: { args, context in - try systemUI.captureCamera(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .cameraUIScanData, - title: "Scan text or barcodes with camera UI", - summary: "Present VisionKit live data scanner UI and return recognized text or barcode payloads.", - tags: ["camera", "scan", "barcode", "text", "visionkit", "system-ui"], - example: "await apple.camera.scanData({ mode: 'barcode', returnsOnFirstResult: true })", - optionalArguments: ["mode", "recognizedDataTypes", "languages", "qualityLevel", "recognizesMultipleItems", "returnsOnFirstResult", "timeoutMs"], - argumentTypes: [ - "mode": .string, - "recognizedDataTypes": .array, - "languages": .array, - "qualityLevel": .string, - "recognizesMultipleItems": .bool, - "returnsOnFirstResult": .bool, - "timeoutMs": .number, - ], - argumentHints: [ - "mode": "any (default), text, or barcode.", - "recognizedDataTypes": "Optional array containing text and/or barcode; overrides mode.", - "languages": "Optional text recognition language identifiers.", - "qualityLevel": "balanced (default), fast, or accurate.", - "recognizesMultipleItems": "Whether the scanner tracks multiple items at once; default false.", - "returnsOnFirstResult": "Whether to dismiss as soon as data is recognized; default true.", - "timeoutMs": "Optional timeout for waiting on a scan result or cancellation.", - ], - resultSummary: "Object with action and items containing text transcripts or barcode payloads." - ), - handler: { args, context in - try systemUI.scanData(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .mailUICompose, - title: "Compose mail with system UI", - summary: "Present system mail compose UI with optional recipients, body, and sandbox file attachments.", - tags: ["mail", "compose", "system-ui", "share"], - example: "await apple.mail.compose({ to: ['alex@example.com'], subject: 'Report', body: 'Attached.', attachments: [{ path: 'tmp:report.pdf' }] })", - optionalArguments: ["to", "cc", "bcc", "subject", "body", "isHTML", "attachments", "timeoutMs"], - argumentTypes: [ - "to": .array, - "cc": .array, - "bcc": .array, - "subject": .string, - "body": .string, - "isHTML": .bool, - "attachments": .array, - "timeoutMs": .number, - ], - argumentHints: [ - "to": "Optional array of recipient email strings.", - "cc": "Optional array of CC email strings.", - "bcc": "Optional array of BCC email strings.", - "subject": "Optional subject.", - "body": "Optional message body.", - "isHTML": "Whether body should be treated as HTML; default false.", - "attachments": "Optional array of { path, mimeType?, filename? } sandbox file attachments.", - "timeoutMs": "Optional timeout for waiting on user completion.", - ], - resultSummary: "Object with action sent/saved/cancelled/failed." - ), - handler: { args, context in - try systemUI.composeMail(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .messagesUICompose, - title: "Compose message with system UI", - summary: "Present system Messages compose UI with optional recipients, body, and sandbox file attachments.", - tags: ["messages", "sms", "compose", "system-ui", "share"], - example: "await apple.messages.compose({ recipients: ['4085551212'], body: 'Report ready' })", - optionalArguments: ["recipients", "subject", "body", "attachments", "timeoutMs"], - argumentTypes: [ - "recipients": .array, - "subject": .string, - "body": .string, - "attachments": .array, - "timeoutMs": .number, - ], - argumentHints: [ - "recipients": "Optional array of phone number or address strings.", - "subject": "Optional subject on devices/accounts that support it.", - "body": "Optional message body.", - "attachments": "Optional array of { path, filename? } sandbox file attachments.", - "timeoutMs": "Optional timeout for waiting on user completion.", - ], - resultSummary: "Object with action sent/cancelled/failed." - ), - handler: { args, context in - try systemUI.composeMessage(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .printUIPresent, - title: "Present print UI", - summary: "Present the system print sheet for one or more sandbox files.", - tags: ["print", "documents", "system-ui", "export"], - example: "await apple.print.present({ path: 'tmp:report.pdf', jobName: 'Report' })", - optionalArguments: ["path", "paths", "jobName", "outputType", "showsNumberOfCopies", "timeoutMs"], - argumentTypes: [ - "path": .string, - "paths": .array, - "jobName": .string, - "outputType": .string, - "showsNumberOfCopies": .bool, - "timeoutMs": .number, - ], - argumentHints: [ - "path": "Single sandbox file path to print.", - "paths": "Optional array of sandbox file paths to print.", - "jobName": "Optional print job name.", - "outputType": "general (default), photo, or grayscale.", - "showsNumberOfCopies": "Whether copy count controls are shown; default true.", - "timeoutMs": "Optional timeout for waiting on print completion/cancellation.", - ], - resultSummary: "Object with action/completed for the print interaction." - ), - handler: { args, context in - try systemUI.presentPrint(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .webUIPresent, - title: "Present web page with system UI", - summary: "Present an HTTP(S) URL with the system Safari view controller.", - tags: ["web", "safari", "browser", "system-ui"], - example: "await apple.web.present({ url: 'https://example.com' })", - requiredArguments: ["url"], - optionalArguments: ["entersReaderIfAvailable", "timeoutMs"], - argumentTypes: [ - "url": .string, - "entersReaderIfAvailable": .bool, - "timeoutMs": .number, - ], - argumentHints: [ - "url": "Absolute HTTP(S) URL to present.", - "entersReaderIfAvailable": "Whether Safari may enter Reader automatically; default false.", - "timeoutMs": "Optional timeout for waiting on dismissal.", - ], - resultSummary: "Object with action dismissed." - ), - handler: { args, context in - try systemUI.presentWeb(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .authUIWebAuthenticate, - title: "Authenticate with system web UI", - summary: "Start an ASWebAuthenticationSession for OAuth-style browser authentication.", - tags: ["auth", "oauth", "web", "browser", "system-ui"], - example: "await apple.auth.webAuthenticate({ url: 'https://example.com/oauth', callbackURLScheme: 'myapp' })", - requiredArguments: ["url"], - optionalArguments: ["callbackURLScheme", "prefersEphemeralSession", "timeoutMs"], - argumentTypes: [ - "url": .string, - "callbackURLScheme": .string, - "prefersEphemeralSession": .bool, - "timeoutMs": .number, - ], - argumentHints: [ - "url": "Absolute HTTP(S) authentication URL.", - "callbackURLScheme": "Optional custom URL scheme that completes the session.", - "prefersEphemeralSession": "Whether to prefer a private browser session; default false.", - "timeoutMs": "Optional timeout for waiting on callback/cancellation.", - ], - resultSummary: "Object with action callback/cancelled and callbackURL when available." - ), - handler: { args, context in - try systemUI.authenticateWeb(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .uiAlertPresent, - title: "Present alert with custom buttons", - summary: "Present a system alert or action sheet and return the button the user selects.", - tags: ["ui", "alert", "dialog", "system-ui"], - example: "await apple.ui.presentAlert({ title: 'Delete draft?', message: 'This cannot be undone.', buttons: [{ id: 'cancel', title: 'Cancel', style: 'cancel' }, { id: 'delete', title: 'Delete', style: 'destructive' }] })", - requiredArguments: ["buttons"], - optionalArguments: ["title", "message", "preferredStyle", "timeoutMs"], - argumentTypes: [ - "title": .string, - "message": .string, - "preferredStyle": .string, - "buttons": .array, - "timeoutMs": .number, - ], - argumentHints: [ - "title": "Optional alert title.", - "message": "Optional alert message.", - "preferredStyle": "alert (default) or actionSheet.", - "buttons": "Array of { id?, title, style? }; style is default, cancel, or destructive. At most one cancel button.", - "timeoutMs": "Optional timeout for waiting on user selection.", - ], - resultSummary: "Object with action/buttonID/buttonTitle/buttonIndex/style for the selected button." - ), - handler: { args, context in - try systemUI.presentAlert(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .uiPromptPresent, - title: "Present prompt with text fields", - summary: "Present a system alert with one or more text fields and custom buttons.", - tags: ["ui", "alert", "prompt", "input", "system-ui"], - example: "await apple.ui.presentPrompt({ title: 'Name', fields: [{ id: 'name', placeholder: 'Name' }], buttons: [{ id: 'cancel', title: 'Cancel', style: 'cancel' }, { id: 'ok', title: 'OK' }] })", - requiredArguments: ["fields", "buttons"], - optionalArguments: ["title", "message", "timeoutMs"], - argumentTypes: [ - "title": .string, - "message": .string, - "fields": .array, - "buttons": .array, - "timeoutMs": .number, - ], - argumentHints: [ - "fields": "Array of { id?, placeholder?, text?/defaultValue?, secure?, keyboardType? }. keyboardType is default, email, number, phone, or url.", - "buttons": "Array of { id?, title, style? }; style is default, cancel, or destructive. At most one cancel button.", - "timeoutMs": "Optional timeout for waiting on user selection.", - ], - resultSummary: "Object with selected button metadata and values keyed by field id." - ), - handler: { args, context in - try systemUI.presentPrompt(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .settingsUIOpen, - title: "Open app settings", - summary: "Open the host app's Settings page so the user can recover denied permissions.", - tags: ["settings", "permissions", "system-ui"], - example: "await apple.settings.open()", - optionalArguments: ["timeoutMs"], - argumentTypes: [ - "timeoutMs": .number, - ], - argumentHints: [ - "timeoutMs": "Optional timeout for waiting on UIApplication.open completion.", - ], - resultSummary: "Object with action opened/failed and opened boolean." - ), - handler: { args, context in - try systemUI.openSettings(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .visionImageAnalyze, - title: "Analyze image with Vision", - summary: "Run on-device image analysis for labels/text/barcodes on sandbox image paths.", - tags: ["vision", "image-analysis", "ml"], - example: "await apple.vision.analyzeImage({ path: 'tmp:receipt.jpg', features: ['text'], maxResults: 10 })", - requiredArguments: ["path"], - optionalArguments: ["features", "maxResults"], - argumentHints: [ - "path": "Sandbox image path to analyze.", - "features": "Optional array including labels/text/barcodes.", - "maxResults": "Max observations returned per feature, default 5.", - ], - resultSummary: "Object containing requested analysis sections such as labels/text/barcodes." - ), - handler: { args, context in - try vision.analyzeImage(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .notificationsPermissionRequest, - title: "Request notification permission", - summary: "Request local notification authorization from the user.", - tags: ["notifications", "permission"], - example: "await apple.notifications.requestPermission()", - resultSummary: "Object with status/granted fields." - ), - handler: { _, context in - try notifications.requestPermission(context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .notificationsSchedule, - title: "Schedule local notification", - summary: "Schedule a local notification using time interval or fireDate trigger.", - tags: ["notifications", "local", "schedule"], - example: "await apple.notifications.schedule({ title: 'Stand up', body: 'Stretch break', secondsFromNow: 900 })", - requiredPermissions: [.notifications], - requiredArguments: ["title"], - optionalArguments: ["identifier", "subtitle", "body", "secondsFromNow", "fireDate", "repeats"], - argumentHints: [ - "title": "Notification title text.", - "identifier": "Optional request identifier; defaults to codemode UUID.", - "secondsFromNow": "Delay in seconds for time interval trigger (default 5).", - "fireDate": "Optional ISO8601 timestamp for calendar trigger.", - "repeats": "Boolean repeat flag (time interval requires >= 60 seconds).", - ], - resultSummary: "Object with identifier/scheduled/repeats." - ), - handler: { args, context in - try notifications.schedule(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .notificationsPendingRead, - title: "List pending local notifications", - summary: "List pending local notification requests.", - tags: ["notifications", "local", "schedule"], - example: "await apple.notifications.listPending({ limit: 20 })", - requiredPermissions: [.notifications], - optionalArguments: ["limit"], - argumentHints: [ - "limit": "Max number of pending requests to return, default 50.", - ], - resultSummary: "Array of pending requests with identifiers/content/trigger metadata." - ), - handler: { args, context in - try notifications.readPending(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .notificationsPendingDelete, - title: "Delete pending local notifications", - summary: "Delete pending local notifications by identifier list or clear all.", - tags: ["notifications", "local", "schedule"], - example: "await apple.notifications.cancelPending({ identifiers: ['codemode.1', 'codemode.2'] })", - requiredPermissions: [.notifications], - optionalArguments: ["identifier", "identifiers"], - argumentHints: [ - "identifier": "Single pending request identifier to remove.", - "identifiers": "Array of pending request identifiers to remove. Omit both to clear all pending requests.", - ], - resultSummary: "Object with deleted/count fields." - ), - handler: { args, context in - try notifications.deletePending(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .alarmPermissionRequest, - title: "Request AlarmKit permission", - summary: "Request AlarmKit authorization from the user.", - tags: ["alarmkit", "permission", "alarms"], - example: "await ios.alarm.requestPermission()", - resultSummary: "Object with status/granted fields." - ), - handler: { _, context in - try alarm.requestPermission(context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .alarmRead, - title: "List scheduled alarms", - summary: "List scheduled alarms known to the bridge runtime.", - tags: ["alarmkit", "alarms", "schedule"], - example: "await ios.alarm.list({ limit: 20 })", - requiredPermissions: [.alarmKit], - optionalArguments: ["limit"], - argumentHints: [ - "limit": "Max number of scheduled alarms returned, default 50.", - ], - resultSummary: "Array of scheduled alarms with identifier/title/timing fields." - ), - handler: { args, context in - try alarm.read(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .alarmSchedule, - title: "Schedule AlarmKit alarm", - summary: "Schedule an AlarmKit alarm using secondsFromNow or fireDate.", - tags: ["alarmkit", "alarms", "schedule"], - example: "await ios.alarm.schedule({ title: 'Wake up', secondsFromNow: 1800 })", - requiredPermissions: [.alarmKit], - requiredArguments: ["title"], - optionalArguments: ["identifier", "secondsFromNow", "fireDate"], - argumentHints: [ - "title": "Alarm title shown in presentation.", - "identifier": "Optional UUID string; generated when omitted.", - "secondsFromNow": "Fallback relative delay in seconds, default 60.", - "fireDate": "Optional absolute ISO8601 date; used when provided.", - ], - resultSummary: "Object with identifier/scheduled/title." - ), - handler: { args, context in - try alarm.schedule(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .alarmCancel, - title: "Cancel scheduled alarms", - summary: "Cancel one or more scheduled alarms by identifier, or clear all known alarms.", - tags: ["alarmkit", "alarms", "schedule"], - example: "await ios.alarm.cancel({ identifiers: ['8F11679B-92E8-4D2F-84B4-4D0A7C95E3C3'] })", - requiredPermissions: [.alarmKit], - optionalArguments: ["identifier", "identifiers"], - argumentHints: [ - "identifier": "Single alarm identifier UUID string.", - "identifiers": "Array of alarm identifier UUID strings. Omit both to cancel all known alarms.", - ], - resultSummary: "Object with deleted/count fields." - ), - handler: { args, context in - try alarm.cancel(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .healthPermissionRequest, - title: "Request HealthKit permission", - summary: "Request HealthKit authorization for requested read/write types.", - tags: ["healthkit", "health", "permission"], - example: "await apple.health.requestPermission({ readTypes: ['stepCount', 'heartRate'], writeTypes: ['stepCount'] })", - optionalArguments: ["readTypes", "writeTypes"], - argumentTypes: [ - "readTypes": .array, - "writeTypes": .array, - ], - argumentHints: [ - "readTypes": "Optional array of type names to read, e.g. stepCount, heartRate, activeEnergyBurned.", - "writeTypes": "Optional array of type names to write (quantity types only).", - ], - resultSummary: "Object with status/granted fields and requested type arrays." - ), - handler: { args, context in - try health.requestPermission(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .healthRead, - title: "Read HealthKit samples", - summary: "Read HealthKit samples for a supported type and date range.", - tags: ["healthkit", "health", "query"], - example: "await apple.health.read({ type: 'stepCount', start: '2026-03-03T00:00:00Z', end: '2026-03-04T00:00:00Z', limit: 25, unit: 'count' })", - requiredArguments: ["type"], - optionalArguments: ["start", "end", "limit", "unit"], - argumentTypes: [ - "type": .string, - "start": .string, - "end": .string, - "limit": .number, - "unit": .string, - ], - argumentHints: [ - "type": "Supported: stepCount, heartRate, activeEnergyBurned, bodyMass, distanceWalkingRunning, sleepAnalysis, workout.", - "start": "Optional ISO8601 start timestamp; defaults to last 24h.", - "end": "Optional ISO8601 end timestamp; defaults to now.", - "limit": "Max number of samples, default 50.", - "unit": "Optional unit override for quantity types (count, bpm, kcal, kg, m).", - ], - resultSummary: "Array of samples with identifier/type/value and timing metadata." - ), - handler: { args, context in - try health.read(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .healthWrite, - title: "Write HealthKit quantity sample", - summary: "Write a HealthKit quantity sample for supported writable quantity types.", - tags: ["healthkit", "health", "write"], - example: "await apple.health.write({ type: 'stepCount', value: 1200, unit: 'count', start: '2026-03-04T08:00:00Z', end: '2026-03-04T08:30:00Z' })", - requiredArguments: ["type", "value"], - optionalArguments: ["unit", "start", "end"], - argumentTypes: [ - "type": .string, - "value": .number, - "unit": .string, - "start": .string, - "end": .string, - ], - argumentHints: [ - "type": "Writable types: stepCount, heartRate, activeEnergyBurned, bodyMass, distanceWalkingRunning.", - "value": "Numeric sample value.", - "unit": "Optional unit (count, bpm, kcal, kg, m).", - "start": "Optional ISO8601 start timestamp; defaults to now.", - "end": "Optional ISO8601 end timestamp; defaults to start.", - ], - resultSummary: "Object with identifier/type/value/unit and written=true." - ), - handler: { args, context in - try health.write(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .homeRead, - title: "Read HomeKit graph", - summary: "Read homes/accessories/services (and optional characteristics) from HomeKit.", - tags: ["homekit", "iot", "devices"], - example: "await apple.home.list({ includeCharacteristics: true, limit: 5 })", - requiredPermissions: [.homeKit], - optionalArguments: ["includeCharacteristics", "limit"], - argumentHints: [ - "includeCharacteristics": "Boolean; include characteristic details when true.", - "limit": "Max number of homes to return, default 10.", - ], - resultSummary: "Array of homes with accessories/services snapshot." - ), - handler: { args, context in - try home.read(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .homeWrite, - title: "Write HomeKit characteristic", - summary: "Write a value to a writable HomeKit characteristic for a target accessory.", - tags: ["homekit", "iot", "devices", "control"], - example: "await apple.home.writeCharacteristic({ accessoryIdentifier: 'UUID', characteristicType: 'HMCharacteristicTypePowerState', value: true })", - requiredPermissions: [.homeKit], - requiredArguments: ["accessoryIdentifier", "characteristicType", "value"], - optionalArguments: ["serviceType"], - argumentTypes: [ - "accessoryIdentifier": .string, - "characteristicType": .string, - "value": .any, - "serviceType": .string, - ], - argumentHints: [ - "accessoryIdentifier": "Accessory UUID string from home.read output.", - "characteristicType": "Characteristic type identifier (e.g. HMCharacteristicTypePowerState).", - "value": "Target value; string/number/bool/null.", - "serviceType": "Optional service type filter for characteristic lookup.", - ], - resultSummary: "Object with accessoryIdentifier/characteristicType/written." - ), - handler: { args, context in - try home.write(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .mediaMetadataRead, - title: "Read media metadata", - summary: "Read duration and track metadata from media files.", - tags: ["media", "avfoundation", "metadata"], - example: "await apple.media.metadata({ path: 'tmp:video.mov' })", - requiredArguments: ["path"], - argumentHints: [ - "path": "Sandbox path like tmp:clip.mov.", - ], - resultSummary: "Object with path/durationSeconds/tracks." - ), - handler: { args, context in - try media.metadata(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .mediaFrameExtract, - title: "Extract video frame", - summary: "Extract frame at time offset and persist JPEG output.", - tags: ["media", "avfoundation", "thumbnail"], - example: "await apple.media.extractFrame({ path: 'tmp:video.mov', timeMs: 1500 })", - requiredArguments: ["path"], - optionalArguments: ["timeMs", "outputPath"], - argumentHints: [ - "path": "Input video sandbox path.", - "timeMs": "Frame timestamp in milliseconds; default 0.", - "outputPath": "Optional output sandbox path; defaults to tmp-generated JPEG.", - ], - resultSummary: "Object with output path and artifactID." - ), - handler: { args, context in - try media.extractFrame(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .mediaTranscode, - title: "Transcode media", - summary: "Transcode media into MP4 with preset quality.", - tags: ["media", "avfoundation", "transcode"], - example: "await apple.media.transcode({ path: 'tmp:input.mov', preset: 'AVAssetExportPresetMediumQuality' })", - requiredArguments: ["path"], - optionalArguments: ["outputPath", "preset"], - argumentHints: [ - "path": "Input media sandbox path.", - "outputPath": "Optional output sandbox path; defaults to tmp-generated mp4.", - "preset": "AVAssetExportSession preset string; default AVAssetExportPresetMediumQuality.", - ], - resultSummary: "Object with output path/artifactID/preset." - ), - handler: { args, context in - try media.transcode(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .fsList, - title: "List directory", - summary: "List files/directories within allowed sandbox roots as entry objects.", - tags: ["filesystem", "io", "fs"], - example: "await apple.fs.list({ path: 'tmp:' })", - requiredArguments: ["path"], - argumentHints: [ - "path": "Directory path using allowed root prefix (tmp:, caches:, documents:).", - ], - resultSummary: "Array of entry objects with name/path/isDirectory/size. Use entry.name for filenames; fs.promises.readdir returns the same entry objects, not strings." - ), - handler: { args, context in - try fs.list(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .fsRead, - title: "Read file", - summary: "Read text/base64 file data within allowed sandbox roots.", - tags: ["filesystem", "io", "fs"], - example: "await apple.fs.read({ path: 'tmp:data.json', encoding: 'utf8' })", - requiredArguments: ["path"], - optionalArguments: ["encoding"], - argumentHints: [ - "path": "File path using allowed root prefix.", - "encoding": "utf8 (default) or base64.", - ], - resultSummary: "Object with path plus text or base64 field." - ), - handler: { args, context in - try fs.read(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .fsWrite, - title: "Write file", - summary: "Write text/base64 file data within allowed sandbox roots.", - tags: ["filesystem", "io", "fs"], - example: "await apple.fs.write({ path: 'tmp:data.json', data: '{\"ok\":true}' })", - requiredArguments: ["path"], - optionalArguments: ["data", "encoding"], - argumentHints: [ - "path": "File path using allowed root prefix.", - "data": "UTF-8 text or base64 string depending on encoding.", - "encoding": "utf8 (default) or base64.", - ], - resultSummary: "Object with path and bytesWritten." - ), - handler: { args, context in - try fs.write(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .fsMove, - title: "Move file", - summary: "Move file/directory within allowed sandbox roots.", - tags: ["filesystem", "io", "fs"], - example: "await apple.fs.move({ from: 'tmp:a.txt', to: 'tmp:b.txt' })", - requiredArguments: ["from", "to"], - argumentHints: [ - "from": "Source sandbox path.", - "to": "Destination sandbox path.", - ], - resultSummary: "Object with from/to resolved paths." - ), - handler: { args, context in - try fs.move(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .fsCopy, - title: "Copy file", - summary: "Copy file/directory within allowed sandbox roots.", - tags: ["filesystem", "io", "fs"], - example: "await apple.fs.copy({ from: 'tmp:a.txt', to: 'tmp:b.txt' })", - requiredArguments: ["from", "to"], - argumentHints: [ - "from": "Source sandbox path.", - "to": "Destination sandbox path.", - ], - resultSummary: "Object with from/to resolved paths." - ), - handler: { args, context in - try fs.copy(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .fsDelete, - title: "Delete file", - summary: "Delete file/directory within allowed sandbox roots.", - tags: ["filesystem", "io", "fs"], - example: "await apple.fs.delete({ path: 'tmp:data.json' })", - requiredArguments: ["path"], - optionalArguments: ["recursive"], - argumentHints: [ - "path": "Path to file or directory.", - "recursive": "Required as true when deleting a directory.", - ], - resultSummary: "Object with deleted flag and path." - ), - handler: { args, context in - try fs.delete(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .fsStat, - title: "Stat path", - summary: "Read file metadata within allowed sandbox roots.", - tags: ["filesystem", "io", "fs"], - example: "await apple.fs.stat({ path: 'tmp:data.json' })", - requiredArguments: ["path"], - argumentHints: [ - "path": "File or directory path.", - ], - resultSummary: "Object with path/isDirectory/size/createdAt/modifiedAt." - ), - handler: { args, context in - try fs.stat(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .fsMkdir, - title: "Create directory", - summary: "Create directories within allowed sandbox roots.", - tags: ["filesystem", "io", "fs"], - example: "await apple.fs.mkdir({ path: 'tmp:artifacts', recursive: true })", - requiredArguments: ["path"], - optionalArguments: ["recursive"], - argumentHints: [ - "path": "Directory path to create.", - "recursive": "Boolean; default true.", - ], - resultSummary: "Object with created flag and path." - ), - handler: { args, context in - try fs.mkdir(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .fsExists, - title: "Check path exists", - summary: "Check if file/directory exists within allowed sandbox roots.", - tags: ["filesystem", "io", "fs"], - example: "await apple.fs.exists({ path: 'tmp:data.json' })", - requiredArguments: ["path"], - argumentHints: [ - "path": "File or directory path to check.", - ], - resultSummary: "Boolean." - ), - handler: { args, context in - try fs.exists(arguments: args, context: context) - } - ), - CapabilityRegistration( - descriptor: .init( - id: .fsAccess, - title: "Check path access", - summary: "Check read/write access for path within allowed sandbox roots.", - tags: ["filesystem", "io", "fs"], - example: "await apple.fs.access({ path: 'tmp:data.json' })", - requiredArguments: ["path"], - argumentHints: [ - "path": "File or directory path to inspect.", - ], - resultSummary: "Object with readable/writable/path." - ), - handler: { args, context in - try fs.access(arguments: args, context: context) - } - ), - ] + func loadAll() -> [CapabilityRegistration] { + [ + networkRegistrations(), + keychainRegistrations(), + locationAndWeatherRegistrations(), + calendarAndReminderRegistrations(), + contactRegistrations(), + photoAndDocumentRegistrations(), + interactionUIRegistrations(), + visionRegistrations(), + notificationRegistrations(), + alarmRegistrations(), + healthRegistrations(), + homeRegistrations(), + mediaRegistrations(), + bigTicketAppleRegistrations(), + filesystemRegistrations(), + ].flatMap { $0 } } } diff --git a/Sources/CodeMode/Bridges/EventKitBridge.swift b/Sources/CodeMode/Bridges/EventKitBridge.swift index 805e396..b393005 100644 --- a/Sources/CodeMode/Bridges/EventKitBridge.swift +++ b/Sources/CodeMode/Bridges/EventKitBridge.swift @@ -18,17 +18,16 @@ public final class EventKitBridge: @unchecked Sendable { let start = isoDate(arguments.string("start")) ?? Date() let end = isoDate(arguments.string("end")) ?? Calendar.current.date(byAdding: .day, value: 14, to: start) ?? start let limit = arguments.int("limit") ?? 50 + let calendars = try resolveCalendars( + from: arguments, + in: store, + entityType: .event, + capability: "calendar.read" + ) - let predicate = store.predicateForEvents(withStart: start, end: end, calendars: nil) + let predicate = store.predicateForEvents(withStart: start, end: end, calendars: calendars) let events = store.events(matching: predicate).prefix(max(1, limit)).map { event in - JSONValue.object([ - "identifier": .string(event.eventIdentifier ?? ""), - "title": .string(event.title ?? ""), - "startDate": .string(event.startDate.ISO8601Format()), - "endDate": .string(event.endDate.ISO8601Format()), - "notes": .string(event.notes ?? ""), - "calendarTitle": .string(event.calendar.title), - ]) + Self.eventJSON(event) } return .array(Array(events)) @@ -39,32 +38,52 @@ public final class EventKitBridge: @unchecked Sendable { } public func writeEvent(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - let status = resolvePermission(.calendarWriteOnly, context: context) + let operation = eventOperation(arguments) + let permission: PermissionKind = operation == "update" ? .calendar : .calendarWriteOnly + let status = resolvePermission(permission, context: context) guard status == .granted else { - throw BridgeError.permissionDenied(.calendarWriteOnly) + throw BridgeError.permissionDenied(permission) } #if canImport(EventKit) - guard let title = arguments.string("title"), let startText = arguments.string("start"), let endText = arguments.string("end"), let start = isoDate(startText), let end = isoDate(endText) else { - throw BridgeError.invalidArguments("calendar.write requires title/start/end ISO8601") + switch operation { + case "create": + return try createEvent(arguments: arguments) + case "update": + return try updateEvent(arguments: arguments) + default: + throw BridgeError.invalidArguments("calendar.write operation must be create or update") + } + #else + _ = arguments + throw BridgeError.unsupportedPlatform("EventKit") + #endif + } + + public func deleteEvent(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let status = resolvePermission(.calendar, context: context) + guard status == .granted else { + throw BridgeError.permissionDenied(.calendar) } + #if canImport(EventKit) + guard let identifier = arguments.string("identifier"), identifier.isEmpty == false else { + throw BridgeError.invalidArguments("calendar.delete requires identifier") + } let store = EKEventStore() - let event = EKEvent(eventStore: store) - event.title = title - event.startDate = start - event.endDate = end - event.notes = arguments.string("notes") - event.calendar = store.defaultCalendarForNewEvents + guard let event = store.event(withIdentifier: identifier) else { + throw BridgeError.invalidArguments("calendar.delete could not find event identifier \(identifier)") + } + let span = try eventSpan(arguments.string("span")) do { - try store.save(event, span: .thisEvent) + try store.remove(event, span: span) return .object([ - "identifier": .string(event.eventIdentifier ?? ""), - "title": .string(title), + "identifier": .string(identifier), + "deleted": .bool(true), ]) } catch { - throw BridgeError.nativeFailure("calendar.write failed: \(error.localizedDescription)") + throw BridgeError.nativeFailure("calendar.delete failed: \(error.localizedDescription)") } #else _ = arguments @@ -82,17 +101,33 @@ public final class EventKitBridge: @unchecked Sendable { let store = EKEventStore() let semaphore = DispatchSemaphore(value: 0) var result: [JSONValue] = [] + let includeCompleted = arguments.bool("includeCompleted") ?? false + let start = isoDate(arguments.string("start")) + let end = isoDate(arguments.string("end")) + let limit = max(1, arguments.int("limit") ?? 50) + let calendars = try resolveCalendars( + from: arguments, + in: store, + entityType: .reminder, + capability: "reminders.read" + ) - let predicate = store.predicateForIncompleteReminders(withDueDateStarting: nil, ending: nil, calendars: nil) + let predicate = includeCompleted + ? store.predicateForReminders(in: calendars) + : store.predicateForIncompleteReminders(withDueDateStarting: start, ending: end, calendars: calendars) store.fetchReminders(matching: predicate) { reminders in - result = (reminders ?? []).prefix(max(1, arguments.int("limit") ?? 50)).map { reminder in - .object([ - "identifier": .string(reminder.calendarItemIdentifier), - "title": .string(reminder.title), - "isCompleted": .bool(reminder.isCompleted), - "dueDate": .string(reminder.dueDateComponents?.date?.ISO8601Format() ?? ""), - ]) - } + let filtered = (reminders ?? []) + .filter { reminder in + guard includeCompleted else { return true } + guard let dueDate = reminder.dueDateComponents?.date else { + return start == nil && end == nil + } + if let start, dueDate < start { return false } + if let end, dueDate > end { return false } + return true + } + .prefix(limit) + result = filtered.map { Self.reminderJSON($0) } semaphore.signal() } @@ -111,27 +146,43 @@ public final class EventKitBridge: @unchecked Sendable { } #if canImport(EventKit) - guard let title = arguments.string("title") else { - throw BridgeError.invalidArguments("reminders.write requires title") + switch reminderOperation(arguments) { + case "create": + return try createReminder(arguments: arguments) + case "update", "complete": + return try updateReminder(arguments: arguments) + default: + throw BridgeError.invalidArguments("reminders.write operation must be create, update, or complete") } + #else + _ = arguments + throw BridgeError.unsupportedPlatform("EventKit") + #endif + } - let store = EKEventStore() - let reminder = EKReminder(eventStore: store) - reminder.calendar = store.defaultCalendarForNewReminders() - reminder.title = title + public func deleteReminder(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let status = resolvePermission(.reminders, context: context) + guard status == .granted else { + throw BridgeError.permissionDenied(.reminders) + } - if let dueDateText = arguments.string("dueDate"), let dueDate = isoDate(dueDateText) { - reminder.dueDateComponents = Calendar.current.dateComponents(in: .current, from: dueDate) + #if canImport(EventKit) + guard let identifier = arguments.string("identifier"), identifier.isEmpty == false else { + throw BridgeError.invalidArguments("reminders.delete requires identifier") + } + let store = EKEventStore() + guard let reminder = store.calendarItem(withIdentifier: identifier) as? EKReminder else { + throw BridgeError.invalidArguments("reminders.delete could not find reminder identifier \(identifier)") } do { - try store.save(reminder, commit: true) + try store.remove(reminder, commit: true) return .object([ - "identifier": .string(reminder.calendarItemIdentifier), - "title": .string(title), + "identifier": .string(identifier), + "deleted": .bool(true), ]) } catch { - throw BridgeError.nativeFailure("reminders.write failed: \(error.localizedDescription)") + throw BridgeError.nativeFailure("reminders.delete failed: \(error.localizedDescription)") } #else _ = arguments @@ -144,7 +195,323 @@ public final class EventKitBridge: @unchecked Sendable { } private func isoDate(_ text: String?) -> Date? { - guard let text else { return nil } + guard let text, text.isEmpty == false else { return nil } return ISO8601DateFormatter().date(from: text) } + + private func eventOperation(_ arguments: [String: JSONValue]) -> String { + if let operation = arguments.string("operation")?.lowercased() { + return operation + } + return arguments.string("identifier") == nil ? "create" : "update" + } + + private func reminderOperation(_ arguments: [String: JSONValue]) -> String { + if let operation = arguments.string("operation")?.lowercased() { + return operation + } + return arguments.string("identifier") == nil ? "create" : "update" + } + + #if canImport(EventKit) + private func createEvent(arguments: [String: JSONValue]) throws -> JSONValue { + guard let title = arguments.string("title"), title.isEmpty == false, + let startText = arguments.string("start"), + let endText = arguments.string("end"), + let start = isoDate(startText), + let end = isoDate(endText) + else { + throw BridgeError.invalidArguments("calendar.write create requires title/start/end ISO8601") + } + + let store = EKEventStore() + let event = EKEvent(eventStore: store) + event.title = title + event.startDate = start + event.endDate = end + event.notes = arguments.string("notes") + event.location = arguments.string("location") + event.isAllDay = arguments.bool("isAllDay") ?? false + if let urlText = arguments.string("url"), urlText.isEmpty == false { + guard let url = URL(string: urlText) else { + throw BridgeError.invalidArguments("calendar.write url must be valid when provided") + } + event.url = url + } + if let calendarIdentifier = arguments.string("calendarIdentifier") { + event.calendar = try calendar( + calendarIdentifier, + in: store, + entityType: .event, + capability: "calendar.write" + ) + } else { + event.calendar = store.defaultCalendarForNewEvents + } + + do { + try store.save(event, span: .thisEvent) + return Self.eventJSON(event) + } catch { + throw BridgeError.nativeFailure("calendar.write failed: \(error.localizedDescription)") + } + } + + private func updateEvent(arguments: [String: JSONValue]) throws -> JSONValue { + guard let identifier = arguments.string("identifier"), identifier.isEmpty == false else { + throw BridgeError.invalidArguments("calendar.write update requires identifier") + } + + let patchFields = ["title", "start", "end", "notes", "location", "url", "isAllDay", "calendarIdentifier"] + guard patchFields.contains(where: { arguments.keys.contains($0) }) else { + throw BridgeError.invalidArguments("calendar.write update requires at least one patch field") + } + + let store = EKEventStore() + guard let event = store.event(withIdentifier: identifier) else { + throw BridgeError.invalidArguments("calendar.write could not find event identifier \(identifier)") + } + + if arguments.keys.contains("title") { + guard let title = arguments.string("title"), title.isEmpty == false else { + throw BridgeError.invalidArguments("calendar.write title cannot be empty") + } + event.title = title + } + if arguments.keys.contains("start") { + guard let start = isoDate(arguments.string("start")) else { + throw BridgeError.invalidArguments("calendar.write start must be ISO8601 when provided") + } + event.startDate = start + } + if arguments.keys.contains("end") { + guard let end = isoDate(arguments.string("end")) else { + throw BridgeError.invalidArguments("calendar.write end must be ISO8601 when provided") + } + event.endDate = end + } + if arguments.keys.contains("notes") { + event.notes = arguments.string("notes") + } + if arguments.keys.contains("location") { + event.location = arguments.string("location") + } + if arguments.keys.contains("url") { + let urlText = arguments.string("url") ?? "" + event.url = urlText.isEmpty ? nil : URL(string: urlText) + if urlText.isEmpty == false, event.url == nil { + throw BridgeError.invalidArguments("calendar.write url must be valid when provided") + } + } + if arguments.keys.contains("isAllDay") { + event.isAllDay = arguments.bool("isAllDay") ?? false + } + if let calendarIdentifier = arguments.string("calendarIdentifier") { + event.calendar = try calendar( + calendarIdentifier, + in: store, + entityType: .event, + capability: "calendar.write" + ) + } + + do { + try store.save(event, span: .thisEvent) + return Self.eventJSON(event) + } catch { + throw BridgeError.nativeFailure("calendar.write failed: \(error.localizedDescription)") + } + } + + private func createReminder(arguments: [String: JSONValue]) throws -> JSONValue { + guard let title = arguments.string("title"), title.isEmpty == false else { + throw BridgeError.invalidArguments("reminders.write create requires title") + } + + let store = EKEventStore() + let reminder = EKReminder(eventStore: store) + reminder.calendar = try reminderCalendar(from: arguments, in: store) + reminder.title = title + reminder.notes = arguments.string("notes") + reminder.priority = arguments.int("priority") ?? 0 + + if let dueDateText = arguments.string("dueDate"), dueDateText.isEmpty == false { + guard let dueDate = isoDate(dueDateText) else { + throw BridgeError.invalidArguments("reminders.write dueDate must be ISO8601 when provided") + } + reminder.dueDateComponents = Calendar.current.dateComponents(in: .current, from: dueDate) + } + + do { + try store.save(reminder, commit: true) + return Self.reminderJSON(reminder) + } catch { + throw BridgeError.nativeFailure("reminders.write failed: \(error.localizedDescription)") + } + } + + private func updateReminder(arguments: [String: JSONValue]) throws -> JSONValue { + guard let identifier = arguments.string("identifier"), identifier.isEmpty == false else { + throw BridgeError.invalidArguments("reminders.write update requires identifier") + } + + let patchFields = ["title", "dueDate", "notes", "isCompleted", "priority", "calendarIdentifier"] + guard patchFields.contains(where: { arguments.keys.contains($0) }) else { + throw BridgeError.invalidArguments("reminders.write update requires at least one patch field") + } + + let store = EKEventStore() + guard let reminder = store.calendarItem(withIdentifier: identifier) as? EKReminder else { + throw BridgeError.invalidArguments("reminders.write could not find reminder identifier \(identifier)") + } + + if arguments.keys.contains("title") { + guard let title = arguments.string("title"), title.isEmpty == false else { + throw BridgeError.invalidArguments("reminders.write title cannot be empty") + } + reminder.title = title + } + if arguments.keys.contains("dueDate") { + if let dueDateText = arguments.string("dueDate"), dueDateText.isEmpty == false { + guard let dueDate = isoDate(dueDateText) else { + throw BridgeError.invalidArguments("reminders.write dueDate must be ISO8601 when provided") + } + reminder.dueDateComponents = Calendar.current.dateComponents(in: .current, from: dueDate) + } else { + reminder.dueDateComponents = nil + } + } + if arguments.keys.contains("notes") { + reminder.notes = arguments.string("notes") + } + if arguments.keys.contains("isCompleted") { + let isCompleted = arguments.bool("isCompleted") ?? false + reminder.isCompleted = isCompleted + reminder.completionDate = isCompleted ? (reminder.completionDate ?? Date()) : nil + } + if let priority = arguments.int("priority") { + reminder.priority = priority + } + if arguments.keys.contains("calendarIdentifier") { + reminder.calendar = try reminderCalendar(from: arguments, in: store) + } + + do { + try store.save(reminder, commit: true) + return Self.reminderJSON(reminder) + } catch { + throw BridgeError.nativeFailure("reminders.write failed: \(error.localizedDescription)") + } + } + + private func resolveCalendars( + from arguments: [String: JSONValue], + in store: EKEventStore, + entityType: EKEntityType, + capability: String + ) throws -> [EKCalendar]? { + var identifiers: [String] = [] + if let identifier = arguments.string("calendarIdentifier") { + identifiers.append(identifier) + } + if let values = arguments.array("calendarIdentifiers") { + for value in values { + guard let identifier = value.stringValue else { + throw BridgeError.invalidArguments("\(capability) calendarIdentifiers must contain strings") + } + identifiers.append(identifier) + } + } + + guard identifiers.isEmpty == false else { + return nil + } + + let calendars = store.calendars(for: entityType) + let uniqueIdentifiers = Set(identifiers) + let matches = calendars.filter { uniqueIdentifiers.contains($0.calendarIdentifier) } + let matchedIdentifiers = Set(matches.map(\.calendarIdentifier)) + let missing = uniqueIdentifiers.subtracting(matchedIdentifiers) + if missing.isEmpty == false { + throw BridgeError.invalidArguments("\(capability) could not find calendarIdentifier \(missing.sorted().joined(separator: ", "))") + } + return matches + } + + private func calendar( + _ identifier: String, + in store: EKEventStore, + entityType: EKEntityType, + capability: String + ) throws -> EKCalendar { + guard identifier.isEmpty == false else { + throw BridgeError.invalidArguments("\(capability) calendarIdentifier cannot be empty") + } + guard let calendar = store.calendars(for: entityType).first(where: { $0.calendarIdentifier == identifier }) else { + throw BridgeError.invalidArguments("\(capability) could not find calendarIdentifier \(identifier)") + } + return calendar + } + + private func reminderCalendar(from arguments: [String: JSONValue], in store: EKEventStore) throws -> EKCalendar { + if let calendarIdentifier = arguments.string("calendarIdentifier") { + return try calendar( + calendarIdentifier, + in: store, + entityType: .reminder, + capability: "reminders.write" + ) + } + guard let calendar = store.defaultCalendarForNewReminders() else { + throw BridgeError.nativeFailure("reminders.write could not resolve a default reminders calendar") + } + return calendar + } + + private func eventSpan(_ text: String?) throws -> EKSpan { + switch text?.lowercased() ?? "thisevent" { + case "thisevent", "this_event", "this": + return .thisEvent + case "futureevents", "future_events", "future": + return .futureEvents + default: + throw BridgeError.invalidArguments("calendar.delete span must be thisEvent or futureEvents") + } + } + + private static func eventJSON(_ event: EKEvent) -> JSONValue { + .object([ + "identifier": .string(event.eventIdentifier ?? ""), + "title": .string(event.title ?? ""), + "startDate": .string(event.startDate.ISO8601Format()), + "endDate": .string(event.endDate.ISO8601Format()), + "notes": .string(event.notes ?? ""), + "calendarIdentifier": .string(event.calendar.calendarIdentifier), + "calendarTitle": .string(event.calendar.title), + "location": .string(event.location ?? ""), + "url": .string(event.url?.absoluteString ?? ""), + "isAllDay": .bool(event.isAllDay), + ]) + } + + private static func reminderJSON(_ reminder: EKReminder) -> JSONValue { + var object: [String: JSONValue] = [ + "identifier": .string(reminder.calendarItemIdentifier), + "title": .string(reminder.title), + "isCompleted": .bool(reminder.isCompleted), + "dueDate": .string(reminder.dueDateComponents?.date?.ISO8601Format() ?? ""), + "completionDate": .string(reminder.completionDate?.ISO8601Format() ?? ""), + "notes": .string(reminder.notes ?? ""), + "priority": .number(Double(reminder.priority)), + ] + if let calendar = reminder.calendar { + object["calendarIdentifier"] = .string(calendar.calendarIdentifier) + object["calendarTitle"] = .string(calendar.title) + } else { + object["calendarIdentifier"] = .string("") + object["calendarTitle"] = .string("") + } + return .object(object) + } + #endif } diff --git a/Sources/CodeMode/Bridges/HealthBridge.swift b/Sources/CodeMode/Bridges/HealthBridge.swift index 319cb88..0732e72 100644 --- a/Sources/CodeMode/Bridges/HealthBridge.swift +++ b/Sources/CodeMode/Bridges/HealthBridge.swift @@ -143,11 +143,7 @@ public final class HealthBridge: @unchecked Sendable { case .denied, .restricted: throw BridgeError.permissionDenied(.healthKit) case .notDetermined: - let requested = context.permissionBroker.request(for: .healthKit) - context.recordPermission(.healthKit, status: requested) - if requested != .granted { - throw BridgeError.permissionDenied(.healthKit) - } + break case .granted, .writeOnly, .unavailable: break } diff --git a/Sources/CodeMode/Bridges/NetworkBridge.swift b/Sources/CodeMode/Bridges/NetworkBridge.swift index 8b44e0e..8f9ae00 100644 --- a/Sources/CodeMode/Bridges/NetworkBridge.swift +++ b/Sources/CodeMode/Bridges/NetworkBridge.swift @@ -1,6 +1,8 @@ import Foundation public final class NetworkBridge: @unchecked Sendable { + private static let defaultTimeoutMs = 30_000 + private let session: URLSession public init(session: URLSession = .shared) { @@ -8,14 +10,29 @@ public final class NetworkBridge: @unchecked Sendable { } public func fetch(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - guard let urlString = arguments.string("url"), let url = URL(string: urlString) else { - throw BridgeError.invalidArguments("network.fetch requires valid 'url'") + guard let urlString = arguments.string("url"), + let url = URL(string: urlString), + let scheme = url.scheme?.lowercased(), + ["http", "https"].contains(scheme), + url.host?.isEmpty == false + else { + throw BridgeError.invalidArguments("network.fetch requires an absolute HTTP(S) 'url'") } let options = arguments.object("options") ?? [:] + let timeoutMs = options.int("timeoutMs") ?? Self.defaultTimeoutMs + guard timeoutMs > 0 else { + throw BridgeError.invalidArguments("network.fetch options.timeoutMs must be greater than 0") + } + + let responseEncoding = options.string("responseEncoding")?.lowercased() ?? "text" + guard ["text", "base64"].contains(responseEncoding) else { + throw BridgeError.invalidArguments("network.fetch options.responseEncoding must be text or base64") + } + var request = URLRequest(url: url) request.httpMethod = options.string("method")?.uppercased() ?? "GET" - request.timeoutInterval = 30 + request.timeoutInterval = TimeInterval(timeoutMs) / 1_000 if let headers = options.object("headers") { for (key, value) in headers { @@ -25,8 +42,19 @@ public final class NetworkBridge: @unchecked Sendable { } } - if let body = options.string("body") { + let body = options.string("body") + let bodyBase64 = options.string("bodyBase64") + if body != nil, bodyBase64 != nil { + throw BridgeError.invalidArguments("network.fetch options.body and options.bodyBase64 are mutually exclusive") + } + + if let body { request.httpBody = Data(body.utf8) + } else if let bodyBase64 { + guard let data = Data(base64Encoded: bodyBase64) else { + throw BridgeError.invalidArguments("network.fetch options.bodyBase64 must be valid base64") + } + request.httpBody = data } let semaphore = DispatchSemaphore(value: 0) @@ -42,8 +70,9 @@ public final class NetworkBridge: @unchecked Sendable { } task.resume() - guard semaphore.wait(timeout: .now() + 30) == .success else { - throw BridgeError.timeout(milliseconds: 30_000) + guard semaphore.wait(timeout: .now() + TimeInterval(timeoutMs) / 1_000) == .success else { + task.cancel() + throw BridgeError.timeout(milliseconds: timeoutMs) } guard let result = resultBox.get() else { @@ -58,20 +87,27 @@ public final class NetworkBridge: @unchecked Sendable { throw BridgeError.nativeFailure("network.fetch received non-HTTP response") } - let bodyText = responseData.flatMap { String(data: $0, encoding: .utf8) } ?? "" + let responseData = responseData ?? Data() let headers = httpResponse.allHeaderFields.reduce(into: [String: JSONValue]()) { partial, pair in partial[String(describing: pair.key)] = .string(String(describing: pair.value)) } context.log(.info, message: "fetch \(request.httpMethod ?? "GET") \(urlString) -> \(httpResponse.statusCode)") - return .object([ + var object: [String: JSONValue] = [ "ok": .bool((200...299).contains(httpResponse.statusCode)), "status": .number(Double(httpResponse.statusCode)), "statusText": .string(HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode)), "headers": .object(headers), - "bodyText": .string(bodyText), - ]) + ] + if responseEncoding == "base64" { + object["bodyBase64"] = .string(responseData.base64EncodedString()) + object["bodyText"] = .string("") + } else { + object["bodyText"] = .string(String(data: responseData, encoding: .utf8) ?? "") + } + + return .object(object) } } } diff --git a/Sources/CodeMode/Bridges/NotificationsBridge.swift b/Sources/CodeMode/Bridges/NotificationsBridge.swift index cec76fa..da6fc2a 100644 --- a/Sources/CodeMode/Bridges/NotificationsBridge.swift +++ b/Sources/CodeMode/Bridges/NotificationsBridge.swift @@ -36,7 +36,16 @@ public final class NotificationsBridge: @unchecked Sendable { content.title = title content.subtitle = subtitle content.body = body - content.sound = .default + content.sound = try notificationSound(arguments.string("sound")) + if let badge = arguments.int("badge") { + guard badge >= 0 else { + throw BridgeError.invalidArguments("notifications.schedule badge must be greater than or equal to 0") + } + content.badge = NSNumber(value: badge) + } + content.userInfo = try notificationUserInfo(from: arguments.object("userInfo")) + content.threadIdentifier = arguments.string("threadIdentifier") ?? "" + content.categoryIdentifier = arguments.string("categoryIdentifier") ?? "" let trigger: UNNotificationTrigger if let fireDateText = arguments.string("fireDate"), let fireDate = isoDate(fireDateText) { @@ -94,14 +103,47 @@ public final class NotificationsBridge: @unchecked Sendable { UNUserNotificationCenter.current().getPendingNotificationRequests { requests in let entries: [JSONValue] = requests.prefix(limit).map { request in - JSONValue.object([ - "identifier": .string(request.identifier), - "title": .string(request.content.title), - "subtitle": .string(request.content.subtitle), - "body": .string(request.content.body), - "triggerType": .string(Self.triggerType(request.trigger)), - "repeats": .bool(Self.triggerRepeats(request.trigger)), - ]) + Self.notificationJSON( + identifier: request.identifier, + content: request.content, + trigger: request.trigger, + deliveredDate: nil + ) + } + payload.set(entries) + semaphore.signal() + } + + if semaphore.wait(timeout: .now() + 10) == .timedOut { + throw BridgeError.timeout(milliseconds: 10_000) + } + + return .array(payload.get()) + #else + _ = arguments + throw BridgeError.unsupportedPlatform("UserNotifications") + #endif + } + + public func readDelivered(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let status = resolvePermission(context: context) + guard status == .granted else { + throw BridgeError.permissionDenied(.notifications) + } + + #if canImport(UserNotifications) + let limit = max(1, arguments.int("limit") ?? 50) + let semaphore = DispatchSemaphore(value: 0) + let payload = LockedBox<[JSONValue]>([]) + + UNUserNotificationCenter.current().getDeliveredNotifications { notifications in + let entries: [JSONValue] = notifications.prefix(limit).map { notification in + Self.notificationJSON( + identifier: notification.request.identifier, + content: notification.request.content, + trigger: notification.request.trigger, + deliveredDate: notification.date + ) } payload.set(entries) semaphore.signal() @@ -118,6 +160,37 @@ public final class NotificationsBridge: @unchecked Sendable { #endif } + public func deleteDelivered(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let status = resolvePermission(context: context) + guard status == .granted else { + throw BridgeError.permissionDenied(.notifications) + } + + #if canImport(UserNotifications) + let center = UNUserNotificationCenter.current() + var removedCount = 0 + + if let identifier = arguments.string("identifier"), identifier.isEmpty == false { + center.removeDeliveredNotifications(withIdentifiers: [identifier]) + removedCount = 1 + } else if let identifiers = arguments.array("identifiers")?.compactMap(\.stringValue), identifiers.isEmpty == false { + center.removeDeliveredNotifications(withIdentifiers: identifiers) + removedCount = identifiers.count + } else { + center.removeAllDeliveredNotifications() + removedCount = -1 + } + + return .object([ + "deleted": .bool(true), + "count": .number(Double(removedCount)), + ]) + #else + _ = arguments + throw BridgeError.unsupportedPlatform("UserNotifications") + #endif + } + public func deletePending(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { let status = resolvePermission(context: context) guard status == .granted else { @@ -159,6 +232,82 @@ public final class NotificationsBridge: @unchecked Sendable { } #if canImport(UserNotifications) + private func notificationSound(_ name: String?) throws -> UNNotificationSound? { + switch name?.lowercased() ?? "default" { + case "default": + return .default + case "none": + return nil + default: + guard let name, name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { + throw BridgeError.invalidArguments("notifications.schedule sound cannot be empty") + } + return UNNotificationSound(named: UNNotificationSoundName(name)) + } + } + + private func notificationUserInfo(from object: [String: JSONValue]?) throws -> [AnyHashable: Any] { + guard let object else { + return [:] + } + + var result: [AnyHashable: Any] = [:] + for (key, value) in object { + result[AnyHashable(key)] = try propertyListValue(value, path: "userInfo.\(key)") + } + return result + } + + private func propertyListValue(_ value: JSONValue, path: String) throws -> Any { + switch value { + case let .string(value): + return value + case let .number(value): + return value + case let .bool(value): + return value + case let .array(values): + return try values.enumerated().map { index, value in + try propertyListValue(value, path: "\(path)[\(index)]") + } + case let .object(object): + var result: [String: Any] = [:] + for (key, value) in object { + result[key] = try propertyListValue(value, path: "\(path).\(key)") + } + return result + case .null: + throw BridgeError.invalidArguments("notifications.schedule \(path) cannot be null") + } + } + + private static func notificationJSON( + identifier: String, + content: UNNotificationContent, + trigger: UNNotificationTrigger?, + deliveredDate: Date? + ) -> JSONValue { + var object: [String: JSONValue] = [ + "identifier": .string(identifier), + "title": .string(content.title), + "subtitle": .string(content.subtitle), + "body": .string(content.body), + "sound": .string(content.sound == nil ? "none" : "present"), + "threadIdentifier": .string(content.threadIdentifier), + "categoryIdentifier": .string(content.categoryIdentifier), + "userInfo": JSONValue(any: content.userInfo), + "triggerType": .string(Self.triggerType(trigger)), + "repeats": .bool(Self.triggerRepeats(trigger)), + ] + if let badge = content.badge { + object["badge"] = .number(badge.doubleValue) + } + if let deliveredDate { + object["deliveredDate"] = .string(deliveredDate.ISO8601Format()) + } + return .object(object) + } + private static func triggerType(_ trigger: UNNotificationTrigger?) -> String { switch trigger { case is UNCalendarNotificationTrigger: diff --git a/Sources/CodeMode/Bridges/SimpleBuiltInCodeModeProviders.swift b/Sources/CodeMode/Bridges/SimpleBuiltInCodeModeProviders.swift new file mode 100644 index 0000000..b9f2592 --- /dev/null +++ b/Sources/CodeMode/Bridges/SimpleBuiltInCodeModeProviders.swift @@ -0,0 +1,315 @@ +import Foundation + +protocol BuiltInCodeModeProvider: Sendable { + func capabilityRegistrations() -> [CapabilityRegistration] +} + +private struct BuiltInToolArgument { + var name: String + var type: CapabilityArgumentType + var optional: Bool + var hint: String + + init(_ name: String, _ type: CapabilityArgumentType, optional: Bool = false, hint: String) { + self.name = name + self.type = type + self.optional = optional + self.hint = hint + } +} + +private protocol BuiltInCodeModeTool: Sendable { + associatedtype Arguments: Sendable + + static var codeModePath: String { get } + static var codeModeTitle: String { get } + static var codeModeSummary: String { get } + static var codeModeTags: [String] { get } + static var codeModeExample: String { get } + static var codeModeArguments: [BuiltInToolArgument] { get } + static var codeModeResultSummary: String { get } + + func decode(arguments: [String: JSONValue]) throws -> Arguments + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue +} + +private extension BuiltInCodeModeTool { + static var requiredArguments: [String] { + codeModeArguments.filter { $0.optional == false }.map(\.name) + } + + static var optionalArguments: [String] { + codeModeArguments.filter(\.optional).map(\.name) + } + + static var argumentTypes: [String: CapabilityArgumentType] { + Dictionary(uniqueKeysWithValues: codeModeArguments.map { ($0.name, $0.type) }) + } + + static var argumentHints: [String: String] { + Dictionary(uniqueKeysWithValues: codeModeArguments.map { ($0.name, $0.hint) }) + } + + func codeModeRegistration(capabilityKey: CodeModeCapabilityKey) -> CodeModeRegistration { + CodeModeRegistration( + capabilityKey: capabilityKey, + jsPath: Self.codeModePath, + title: Self.codeModeTitle, + summary: Self.codeModeSummary, + tags: Self.codeModeTags, + example: Self.codeModeExample, + requiredArguments: Self.requiredArguments, + optionalArguments: Self.optionalArguments, + argumentTypes: Self.argumentTypes, + argumentHints: Self.argumentHints, + resultSummary: Self.codeModeResultSummary + ) { rawArguments, context in + let decodedArguments = try decode(arguments: rawArguments) + return try call(arguments: decodedArguments, context: context) + } + } +} + +extension CapabilityRegistration { + init( + builtInCapability: CapabilityID, + jsNames: [String]? = nil, + registration: CodeModeRegistration, + requiredPermissions: [PermissionKind] = [] + ) { + self.init( + jsNames: jsNames ?? [registration.jsPath], + descriptor: CapabilityDescriptor( + id: builtInCapability, + title: registration.title, + summary: registration.summary, + tags: registration.tags, + example: registration.example, + requiredPermissions: requiredPermissions, + requiredArguments: registration.requiredArguments, + optionalArguments: registration.optionalArguments, + argumentTypes: registration.argumentTypes, + argumentHints: registration.argumentHints, + argumentConstraints: registration.argumentConstraints == .none ? nil : registration.argumentConstraints, + resultSummary: registration.resultSummary + ), + handler: registration.handler + ) + } + + fileprivate init( + builtInCapability: CapabilityID, + jsNames: [String]? = nil, + tool: Tool, + requiredPermissions: [PermissionKind] = [] + ) { + self.init( + builtInCapability: builtInCapability, + jsNames: jsNames, + registration: tool.codeModeRegistration(capabilityKey: builtInCapability.codeModeKey), + requiredPermissions: requiredPermissions + ) + } +} + +struct KeychainCodeModeBuiltIns: BuiltInCodeModeProvider { + let keychain: KeychainBridge + + func capabilityRegistrations() -> [CapabilityRegistration] { + [ + CapabilityRegistration( + builtInCapability: .keychainRead, + tool: KeychainReadTool(keychain: keychain) + ), + CapabilityRegistration( + builtInCapability: .keychainWrite, + tool: KeychainWriteTool(keychain: keychain) + ), + CapabilityRegistration( + builtInCapability: .keychainDelete, + tool: KeychainDeleteTool(keychain: keychain) + ), + ] + } +} + +struct LocationWeatherCodeModeBuiltIns: BuiltInCodeModeProvider { + let location: LocationBridge + let weather: WeatherBridge + + func capabilityRegistrations() -> [CapabilityRegistration] { + [ + CapabilityRegistration( + builtInCapability: .locationRead, + jsNames: ["apple.location.getPermissionStatus", "apple.location.getCurrentPosition"], + registration: CodeModeRegistration( + capabilityKey: CapabilityID.locationRead.codeModeKey, + jsPath: "apple.location.getCurrentPosition", + title: "Read location state or coordinates", + summary: "Read location permission status or current coordinates.", + tags: ["location", "permission", "geospatial"], + example: "await apple.location.getCurrentPosition()", + optionalArguments: ["mode"], + argumentTypes: ["mode": .string], + argumentHints: [ + "mode": "permissionStatus or current (default current).", + ], + resultSummary: "Permission status string or coordinates object." + ) { args, context in + try location.read(arguments: args, context: context) + } + ), + CapabilityRegistration( + builtInCapability: .locationPermissionRequest, + tool: LocationPermissionRequestTool(location: location) + ), + CapabilityRegistration( + builtInCapability: .weatherRead, + tool: WeatherReadTool(weather: weather) + ), + ] + } +} + +private struct KeychainReadTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + var key: String + } + + static let codeModePath = "apple.keychain.get" + static let codeModeTitle = "Read Keychain value" + static let codeModeSummary = "Read a string value from app-scoped Keychain storage." + static let codeModeTags = ["security", "token", "keychain"] + static let codeModeExample = "await apple.keychain.get('auth_token')" + static let codeModeArguments = [ + BuiltInToolArgument("key", .string, hint: "Logical key for this secret value."), + ] + static let codeModeResultSummary = "Object { key, value } or null when the key does not exist." + + let keychain: KeychainBridge + + func decode(arguments: [String: JSONValue]) throws -> Arguments { + Arguments(key: try CodeModeArgumentDecoder.require("key", as: String.self, in: arguments)) + } + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try keychain.read(arguments: ["key": .string(arguments.key)]) + } +} + +private struct KeychainWriteTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + var key: String + var value: String? + } + + static let codeModePath = "apple.keychain.set" + static let codeModeTitle = "Write Keychain value" + static let codeModeSummary = "Store or update a string value in app-scoped Keychain storage." + static let codeModeTags = ["security", "token", "keychain"] + static let codeModeExample = "await apple.keychain.set('auth_token', token)" + static let codeModeArguments = [ + BuiltInToolArgument("key", .string, hint: "Logical key for this secret value."), + BuiltInToolArgument("value", .string, optional: true, hint: "Secret string value. Defaults to empty string when omitted."), + ] + static let codeModeResultSummary = "Object { key, written: true }." + + let keychain: KeychainBridge + + func decode(arguments: [String: JSONValue]) throws -> Arguments { + Arguments( + key: try CodeModeArgumentDecoder.require("key", as: String.self, in: arguments), + value: try CodeModeArgumentDecoder.optional("value", as: String.self, in: arguments) + ) + } + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + var payload: [String: JSONValue] = ["key": .string(arguments.key)] + if let value = arguments.value { + payload["value"] = .string(value) + } + return try keychain.write(arguments: payload) + } +} + +private struct KeychainDeleteTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + var key: String + } + + static let codeModePath = "apple.keychain.delete" + static let codeModeTitle = "Delete Keychain value" + static let codeModeSummary = "Delete an app-scoped Keychain value." + static let codeModeTags = ["security", "token", "keychain"] + static let codeModeExample = "await apple.keychain.delete('auth_token')" + static let codeModeArguments = [ + BuiltInToolArgument("key", .string, hint: "Logical key for value removal."), + ] + static let codeModeResultSummary = "Object { key, deleted: true }." + + let keychain: KeychainBridge + + func decode(arguments: [String: JSONValue]) throws -> Arguments { + Arguments(key: try CodeModeArgumentDecoder.require("key", as: String.self, in: arguments)) + } + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try keychain.delete(arguments: ["key": .string(arguments.key)]) + } +} + +private struct LocationPermissionRequestTool: BuiltInCodeModeTool { + struct Arguments: Sendable {} + + static let codeModePath = "apple.location.requestPermission" + static let codeModeTitle = "Request location permission" + static let codeModeSummary = "Trigger location when-in-use permission request flow." + static let codeModeTags = ["location", "permission"] + static let codeModeExample = "await apple.location.requestPermission()" + static let codeModeArguments: [BuiltInToolArgument] = [] + static let codeModeResultSummary = "Permission status string." + + let location: LocationBridge + + func decode(arguments: [String: JSONValue]) throws -> Arguments { + Arguments() + } + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + location.requestPermission(context: context) + } +} + +private struct WeatherReadTool: BuiltInCodeModeTool { + struct Arguments: Sendable { + var latitude: Double + var longitude: Double + } + + static let codeModePath = "apple.weather.getCurrentWeather" + static let codeModeTitle = "Read WeatherKit weather" + static let codeModeSummary = "Fetch current weather for a latitude/longitude pair." + static let codeModeTags = ["weather", "forecast", "weatherkit"] + static let codeModeExample = "await apple.weather.getCurrentWeather({ latitude: 37.77, longitude: -122.41 })" + static let codeModeArguments = [ + BuiltInToolArgument("latitude", .number, hint: "Latitude in decimal degrees."), + BuiltInToolArgument("longitude", .number, hint: "Longitude in decimal degrees."), + ] + static let codeModeResultSummary = "Object with temperatureCelsius/condition/symbolName/date." + + let weather: WeatherBridge + + func decode(arguments: [String: JSONValue]) throws -> Arguments { + Arguments( + latitude: try CodeModeArgumentDecoder.require("latitude", as: Double.self, in: arguments), + longitude: try CodeModeArgumentDecoder.require("longitude", as: Double.self, in: arguments) + ) + } + + func call(arguments: Arguments, context: BridgeInvocationContext) throws -> JSONValue { + try weather.read(arguments: [ + "latitude": .number(arguments.latitude), + "longitude": .number(arguments.longitude), + ]) + } +} diff --git a/Sources/CodeMode/Bridges/SystemUIBridge.swift b/Sources/CodeMode/Bridges/SystemUIBridge.swift index 97a1186..77207e9 100644 --- a/Sources/CodeMode/Bridges/SystemUIBridge.swift +++ b/Sources/CodeMode/Bridges/SystemUIBridge.swift @@ -99,7 +99,7 @@ public final class SystemUIBridge: @unchecked Sendable { } public func captureCamera(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - try validatePhotoPickerArguments(arguments, capability: "camera.ui.capture") + try validateCameraCaptureArguments(arguments) try context.checkCancellation() return try context.systemUIPresenter.captureCamera(arguments: arguments, context: context) } @@ -197,6 +197,7 @@ public final class SystemUIBridge: @unchecked Sendable { throw BridgeError.invalidArguments("calendar.ui.presentNewEvent requires \(key) to be an ISO8601 timestamp when provided") } } + try validateTimeoutMs(arguments, capability: "calendar.ui.presentNewEvent") } private func validatePhotoPickerArguments(_ arguments: [String: JSONValue], capability: String = "photos.ui.pick") throws { @@ -214,6 +215,33 @@ public final class SystemUIBridge: @unchecked Sendable { try validateTimeoutMs(arguments, capability: capability) } + private func validateCameraCaptureArguments(_ arguments: [String: JSONValue]) throws { + try validatePhotoPickerArguments(arguments, capability: "camera.ui.capture") + try validateOptionalBool(arguments, key: "allowsEditing", capability: "camera.ui.capture") + + if let cameraDevice = arguments.string("cameraDevice")?.lowercased(), + ["rear", "front"].contains(cameraDevice) == false + { + throw BridgeError.invalidArguments("camera.ui.capture cameraDevice must be rear or front") + } + + if let flashMode = arguments.string("flashMode")?.lowercased(), + ["auto", "on", "off"].contains(flashMode) == false + { + throw BridgeError.invalidArguments("camera.ui.capture flashMode must be auto, on, or off") + } + + if let videoQuality = arguments.string("videoQuality")?.lowercased(), + ["high", "medium", "low", "640x480", "iframe1280x720", "iframe960x540"].contains(videoQuality) == false + { + throw BridgeError.invalidArguments("camera.ui.capture videoQuality must be high, medium, low, 640x480, iFrame1280x720, or iFrame960x540") + } + + if let maximumDurationSeconds = arguments.double("maximumDurationSeconds"), maximumDurationSeconds <= 0 { + throw BridgeError.invalidArguments("camera.ui.capture maximumDurationSeconds must be greater than 0") + } + } + private func validateContactPickerArguments(_ arguments: [String: JSONValue]) throws { if let mode = arguments.string("mode")?.lowercased(), ["single", "multiple"].contains(mode) == false @@ -257,6 +285,7 @@ public final class SystemUIBridge: @unchecked Sendable { throw BridgeError.invalidArguments("ui.alert.present supports at most one cancel button") } + try validateSourceRect(arguments, capability: "ui.alert.present") try validateTimeoutMs(arguments, capability: "ui.alert.present") } @@ -345,6 +374,9 @@ public final class SystemUIBridge: @unchecked Sendable { { throw BridgeError.invalidArguments("camera.ui.scanData qualityLevel must be balanced, fast, or accurate") } + for key in ["recognizesMultipleItems", "returnsOnFirstResult", "isGuidanceEnabled", "isHighlightingEnabled", "isPinchToZoomEnabled", "isHighFrameRateTrackingEnabled"] { + try validateOptionalBool(arguments, key: key, capability: "camera.ui.scanData") + } try validateTimeoutMs(arguments, capability: "camera.ui.scanData") } @@ -445,6 +477,36 @@ public final class SystemUIBridge: @unchecked Sendable { } } + private func validateOptionalBool(_ arguments: [String: JSONValue], key: String, capability: String) throws { + guard arguments.keys.contains(key) else { + return + } + + if arguments.bool(key) == nil { + throw BridgeError.invalidArguments("\(capability) \(key) must be a boolean") + } + } + + private func validateSourceRect(_ arguments: [String: JSONValue], capability: String) throws { + guard arguments.keys.contains("sourceRect") else { + return + } + + guard let sourceRect = arguments.object("sourceRect") else { + throw BridgeError.invalidArguments("\(capability) sourceRect must be an object") + } + for key in ["x", "y", "width", "height"] where sourceRect.double(key) == nil { + throw BridgeError.invalidArguments("\(capability) sourceRect requires numeric x/y/width/height") + } + + if let width = sourceRect.double("width"), width < 0 { + throw BridgeError.invalidArguments("\(capability) sourceRect width must be greater than or equal to 0") + } + if let height = sourceRect.double("height"), height < 0 { + throw BridgeError.invalidArguments("\(capability) sourceRect height must be greater than or equal to 0") + } + } + private func validateTimeoutMs(_ arguments: [String: JSONValue], capability: String) throws { if let timeoutMs = arguments.int("timeoutMs"), timeoutMs <= 0 { throw BridgeError.invalidArguments("\(capability) timeoutMs must be greater than 0") diff --git a/Sources/CodeMode/Catalog/BridgeCatalog.swift b/Sources/CodeMode/Catalog/BridgeCatalog.swift index a0b3524..9e55914 100644 --- a/Sources/CodeMode/Catalog/BridgeCatalog.swift +++ b/Sources/CodeMode/Catalog/BridgeCatalog.swift @@ -8,16 +8,16 @@ struct BridgeCatalog: Sendable { } private let references: [JavaScriptAPIReference] - private let referencesByCapability: [CapabilityID: JavaScriptAPIReference] + private let referencesByCapability: [CodeModeCapabilityKey: JavaScriptAPIReference] private let searchCatalog: JSONValue private let allJavaScriptNames: [String] init(registry: CapabilityRegistry) { - let descriptors = registry.allDescriptors().sorted { $0.id.rawValue < $1.id.rawValue } - let references = descriptors.map(Self.reference(from:)) + let functions = registry.allRegisteredFunctions().sorted { $0.catalogCapability < $1.catalogCapability } + let references = functions.map(Self.reference(from:)) self.references = references - self.referencesByCapability = Dictionary(uniqueKeysWithValues: references.map { ($0.capability, $0) }) + self.referencesByCapability = Dictionary(uniqueKeysWithValues: references.map { ($0.capabilityKey, $0) }) self.allJavaScriptNames = Array(Set(references.flatMap(\.jsNames))).sorted() var byJSName: [String: JavaScriptAPIReference] = [:] @@ -30,14 +30,18 @@ struct BridgeCatalog: Sendable { self.searchCatalog = Self.jsonValue( from: SearchCatalogPayload( references: references, - byCapability: Dictionary(uniqueKeysWithValues: references.map { ($0.capability.rawValue, $0) }), + byCapability: Dictionary(uniqueKeysWithValues: references.map { ($0.capability, $0) }), byJSName: byJSName ) ) } func reference(for capability: CapabilityID) -> JavaScriptAPIReference? { - referencesByCapability[capability] + referencesByCapability[capability.codeModeKey] + } + + func reference(for capabilityKey: CodeModeCapabilityKey) -> JavaScriptAPIReference? { + referencesByCapability[capabilityKey] } func allReferences() -> [JavaScriptAPIReference] { @@ -94,18 +98,21 @@ struct BridgeCatalog: Sendable { } } - private static func reference(from descriptor: CapabilityDescriptor) -> JavaScriptAPIReference { - JavaScriptAPIReference( - capability: descriptor.id, - jsNames: JavaScriptBindingCatalog.names(for: descriptor.id), - summary: descriptor.summary, - tags: descriptor.tags, - example: descriptor.example, - requiredArguments: descriptor.requiredArguments, - optionalArguments: descriptor.optionalArguments, - argumentTypes: descriptor.argumentTypes, - argumentHints: descriptor.argumentHints, - resultSummary: descriptor.resultSummary + private static func reference(from function: RegisteredCodeModeFunction) -> JavaScriptAPIReference { + return JavaScriptAPIReference( + capability: function.catalogCapability, + capabilityKey: function.capabilityKey, + builtInCapability: function.builtInCapability, + jsNames: function.jsNames, + summary: function.summary, + tags: function.tags, + example: function.example, + requiredArguments: function.requiredArguments, + optionalArguments: function.optionalArguments, + argumentTypes: function.argumentTypes, + argumentHints: function.argumentHints, + argumentConstraints: function.argumentConstraints, + resultSummary: function.resultSummary ) } diff --git a/Sources/CodeMode/Catalog/JavaScriptBindingCatalog.swift b/Sources/CodeMode/Catalog/JavaScriptBindingCatalog.swift deleted file mode 100644 index 299cf67..0000000 --- a/Sources/CodeMode/Catalog/JavaScriptBindingCatalog.swift +++ /dev/null @@ -1,116 +0,0 @@ -import Foundation - -enum JavaScriptBindingCatalog { - static func names(for capability: CapabilityID) -> [String] { - bindings[capability] ?? [] - } - - static func allNames(for capabilities: some Sequence) -> Set { - Set(capabilities.flatMap { names(for: $0) }) - } - - static func pruningScript(removing capabilities: some Sequence) -> String { - let bindingsToRemove = capabilities - .flatMap { names(for: $0) } - .sorted() - - guard bindingsToRemove.isEmpty == false else { - return "" - } - - var lines = bindingsToRemove.map { name in - "delete globalThis.\(name);" - } - - let groupPaths = Set( - bindingsToRemove.compactMap { name -> String? in - let components = name.split(separator: ".") - guard components.count >= 2 else { - return nil - } - return components.dropLast().joined(separator: ".") - } - ) - - for path in groupPaths.sorted(by: { lhs, rhs in - lhs.components(separatedBy: ".").count > rhs.components(separatedBy: ".").count - }) { - lines.append("if (globalThis.\(path) && Object.keys(globalThis.\(path)).length === 0) { delete globalThis.\(path); }") - } - - for root in ["apple", "ios", "fs"] { - lines.append("if (globalThis.\(root) && Object.keys(globalThis.\(root)).length === 0) { delete globalThis.\(root); }") - } - - return lines.joined(separator: "\n") - } - - private static let bindings: [CapabilityID: [String]] = [ - .networkFetch: ["fetch"], - .keychainRead: ["apple.keychain.get"], - .keychainWrite: ["apple.keychain.set"], - .keychainDelete: ["apple.keychain.delete"], - .locationRead: ["apple.location.getPermissionStatus", "apple.location.getCurrentPosition"], - .locationPermissionRequest: ["apple.location.requestPermission"], - .weatherRead: ["apple.weather.getCurrentWeather"], - .calendarRead: ["apple.calendar.listEvents"], - .calendarWrite: ["apple.calendar.createEvent"], - .calendarUIPickCalendar: ["apple.calendar.pickCalendar"], - .calendarUIPresentEvent: ["apple.calendar.presentEvent"], - .calendarUIPresentNewEvent: ["apple.calendar.presentNewEvent"], - .remindersRead: ["apple.reminders.listReminders"], - .remindersWrite: ["apple.reminders.createReminder"], - .contactsRead: ["apple.contacts.list"], - .contactsSearch: ["apple.contacts.search"], - .contactsUIPick: ["apple.contacts.pick"], - .contactsUIPresentContact: ["apple.contacts.presentContact"], - .contactsUIPresentNewContact: ["apple.contacts.presentNewContact"], - .photosRead: ["apple.photos.list"], - .photosExport: ["apple.photos.export"], - .photosUIPick: ["apple.photos.pick"], - .photosUIPresentLimitedLibraryPicker: ["apple.photos.presentLimitedLibraryPicker"], - .documentsUIPick: ["apple.documents.pick"], - .documentsUIExport: ["apple.documents.export", "apple.documents.save"], - .documentsUIOpenIn: ["apple.documents.openIn"], - .documentsUIScan: ["apple.documents.scan"], - .shareUIPresent: ["apple.share.present"], - .quickLookUIPreview: ["apple.quicklook.preview"], - .cameraUICapture: ["apple.camera.capture"], - .cameraUIScanData: ["apple.camera.scanData"], - .mailUICompose: ["apple.mail.compose"], - .messagesUICompose: ["apple.messages.compose"], - .printUIPresent: ["apple.print.present"], - .webUIPresent: ["apple.web.present"], - .authUIWebAuthenticate: ["apple.auth.webAuthenticate"], - .uiAlertPresent: ["apple.ui.presentAlert"], - .uiPromptPresent: ["apple.ui.presentPrompt"], - .settingsUIOpen: ["apple.settings.open"], - .visionImageAnalyze: ["apple.vision.analyzeImage"], - .notificationsPermissionRequest: ["apple.notifications.requestPermission"], - .notificationsSchedule: ["apple.notifications.schedule"], - .notificationsPendingRead: ["apple.notifications.listPending"], - .notificationsPendingDelete: ["apple.notifications.cancelPending"], - .alarmPermissionRequest: ["ios.alarm.requestPermission"], - .alarmRead: ["ios.alarm.list"], - .alarmSchedule: ["ios.alarm.schedule"], - .alarmCancel: ["ios.alarm.cancel"], - .healthPermissionRequest: ["apple.health.requestPermission"], - .healthRead: ["apple.health.read"], - .healthWrite: ["apple.health.write"], - .homeRead: ["apple.home.list"], - .homeWrite: ["apple.home.writeCharacteristic"], - .mediaMetadataRead: ["apple.media.metadata"], - .mediaFrameExtract: ["apple.media.extractFrame"], - .mediaTranscode: ["apple.media.transcode"], - .fsList: ["apple.fs.list", "fs.promises.readdir"], - .fsRead: ["apple.fs.read", "fs.promises.readFile"], - .fsWrite: ["apple.fs.write", "fs.promises.writeFile"], - .fsMove: ["apple.fs.move", "fs.promises.rename"], - .fsCopy: ["apple.fs.copy", "fs.promises.copyFile"], - .fsDelete: ["apple.fs.delete", "fs.promises.rm"], - .fsStat: ["apple.fs.stat", "fs.promises.stat"], - .fsMkdir: ["apple.fs.mkdir", "fs.promises.mkdir"], - .fsExists: ["apple.fs.exists"], - .fsAccess: ["apple.fs.access", "fs.promises.access"], - ] -} diff --git a/Sources/CodeMode/Host/CodeModeAgentToolDescriptions.swift b/Sources/CodeMode/Host/CodeModeAgentToolDescriptions.swift index c76a3a1..d8a0a3d 100644 --- a/Sources/CodeMode/Host/CodeModeAgentToolDescriptions.swift +++ b/Sources/CodeMode/Host/CodeModeAgentToolDescriptions.swift @@ -11,6 +11,50 @@ public struct CodeModeAgentToolDescription: Sendable, Codable, Equatable { } public enum CodeModeAgentToolDescriptions { + public static let searchJavaScriptAPIParameterSchema: JSONValue = .object([ + "type": .string("object"), + "properties": .object([ + "code": .object([ + "type": .string("string"), + "description": .string("JavaScript source that evaluates to an async function and returns JSON-serializable catalog output."), + ]), + ]), + "required": .array([.string("code")]), + "additionalProperties": .bool(false), + ]) + + public static let executeJavaScriptParameterSchema: JSONValue = .object([ + "type": .string("object"), + "properties": .object([ + "code": .object([ + "type": .string("string"), + "description": .string("JavaScript body to execute. Use an explicit top-level return for multi-statement outputs; a bare final top-level await expression also returns that awaited value."), + ]), + "allowedCapabilities": .object([ + "type": .string("array"), + "items": .object([ + "type": .string("string"), + ]), + "description": .string("Built-in capability IDs required by the JavaScript, for example fs.read or calendar.write. Use an empty array when no built-in capabilities are needed."), + ]), + "allowedCapabilityKeys": .object([ + "type": .string("array"), + "items": .object([ + "type": .string("string"), + ]), + "description": .string("Custom provider capability keys required by the JavaScript. Built-in capability IDs may also be accepted here by hosts that expose one allowlist field."), + ]), + "timeoutMs": .object([ + "type": .string("integer"), + "minimum": .number(1), + "maximum": .number(60_000), + "description": .string("Optional execution timeout in milliseconds. Defaults to 10000."), + ]), + ]), + "required": .array([.string("code"), .string("allowedCapabilities")]), + "additionalProperties": .bool(false), + ]) + public static let searchJavaScriptAPI = CodeModeAgentToolDescription( name: "searchJavaScriptAPI", description: """ @@ -19,6 +63,8 @@ public enum CodeModeAgentToolDescriptions { Available in your search code: interface JavaScriptAPIReference { capability: string; + capabilityKey: string; + builtInCapability: string | null; jsNames: string[]; summary: string; tags: string[]; @@ -27,6 +73,7 @@ public enum CodeModeAgentToolDescriptions { optionalArguments: string[]; argumentTypes: Record; argumentHints: Record; + argumentConstraints: { allowedStringValues: Record }; resultSummary: string; } @@ -37,13 +84,17 @@ public enum CodeModeAgentToolDescriptions { }; Your code must evaluate to an async function and return JSON-serializable output. + Search has a 2s budget, so slice broad result sets before returning them. Examples: async () => { return api.references .filter(ref => ref.tags.includes("reminders")) + .slice(0, 10) .map(ref => ({ capability: ref.capability, + capabilityKey: ref.capabilityKey, + builtInCapability: ref.builtInCapability, jsNames: ref.jsNames, summary: ref.summary, requiredArguments: ref.requiredArguments, @@ -63,7 +114,20 @@ public enum CodeModeAgentToolDescriptions { public static let executeJavaScript = CodeModeAgentToolDescription( name: "executeJavaScript", description: """ - Execute JavaScript against the CodeMode runtime. Prefer searchJavaScriptAPI first when choosing helpers or arguments. Cross-platform helpers live under apple.* and platform-specific helpers live under platform namespaces such as ios.alarm.*. System UI helpers such as apple.ui.presentAlert, apple.calendar.presentNewEvent, apple.photos.pick, apple.contacts.pick, apple.documents.pick, apple.share.present, apple.quicklook.preview, apple.web.present, and apple.auth.webAuthenticate require a host-provided SystemUIPresenter; camera, document scan, mail, and message compose helpers are iOS-only. Only helpers supported on the current host platform are installed. The runtime already wraps your code in an async function, so use top-level await directly and return the final value with an explicit top-level return statement; do not use an unreturned async IIFE as the final expression. Include only the required capabilities in allowedCapabilities. Execution streams logs and diagnostics and returns structured CodeModeToolError failures for syntax errors, missing JS helpers, runtime throws, validation failures, permission denials, timeouts, cancellation, and internal errors. + Execute JavaScript against the CodeMode runtime. Prefer searchJavaScriptAPI first when choosing helpers or arguments. Cross-platform helpers live under apple.* and platform-specific helpers live under platform namespaces such as ios.alarm.*; custom host providers may expose additional namespaces. System UI helpers such as apple.ui.presentAlert, apple.calendar.presentNewEvent, apple.photos.pick, apple.contacts.pick, apple.documents.pick, apple.share.present, apple.quicklook.preview, apple.web.present, and apple.auth.webAuthenticate require a host-provided SystemUIPresenter; camera, document scan, mail, and message compose helpers are iOS-only. Only helpers supported on the current host platform are installed. + + Calling conventions: apple.* helpers take one object argument, for example apple.fs.read({ path: "tmp:file.txt" }) and often return structured objects such as { text, base64 }. Node-style filesystem aliases use positional arguments, for example fs.promises.readFile("tmp:file.txt", "utf8"), and return Node-like values such as a string for readFile. fetch(url, options) is a global helper and returns a Response-like object with text(), json(), headers.get(), status, and ok. + + Return semantics: the runtime wraps your code in an async function. For multi-statement code, return the final graded value with an explicit top-level return statement. A script that is only a bare final top-level await expression, such as await apple.fs.read({ path: "tmp:file.txt" }), returns that awaited value. Do not use an unreturned async IIFE as the final expression. setTimeout callbacks fire synchronously in this runtime. + + Allowlisting: include only the required built-in capabilities in allowedCapabilities and custom provider keys in allowedCapabilityKeys. Execution defaults to a 10000ms timeout and returns structured CodeModeToolError failures for syntax errors, missing JS helpers, runtime throws, validation failures, permission denials, timeouts, cancellation, and internal errors. + + Error repair guide: + JS_API_NOT_FOUND: use the suggested JS helper names or searchJavaScriptAPI. + CAPABILITY_DENIED: add the exact capability to allowedCapabilities or allowedCapabilityKeys and retry. + PERMISSION_DENIED: the capability is allowlisted but the OS, host, or custom provider denied permission; request permission if a helper exists, otherwise tell the user or host. + UI_PRESENTER_UNAVAILABLE: host configuration issue; do not retry the same call. + INVALID_ARGUMENTS: use the catalog requiredArguments, optionalArguments, argumentHints, and example. """ ) diff --git a/Sources/CodeMode/Host/CodeModeAgentTools.swift b/Sources/CodeMode/Host/CodeModeAgentTools.swift index 8a8ec2d..2163504 100644 --- a/Sources/CodeMode/Host/CodeModeAgentTools.swift +++ b/Sources/CodeMode/Host/CodeModeAgentTools.swift @@ -6,18 +6,42 @@ public final class CodeModeAgentTools: @unchecked Sendable { private let runtime: BridgeRuntime public init(config: CodeModeConfiguration = .init()) { + let allDefaultRegistrations = DefaultCapabilityLoader.loadAllRegistrations( + fileSystem: config.fileSystem, + eventInbox: config.eventInbox, + cloudKitClient: config.cloudKitClient, + remoteNotificationsClient: config.remoteNotificationsClient, + speechClient: config.speechClient, + appIntentsClient: config.appIntentsClient, + foundationModelsClient: config.foundationModelsClient, + activityClient: config.activityClient, + mapsClient: config.mapsClient, + musicClient: config.musicClient, + passKitClient: config.passKitClient, + storeKitClient: config.storeKitClient + ) let registrations = CapabilityPlatformSupport.filter( - DefaultCapabilityLoader.loadAllRegistrations(fileSystem: config.fileSystem), + allDefaultRegistrations, + for: config.hostPlatform + ) + let unsupportedBuiltInJavaScriptNames = CapabilityPlatformSupport.unsupportedJavaScriptNames( + from: allDefaultRegistrations, for: config.hostPlatform ) - let registry = CapabilityRegistry(registrations: registrations) + let providerRegistrations = config.codeModeProviders.flatMap { $0.codeModeRegistrations() } + let registry = CapabilityRegistry(registrations: registrations, codeModeRegistrations: providerRegistrations) self.registry = registry self.catalog = BridgeCatalog(registry: registry) - self.runtime = BridgeRuntime(registry: registry, catalog: self.catalog, config: config) + self.runtime = BridgeRuntime( + registry: registry, + catalog: self.catalog, + config: config, + unsupportedBuiltInJavaScriptNames: unsupportedBuiltInJavaScriptNames + ) } public func searchJavaScriptAPI(_ request: JavaScriptAPISearchRequest) async throws -> JavaScriptAPISearchResponse { - try runtime.search(request) + try await runtime.searchAsync(request) } public func executeJavaScript(_ request: JavaScriptExecutionRequest) async throws -> JavaScriptExecutionCall { diff --git a/Sources/CodeMode/Host/HostConfigurationValidator.swift b/Sources/CodeMode/Host/HostConfigurationValidator.swift index 60c8f19..d9eb7f6 100644 --- a/Sources/CodeMode/Host/HostConfigurationValidator.swift +++ b/Sources/CodeMode/Host/HostConfigurationValidator.swift @@ -40,11 +40,18 @@ public enum HostConfigurationValidator { keys.insert("NSCalendarsWriteOnlyAccessUsageDescription") } - if capabilities.contains(.calendarRead) || capabilities.contains(.calendarUIPresentEvent) { + if capabilities.contains(.calendarRead) || + capabilities.contains(.calendarWrite) || + capabilities.contains(.calendarDelete) || + capabilities.contains(.calendarUIPresentEvent) + { keys.insert("NSCalendarsFullAccessUsageDescription") } - if capabilities.contains(.remindersRead) || capabilities.contains(.remindersWrite) { + if capabilities.contains(.remindersRead) || + capabilities.contains(.remindersWrite) || + capabilities.contains(.remindersDelete) + { keys.insert("NSRemindersFullAccessUsageDescription") } @@ -66,6 +73,25 @@ public enum HostConfigurationValidator { keys.insert("NSMicrophoneUsageDescription") } + if capabilities.contains(.speechPermissionRequest) || + capabilities.contains(.speechFileTranscribe) || + capabilities.contains(.speechMicrophoneTranscribe) + { + keys.insert("NSSpeechRecognitionUsageDescription") + } + + if capabilities.contains(.speechMicrophoneTranscribe) { + keys.insert("NSMicrophoneUsageDescription") + } + + if capabilities.contains(.musicPermissionRequest) || + capabilities.contains(.musicLibraryRead) || + capabilities.contains(.musicPlaylistWrite) || + capabilities.contains(.musicPlaybackControl) + { + keys.insert("NSAppleMusicUsageDescription") + } + if capabilities.contains(.homeRead) || capabilities.contains(.homeWrite) { keys.insert("NSHomeKitUsageDescription") } @@ -123,6 +149,13 @@ public enum HostConfigurationValidator { if requiredCapabilities.contains(.notificationsSchedule) || requiredCapabilities.contains(.notificationsPendingRead) || requiredCapabilities.contains(.notificationsPendingDelete) || + requiredCapabilities.contains(.notificationsDeliveredRead) || + requiredCapabilities.contains(.notificationsDeliveredDelete) || + requiredCapabilities.contains(.notificationsRemoteRegister) || + requiredCapabilities.contains(.notificationsRemoteTokenRead) || + requiredCapabilities.contains(.notificationsSettingsRead) || + requiredCapabilities.contains(.notificationsCategoriesSet) || + requiredCapabilities.contains(.notificationsResponsesRead) || requiredCapabilities.contains(.notificationsPermissionRequest) { issues.append( @@ -134,6 +167,151 @@ public enum HostConfigurationValidator { ) } + if requiredCapabilities.contains(.cloudKitAccountStatus) || + requiredCapabilities.contains(.cloudKitRecordsQuery) || + requiredCapabilities.contains(.cloudKitRecordSave) || + requiredCapabilities.contains(.cloudKitRecordDelete) || + requiredCapabilities.contains(.cloudKitSubscriptionSave) || + requiredCapabilities.contains(.cloudKitSubscriptionEventsRead) + { + issues.append( + HostConfigurationIssue( + severity: .warning, + key: "CloudKit capability", + message: "Ensure iCloud/CloudKit entitlements, containers, and host CloudKitClient configuration are enabled before using cloudkit.* capabilities." + ) + ) + } + + if requiredCapabilities.contains(.notificationsRemoteRegister) || + requiredCapabilities.contains(.notificationsRemoteTokenRead) || + requiredCapabilities.contains(.notificationsCategoriesSet) || + requiredCapabilities.contains(.notificationsResponsesRead) + { + issues.append( + HostConfigurationIssue( + severity: .warning, + key: "APNs client configuration", + message: "Remote notification capabilities are client-side only; ensure APS environment entitlement, AppDelegate token plumbing, categories/actions, and any background modes are configured by the host." + ) + ) + } + + if requiredCapabilities.contains(.speechPermissionRequest) || + requiredCapabilities.contains(.speechStatus) || + requiredCapabilities.contains(.speechFileTranscribe) || + requiredCapabilities.contains(.speechMicrophoneTranscribe) + { + issues.append( + HostConfigurationIssue( + severity: .warning, + key: "Speech capability", + message: "Ensure Speech framework availability and a host SpeechClient adapter before using speech.* transcription capabilities." + ) + ) + } + + if requiredCapabilities.contains(.appIntentsList) || + requiredCapabilities.contains(.appIntentsRun) || + requiredCapabilities.contains(.appIntentsDonate) || + requiredCapabilities.contains(.appIntentsOpen) || + requiredCapabilities.contains(.appIntentsHandoffsRead) + { + issues.append( + HostConfigurationIssue( + severity: .warning, + key: "App Intents adapters", + message: "App Intents capabilities require host-registered adapters; dynamic AppIntent generation from JavaScript is not supported." + ) + ) + } + + if requiredCapabilities.contains(.foundationModelsStatus) || + requiredCapabilities.contains(.foundationModelsGenerate) || + requiredCapabilities.contains(.foundationModelsExtract) + { + issues.append( + HostConfigurationIssue( + severity: .warning, + key: "Foundation Models availability", + message: "Ensure Foundation Models platform availability and host-defined schemas/adapters before using foundationModels.* capabilities." + ) + ) + } + + if requiredCapabilities.contains(.activityList) || + requiredCapabilities.contains(.activityStart) || + requiredCapabilities.contains(.activityUpdate) || + requiredCapabilities.contains(.activityEnd) || + requiredCapabilities.contains(.activityPushTokenRead) + { + issues.append( + HostConfigurationIssue( + severity: .warning, + key: "ActivityKit adapters", + message: "Live Activity capabilities require ActivityKit support, host-registered activity adapters, and Widget/remote-update configuration when push tokens are used." + ) + ) + } + + if requiredCapabilities.contains(.musicPermissionRequest) || + requiredCapabilities.contains(.musicSubscriptionStatus) || + requiredCapabilities.contains(.musicCatalogSearch) || + requiredCapabilities.contains(.musicCatalogDetails) || + requiredCapabilities.contains(.musicLibraryRead) || + requiredCapabilities.contains(.musicPlaylistWrite) || + requiredCapabilities.contains(.musicPlaybackControl) + { + issues.append( + HostConfigurationIssue( + severity: .warning, + key: "MusicKit capability", + message: "Ensure MusicKit/media-library entitlement, subscription handling, and host MusicClient configuration before using music.* capabilities." + ) + ) + } + + if requiredCapabilities.contains(.passKitWalletStatus) || + requiredCapabilities.contains(.passKitPassesRead) || + requiredCapabilities.contains(.passKitPassAdd) || + requiredCapabilities.contains(.passKitPassPresent) + { + issues.append( + HostConfigurationIssue( + severity: .warning, + key: "PassKit Wallet capability", + message: "Ensure Wallet capability, pass type identifiers, and host PassKitClient configuration before using wallet pass capabilities." + ) + ) + } + + if requiredCapabilities.contains(.passKitApplePayStatus) || + requiredCapabilities.contains(.passKitApplePayPresent) + { + issues.append( + HostConfigurationIssue( + severity: .warning, + key: "Apple Pay merchant configuration", + message: "Apple Pay capabilities must use host merchant configuration and explicit user-visible confirmation; arbitrary merchant setup is not accepted from JavaScript." + ) + ) + } + + if requiredCapabilities.contains(.storeKitProductsRead) || + requiredCapabilities.contains(.storeKitEntitlementsRead) || + requiredCapabilities.contains(.storeKitPurchase) || + requiredCapabilities.contains(.storeKitRestore) || + requiredCapabilities.contains(.storeKitTransactionsRead) + { + issues.append( + HostConfigurationIssue( + severity: .warning, + key: "StoreKit configuration", + message: "Ensure StoreKit products are host-configured and purchase/restore flows require explicit user-visible confirmation." + ) + ) + } + if requiredCapabilities.contains(.homeRead) || requiredCapabilities.contains(.homeWrite) { issues.append( HostConfigurationIssue( diff --git a/Sources/CodeMode/Registry/CapabilityRegistry.swift b/Sources/CodeMode/Registry/CapabilityRegistry.swift index 068be2c..1914b78 100644 --- a/Sources/CodeMode/Registry/CapabilityRegistry.swift +++ b/Sources/CodeMode/Registry/CapabilityRegistry.swift @@ -33,6 +33,151 @@ public enum CapabilityArgumentType: String, Sendable, Codable, Equatable { } } +public struct CapabilityArgumentConstraints: Sendable, Codable, Equatable { + public var allowedStringValues: [String: [String]] + + public init(allowedStringValues: [String: [String]] = [:]) { + self.allowedStringValues = allowedStringValues.mapValues(Self.uniqueValues) + } + + public static let none = CapabilityArgumentConstraints() + + public static func defaults(for capability: CapabilityID) -> CapabilityArgumentConstraints { + switch capability { + case .networkFetch: + return .init(allowedStringValues: [ + "options.responseEncoding": ["text", "base64"], + ]) + case .calendarWrite: + return .init(allowedStringValues: [ + "operation": ["create", "update"], + ]) + case .calendarDelete: + return .init(allowedStringValues: [ + "span": ["thisEvent", "futureEvents"], + ]) + case .calendarUIPickCalendar: + return .init(allowedStringValues: [ + "selectionStyle": ["single", "multiple"], + "displayStyle": ["writable", "all"], + ]) + case .remindersWrite: + return .init(allowedStringValues: [ + "operation": ["create", "update", "complete"], + ]) + case .photosRead: + return .init(allowedStringValues: [ + "mediaType": ["any", "image", "photo", "video"], + ]) + case .photosUIPick: + return .init(allowedStringValues: [ + "mediaType": ["any", "image", "photo", "video"], + ]) + case .contactsUIPick: + return .init(allowedStringValues: [ + "mode": ["single", "multiple"], + ]) + case .cameraUICapture: + return .init(allowedStringValues: [ + "mediaType": ["any", "image", "photo", "video"], + "cameraDevice": ["rear", "front"], + "flashMode": ["auto", "on", "off"], + "videoQuality": ["high", "medium", "low", "640x480", "iFrame1280x720", "iFrame960x540", "iframe1280x720", "iframe960x540"], + ]) + case .cameraUIScanData: + return .init(allowedStringValues: [ + "mode": ["any", "text", "barcode"], + "qualityLevel": ["balanced", "fast", "accurate"], + ]) + case .printUIPresent: + return .init(allowedStringValues: [ + "outputType": ["general", "photo", "grayscale"], + ]) + case .uiAlertPresent: + return .init(allowedStringValues: [ + "preferredStyle": ["alert", "actionSheet", "actionsheet"], + ]) + case .cloudKitRecordsQuery, .cloudKitRecordSave, .cloudKitRecordDelete, .cloudKitSubscriptionSave: + return .init(allowedStringValues: [ + "database": ["private", "shared", "public"], + ]) + case .activityEnd: + return .init(allowedStringValues: [ + "dismissalPolicy": ["default", "immediate"], + ]) + case .mapsRouteEstimate, .mapsOpen: + return .init(allowedStringValues: [ + "transportType": ["automobile", "walking", "transit", "any"], + ]) + case .musicPlaybackControl: + return .init(allowedStringValues: [ + "action": ["play", "pause", "stop", "skipToNext", "skipToPrevious", "playCatalog", "playLibrary"], + ]) + default: + return .none + } + } + + func validate(arguments: [String: JSONValue], capability: CapabilityID) throws { + try validate(arguments: arguments, capabilityName: capability.rawValue) + } + + func validate(arguments: [String: JSONValue], capabilityName: String) throws { + for (path, allowed) in allowedStringValues.sorted(by: { $0.key < $1.key }) { + guard let value = Self.value(atPath: path, in: arguments) else { + continue + } + guard let string = value.stringValue else { + throw BridgeError.invalidArguments("\(capabilityName) expected '\(path)' as string, received \(Self.jsonTypeName(for: value))") + } + guard allowed.contains(string) else { + throw BridgeError.invalidArguments("\(capabilityName) \(path) must be one of \(allowed.joined(separator: ", "))") + } + } + } + + private static func uniqueValues(_ values: [String]) -> [String] { + var seen: Set = [] + var result: [String] = [] + for value in values where seen.insert(value).inserted { + result.append(value) + } + return result + } + + private static func value(atPath path: String, in root: [String: JSONValue]) -> JSONValue? { + let segments = path.split(separator: ".").map(String.init) + guard segments.isEmpty == false else { return nil } + + var current: JSONValue = .object(root) + for segment in segments { + guard let object = current.objectValue, let next = object[segment] else { + return nil + } + current = next + } + + return current + } + + private static func jsonTypeName(for value: JSONValue) -> String { + switch value { + case .string: + return "string" + case .number: + return "number" + case .bool: + return "bool" + case .object: + return "object" + case .array: + return "array" + case .null: + return "null" + } + } +} + public struct CapabilityDescriptor: Sendable, Equatable { public var id: CapabilityID public var title: String @@ -44,6 +189,7 @@ public struct CapabilityDescriptor: Sendable, Equatable { public var optionalArguments: [String] public var argumentTypes: [String: CapabilityArgumentType] public var argumentHints: [String: String] + public var argumentConstraints: CapabilityArgumentConstraints public var resultSummary: String public init( @@ -57,6 +203,7 @@ public struct CapabilityDescriptor: Sendable, Equatable { optionalArguments: [String] = [], argumentTypes: [String: CapabilityArgumentType] = [:], argumentHints: [String: String] = [:], + argumentConstraints: CapabilityArgumentConstraints? = nil, resultSummary: String = "JSON value" ) { self.id = id @@ -69,6 +216,7 @@ public struct CapabilityDescriptor: Sendable, Equatable { self.optionalArguments = optionalArguments self.argumentTypes = argumentTypes.isEmpty ? CapabilityDescriptor.inferArgumentTypes(required: requiredArguments, optional: optionalArguments) : argumentTypes self.argumentHints = argumentHints + self.argumentConstraints = argumentConstraints ?? CapabilityArgumentConstraints.defaults(for: id) self.resultSummary = resultSummary } @@ -79,6 +227,9 @@ public struct CapabilityDescriptor: Sendable, Equatable { "options.method": .string, "options.headers": .object, "options.body": .string, + "options.bodyBase64": .string, + "options.timeoutMs": .number, + "options.responseEncoding": .string, "key": .string, "value": .string, @@ -93,11 +244,19 @@ public struct CapabilityDescriptor: Sendable, Equatable { "title": .string, "notes": .string, "location": .string, + "calendarIdentifier": .string, + "calendarIdentifiers": .array, + "isAllDay": .bool, + "span": .string, + "operation": .string, "selectionStyle": .string, "displayStyle": .string, "allowsEditing": .bool, "allowsCalendarPreview": .bool, "dueDate": .string, + "includeCompleted": .bool, + "isCompleted": .bool, + "priority": .number, "query": .string, "limit": .number, "identifiers": .array, @@ -105,6 +264,14 @@ public struct CapabilityDescriptor: Sendable, Equatable { "mediaType": .string, "outputDirectory": .string, "timeoutMs": .number, + "cameraDevice": .string, + "flashMode": .string, + "videoQuality": .string, + "maximumDurationSeconds": .number, + "isGuidanceEnabled": .bool, + "isHighlightingEnabled": .bool, + "isPinchToZoomEnabled": .bool, + "isHighFrameRateTrackingEnabled": .bool, "displayedPropertyKeys": .array, "allowsActions": .bool, "givenName": .string, @@ -128,6 +295,7 @@ public struct CapabilityDescriptor: Sendable, Equatable { "prefersEphemeralSession": .bool, "preferredStyle": .string, "buttons": .array, + "sourceRect": .object, "features": .array, "maxResults": .number, "identifier": .string, @@ -136,10 +304,80 @@ public struct CapabilityDescriptor: Sendable, Equatable { "secondsFromNow": .number, "fireDate": .string, "repeats": .bool, + "sound": .string, + "badge": .number, + "userInfo": .object, + "threadIdentifier": .string, + "categoryIdentifier": .string, "includeCharacteristics": .bool, "accessoryIdentifier": .string, "serviceType": .string, "characteristicType": .string, + "containerIdentifier": .string, + "database": .string, + "recordType": .string, + "recordName": .string, + "fields": .object, + "zoneID": .string, + "predicate": .any, + "sortDescriptors": .array, + "desiredKeys": .array, + "savePolicy": .string, + "subscriptionID": .string, + "firesOnRecordCreation": .bool, + "firesOnRecordUpdate": .bool, + "firesOnRecordDeletion": .bool, + "afterCursor": .string, + "types": .array, + "categories": .array, + "actionIdentifier": .string, + "locale": .string, + "requiresOnDeviceRecognition": .bool, + "taskHint": .string, + "partialResults": .bool, + "domain": .string, + "parameters": .object, + "instructions": .string, + "temperature": .number, + "maxTokens": .number, + "schema": .object, + "schemaIdentifier": .string, + "input": .string, + "activityType": .string, + "attributes": .object, + "contentState": .object, + "pushType": .string, + "staleDate": .string, + "alert": .object, + "dismissalPolicy": .string, + "address": .string, + "region": .object, + "resultTypes": .array, + "origin": .object, + "destination": .object, + "transportType": .string, + "departureDate": .string, + "arrivalDate": .string, + "term": .string, + "countryCode": .string, + "type": .string, + "catalogIDs": .array, + "libraryIDs": .array, + "description": .string, + "playlistID": .string, + "catalogID": .string, + "libraryID": .string, + "action": .string, + "queue": .object, + "startPlaying": .bool, + "passTypeIdentifier": .string, + "serialNumber": .string, + "paymentRequestID": .string, + "networks": .array, + "confirmed": .bool, + "productID": .string, + "productIDs": .array, + "appAccountToken": .string, "path": .string, "encoding": .string, @@ -162,22 +400,91 @@ public struct CapabilityDescriptor: Sendable, Equatable { public struct CapabilityRegistration: Sendable { public var descriptor: CapabilityDescriptor + public var jsNames: [String] public var handler: CapabilityHandler - public init(descriptor: CapabilityDescriptor, handler: @escaping CapabilityHandler) { + public init(jsNames: [String] = [], descriptor: CapabilityDescriptor, handler: @escaping CapabilityHandler) { self.descriptor = descriptor + self.jsNames = jsNames self.handler = handler } } +struct RegisteredCodeModeFunction: Sendable { + var capabilityKey: CodeModeCapabilityKey + var builtInCapability: CapabilityID? + var jsNames: [String] + var title: String + var summary: String + var tags: [String] + var example: String + var requiredPermissions: [PermissionKind] + var requiredArguments: [String] + var optionalArguments: [String] + var argumentTypes: [String: CapabilityArgumentType] + var argumentHints: [String: String] + var argumentConstraints: CapabilityArgumentConstraints + var resultSummary: String + var handler: CapabilityHandler + + var catalogCapability: String { + builtInCapability?.rawValue ?? capabilityKey.rawValue + } + + var validationName: String { + catalogCapability + } + + init(_ registration: CapabilityRegistration) { + let descriptor = registration.descriptor + self.capabilityKey = descriptor.id.codeModeKey + self.builtInCapability = descriptor.id + self.jsNames = registration.jsNames + self.title = descriptor.title + self.summary = descriptor.summary + self.tags = descriptor.tags + self.example = descriptor.example + self.requiredPermissions = descriptor.requiredPermissions + self.requiredArguments = descriptor.requiredArguments + self.optionalArguments = descriptor.optionalArguments + self.argumentTypes = descriptor.argumentTypes + self.argumentHints = descriptor.argumentHints + self.argumentConstraints = descriptor.argumentConstraints + self.resultSummary = descriptor.resultSummary + self.handler = registration.handler + } + + init(_ registration: CodeModeRegistration) { + self.capabilityKey = registration.capabilityKey + self.builtInCapability = nil + self.jsNames = [registration.jsPath] + self.title = registration.title + self.summary = registration.summary + self.tags = registration.tags + self.example = registration.example + self.requiredPermissions = [] + self.requiredArguments = registration.requiredArguments + self.optionalArguments = registration.optionalArguments + self.argumentTypes = registration.argumentTypes + self.argumentHints = registration.argumentHints + self.argumentConstraints = registration.argumentConstraints + self.resultSummary = registration.resultSummary + self.handler = registration.handler + } +} + public final class CapabilityRegistry: @unchecked Sendable { private let lock = NSLock() private var registrations: [CapabilityID: CapabilityRegistration] = [:] + private var codeModeRegistrations: [CodeModeCapabilityKey: CodeModeRegistration] = [:] - public init(registrations: [CapabilityRegistration] = []) { + public init(registrations: [CapabilityRegistration] = [], codeModeRegistrations: [CodeModeRegistration] = []) { for registration in registrations { self.registrations[registration.descriptor.id] = registration } + for registration in codeModeRegistrations { + self.codeModeRegistrations[registration.capabilityKey] = registration + } } public func register(_ registration: CapabilityRegistration) { @@ -194,73 +501,143 @@ public final class CapabilityRegistry: @unchecked Sendable { lock.unlock() } + public func register(_ registration: CodeModeRegistration) { + lock.lock() + codeModeRegistrations[registration.capabilityKey] = registration + lock.unlock() + } + + public func register(_ registrations: [CodeModeRegistration]) { + lock.lock() + for registration in registrations { + self.codeModeRegistrations[registration.capabilityKey] = registration + } + lock.unlock() + } + public func descriptor(for capability: CapabilityID) -> CapabilityDescriptor? { lock.lock() defer { lock.unlock() } return registrations[capability]?.descriptor } + public func registration(for capability: CapabilityID) -> CapabilityRegistration? { + lock.lock() + defer { lock.unlock() } + return registrations[capability] + } + + public func allCapabilityRegistrations() -> [CapabilityRegistration] { + lock.lock() + defer { lock.unlock() } + return registrations.values.map { $0 } + } + public func allDescriptors() -> [CapabilityDescriptor] { lock.lock() defer { lock.unlock() } return registrations.values.map(\.descriptor) } + public func allCodeModeRegistrations() -> [CodeModeRegistration] { + lock.lock() + defer { lock.unlock() } + return codeModeRegistrations.values.map { $0 } + } + + func registeredFunction(for capability: CapabilityID) -> RegisteredCodeModeFunction? { + lock.lock() + defer { lock.unlock() } + return registrations[capability].map(RegisteredCodeModeFunction.init) + } + + func registeredFunction(for capabilityKey: CodeModeCapabilityKey) -> RegisteredCodeModeFunction? { + lock.lock() + defer { lock.unlock() } + if let builtIn = CapabilityID(rawValue: capabilityKey.rawValue), + let registration = registrations[builtIn] + { + return RegisteredCodeModeFunction(registration) + } + return codeModeRegistrations[capabilityKey].map(RegisteredCodeModeFunction.init) + } + + func allRegisteredFunctions() -> [RegisteredCodeModeFunction] { + lock.lock() + defer { lock.unlock() } + return registrations.values.map(RegisteredCodeModeFunction.init) + + codeModeRegistrations.values.map(RegisteredCodeModeFunction.init) + } + public func invoke(_ capabilityID: String, arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { try context.checkCancellation() - guard let capability = CapabilityID(rawValue: capabilityID) else { - throw BridgeError.capabilityNotFound(capabilityID) + if let capability = CapabilityID(rawValue: capabilityID) { + return try invokeBuiltIn(capability, arguments: arguments, context: context) } - guard context.allowedCapabilities.contains(capability) else { + return try invokeCodeMode(CodeModeCapabilityKey(rawValue: capabilityID), arguments: arguments, context: context) + } + + private func invokeBuiltIn(_ capability: CapabilityID, arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + guard context.allowedCapabilities.contains(capability) || context.allowedCapabilityKeys.contains(capability.codeModeKey) else { throw BridgeError.capabilityDenied(capability) } - lock.lock() - let registration = registrations[capability] - lock.unlock() - - guard let registration else { - throw BridgeError.capabilityNotFound(capabilityID) + guard let function = registeredFunction(for: capability) else { + throw BridgeError.capabilityNotFound(capability.rawValue) } - try validateArguments(arguments, for: capability, descriptor: registration.descriptor) + return try invoke(function, arguments: arguments, context: context) + } - for permission in registration.descriptor.requiredPermissions { - try context.checkCancellation() - let status = context.permissionBroker.status(for: permission) - context.recordPermission(permission, status: status) + private func invokeCodeMode(_ capabilityKey: CodeModeCapabilityKey, arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + guard context.allowedCapabilityKeys.contains(capabilityKey) else { + throw BridgeError.capabilityKeyDenied(capabilityKey) + } - let resolvedStatus: PermissionStatus - if status == .notDetermined { - let requested = context.permissionBroker.request(for: permission) - context.recordPermission(permission, status: requested) - resolvedStatus = requested - } else { - resolvedStatus = status - } + guard let function = registeredFunction(for: capabilityKey) else { + throw BridgeError.capabilityNotFound(capabilityKey.rawValue) + } - guard resolvedStatus == .granted else { - throw BridgeError.permissionDenied(permission) - } + return try invoke(function, arguments: arguments, context: context) + } - context.markPermissionValidated(permission) + private func invoke(_ function: RegisteredCodeModeFunction, arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try validateArguments(arguments, for: function) + try validatePermissions(function.requiredPermissions, context: context) + try context.checkCancellation() + return try mapCodeModeFunctionError { + try function.handler(arguments, context) } + } - try context.checkCancellation() - return try registration.handler(arguments, context) + private func mapCodeModeFunctionError(_ body: () throws -> JSONValue) throws -> JSONValue { + do { + return try body() + } catch let error as CodeModeFunctionError { + switch error { + case let .invalidArguments(message): + throw BridgeError.invalidArguments(message) + case let .unsupportedPlatform(feature): + throw BridgeError.unsupportedPlatform(feature) + case let .permissionDenied(message): + throw BridgeError.customPermissionDenied(message) + case let .nativeFailure(message): + throw BridgeError.nativeFailure(message) + } + } } - private func validateArguments(_ arguments: [String: JSONValue], for capability: CapabilityID, descriptor: CapabilityDescriptor) throws { - let required = descriptor.requiredArguments - let optional = descriptor.optionalArguments - let typed = descriptor.argumentTypes + private func validateArguments(_ arguments: [String: JSONValue], for function: RegisteredCodeModeFunction) throws { + let required = function.requiredArguments + let optional = function.optionalArguments + let typed = function.argumentTypes + let name = function.validationName let missing = required.filter { value(atPath: $0, in: arguments) == nil } if missing.isEmpty == false { - let names = missing.joined(separator: ", ") - throw BridgeError.invalidArguments("\(capability.rawValue) missing required arguments: \(names)") + throw BridgeError.invalidArguments("\(name) missing required arguments: \(missing.joined(separator: ", "))") } for (path, expectedType) in typed.sorted(by: { $0.key < $1.key }) { @@ -270,19 +647,51 @@ public final class CapabilityRegistry: @unchecked Sendable { guard expectedType.matches(value) else { throw BridgeError.invalidArguments( - "\(capability.rawValue) expected '\(path)' as \(expectedType.rawValue), received \(jsonTypeName(for: value))" + "\(name) expected '\(path)' as \(expectedType.rawValue), received \(jsonTypeName(for: value))" ) } } + try function.argumentConstraints.validate(arguments: arguments, capabilityName: name) + let allowedNames = Set(required + optional + Array(typed.keys)) if allowedNames.isEmpty == false { let allowedTopLevel = Set(allowedNames.map(firstPathSegment)) let unknown = arguments.keys.sorted().filter { allowedTopLevel.contains($0) == false } if unknown.isEmpty == false { - throw BridgeError.invalidArguments("\(capability.rawValue) received unknown arguments: \(unknown.joined(separator: ", "))") + throw BridgeError.invalidArguments("\(name) received unknown arguments: \(unknown.joined(separator: ", "))") + } + } + } + + private func validatePermissions(_ permissions: [PermissionKind], context: BridgeInvocationContext) throws { + for permission in permissions { + try context.checkCancellation() + let status = context.permissionBroker.status(for: permission) + context.recordPermission(permission, status: status) + + let resolvedStatus: PermissionStatus + if status == .notDetermined { + let requested = context.permissionBroker.request(for: permission) + context.recordPermission(permission, status: requested) + resolvedStatus = requested + } else { + resolvedStatus = status } + + guard permissionStatus(resolvedStatus, satisfies: permission) else { + throw BridgeError.permissionDenied(permission) + } + + context.markPermissionValidated(permission) + } + } + + private func permissionStatus(_ status: PermissionStatus, satisfies permission: PermissionKind) -> Bool { + if permission == .calendarWriteOnly, status == .writeOnly { + return true } + return status == .granted } private func value(atPath path: String, in root: [String: JSONValue]) -> JSONValue? { diff --git a/Sources/CodeMode/Runtime/BridgeInvocationContext.swift b/Sources/CodeMode/Runtime/BridgeInvocationContext.swift index f963b1d..4a64acf 100644 --- a/Sources/CodeMode/Runtime/BridgeInvocationContext.swift +++ b/Sources/CodeMode/Runtime/BridgeInvocationContext.swift @@ -3,6 +3,7 @@ import Foundation public final class BridgeInvocationContext: @unchecked Sendable { public let executionContext: ExecutionContext public let allowedCapabilities: Set + public let allowedCapabilityKeys: Set public let pathPolicy: any PathPolicy public let artifactStore: any ArtifactStore public let permissionBroker: any PermissionBroker @@ -17,6 +18,7 @@ public final class BridgeInvocationContext: @unchecked Sendable { init( executionContext: ExecutionContext, allowedCapabilities: Set, + allowedCapabilityKeys: Set = [], pathPolicy: any PathPolicy, artifactStore: any ArtifactStore, permissionBroker: any PermissionBroker, @@ -27,6 +29,7 @@ public final class BridgeInvocationContext: @unchecked Sendable { ) { self.executionContext = executionContext self.allowedCapabilities = allowedCapabilities + self.allowedCapabilityKeys = allowedCapabilityKeys.union(allowedCapabilities.map(\.codeModeKey)) self.pathPolicy = pathPolicy self.artifactStore = artifactStore self.permissionBroker = permissionBroker diff --git a/Sources/CodeMode/Runtime/BridgeRuntime.swift b/Sources/CodeMode/Runtime/BridgeRuntime.swift index 17c8454..fb467c4 100644 --- a/Sources/CodeMode/Runtime/BridgeRuntime.swift +++ b/Sources/CodeMode/Runtime/BridgeRuntime.swift @@ -13,17 +13,31 @@ final class BridgeRuntime: @unchecked Sendable { var code: String? var message: String var capability: CapabilityID? + var capabilityKey: CodeModeCapabilityKey? var functionName: String? + var suggestions: [String] } private let registry: CapabilityRegistry private let catalog: BridgeCatalog private let config: CodeModeConfiguration - - init(registry: CapabilityRegistry, catalog: BridgeCatalog, config: CodeModeConfiguration) { + private let unsupportedBuiltInJavaScriptNames: [String] + private let executionQueue = DispatchQueue( + label: "CodeMode.BridgeRuntime.execution", + qos: .userInitiated, + attributes: .concurrent + ) + + init( + registry: CapabilityRegistry, + catalog: BridgeCatalog, + config: CodeModeConfiguration, + unsupportedBuiltInJavaScriptNames: [String] = [] + ) { self.registry = registry self.catalog = catalog self.config = config + self.unsupportedBuiltInJavaScriptNames = unsupportedBuiltInJavaScriptNames } func search(_ request: JavaScriptAPISearchRequest) throws -> JavaScriptAPISearchResponse { @@ -40,6 +54,7 @@ final class BridgeRuntime: @unchecked Sendable { let invocationContext = BridgeInvocationContext( executionContext: .init(), allowedCapabilities: [], + allowedCapabilityKeys: [], pathPolicy: config.pathPolicy, artifactStore: config.artifactStore, permissionBroker: config.permissionBroker, @@ -80,6 +95,12 @@ final class BridgeRuntime: @unchecked Sendable { ) } + func searchAsync(_ request: JavaScriptAPISearchRequest) async throws -> JavaScriptAPISearchResponse { + try await runOnExecutionQueue { + try self.search(request) + } + } + func makeExecutionCall(_ request: JavaScriptExecutionRequest) -> JavaScriptExecutionCall { let cancellationController = ExecutionCancellationController() let continuationBox = LockedBox.Continuation?>(nil) @@ -92,11 +113,13 @@ final class BridgeRuntime: @unchecked Sendable { let resultTask = Task { do { - let result = try self.execute( - request, - transcript: transcript, - cancellationController: cancellationController - ) + let result = try await self.runOnExecutionQueue { + try self.execute( + request, + transcript: transcript, + cancellationController: cancellationController + ) + } continuationBox.get()?.yield(.finished) continuationBox.get()?.finish() return result @@ -140,6 +163,20 @@ final class BridgeRuntime: @unchecked Sendable { ) } + private func runOnExecutionQueue( + _ operation: @escaping @Sendable () throws -> Output + ) async throws -> Output { + try await withCheckedThrowingContinuation { continuation in + executionQueue.async { + do { + continuation.resume(returning: try operation()) + } catch { + continuation.resume(throwing: error) + } + } + } + } + private func execute( _ request: JavaScriptExecutionRequest, transcript: ExecutionTranscript, @@ -148,6 +185,7 @@ final class BridgeRuntime: @unchecked Sendable { let invocationContext = BridgeInvocationContext( executionContext: request.context, allowedCapabilities: Set(request.allowedCapabilities), + allowedCapabilityKeys: Set(request.allowedCapabilityKeys), pathPolicy: config.pathPolicy, artifactStore: config.artifactStore, permissionBroker: config.permissionBroker, @@ -214,9 +252,11 @@ final class BridgeRuntime: @unchecked Sendable { } catch { let bridgeError = (error as? BridgeError) ?? BridgeError.nativeFailure(error.localizedDescription) let capabilityID = CapabilityID(rawValue: capability) + let capabilityKey = CodeModeCapabilityKey(rawValue: capability) let errorPayload = self.bridgeFailurePayload( for: bridgeError, - capability: capabilityID + capability: capabilityID, + capabilityKey: capabilityKey ) invocationContext.log(.error, message: "Capability failed \(capability): \(errorPayload.message)") invocationContext.auditLogger.log(AuditEvent(capability: capability, message: "failed: \(errorPayload.message)")) @@ -226,7 +266,8 @@ final class BridgeRuntime: @unchecked Sendable { "error": .object([ "code": .string(errorPayload.code), "message": .string(errorPayload.message), - "capability": capabilityID.map { .string($0.rawValue) } ?? .null, + "capability": .string(capabilityKey.rawValue), + "suggestions": .array(errorPayload.suggestions.map { .string($0) }), ]), ]) let encoded = try? JSONEncoder.codeModeBridge.encode(envelope) @@ -256,9 +297,26 @@ final class BridgeRuntime: @unchecked Sendable { ) } - let supportedCapabilities = Set(registry.allDescriptors().map(\.id)) - let unsupportedCapabilities = CapabilityID.allCases.filter { supportedCapabilities.contains($0) == false } - let pruningScript = JavaScriptBindingCatalog.pruningScript(removing: unsupportedCapabilities) + let builtInScript = RuntimeJavaScript.builtInBootstrap(for: registry.allCapabilityRegistrations()) + if builtInScript.isEmpty == false, context.evaluateScript(builtInScript) == nil { + let message = lastException.get()?.message ?? "Failed to install built-in JavaScript bindings" + throw CodeModeToolError( + code: "INTERNAL_FAILURE", + message: message, + diagnostics: [ + ToolDiagnostic( + severity: .error, + code: "JS_BUILTIN_BOOTSTRAP", + message: message, + category: "internal" + ) + ] + ) + } + + let pruningScript = RuntimeJavaScript.pruningScript( + removingJavaScriptNames: unsupportedBuiltInJavaScriptNames + ) if pruningScript.isEmpty == false, context.evaluateScript(pruningScript) == nil { let message = lastException.get()?.message ?? "Failed to prune unsupported JavaScript bindings" @@ -275,6 +333,30 @@ final class BridgeRuntime: @unchecked Sendable { ] ) } + + let providerScript = RuntimeJavaScript.providerBootstrap(for: registry.allCodeModeRegistrations()) + if providerScript.isEmpty == false, context.evaluateScript(providerScript) == nil { + let message = lastException.get()?.message ?? "Failed to install provider JavaScript bindings" + throw CodeModeToolError( + code: "INTERNAL_FAILURE", + message: message, + diagnostics: [ + ToolDiagnostic( + severity: .error, + code: "JS_PROVIDER_BOOTSTRAP", + message: message, + category: "internal" + ) + ] + ) + } + + _ = context.evaluateScript( + """ + delete globalThis.__codemodeInstallBinding; + delete globalThis.__codemodeInstallBindingIfMissing; + """ + ) } private func installSearchRuntime( @@ -330,12 +412,13 @@ final class BridgeRuntime: @unchecked Sendable { cancellationController: ExecutionCancellationController, lastException: LockedBox ) throws -> JSONValue? { + let executionCode = Self.returningBareAwaitCode(from: code) ?? code let script = """ globalThis.__codemode.state = 'pending'; globalThis.__codemode.result = undefined; globalThis.__codemode.error = null; (async function(){ - \(indented(code, prefix: " ")) + \(indented(executionCode, prefix: " ")) })() .then(function(value){ globalThis.__codemode.state = 'fulfilled'; @@ -344,10 +427,11 @@ final class BridgeRuntime: @unchecked Sendable { .catch(function(error){ globalThis.__codemode.state = 'rejected'; globalThis.__codemode.error = { - message: String(error), + message: error && error.code && error.message ? String(error.message) : String(error), code: error && error.code ? String(error.code) : null, capability: error && error.capability ? String(error.capability) : null, - functionName: error && error.functionName ? String(error.functionName) : null + functionName: error && error.functionName ? String(error.functionName) : null, + suggestions: error && Array.isArray(error.suggestions) ? error.suggestions.map(function(value){ return String(value); }) : [] }; }); """ @@ -415,6 +499,19 @@ final class BridgeRuntime: @unchecked Sendable { ) } + private static func returningBareAwaitCode(from code: String) -> String? { + var trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines) + while trimmed.hasSuffix(";") { + trimmed.removeLast() + trimmed = trimmed.trimmingCharacters(in: .whitespacesAndNewlines) + } + guard trimmed.hasPrefix("await "), + !trimmed.dropFirst("await ".count).contains(";") else { + return nil + } + return "return \(trimmed);" + } + private func runSearchScript( _ code: String, timeoutMs: Int, @@ -533,12 +630,17 @@ final class BridgeRuntime: @unchecked Sendable { return try JSONDecoder.codeModeBridge.decode(JSONValue.self, from: data) } - private func bridgeFailurePayload(for error: BridgeError, capability: CapabilityID?) -> CodeModeToolError { - let (message, suggestions) = enrichedBridgeFailure(for: error, capability: capability) + private func bridgeFailurePayload( + for error: BridgeError, + capability: CapabilityID?, + capabilityKey: CodeModeCapabilityKey? + ) -> CodeModeToolError { + let (message, suggestions) = enrichedBridgeFailure(for: error, capability: capability, capabilityKey: capabilityKey) return CodeModeToolError( code: error.diagnosticCode, message: message, capability: capability, + capabilityKey: capabilityKey, suggestions: suggestions ) } @@ -557,12 +659,15 @@ final class BridgeRuntime: @unchecked Sendable { ) if isToolFailureCode(code) { - let suggestions = bridgeSuggestions(for: payload.capability) + let suggestions = payload.suggestions.isEmpty + ? bridgeSuggestions(for: payload.capability, capabilityKey: payload.capabilityKey) + : payload.suggestions return CodeModeToolError( code: code, message: payload.message, functionName: payload.functionName, capability: payload.capability, + capabilityKey: payload.capabilityKey, suggestions: suggestions, diagnostics: result.diagnostics, logs: result.logs, @@ -629,9 +734,11 @@ final class BridgeRuntime: @unchecked Sendable { } private func rejectionPayload(from context: JSContext) -> RejectionPayload { - let capability = normalizedOptionalString( + let capabilityString = normalizedOptionalString( context.evaluateScript("globalThis.__codemode.error?.capability ?? null")?.toString() - ).flatMap(CapabilityID.init(rawValue:)) + ) + let capability = capabilityString.flatMap(CapabilityID.init(rawValue:)) + let capabilityKey = capabilityString.map(CodeModeCapabilityKey.init(rawValue:)) return RejectionPayload( code: normalizedOptionalString( @@ -641,12 +748,24 @@ final class BridgeRuntime: @unchecked Sendable { context.evaluateScript("globalThis.__codemode.error?.message ?? null")?.toString() ) ?? "JavaScript promise rejected", capability: capability, + capabilityKey: capabilityKey, functionName: normalizedOptionalString( context.evaluateScript("globalThis.__codemode.error?.functionName ?? null")?.toString() - ) + ), + suggestions: rejectedSuggestions(from: context) ) } + private func rejectedSuggestions(from context: JSContext) -> [String] { + guard let json = context.evaluateScript("JSON.stringify(globalThis.__codemode.error?.suggestions ?? [])")?.toString(), + let data = json.data(using: .utf8), + let suggestions = try? JSONDecoder.codeModeBridge.decode([String].self, from: data) + else { + return [] + } + return suggestions + } + private func event(for error: CodeModeToolError) -> JavaScriptExecutionEvent { switch error.code { case "JS_SYNTAX_ERROR": @@ -666,6 +785,7 @@ final class BridgeRuntime: @unchecked Sendable { transcript: BridgeInvocationContext, functionName: String? = nil, capability: CapabilityID? = nil, + capabilityKey: CodeModeCapabilityKey? = nil, suggestions: [String] = [] ) -> CodeModeToolError { CodeModeToolError( @@ -673,6 +793,7 @@ final class BridgeRuntime: @unchecked Sendable { message: message, functionName: functionName, capability: capability, + capabilityKey: capabilityKey, suggestions: suggestions, diagnostics: transcript.allDiagnostics(), logs: transcript.allLogs(), @@ -681,7 +802,19 @@ final class BridgeRuntime: @unchecked Sendable { } private func bridgeSuggestions(for capability: CapabilityID?) -> [String] { - guard let capability, let reference = catalog.reference(for: capability) else { + bridgeSuggestions(for: capability, capabilityKey: capability?.codeModeKey) + } + + private func bridgeSuggestions(for capability: CapabilityID?, capabilityKey: CodeModeCapabilityKey?) -> [String] { + let reference: JavaScriptAPIReference? + if let capability { + reference = catalog.reference(for: capability) + } else if let capabilityKey { + reference = catalog.reference(for: capabilityKey) + } else { + reference = nil + } + guard let reference else { return [] } @@ -696,12 +829,74 @@ final class BridgeRuntime: @unchecked Sendable { return suggestions } - private func enrichedBridgeFailure(for error: BridgeError, capability: CapabilityID?) -> (String, [String]) { - guard case .invalidArguments = error else { - return (error.localizedDescription, bridgeSuggestions(for: capability)) + private func enrichedBridgeFailure( + for error: BridgeError, + capability: CapabilityID?, + capabilityKey: CodeModeCapabilityKey? + ) -> (String, [String]) { + let suggestions: [String] + switch error { + case let .capabilityDenied(capability): + suggestions = [ + "Add \"\(capability.rawValue)\" to allowedCapabilities and retry.", + "If your host uses a unified capability-key allowlist, add \"\(capability.rawValue)\" to allowedCapabilityKeys.", + ] + bridgeSuggestions(for: capability, capabilityKey: capability.codeModeKey) + case let .capabilityKeyDenied(capabilityKey): + suggestions = [ + "Add \"\(capabilityKey.rawValue)\" to allowedCapabilityKeys and retry.", + "Custom provider capabilities are not enabled by allowedCapabilities.", + ] + bridgeSuggestions(for: nil, capabilityKey: capabilityKey) + case let .permissionDenied(permission): + suggestions = permissionDeniedSuggestions(for: permission) + case .customPermissionDenied: + suggestions = [ + "The custom provider denied permission after capability allowlisting succeeded.", + "This is not repaired by adding more allowedCapabilities; request provider permission or ask the host/user to grant access.", + ] + case .uiPresenterUnavailable: + suggestions = [ + "Configure CodeModeConfiguration.systemUIPresenter before using UI-presenting helpers.", + "Do not retry this helper until the host provides a SystemUIPresenter.", + ] + default: + suggestions = bridgeSuggestions(for: capability, capabilityKey: capabilityKey) } - return (error.localizedDescription, bridgeSuggestions(for: capability)) + return (error.localizedDescription, suggestions) + } + + private func permissionDeniedSuggestions(for permission: PermissionKind) -> [String] { + var suggestions = [ + "Permission \"\(permission.rawValue)\" was denied after capability allowlisting succeeded.", + "Do not repair this by adding more allowedCapabilities; the host or OS permission must change.", + ] + + if let helper = permissionRequestHelper(for: permission) { + suggestions.append("If appropriate, call \(helper) first with its request capability allowlisted, then retry the original helper.") + } else { + suggestions.append("No CodeMode request helper is available for this permission; ask the user or host app to grant access.") + } + + return suggestions + } + + private func permissionRequestHelper(for permission: PermissionKind) -> String? { + switch permission { + case .locationWhenInUse: + return "apple.location.requestPermission()" + case .notifications: + return "apple.notifications.requestPermission()" + case .alarmKit: + return "ios.alarm.requestPermission()" + case .healthKit: + return "apple.health.requestPermission({ readTypes: [...], writeTypes: [...] })" + case .speechRecognition: + return "apple.speech.requestPermission()" + case .music: + return "apple.music.requestPermission()" + case .contacts, .calendar, .calendarWriteOnly, .reminders, .photoLibrary, .homeKit, .microphone: + return nil + } } private func formatArguments(_ names: [String], types: [String: CapabilityArgumentType]) -> String { @@ -776,6 +971,7 @@ final class BridgeRuntime: @unchecked Sendable { || normalized.hasPrefix("ios.") || normalized.hasPrefix("fs.") || normalized.hasPrefix("path.") + || catalog.closestFunctionNames(to: name).isEmpty == false { return true } diff --git a/Sources/CodeMode/Runtime/RuntimeJavaScript.swift b/Sources/CodeMode/Runtime/RuntimeJavaScript.swift index d1c2d8e..f10ef84 100644 --- a/Sources/CodeMode/Runtime/RuntimeJavaScript.swift +++ b/Sources/CodeMode/Runtime/RuntimeJavaScript.swift @@ -1,6 +1,115 @@ import Foundation enum RuntimeJavaScript { + static func pruningScript(removingJavaScriptNames names: some Sequence) -> String { + let bindingsToRemove = Array(Set(names)).sorted() + + guard bindingsToRemove.isEmpty == false else { + return "" + } + + let deleteCalls = bindingsToRemove.map { name in + "__codemodeDeletePath(\(jsonString(name)));" + } + + let groupPaths = Set( + bindingsToRemove.compactMap { name -> String? in + let components = name.split(separator: ".") + guard components.count >= 2 else { + return nil + } + return components.dropLast().joined(separator: ".") + } + ) + + let groupCleanupCalls = groupPaths.sorted(by: { lhs, rhs in + lhs.components(separatedBy: ".").count > rhs.components(separatedBy: ".").count + }).map { path in + "__codemodeDeleteIfEmpty(\(jsonString(path)));" + } + + let rootCleanupCalls = ["apple", "ios", "fs"].map { root in + "__codemodeDeleteIfEmpty(\(jsonString(root)));" + } + + return """ + (function(){ + function __codemodeResolveParent(path) { + const segments = String(path).split('.').filter(function(segment){ return segment.length > 0; }); + if (segments.length === 0) return null; + let target = globalThis; + for (let i = 0; i < segments.length - 1; i++) { + if (!target || typeof target[segments[i]] === 'undefined') return null; + target = target[segments[i]]; + } + return { target: target, property: segments[segments.length - 1] }; + } + function __codemodeDeletePath(path) { + const binding = __codemodeResolveParent(path); + if (binding && binding.target) delete binding.target[binding.property]; + } + function __codemodeResolvePath(path) { + const segments = String(path).split('.').filter(function(segment){ return segment.length > 0; }); + let target = globalThis; + for (let i = 0; i < segments.length; i++) { + if (!target || typeof target[segments[i]] === 'undefined') return undefined; + target = target[segments[i]]; + } + return target; + } + function __codemodeDeleteIfEmpty(path) { + const value = __codemodeResolvePath(path); + if (value && typeof value === 'object' && Object.keys(value).length === 0) { + __codemodeDeletePath(path); + } + } + \(indent((deleteCalls + groupCleanupCalls + rootCleanupCalls).joined(separator: "\n"), prefix: " ")) + })(); + """ + } + + static func builtInBootstrap(for registrations: [CapabilityRegistration]) -> String { + let commands = registrations + .sorted { $0.descriptor.id.rawValue < $1.descriptor.id.rawValue } + .flatMap { registration in + registration.jsNames.sorted().map { jsName in + "__codemodeInstallBindingIfMissing(\(jsonString(jsName)), \(jsonString(registration.descriptor.id.codeModeKey.rawValue)));" + } + } + guard commands.isEmpty == false else { + return "" + } + return commands.joined(separator: "\n") + } + + static func providerBootstrap(for registrations: [CodeModeRegistration]) -> String { + let commands = registrations + .sorted { $0.jsPath < $1.jsPath } + .map { registration in + "__codemodeInstallBinding(\(jsonString(registration.jsPath)), \(jsonString(registration.capabilityKey.rawValue)));" + } + guard commands.isEmpty == false else { + return "" + } + return commands.joined(separator: "\n") + } + + private static func jsonString(_ value: String) -> String { + guard let data = try? JSONEncoder.codeModeBridge.encode(value), + let string = String(data: data, encoding: .utf8) + else { + return "\"\"" + } + return string + } + + private static func indent(_ value: String, prefix: String) -> String { + value + .split(separator: "\n", omittingEmptySubsequences: false) + .map { $0.isEmpty ? "" : prefix + $0 } + .joined(separator: "\n") + } + static let searchBootstrap = """ globalThis.__codemode = globalThis.__codemode || {}; globalThis.__codemode.state = 'idle'; @@ -24,6 +133,10 @@ enum RuntimeJavaScript { """ static let bootstrap = """ + (function(){ + const __codemodeInvokeSync = globalThis.__bridgeInvokeSync; + delete globalThis.__bridgeInvokeSync; + globalThis.__codemode = globalThis.__codemode || {}; globalThis.__codemode.state = 'idle'; globalThis.__codemode.result = null; @@ -31,12 +144,13 @@ enum RuntimeJavaScript { function __invoke(capability, args) { const payload = JSON.stringify(args ?? {}); - const raw = __bridgeInvokeSync(String(capability), payload); + const raw = __codemodeInvokeSync(String(capability), payload); const envelope = JSON.parse(String(raw || '{}')); if (!envelope.ok) { const error = new Error(envelope.error && envelope.error.message ? envelope.error.message : 'Bridge call failed'); error.code = envelope.error && envelope.error.code ? envelope.error.code : 'BRIDGE_ERROR'; error.capability = envelope.error && envelope.error.capability ? envelope.error.capability : null; + error.suggestions = envelope.error && Array.isArray(envelope.error.suggestions) ? envelope.error.suggestions.map(function(value){ return String(value); }) : []; throw error; } return envelope.value; @@ -46,6 +160,39 @@ enum RuntimeJavaScript { return Promise.resolve().then(function(){ return __invoke(capability, args); }); } + function __codemodeResolveBinding(jsPath) { + const segments = String(jsPath).split('.').filter(function(segment){ return segment.length > 0; }); + if (segments.length === 0) { + throw new Error('Invalid CodeMode JS path'); + } + let target = globalThis; + for (let i = 0; i < segments.length - 1; i++) { + const segment = segments[i]; + if (!target[segment] || typeof target[segment] !== 'object') { + target[segment] = {}; + } + target = target[segment]; + } + return { target: target, property: segments[segments.length - 1] }; + } + + function __codemodeInstallResolvedBinding(binding, capability) { + binding.target[binding.property] = function(args) { + return __invokeAsync(capability, args || {}); + }; + } + + function __codemodeInstallBinding(jsPath, capability) { + __codemodeInstallResolvedBinding(__codemodeResolveBinding(jsPath), capability); + } + + function __codemodeInstallBindingIfMissing(jsPath, capability) { + const binding = __codemodeResolveBinding(jsPath); + if (typeof binding.target[binding.property] === 'undefined') { + __codemodeInstallResolvedBinding(binding, capability); + } + } + globalThis.console = { log: function(){ __nativeConsoleLog(Array.from(arguments).map(function(v){ return String(v); }).join(' ')); }, info: function(){ __nativeConsoleLog(Array.from(arguments).map(function(v){ return String(v); }).join(' ')); }, @@ -73,18 +220,36 @@ enum RuntimeJavaScript { } if (typeof URL === 'undefined') { - globalThis.URL = function(url){ this.href = String(url); }; + globalThis.URL = function(url){ + this.href = String(url); + this.toString = function(){ return this.href; }; + this.valueOf = function(){ return this.href; }; + }; } function __response(payload) { const bodyText = payload && payload.bodyText ? String(payload.bodyText) : ''; + const bodyBase64 = payload && payload.bodyBase64 ? String(payload.bodyBase64) : ''; + const headerValues = payload && payload.headers ? payload.headers : {}; + const headers = Object.assign({}, headerValues); + headers.get = function(name) { + const target = String(name || '').toLowerCase(); + const keys = Object.keys(headerValues); + for (let i = 0; i < keys.length; i++) { + if (String(keys[i]).toLowerCase() === target) { + return headerValues[keys[i]]; + } + } + return null; + }; return { ok: !!(payload && payload.ok), status: payload && payload.status ? Number(payload.status) : 0, statusText: payload && payload.statusText ? String(payload.statusText) : '', - headers: payload && payload.headers ? payload.headers : {}, + headers: headers, text: function(){ return Promise.resolve(bodyText); }, - json: function(){ return Promise.resolve(bodyText.length ? JSON.parse(bodyText) : null); } + json: function(){ return Promise.resolve(bodyText.length ? JSON.parse(bodyText) : null); }, + base64: function(){ return Promise.resolve(bodyBase64); } }; } @@ -105,13 +270,11 @@ enum RuntimeJavaScript { getCurrentPosition: function() { return __invokeAsync('location.read', { mode: 'current' }); } }; - globalThis.apple.weather = { - getCurrentWeather: function(coords) { return __invokeAsync('weather.read', coords || {}); } - }; - globalThis.apple.calendar = { listEvents: function(args) { return __invokeAsync('calendar.read', args || {}); }, - createEvent: function(args) { return __invokeAsync('calendar.write', args || {}); }, + createEvent: function(args) { return __invokeAsync('calendar.write', Object.assign({}, args || {}, { operation: 'create' })); }, + updateEvent: function(args) { return __invokeAsync('calendar.write', Object.assign({}, args || {}, { operation: 'update' })); }, + deleteEvent: function(args) { return __invokeAsync('calendar.delete', args || {}); }, pickCalendar: function(args) { return __invokeAsync('calendar.ui.pickCalendar', args || {}); }, presentEvent: function(args) { return __invokeAsync('calendar.ui.presentEvent', args || {}); }, presentNewEvent: function(args) { return __invokeAsync('calendar.ui.presentNewEvent', args || {}); } @@ -119,7 +282,10 @@ enum RuntimeJavaScript { globalThis.apple.reminders = { listReminders: function(args) { return __invokeAsync('reminders.read', args || {}); }, - createReminder: function(args) { return __invokeAsync('reminders.write', args || {}); } + createReminder: function(args) { return __invokeAsync('reminders.write', Object.assign({}, args || {}, { operation: 'create' })); }, + updateReminder: function(args) { return __invokeAsync('reminders.write', Object.assign({}, args || {}, { operation: 'update' })); }, + completeReminder: function(args) { return __invokeAsync('reminders.write', Object.assign({ isCompleted: true }, args || {}, { operation: 'complete' })); }, + deleteReminder: function(args) { return __invokeAsync('reminders.delete', args || {}); } }; globalThis.apple.contacts = { @@ -187,55 +353,6 @@ enum RuntimeJavaScript { open: function(args) { return __invokeAsync('settings.ui.open', args || {}); } }; - globalThis.apple.vision = { - analyzeImage: function(args) { return __invokeAsync('vision.image.analyze', args || {}); } - }; - - globalThis.apple.notifications = { - requestPermission: function() { return __invokeAsync('notifications.permission.request', {}); }, - schedule: function(args) { return __invokeAsync('notifications.schedule', args || {}); }, - listPending: function(args) { return __invokeAsync('notifications.pending.read', args || {}); }, - cancelPending: function(args) { return __invokeAsync('notifications.pending.delete', args || {}); } - }; - - globalThis.ios = globalThis.ios || {}; - globalThis.ios.alarm = { - requestPermission: function() { return __invokeAsync('alarm.permission.request', {}); }, - list: function(args) { return __invokeAsync('alarm.read', args || {}); }, - schedule: function(args) { return __invokeAsync('alarm.schedule', args || {}); }, - cancel: function(args) { return __invokeAsync('alarm.cancel', args || {}); } - }; - - globalThis.apple.health = { - requestPermission: function(args) { return __invokeAsync('health.permission.request', args || {}); }, - read: function(args) { return __invokeAsync('health.read', args || {}); }, - write: function(args) { return __invokeAsync('health.write', args || {}); } - }; - - globalThis.apple.home = { - list: function(args) { return __invokeAsync('home.read', args || {}); }, - writeCharacteristic: function(args) { return __invokeAsync('home.write', args || {}); } - }; - - globalThis.apple.media = { - metadata: function(args) { return __invokeAsync('media.metadata.read', args || {}); }, - extractFrame: function(args) { return __invokeAsync('media.frame.extract', args || {}); }, - transcode: function(args) { return __invokeAsync('media.transcode', args || {}); } - }; - - globalThis.apple.fs = { - list: function(args) { return __invokeAsync('fs.list', args || {}); }, - read: function(args) { return __invokeAsync('fs.read', args || {}); }, - write: function(args) { return __invokeAsync('fs.write', args || {}); }, - move: function(args) { return __invokeAsync('fs.move', args || {}); }, - copy: function(args) { return __invokeAsync('fs.copy', args || {}); }, - delete: function(args) { return __invokeAsync('fs.delete', args || {}); }, - stat: function(args) { return __invokeAsync('fs.stat', args || {}); }, - mkdir: function(args) { return __invokeAsync('fs.mkdir', args || {}); }, - exists: function(args) { return __invokeAsync('fs.exists', args || {}); }, - access: function(args) { return __invokeAsync('fs.access', args || {}); } - }; - globalThis.fs = { promises: { readFile: function(path, options) { @@ -274,5 +391,8 @@ enum RuntimeJavaScript { .join('/'); } }; + globalThis.__codemodeInstallBinding = __codemodeInstallBinding; + globalThis.__codemodeInstallBindingIfMissing = __codemodeInstallBindingIfMissing; + })(); """ } diff --git a/Sources/CodeMode/Security/PathPolicy.swift b/Sources/CodeMode/Security/PathPolicy.swift index a832d35..6ba31f8 100644 --- a/Sources/CodeMode/Security/PathPolicy.swift +++ b/Sources/CodeMode/Security/PathPolicy.swift @@ -49,11 +49,12 @@ public struct DefaultPathPolicy: PathPolicy { } let normalized = url.standardizedFileURL - guard isAllowed(normalized) else { + let containmentURL = resolveSymlinksForContainment(normalized) + guard isAllowed(containmentURL) else { throw BridgeError.pathViolation("Path is outside allowed roots: \(cleaned)") } - return normalized + return containmentURL } private func parseScoped(path: String) -> (base: URL, suffix: String)? { @@ -81,11 +82,31 @@ public struct DefaultPathPolicy: PathPolicy { } private func isAllowed(_ url: URL) -> Bool { - let allowedRoots = [config.tmpRoot, config.cachesRoot, config.documentsRoot, config.appGroupRoot].compactMap { $0?.standardizedFileURL.path } - let path = url.standardizedFileURL.path + let allowedRoots = [config.tmpRoot, config.cachesRoot, config.documentsRoot, config.appGroupRoot] + .compactMap { root in + root.map { resolveSymlinksForContainment($0.standardizedFileURL).path } + } + let path = resolveSymlinksForContainment(url.standardizedFileURL).path return allowedRoots.contains { allowed in path == allowed || path.hasPrefix(allowed + "/") } } + + private func resolveSymlinksForContainment(_ url: URL) -> URL { + var existingAncestor = url.standardizedFileURL + var missingComponents: [String] = [] + let fileManager = FileManager.default + + while fileManager.fileExists(atPath: existingAncestor.path) == false, + existingAncestor.path != existingAncestor.deletingLastPathComponent().path { + missingComponents.insert(existingAncestor.lastPathComponent, at: 0) + existingAncestor.deleteLastPathComponent() + } + + let resolvedAncestor = existingAncestor.resolvingSymlinksInPath().standardizedFileURL + return missingComponents.reduce(resolvedAncestor) { partial, component in + partial.appendingPathComponent(component) + }.standardizedFileURL + } } diff --git a/Sources/CodeMode/Security/Permission.swift b/Sources/CodeMode/Security/Permission.swift index 87714d3..cf10c4b 100644 --- a/Sources/CodeMode/Security/Permission.swift +++ b/Sources/CodeMode/Security/Permission.swift @@ -11,6 +11,9 @@ public enum PermissionKind: String, Sendable, Codable, CaseIterable { case alarmKit = "alarmKit" case healthKit = "healthKit" case homeKit = "homeKit" + case speechRecognition = "speech.recognition" + case microphone = "microphone" + case music = "music" } public enum PermissionStatus: String, Sendable, Codable, Equatable { diff --git a/Sources/CodeMode/Security/SystemPermissionBroker.swift b/Sources/CodeMode/Security/SystemPermissionBroker.swift index 93fd916..cb73e53 100644 --- a/Sources/CodeMode/Security/SystemPermissionBroker.swift +++ b/Sources/CodeMode/Security/SystemPermissionBroker.swift @@ -20,6 +20,18 @@ import Photos import UserNotifications #endif +#if canImport(Speech) +import Speech +#endif + +#if canImport(AVFoundation) +import AVFoundation +#endif + +#if canImport(MediaPlayer) && !os(macOS) && !os(watchOS) +import MediaPlayer +#endif + #if canImport(AlarmKit) import AlarmKit #endif @@ -57,6 +69,12 @@ public final class SystemPermissionBroker: PermissionBroker, @unchecked Sendable return healthKitStatus() case .homeKit: return homeKitStatus() + case .speechRecognition: + return speechRecognitionStatus() + case .microphone: + return microphoneStatus() + case .music: + return musicStatus() } } @@ -82,6 +100,12 @@ public final class SystemPermissionBroker: PermissionBroker, @unchecked Sendable return requestHealthKitPermission() case .homeKit: return requestHomeKitPermission() + case .speechRecognition: + return requestSpeechRecognitionPermission() + case .microphone: + return requestMicrophonePermission() + case .music: + return requestMusicPermission() } } @@ -314,12 +338,69 @@ public final class SystemPermissionBroker: PermissionBroker, @unchecked Sendable #endif } + private func speechRecognitionStatus() -> PermissionStatus { + #if canImport(Speech) + switch SFSpeechRecognizer.authorizationStatus() { + case .authorized: + return .granted + case .denied: + return .denied + case .restricted: + return .restricted + case .notDetermined: + return .notDetermined + @unknown default: + return .unavailable + } + #else + return .unavailable + #endif + } + + private func microphoneStatus() -> PermissionStatus { + #if canImport(AVFoundation) + switch AVCaptureDevice.authorizationStatus(for: .audio) { + case .authorized: + return .granted + case .denied: + return .denied + case .restricted: + return .restricted + case .notDetermined: + return .notDetermined + @unknown default: + return .unavailable + } + #else + return .unavailable + #endif + } + + private func musicStatus() -> PermissionStatus { + #if canImport(MediaPlayer) && !os(macOS) && !os(watchOS) + switch MPMediaLibrary.authorizationStatus() { + case .authorized: + return .granted + case .denied: + return .denied + case .restricted: + return .restricted + case .notDetermined: + return .notDetermined + @unknown default: + return .unavailable + } + #else + return .unavailable + #endif + } + private func healthKitStatus() -> PermissionStatus { #if canImport(HealthKit) guard HKHealthStore.isHealthDataAvailable() else { return .unavailable } - return .granted + return .notDetermined #else return .unavailable #endif @@ -494,6 +575,45 @@ public final class SystemPermissionBroker: PermissionBroker, @unchecked Sendable healthKitStatus() } + private func requestSpeechRecognitionPermission() -> PermissionStatus { + #if canImport(Speech) + let semaphore = DispatchSemaphore(value: 0) + SFSpeechRecognizer.requestAuthorization { _ in + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + 10) + return speechRecognitionStatus() + #else + return .unavailable + #endif + } + + private func requestMicrophonePermission() -> PermissionStatus { + #if canImport(AVFoundation) + let semaphore = DispatchSemaphore(value: 0) + AVCaptureDevice.requestAccess(for: .audio) { _ in + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + 10) + return microphoneStatus() + #else + return .unavailable + #endif + } + + private func requestMusicPermission() -> PermissionStatus { + #if canImport(MediaPlayer) && !os(macOS) && !os(watchOS) + let semaphore = DispatchSemaphore(value: 0) + MPMediaLibrary.requestAuthorization { _ in + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + 10) + return musicStatus() + #else + return .unavailable + #endif + } + private func requestAlarmKitPermission() -> PermissionStatus { #if canImport(AlarmKit) && os(iOS) if #available(iOS 26.0, *) { diff --git a/Sources/CodeMode/Support/CapabilityPlatformSupport.swift b/Sources/CodeMode/Support/CapabilityPlatformSupport.swift index 81782b9..fd2dfe2 100644 --- a/Sources/CodeMode/Support/CapabilityPlatformSupport.swift +++ b/Sources/CodeMode/Support/CapabilityPlatformSupport.swift @@ -40,13 +40,22 @@ enum CapabilityPlatformSupport { return registrations.filter { supported.contains($0.descriptor.id) } } + static func unsupportedJavaScriptNames(from registrations: [CapabilityRegistration], for platform: HostPlatform = .current) -> [String] { + let supported = supportedCapabilities(for: platform) + return registrations + .filter { supported.contains($0.descriptor.id) == false } + .flatMap(\.jsNames) + } + private static let crossAppleCapabilities: Set = [ .locationRead, .weatherRead, .calendarRead, .calendarWrite, + .calendarDelete, .remindersRead, .remindersWrite, + .remindersDelete, .contactsRead, .contactsSearch, .photosRead, @@ -56,6 +65,8 @@ enum CapabilityPlatformSupport { .notificationsSchedule, .notificationsPendingRead, .notificationsPendingDelete, + .notificationsDeliveredRead, + .notificationsDeliveredDelete, .healthPermissionRequest, .healthRead, .healthWrite, @@ -64,6 +75,46 @@ enum CapabilityPlatformSupport { .mediaMetadataRead, .mediaFrameExtract, .mediaTranscode, + .cloudKitAccountStatus, + .cloudKitRecordsQuery, + .cloudKitRecordSave, + .cloudKitRecordDelete, + .cloudKitSubscriptionSave, + .cloudKitSubscriptionEventsRead, + .notificationsRemoteRegister, + .notificationsRemoteTokenRead, + .notificationsSettingsRead, + .notificationsCategoriesSet, + .notificationsResponsesRead, + .speechPermissionRequest, + .speechStatus, + .speechFileTranscribe, + .speechMicrophoneTranscribe, + .appIntentsList, + .appIntentsRun, + .appIntentsDonate, + .appIntentsOpen, + .appIntentsHandoffsRead, + .foundationModelsStatus, + .foundationModelsGenerate, + .foundationModelsExtract, + .mapsGeocode, + .mapsReverseGeocode, + .mapsSearch, + .mapsRouteEstimate, + .mapsOpen, + .musicPermissionRequest, + .musicSubscriptionStatus, + .musicCatalogSearch, + .musicCatalogDetails, + .musicLibraryRead, + .musicPlaylistWrite, + .musicPlaybackControl, + .storeKitProductsRead, + .storeKitEntitlementsRead, + .storeKitPurchase, + .storeKitRestore, + .storeKitTransactionsRead, ] private static let iOSCapabilities = crossAppleCapabilities.union([ @@ -96,6 +147,17 @@ enum CapabilityPlatformSupport { .alarmRead, .alarmSchedule, .alarmCancel, + .activityList, + .activityStart, + .activityUpdate, + .activityEnd, + .activityPushTokenRead, + .passKitWalletStatus, + .passKitPassesRead, + .passKitPassAdd, + .passKitPassPresent, + .passKitApplePayStatus, + .passKitApplePayPresent, ]) private static let macOSCapabilities = crossAppleCapabilities diff --git a/Sources/CodeMode/Support/JSONValue.swift b/Sources/CodeMode/Support/JSONValue.swift index 73e57e6..d96eec3 100644 --- a/Sources/CodeMode/Support/JSONValue.swift +++ b/Sources/CodeMode/Support/JSONValue.swift @@ -81,6 +81,10 @@ public extension JSONValue { } else { self = .number(value.doubleValue) } + case let value as [AnyHashable: Any]: + self = .object(value.reduce(into: [String: JSONValue]()) { partial, pair in + partial[String(describing: pair.key)] = JSONValue(any: pair.value) + }) case let value as [String: Any]: self = .object(value.mapValues { JSONValue(any: $0) }) case let value as [Any]: diff --git a/Sources/CodeMode/Support/UIKitSystemUIPresenter+Calendar.swift b/Sources/CodeMode/Support/UIKitSystemUIPresenter+Calendar.swift new file mode 100644 index 0000000..c3e95b3 --- /dev/null +++ b/Sources/CodeMode/Support/UIKitSystemUIPresenter+Calendar.swift @@ -0,0 +1,145 @@ +import Foundation + +#if canImport(UIKit) && (os(iOS) || os(visionOS)) +@preconcurrency import AuthenticationServices +@preconcurrency import Contacts +@preconcurrency import ContactsUI +@preconcurrency import EventKit +@preconcurrency import EventKitUI +@preconcurrency import Photos +@preconcurrency import PhotosUI +@preconcurrency import QuickLook +@preconcurrency import SafariServices +@preconcurrency import UIKit +@preconcurrency import UniformTypeIdentifiers +#if canImport(MessageUI) +@preconcurrency import MessageUI +#endif +#if canImport(VisionKit) +@preconcurrency import VisionKit +#endif + +extension UIKitSystemUIPresenter { + public func pickCalendar(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let selectionStyle = arguments.string("selectionStyle")?.lowercased() == "multiple" + ? EKCalendarChooserSelectionStyle.multiple + : EKCalendarChooserSelectionStyle.single + let displayStyle = arguments.string("displayStyle")?.lowercased() == "all" + ? EKCalendarChooserDisplayStyle.allCalendars + : EKCalendarChooserDisplayStyle.writableCalendarsOnly + let token = UUID() + + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + let store = EKEventStore() + let controller = EKCalendarChooser( + selectionStyle: selectionStyle, + displayStyle: displayStyle, + entityType: .event, + eventStore: store + ) + controller.showsDoneButton = true + controller.showsCancelButton = true + + let coordinator = CalendarChooserCoordinator { [weak self] calendars in + self?.releaseCoordinator(token) + complete(.success(.array(calendars.map { self?.mapCalendar($0) ?? .null }))) + } + self.retainCoordinator(coordinator, token: token) + controller.delegate = coordinator + + presenter.present(UINavigationController(rootViewController: controller), animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + } + + public func presentCalendarEvent(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let identifier = arguments.string("identifier") ?? "" + let token = UUID() + + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + let store = EKEventStore() + guard let event = store.event(withIdentifier: identifier) else { + complete(.failure(.invalidArguments("calendar.ui.presentEvent could not find event identifier \(identifier)"))) + return + } + + let controller = EKEventViewController() + controller.event = event + controller.allowsEditing = arguments.bool("allowsEditing") ?? false + controller.allowsCalendarPreview = arguments.bool("allowsCalendarPreview") ?? true + + let coordinator = CalendarEventViewCoordinator { [weak self] in + self?.releaseCoordinator(token) + complete(.success(.object(["action": .string("dismissed")]))) + } + self.retainCoordinator(coordinator, token: token) + controller.delegate = coordinator + + presenter.present(UINavigationController(rootViewController: controller), animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + } + + public func presentNewCalendarEvent(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let formatter = ISO8601DateFormatter() + let token = UUID() + + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + let store = EKEventStore() + let event = EKEvent(eventStore: store) + event.title = arguments.string("title") ?? "" + event.notes = arguments.string("notes") + event.location = arguments.string("location") + + let startDate = arguments.string("start").flatMap { formatter.date(from: $0) } ?? Date() + event.startDate = startDate + event.endDate = arguments.string("end").flatMap { formatter.date(from: $0) } + ?? Calendar.current.date(byAdding: .hour, value: 1, to: startDate) + ?? startDate + event.calendar = store.defaultCalendarForNewEvents + + let controller = EKEventEditViewController() + controller.eventStore = store + controller.event = event + + let coordinator = CalendarEventEditCoordinator { [weak self] action, event in + var object: [String: JSONValue] = [ + "action": .string(self?.actionString(action) ?? "unknown"), + ] + if action == .saved, let event { + object["identifier"] = .string(event.eventIdentifier ?? "") + object["title"] = .string(event.title ?? "") + } + self?.releaseCoordinator(token) + complete(.success(.object(object))) + } + self.retainCoordinator(coordinator, token: token) + controller.editViewDelegate = coordinator + presenter.present(controller, animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + } + +} +#endif diff --git a/Sources/CodeMode/Support/UIKitSystemUIPresenter+CommunicationWeb.swift b/Sources/CodeMode/Support/UIKitSystemUIPresenter+CommunicationWeb.swift new file mode 100644 index 0000000..8fd4a7c --- /dev/null +++ b/Sources/CodeMode/Support/UIKitSystemUIPresenter+CommunicationWeb.swift @@ -0,0 +1,596 @@ +import Foundation + +#if canImport(UIKit) && (os(iOS) || os(visionOS)) +@preconcurrency import AuthenticationServices +@preconcurrency import Contacts +@preconcurrency import ContactsUI +@preconcurrency import EventKit +@preconcurrency import EventKitUI +@preconcurrency import Photos +@preconcurrency import PhotosUI +@preconcurrency import QuickLook +@preconcurrency import SafariServices +@preconcurrency import UIKit +@preconcurrency import UniformTypeIdentifiers +#if canImport(MessageUI) +@preconcurrency import MessageUI +#endif +#if canImport(VisionKit) +@preconcurrency import VisionKit +#endif + +extension UIKitSystemUIPresenter { + public func presentShareSheet(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let token = UUID() + + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + let items = try self.shareItems(from: arguments, context: context) + let controller = UIActivityViewController(activityItems: items, applicationActivities: nil) + controller.excludedActivityTypes = (arguments.array("excludedActivityTypes") ?? []) + .compactMap(\.stringValue) + .map(UIActivity.ActivityType.init(rawValue:)) + if let subject = arguments.string("subject") { + controller.setValue(subject, forKey: "subject") + } + controller.popoverPresentationController?.sourceView = presenter.view + controller.popoverPresentationController?.sourceRect = CGRect( + x: presenter.view.bounds.midX, + y: presenter.view.bounds.midY, + width: 1, + height: 1 + ) + + self.retainCoordinator(controller, token: token) + controller.completionWithItemsHandler = { [weak self] activityType, completed, _, error in + self?.releaseCoordinator(token) + if let error { + complete(.failure(.nativeFailure(error.localizedDescription))) + return + } + var object: [String: JSONValue] = [ + "action": .string(completed ? "completed" : "cancelled"), + "completed": .bool(completed), + ] + if let activityType { + object["activityType"] = .string(activityType.rawValue) + } + complete(.success(.object(object))) + } + + presenter.present(controller, animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + } + + public func previewQuickLook(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let urls = try sandboxURLs(arguments: arguments, context: context) + let token = UUID() + + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + let controller = QLPreviewController() + let coordinator = QuickLookCoordinator(urls: urls) { [weak self] in + self?.releaseCoordinator(token) + complete(.success(.object([ + "action": .string("dismissed"), + "count": .number(Double(urls.count)), + ]))) + } + self.retainCoordinator(coordinator, token: token) + controller.dataSource = coordinator + controller.delegate = coordinator + presenter.present(controller, animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + } + + public func captureCamera(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + #if os(iOS) + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let mediaType = arguments.string("mediaType")?.lowercased() ?? "any" + let outputDirectory = try outputDirectoryURL(from: arguments, context: context) + let token = UUID() + + let capture: CameraCaptureResult? = try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + guard UIImagePickerController.isSourceTypeAvailable(.camera) else { + complete(.failure(.unsupportedPlatform("camera.ui.capture"))) + return + } + + let presenter = try self.requirePresenter() + let controller = UIImagePickerController() + controller.sourceType = .camera + let cameraDevice = self.cameraDevice(from: arguments) + guard UIImagePickerController.isCameraDeviceAvailable(cameraDevice) else { + complete(.failure(.unsupportedPlatform("camera.ui.capture \(arguments.string("cameraDevice") ?? "rear")"))) + return + } + controller.cameraDevice = cameraDevice + controller.cameraFlashMode = self.cameraFlashMode(from: arguments) + controller.videoQuality = self.cameraVideoQuality(from: arguments) + if let maximumDurationSeconds = arguments.double("maximumDurationSeconds") { + controller.videoMaximumDuration = maximumDurationSeconds + } + let mediaTypes = self.cameraMediaTypes(for: mediaType) + guard mediaTypes.isEmpty == false else { + complete(.failure(.unsupportedPlatform("camera.ui.capture \(mediaType)"))) + return + } + controller.mediaTypes = mediaTypes + controller.allowsEditing = arguments.bool("allowsEditing") ?? false + + let coordinator = CameraCaptureCoordinator(outputDirectory: outputDirectory) { [weak self] result in + self?.releaseCoordinator(token) + complete(result) + } + self.retainCoordinator(coordinator, token: token) + controller.delegate = coordinator + presenter.present(controller, animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + + guard let capture else { + return .object(["action": .string("cancelled")]) + } + return try fileArtifactMetadata( + url: capture.url, + artifactName: capture.url.lastPathComponent, + context: context, + mediaType: capture.mediaType, + typeIdentifier: capture.typeIdentifier, + extra: ["action": .string("captured")] + ) + #else + _ = arguments + _ = context + throw BridgeError.unsupportedPlatform("camera.ui.capture") + #endif + } + + public func scanData(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + #if canImport(VisionKit) + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let token = UUID() + + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + guard DataScannerViewController.isSupported, DataScannerViewController.isAvailable else { + complete(.failure(.unsupportedPlatform("camera.ui.scanData"))) + return + } + + let presenter = try self.requirePresenter() + let controller = DataScannerViewController( + recognizedDataTypes: self.scannerRecognizedDataTypes(from: arguments), + qualityLevel: self.scannerQualityLevel(from: arguments), + recognizesMultipleItems: arguments.bool("recognizesMultipleItems") ?? false, + isHighFrameRateTrackingEnabled: arguments.bool("isHighFrameRateTrackingEnabled") ?? true, + isPinchToZoomEnabled: arguments.bool("isPinchToZoomEnabled") ?? true, + isGuidanceEnabled: arguments.bool("isGuidanceEnabled") ?? true, + isHighlightingEnabled: arguments.bool("isHighlightingEnabled") ?? true + ) + let navigation = UINavigationController(rootViewController: controller) + let coordinator = DataScannerCoordinator( + controller: controller, + navigationController: navigation, + returnsOnFirstResult: arguments.bool("returnsOnFirstResult") ?? true + ) { [weak self] result in + self?.releaseCoordinator(token) + complete(result) + } + self.retainCoordinator(coordinator, token: token) + controller.delegate = coordinator + controller.navigationItem.leftBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .cancel, + target: coordinator, + action: #selector(DataScannerCoordinator.cancel) + ) + + presenter.present(navigation, animated: true) { + do { + try controller.startScanning() + } catch let error as DataScannerViewController.ScanningUnavailable { + navigation.dismiss(animated: true) + self.releaseCoordinator(token) + complete(.failure(.unsupportedPlatform("camera.ui.scanData \(error)"))) + } catch { + navigation.dismiss(animated: true) + self.releaseCoordinator(token) + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + #else + _ = arguments + _ = context + throw BridgeError.unsupportedPlatform("camera.ui.scanData") + #endif + } + + public func composeMail(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + #if canImport(MessageUI) && os(iOS) + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let token = UUID() + + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + guard MFMailComposeViewController.canSendMail() else { + complete(.failure(.unsupportedPlatform("mail.ui.compose"))) + return + } + + let presenter = try self.requirePresenter() + let controller = MFMailComposeViewController() + controller.setToRecipients(self.stringArray(arguments, key: "to")) + controller.setCcRecipients(self.stringArray(arguments, key: "cc")) + controller.setBccRecipients(self.stringArray(arguments, key: "bcc")) + controller.setSubject(arguments.string("subject") ?? "") + controller.setMessageBody(arguments.string("body") ?? "", isHTML: arguments.bool("isHTML") ?? false) + try self.addMailAttachments(from: arguments, to: controller, context: context) + + let coordinator = MailComposeCoordinator { [weak self] result in + self?.releaseCoordinator(token) + complete(.success(.object(["action": .string(self?.mailActionString(result) ?? "unknown")]))) + } + self.retainCoordinator(coordinator, token: token) + controller.mailComposeDelegate = coordinator + presenter.present(controller, animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + #else + _ = arguments + _ = context + throw BridgeError.unsupportedPlatform("mail.ui.compose") + #endif + } + + public func composeMessage(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + #if canImport(MessageUI) && os(iOS) + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let token = UUID() + + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + guard MFMessageComposeViewController.canSendText() else { + complete(.failure(.unsupportedPlatform("messages.ui.compose"))) + return + } + + let presenter = try self.requirePresenter() + let controller = MFMessageComposeViewController() + controller.recipients = self.stringArray(arguments, key: "recipients") + controller.body = arguments.string("body") + if MFMessageComposeViewController.canSendSubject() { + controller.subject = arguments.string("subject") + } + try self.addMessageAttachments(from: arguments, to: controller, context: context) + + let coordinator = MessageComposeCoordinator { [weak self] result in + self?.releaseCoordinator(token) + complete(.success(.object(["action": .string(self?.messageActionString(result) ?? "unknown")]))) + } + self.retainCoordinator(coordinator, token: token) + controller.messageComposeDelegate = coordinator + presenter.present(controller, animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + #else + _ = arguments + _ = context + throw BridgeError.unsupportedPlatform("messages.ui.compose") + #endif + } + + public func presentPrint(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let urls = try sandboxURLs(arguments: arguments, context: context) + let token = UUID() + + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + let controller = UIPrintInteractionController.shared + let printInfo = UIPrintInfo(dictionary: nil) + printInfo.jobName = arguments.string("jobName") ?? urls.first?.lastPathComponent ?? "CodeMode Print Job" + printInfo.outputType = self.printOutputType(from: arguments) + controller.printInfo = printInfo + controller.showsNumberOfCopies = arguments.bool("showsNumberOfCopies") ?? true + if urls.count == 1 { + controller.printingItem = urls[0] + controller.printingItems = nil + } else { + controller.printingItem = nil + controller.printingItems = urls + } + + let coordinator = PrintCoordinator(parent: presenter) + self.retainCoordinator(coordinator, token: token) + controller.delegate = coordinator + + guard controller.present(animated: true, completionHandler: { [weak self] controller, completed, error in + controller.delegate = nil + self?.releaseCoordinator(token) + if let error { + complete(.failure(.nativeFailure(error.localizedDescription))) + return + } + complete(.success(.object([ + "action": .string(completed ? "completed" : "cancelled"), + "completed": .bool(completed), + ]))) + }) else { + controller.delegate = nil + self.releaseCoordinator(token) + complete(.failure(.unsupportedPlatform("print.ui.present"))) + return + } + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + } + + public func presentWeb(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let url = URL(string: arguments.string("url") ?? "")! + let token = UUID() + + #if os(iOS) + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + let configuration = SFSafariViewController.Configuration() + configuration.entersReaderIfAvailable = arguments.bool("entersReaderIfAvailable") ?? false + let controller = SFSafariViewController(url: url, configuration: configuration) + let coordinator = SafariCoordinator { [weak self] in + self?.releaseCoordinator(token) + complete(.success(.object(["action": .string("dismissed")]))) + } + self.retainCoordinator(coordinator, token: token) + controller.delegate = coordinator + presenter.present(controller, animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + #else + return try runUIOperation(timeoutMs: timeoutMs) { complete in + UIApplication.shared.open(url, options: [:]) { success in + complete(.success(.object([ + "action": .string(success ? "opened" : "failed"), + "opened": .bool(success), + ]))) + } + } + #endif + } + + public func authenticateWeb(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let url = URL(string: arguments.string("url") ?? "")! + let callbackURLScheme = arguments.string("callbackURLScheme") + let token = UUID() + + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + guard let anchor = presenter.view.window ?? UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .flatMap(\.windows) + .first(where: { $0.isKeyWindow }) + else { + complete(.failure(.uiPresenterUnavailable)) + return + } + + let coordinator = WebAuthenticationCoordinator(anchor: anchor) + let session = ASWebAuthenticationSession( + url: url, + callbackURLScheme: callbackURLScheme + ) { [weak self] callbackURL, error in + self?.releaseCoordinator(token) + if let callbackURL { + complete(.success(.object([ + "action": .string("callback"), + "callbackURL": .string(callbackURL.absoluteString), + ]))) + return + } + + if let error = error as? ASWebAuthenticationSessionError, + error.code == .canceledLogin + { + complete(.success(.object(["action": .string("cancelled")]))) + return + } + + if let error { + complete(.failure(.nativeFailure(error.localizedDescription))) + } else { + complete(.success(.object(["action": .string("cancelled")]))) + } + } + session.presentationContextProvider = coordinator + session.prefersEphemeralWebBrowserSession = arguments.bool("prefersEphemeralSession") ?? false + coordinator.session = session + self.retainCoordinator(coordinator, token: token) + + guard session.start() else { + self.releaseCoordinator(token) + complete(.failure(.nativeFailure("auth.ui.webAuthenticate could not start authentication session"))) + return + } + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + } + + public func presentAlert(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let token = UUID() + + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + let preferredStyle = arguments.string("preferredStyle")?.lowercased() + let controller = UIAlertController( + title: arguments.string("title"), + message: arguments.string("message"), + preferredStyle: preferredStyle == "actionsheet" ? .actionSheet : .alert + ) + + for (index, button) in (arguments.array("buttons") ?? []).enumerated() { + guard let object = button.objectValue else { + complete(.failure(.invalidArguments("ui.alert.present buttons must contain objects"))) + return + } + let title = object.string("title") ?? "" + let id = object.string("id") ?? title + let styleName = object.string("style")?.lowercased() ?? "default" + let actionStyle = self.alertActionStyle(styleName) + controller.addAction( + UIAlertAction(title: title, style: actionStyle) { [weak self] _ in + self?.releaseCoordinator(token) + complete(.success(.object([ + "action": .string("selected"), + "buttonID": .string(id), + "buttonTitle": .string(title), + "buttonIndex": .number(Double(index)), + "style": .string(styleName), + ]))) + } + ) + } + + controller.popoverPresentationController?.sourceView = presenter.view + controller.popoverPresentationController?.sourceRect = self.popoverSourceRect(from: arguments, presenter: presenter) + + self.retainCoordinator(controller, token: token) + presenter.present(controller, animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + } + + public func presentPrompt(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let token = UUID() + + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + let controller = UIAlertController( + title: arguments.string("title"), + message: arguments.string("message"), + preferredStyle: .alert + ) + let fields = arguments.array("fields") ?? [] + + for field in fields { + let object = field.objectValue ?? [:] + controller.addTextField { textField in + textField.placeholder = object.string("placeholder") + textField.text = object.string("text") ?? object.string("defaultValue") + textField.isSecureTextEntry = object.bool("secure") ?? false + textField.keyboardType = self.keyboardType(object.string("keyboardType")) + } + } + + for (index, button) in (arguments.array("buttons") ?? []).enumerated() { + guard let object = button.objectValue else { + complete(.failure(.invalidArguments("ui.prompt.present buttons must contain objects"))) + return + } + let title = object.string("title") ?? "" + let id = object.string("id") ?? title + let styleName = object.string("style")?.lowercased() ?? "default" + let actionStyle = self.alertActionStyle(styleName) + controller.addAction( + UIAlertAction(title: title, style: actionStyle) { [weak self, weak controller] _ in + var values: [String: JSONValue] = [:] + for (fieldIndex, field) in fields.enumerated() { + let fieldID = field.objectValue?.string("id") ?? "\(fieldIndex)" + values[fieldID] = .string(controller?.textFields?[fieldIndex].text ?? "") + } + self?.releaseCoordinator(token) + complete(.success(.object([ + "action": .string("selected"), + "buttonID": .string(id), + "buttonTitle": .string(title), + "buttonIndex": .number(Double(index)), + "style": .string(styleName), + "values": .object(values), + ]))) + } + ) + } + + self.retainCoordinator(controller, token: token) + presenter.present(controller, animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + } + + public func openSettings(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + guard let url = URL(string: UIApplication.openSettingsURLString) else { + throw BridgeError.unsupportedPlatform("settings.ui.open") + } + + return try runUIOperation(timeoutMs: timeoutMs) { complete in + UIApplication.shared.open(url, options: [:]) { success in + complete(.success(.object([ + "action": .string(success ? "opened" : "failed"), + "opened": .bool(success), + ]))) + } + } + } + +} +#endif diff --git a/Sources/CodeMode/Support/UIKitSystemUIPresenter+Documents.swift b/Sources/CodeMode/Support/UIKitSystemUIPresenter+Documents.swift new file mode 100644 index 0000000..be3e9f3 --- /dev/null +++ b/Sources/CodeMode/Support/UIKitSystemUIPresenter+Documents.swift @@ -0,0 +1,178 @@ +import Foundation + +#if canImport(UIKit) && (os(iOS) || os(visionOS)) +@preconcurrency import AuthenticationServices +@preconcurrency import Contacts +@preconcurrency import ContactsUI +@preconcurrency import EventKit +@preconcurrency import EventKitUI +@preconcurrency import Photos +@preconcurrency import PhotosUI +@preconcurrency import QuickLook +@preconcurrency import SafariServices +@preconcurrency import UIKit +@preconcurrency import UniformTypeIdentifiers +#if canImport(MessageUI) +@preconcurrency import MessageUI +#endif +#if canImport(VisionKit) +@preconcurrency import VisionKit +#endif + +extension UIKitSystemUIPresenter { + public func pickDocuments(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let outputDirectory = try outputDirectoryURL(from: arguments, context: context) + let types = try documentContentTypes(from: arguments) + let allowMultiple = arguments.bool("allowMultiple") ?? false + let token = UUID() + + let urls: [URL] = try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + let controller = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true) + controller.allowsMultipleSelection = allowMultiple + + let coordinator = DocumentPickerCoordinator { [weak self] urls in + self?.releaseCoordinator(token) + complete(.success(urls)) + } + self.retainCoordinator(coordinator, token: token) + controller.delegate = coordinator + + presenter.present(controller, animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + + return .array(try urls.enumerated().map { index, url in + try context.checkCancellation() + return try copyExternalFile( + from: url, + suggestedName: url.lastPathComponent.isEmpty ? "document-\(index)" : url.lastPathComponent, + outputDirectory: outputDirectory, + context: context + ) + }) + } + + public func exportDocuments(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let urls = try sandboxURLs(arguments: arguments, context: context) + let asCopy = arguments.bool("asCopy") ?? true + let token = UUID() + + let destinationURLs: [URL] = try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + let controller = UIDocumentPickerViewController(forExporting: urls, asCopy: asCopy) + let coordinator = DocumentPickerCoordinator { [weak self] urls in + self?.releaseCoordinator(token) + complete(.success(urls)) + } + self.retainCoordinator(coordinator, token: token) + controller.delegate = coordinator + presenter.present(controller, animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + + return .object([ + "action": .string(destinationURLs.isEmpty ? "cancelled" : "exported"), + "count": .number(Double(urls.count)), + "destinationURLs": .array(destinationURLs.map { .string($0.absoluteString) }), + ]) + } + + public func openDocument(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let url = try sandboxURL(path: arguments.string("path") ?? "", context: context) + let token = UUID() + + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + let controller = UIDocumentInteractionController(url: url) + controller.name = arguments.string("name") + controller.uti = arguments.string("uti") + + let coordinator = DocumentInteractionCoordinator(presenter: presenter) { [weak self] result in + self?.releaseCoordinator(token) + complete(.success(result)) + } + coordinator.controller = controller + self.retainCoordinator(coordinator, token: token) + controller.delegate = coordinator + + let sourceRect = CGRect( + x: presenter.view.bounds.midX, + y: presenter.view.bounds.midY, + width: 1, + height: 1 + ) + guard controller.presentOpenInMenu(from: sourceRect, in: presenter.view, animated: true) else { + self.releaseCoordinator(token) + complete(.failure(.unsupportedPlatform("documents.ui.openIn"))) + return + } + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + } + + public func scanDocuments(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + #if os(iOS) && canImport(VisionKit) + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let outputDirectory = try outputDirectoryURL(from: arguments, context: context) + let token = UUID() + + let urls: [URL] = try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + guard VNDocumentCameraViewController.isSupported else { + complete(.failure(.unsupportedPlatform("documents.ui.scan"))) + return + } + + let presenter = try self.requirePresenter() + let controller = VNDocumentCameraViewController() + let coordinator = DocumentScanCoordinator(outputDirectory: outputDirectory) { [weak self] result in + self?.releaseCoordinator(token) + complete(result) + } + self.retainCoordinator(coordinator, token: token) + controller.delegate = coordinator + presenter.present(controller, animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + + return .array(try urls.enumerated().map { index, url in + try context.checkCancellation() + return try fileArtifactMetadata( + url: url, + artifactName: url.lastPathComponent, + context: context, + extra: ["pageIndex": .number(Double(index))] + ) + }) + #else + _ = arguments + _ = context + throw BridgeError.unsupportedPlatform("documents.ui.scan") + #endif + } + +} +#endif diff --git a/Sources/CodeMode/Support/UIKitSystemUIPresenter+PhotosContacts.swift b/Sources/CodeMode/Support/UIKitSystemUIPresenter+PhotosContacts.swift new file mode 100644 index 0000000..a1ee96a --- /dev/null +++ b/Sources/CodeMode/Support/UIKitSystemUIPresenter+PhotosContacts.swift @@ -0,0 +1,222 @@ +import Foundation + +#if canImport(UIKit) && (os(iOS) || os(visionOS)) +@preconcurrency import AuthenticationServices +@preconcurrency import Contacts +@preconcurrency import ContactsUI +@preconcurrency import EventKit +@preconcurrency import EventKitUI +@preconcurrency import Photos +@preconcurrency import PhotosUI +@preconcurrency import QuickLook +@preconcurrency import SafariServices +@preconcurrency import UIKit +@preconcurrency import UniformTypeIdentifiers +#if canImport(MessageUI) +@preconcurrency import MessageUI +#endif +#if canImport(VisionKit) +@preconcurrency import VisionKit +#endif + +extension UIKitSystemUIPresenter { + public func pickPhotos(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let mediaType = arguments.string("mediaType")?.lowercased() ?? "any" + let limit = max(1, arguments.int("limit") ?? 1) + let outputDirectory = arguments.string("outputDirectory") ?? "tmp:" + let outputURL = try context.pathPolicy.resolve(path: outputDirectory) + try FileManager.default.createDirectory(at: outputURL, withIntermediateDirectories: true) + let token = UUID() + + let results: [PhotoPickerSelection] = try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + var configuration = PHPickerConfiguration() + configuration.selectionLimit = limit + switch mediaType { + case "image", "photo": + configuration.filter = .images + case "video": + configuration.filter = .videos + default: + configuration.filter = nil + } + + let controller = PHPickerViewController(configuration: configuration) + let coordinator = PhotoPickerCoordinator { [weak self] results in + self?.releaseCoordinator(token) + complete(.success(results.map { PhotoPickerSelection(result: $0) })) + } + self.retainCoordinator(coordinator, token: token) + controller.delegate = coordinator + presenter.present(controller, animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + + var exported: [JSONValue] = [] + exported.reserveCapacity(results.count) + for (index, result) in results.enumerated() { + try context.checkCancellation() + exported.append( + try exportPickerResult( + result, + index: index, + mediaType: mediaType, + outputDirectory: outputURL, + timeoutMs: timeoutMs, + context: context + ) + ) + } + return .array(exported) + } + + public func presentLimitedPhotoLibraryPicker(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let status = PHPhotoLibrary.authorizationStatus(for: .readWrite) + guard status == .limited else { + return .object([ + "action": .string("notLimited"), + "status": .string(photoAuthorizationStatusString(status)), + ]) + } + + return try runUIOperation(timeoutMs: timeoutMs) { complete in + do { + let presenter = try self.requirePresenter() + PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: presenter) { identifiers in + complete(.success(.object([ + "action": .string("completed"), + "status": .string("limited"), + "selectedIdentifiers": .array(identifiers.map(JSONValue.string)), + ]))) + } + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + } + + public func pickContacts(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let mode = arguments.string("mode")?.lowercased() ?? "single" + let displayedPropertyKeys = arguments.array("displayedPropertyKeys")?.compactMap(\.stringValue) + let token = UUID() + + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + let controller = CNContactPickerViewController() + controller.displayedPropertyKeys = displayedPropertyKeys + controller.predicateForSelectionOfContact = NSPredicate(value: true) + + if mode == "multiple" { + let coordinator = ContactMultiplePickerCoordinator { [weak self] contacts in + self?.releaseCoordinator(token) + complete(.success(.array(contacts.map { self?.mapContact($0) ?? .null }))) + } + self.retainCoordinator(coordinator, token: token) + controller.delegate = coordinator + } else { + let coordinator = ContactSinglePickerCoordinator { [weak self] contacts in + self?.releaseCoordinator(token) + complete(.success(.array(contacts.map { self?.mapContact($0) ?? .null }))) + } + self.retainCoordinator(coordinator, token: token) + controller.delegate = coordinator + } + + presenter.present(controller, animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + } + + public func presentContact(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let identifier = arguments.string("identifier") ?? "" + let displayedPropertyKeys = arguments.array("displayedPropertyKeys")?.compactMap(\.stringValue) + let token = UUID() + + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + let contact = try self.fetchContact(identifier: identifier, displayedPropertyKeys: displayedPropertyKeys) + let controller = CNContactViewController(for: contact) + controller.allowsEditing = arguments.bool("allowsEditing") ?? false + controller.allowsActions = arguments.bool("allowsActions") ?? true + controller.displayedPropertyKeys = displayedPropertyKeys + + let coordinator = ContactViewCoordinator { [weak self] contact in + var object: [String: JSONValue] = ["action": .string("dismissed")] + if let contact { + object["contact"] = self?.mapContact(contact) ?? .null + } + self?.releaseCoordinator(token) + complete(.success(.object(object))) + } + self.retainCoordinator(coordinator, token: token) + controller.delegate = coordinator + + presenter.present(UINavigationController(rootViewController: controller), animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + } + + public func presentNewContact(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let token = UUID() + + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + let contact = CNMutableContact() + contact.givenName = arguments.string("givenName") ?? "" + contact.familyName = arguments.string("familyName") ?? "" + contact.organizationName = arguments.string("organization") ?? "" + contact.phoneNumbers = (arguments.array("phoneNumbers") ?? []).compactMap(\.stringValue).map { + CNLabeledValue(label: CNLabelPhoneNumberMobile, value: CNPhoneNumber(stringValue: $0)) + } + contact.emailAddresses = (arguments.array("emailAddresses") ?? []).compactMap(\.stringValue).map { + CNLabeledValue(label: CNLabelHome, value: NSString(string: $0)) + } + + let controller = CNContactViewController(forNewContact: contact) + let coordinator = ContactViewCoordinator { [weak self] contact in + var object: [String: JSONValue] = [ + "action": .string(contact == nil ? "cancelled" : "saved"), + ] + if let contact { + object["contact"] = self?.mapContact(contact) ?? .null + } + self?.releaseCoordinator(token) + complete(.success(.object(object))) + } + self.retainCoordinator(coordinator, token: token) + controller.delegate = coordinator + + presenter.present(UINavigationController(rootViewController: controller), animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + } + +} +#endif diff --git a/Sources/CodeMode/Support/UIKitSystemUIPresenter.swift b/Sources/CodeMode/Support/UIKitSystemUIPresenter.swift index 2f57f67..660b2b1 100644 --- a/Sources/CodeMode/Support/UIKitSystemUIPresenter.swift +++ b/Sources/CodeMode/Support/UIKitSystemUIPresenter.swift @@ -20,7 +20,7 @@ import Foundation #endif public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendable { - private static let defaultTimeoutMs = 300_000 + static let defaultTimeoutMs = 300_000 private let presentingViewControllerProvider: @Sendable () -> UIViewController? private let coordinatorLock = NSLock() @@ -30,1046 +30,7 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl self.presentingViewControllerProvider = presentingViewController } - public func pickCalendar(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs - let selectionStyle = arguments.string("selectionStyle")?.lowercased() == "multiple" - ? EKCalendarChooserSelectionStyle.multiple - : EKCalendarChooserSelectionStyle.single - let displayStyle = arguments.string("displayStyle")?.lowercased() == "all" - ? EKCalendarChooserDisplayStyle.allCalendars - : EKCalendarChooserDisplayStyle.writableCalendarsOnly - let token = UUID() - - return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in - do { - let presenter = try self.requirePresenter() - let store = EKEventStore() - let controller = EKCalendarChooser( - selectionStyle: selectionStyle, - displayStyle: displayStyle, - entityType: .event, - eventStore: store - ) - controller.showsDoneButton = true - controller.showsCancelButton = true - - let coordinator = CalendarChooserCoordinator { [weak self] calendars in - self?.releaseCoordinator(token) - complete(.success(.array(calendars.map { self?.mapCalendar($0) ?? .null }))) - } - self.retainCoordinator(coordinator, token: token) - controller.delegate = coordinator - - presenter.present(UINavigationController(rootViewController: controller), animated: true) - } catch let error as BridgeError { - complete(.failure(error)) - } catch { - complete(.failure(.nativeFailure(error.localizedDescription))) - } - } - } - - public func presentCalendarEvent(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs - let identifier = arguments.string("identifier") ?? "" - let token = UUID() - - return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in - do { - let presenter = try self.requirePresenter() - let store = EKEventStore() - guard let event = store.event(withIdentifier: identifier) else { - complete(.failure(.invalidArguments("calendar.ui.presentEvent could not find event identifier \(identifier)"))) - return - } - - let controller = EKEventViewController() - controller.event = event - controller.allowsEditing = arguments.bool("allowsEditing") ?? false - controller.allowsCalendarPreview = arguments.bool("allowsCalendarPreview") ?? true - - let coordinator = CalendarEventViewCoordinator { [weak self] in - self?.releaseCoordinator(token) - complete(.success(.object(["action": .string("dismissed")]))) - } - self.retainCoordinator(coordinator, token: token) - controller.delegate = coordinator - - presenter.present(UINavigationController(rootViewController: controller), animated: true) - } catch let error as BridgeError { - complete(.failure(error)) - } catch { - complete(.failure(.nativeFailure(error.localizedDescription))) - } - } - } - - public func presentNewCalendarEvent(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - let timeoutMs = Self.defaultTimeoutMs - let formatter = ISO8601DateFormatter() - let token = UUID() - - return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in - do { - let presenter = try self.requirePresenter() - let store = EKEventStore() - let event = EKEvent(eventStore: store) - event.title = arguments.string("title") ?? "" - event.notes = arguments.string("notes") - event.location = arguments.string("location") - - let startDate = arguments.string("start").flatMap { formatter.date(from: $0) } ?? Date() - event.startDate = startDate - event.endDate = arguments.string("end").flatMap { formatter.date(from: $0) } - ?? Calendar.current.date(byAdding: .hour, value: 1, to: startDate) - ?? startDate - event.calendar = store.defaultCalendarForNewEvents - - let controller = EKEventEditViewController() - controller.eventStore = store - controller.event = event - - let coordinator = CalendarEventEditCoordinator { [weak self] action, event in - var object: [String: JSONValue] = [ - "action": .string(self?.actionString(action) ?? "unknown"), - ] - if action == .saved, let event { - object["identifier"] = .string(event.eventIdentifier ?? "") - object["title"] = .string(event.title ?? "") - } - self?.releaseCoordinator(token) - complete(.success(.object(object))) - } - self.retainCoordinator(coordinator, token: token) - controller.editViewDelegate = coordinator - presenter.present(controller, animated: true) - } catch let error as BridgeError { - complete(.failure(error)) - } catch { - complete(.failure(.nativeFailure(error.localizedDescription))) - } - } - } - - public func pickPhotos(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs - let mediaType = arguments.string("mediaType")?.lowercased() ?? "any" - let limit = max(1, arguments.int("limit") ?? 1) - let outputDirectory = arguments.string("outputDirectory") ?? "tmp:" - let outputURL = try context.pathPolicy.resolve(path: outputDirectory) - try FileManager.default.createDirectory(at: outputURL, withIntermediateDirectories: true) - let token = UUID() - - let results: [PhotoPickerSelection] = try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in - do { - let presenter = try self.requirePresenter() - var configuration = PHPickerConfiguration() - configuration.selectionLimit = limit - switch mediaType { - case "image", "photo": - configuration.filter = .images - case "video": - configuration.filter = .videos - default: - configuration.filter = nil - } - - let controller = PHPickerViewController(configuration: configuration) - let coordinator = PhotoPickerCoordinator { [weak self] results in - self?.releaseCoordinator(token) - complete(.success(results.map { PhotoPickerSelection(result: $0) })) - } - self.retainCoordinator(coordinator, token: token) - controller.delegate = coordinator - presenter.present(controller, animated: true) - } catch let error as BridgeError { - complete(.failure(error)) - } catch { - complete(.failure(.nativeFailure(error.localizedDescription))) - } - } - - var exported: [JSONValue] = [] - exported.reserveCapacity(results.count) - for (index, result) in results.enumerated() { - try context.checkCancellation() - exported.append( - try exportPickerResult( - result, - index: index, - mediaType: mediaType, - outputDirectory: outputURL, - timeoutMs: timeoutMs, - context: context - ) - ) - } - return .array(exported) - } - - public func presentLimitedPhotoLibraryPicker(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs - let status = PHPhotoLibrary.authorizationStatus(for: .readWrite) - guard status == .limited else { - return .object([ - "action": .string("notLimited"), - "status": .string(photoAuthorizationStatusString(status)), - ]) - } - - return try runUIOperation(timeoutMs: timeoutMs) { complete in - do { - let presenter = try self.requirePresenter() - PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: presenter) { identifiers in - complete(.success(.object([ - "action": .string("completed"), - "status": .string("limited"), - "selectedIdentifiers": .array(identifiers.map(JSONValue.string)), - ]))) - } - } catch let error as BridgeError { - complete(.failure(error)) - } catch { - complete(.failure(.nativeFailure(error.localizedDescription))) - } - } - } - - public func pickContacts(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs - let mode = arguments.string("mode")?.lowercased() ?? "single" - let displayedPropertyKeys = arguments.array("displayedPropertyKeys")?.compactMap(\.stringValue) - let token = UUID() - - return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in - do { - let presenter = try self.requirePresenter() - let controller = CNContactPickerViewController() - controller.displayedPropertyKeys = displayedPropertyKeys - controller.predicateForSelectionOfContact = NSPredicate(value: true) - - if mode == "multiple" { - let coordinator = ContactMultiplePickerCoordinator { [weak self] contacts in - self?.releaseCoordinator(token) - complete(.success(.array(contacts.map { self?.mapContact($0) ?? .null }))) - } - self.retainCoordinator(coordinator, token: token) - controller.delegate = coordinator - } else { - let coordinator = ContactSinglePickerCoordinator { [weak self] contacts in - self?.releaseCoordinator(token) - complete(.success(.array(contacts.map { self?.mapContact($0) ?? .null }))) - } - self.retainCoordinator(coordinator, token: token) - controller.delegate = coordinator - } - - presenter.present(controller, animated: true) - } catch let error as BridgeError { - complete(.failure(error)) - } catch { - complete(.failure(.nativeFailure(error.localizedDescription))) - } - } - } - - public func presentContact(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs - let identifier = arguments.string("identifier") ?? "" - let displayedPropertyKeys = arguments.array("displayedPropertyKeys")?.compactMap(\.stringValue) - let token = UUID() - - return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in - do { - let presenter = try self.requirePresenter() - let contact = try self.fetchContact(identifier: identifier, displayedPropertyKeys: displayedPropertyKeys) - let controller = CNContactViewController(for: contact) - controller.allowsEditing = arguments.bool("allowsEditing") ?? false - controller.allowsActions = arguments.bool("allowsActions") ?? true - controller.displayedPropertyKeys = displayedPropertyKeys - - let coordinator = ContactViewCoordinator { [weak self] contact in - var object: [String: JSONValue] = ["action": .string("dismissed")] - if let contact { - object["contact"] = self?.mapContact(contact) ?? .null - } - self?.releaseCoordinator(token) - complete(.success(.object(object))) - } - self.retainCoordinator(coordinator, token: token) - controller.delegate = coordinator - - presenter.present(UINavigationController(rootViewController: controller), animated: true) - } catch let error as BridgeError { - complete(.failure(error)) - } catch { - complete(.failure(.nativeFailure(error.localizedDescription))) - } - } - } - - public func presentNewContact(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs - let token = UUID() - - return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in - do { - let presenter = try self.requirePresenter() - let contact = CNMutableContact() - contact.givenName = arguments.string("givenName") ?? "" - contact.familyName = arguments.string("familyName") ?? "" - contact.organizationName = arguments.string("organization") ?? "" - contact.phoneNumbers = (arguments.array("phoneNumbers") ?? []).compactMap(\.stringValue).map { - CNLabeledValue(label: CNLabelPhoneNumberMobile, value: CNPhoneNumber(stringValue: $0)) - } - contact.emailAddresses = (arguments.array("emailAddresses") ?? []).compactMap(\.stringValue).map { - CNLabeledValue(label: CNLabelHome, value: NSString(string: $0)) - } - - let controller = CNContactViewController(forNewContact: contact) - let coordinator = ContactViewCoordinator { [weak self] contact in - var object: [String: JSONValue] = [ - "action": .string(contact == nil ? "cancelled" : "saved"), - ] - if let contact { - object["contact"] = self?.mapContact(contact) ?? .null - } - self?.releaseCoordinator(token) - complete(.success(.object(object))) - } - self.retainCoordinator(coordinator, token: token) - controller.delegate = coordinator - - presenter.present(UINavigationController(rootViewController: controller), animated: true) - } catch let error as BridgeError { - complete(.failure(error)) - } catch { - complete(.failure(.nativeFailure(error.localizedDescription))) - } - } - } - - public func pickDocuments(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs - let outputDirectory = try outputDirectoryURL(from: arguments, context: context) - let types = try documentContentTypes(from: arguments) - let allowMultiple = arguments.bool("allowMultiple") ?? false - let token = UUID() - - let urls: [URL] = try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in - do { - let presenter = try self.requirePresenter() - let controller = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true) - controller.allowsMultipleSelection = allowMultiple - - let coordinator = DocumentPickerCoordinator { [weak self] urls in - self?.releaseCoordinator(token) - complete(.success(urls)) - } - self.retainCoordinator(coordinator, token: token) - controller.delegate = coordinator - - presenter.present(controller, animated: true) - } catch let error as BridgeError { - complete(.failure(error)) - } catch { - complete(.failure(.nativeFailure(error.localizedDescription))) - } - } - - return .array(try urls.enumerated().map { index, url in - try context.checkCancellation() - return try copyExternalFile( - from: url, - suggestedName: url.lastPathComponent.isEmpty ? "document-\(index)" : url.lastPathComponent, - outputDirectory: outputDirectory, - context: context - ) - }) - } - - public func exportDocuments(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs - let urls = try sandboxURLs(arguments: arguments, context: context) - let asCopy = arguments.bool("asCopy") ?? true - let token = UUID() - - let destinationURLs: [URL] = try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in - do { - let presenter = try self.requirePresenter() - let controller = UIDocumentPickerViewController(forExporting: urls, asCopy: asCopy) - let coordinator = DocumentPickerCoordinator { [weak self] urls in - self?.releaseCoordinator(token) - complete(.success(urls)) - } - self.retainCoordinator(coordinator, token: token) - controller.delegate = coordinator - presenter.present(controller, animated: true) - } catch let error as BridgeError { - complete(.failure(error)) - } catch { - complete(.failure(.nativeFailure(error.localizedDescription))) - } - } - - return .object([ - "action": .string(destinationURLs.isEmpty ? "cancelled" : "exported"), - "count": .number(Double(urls.count)), - "destinationURLs": .array(destinationURLs.map { .string($0.absoluteString) }), - ]) - } - - public func openDocument(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs - let url = try sandboxURL(path: arguments.string("path") ?? "", context: context) - let token = UUID() - - return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in - do { - let presenter = try self.requirePresenter() - let controller = UIDocumentInteractionController(url: url) - controller.name = arguments.string("name") - controller.uti = arguments.string("uti") - - let coordinator = DocumentInteractionCoordinator(presenter: presenter) { [weak self] result in - self?.releaseCoordinator(token) - complete(.success(result)) - } - coordinator.controller = controller - self.retainCoordinator(coordinator, token: token) - controller.delegate = coordinator - - let sourceRect = CGRect( - x: presenter.view.bounds.midX, - y: presenter.view.bounds.midY, - width: 1, - height: 1 - ) - guard controller.presentOpenInMenu(from: sourceRect, in: presenter.view, animated: true) else { - self.releaseCoordinator(token) - complete(.failure(.unsupportedPlatform("documents.ui.openIn"))) - return - } - } catch let error as BridgeError { - complete(.failure(error)) - } catch { - complete(.failure(.nativeFailure(error.localizedDescription))) - } - } - } - - public func scanDocuments(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - #if os(iOS) && canImport(VisionKit) - let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs - let outputDirectory = try outputDirectoryURL(from: arguments, context: context) - let token = UUID() - - let urls: [URL] = try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in - do { - guard VNDocumentCameraViewController.isSupported else { - complete(.failure(.unsupportedPlatform("documents.ui.scan"))) - return - } - - let presenter = try self.requirePresenter() - let controller = VNDocumentCameraViewController() - let coordinator = DocumentScanCoordinator(outputDirectory: outputDirectory) { [weak self] result in - self?.releaseCoordinator(token) - complete(result) - } - self.retainCoordinator(coordinator, token: token) - controller.delegate = coordinator - presenter.present(controller, animated: true) - } catch let error as BridgeError { - complete(.failure(error)) - } catch { - complete(.failure(.nativeFailure(error.localizedDescription))) - } - } - - return .array(try urls.enumerated().map { index, url in - try context.checkCancellation() - return try fileArtifactMetadata( - url: url, - artifactName: url.lastPathComponent, - context: context, - extra: ["pageIndex": .number(Double(index))] - ) - }) - #else - _ = arguments - _ = context - throw BridgeError.unsupportedPlatform("documents.ui.scan") - #endif - } - - public func presentShareSheet(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs - let token = UUID() - - return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in - do { - let presenter = try self.requirePresenter() - let items = try self.shareItems(from: arguments, context: context) - let controller = UIActivityViewController(activityItems: items, applicationActivities: nil) - controller.excludedActivityTypes = (arguments.array("excludedActivityTypes") ?? []) - .compactMap(\.stringValue) - .map(UIActivity.ActivityType.init(rawValue:)) - if let subject = arguments.string("subject") { - controller.setValue(subject, forKey: "subject") - } - controller.popoverPresentationController?.sourceView = presenter.view - controller.popoverPresentationController?.sourceRect = CGRect( - x: presenter.view.bounds.midX, - y: presenter.view.bounds.midY, - width: 1, - height: 1 - ) - - self.retainCoordinator(controller, token: token) - controller.completionWithItemsHandler = { [weak self] activityType, completed, _, error in - self?.releaseCoordinator(token) - if let error { - complete(.failure(.nativeFailure(error.localizedDescription))) - return - } - var object: [String: JSONValue] = [ - "action": .string(completed ? "completed" : "cancelled"), - "completed": .bool(completed), - ] - if let activityType { - object["activityType"] = .string(activityType.rawValue) - } - complete(.success(.object(object))) - } - - presenter.present(controller, animated: true) - } catch let error as BridgeError { - complete(.failure(error)) - } catch { - complete(.failure(.nativeFailure(error.localizedDescription))) - } - } - } - - public func previewQuickLook(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs - let urls = try sandboxURLs(arguments: arguments, context: context) - let token = UUID() - - return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in - do { - let presenter = try self.requirePresenter() - let controller = QLPreviewController() - let coordinator = QuickLookCoordinator(urls: urls) { [weak self] in - self?.releaseCoordinator(token) - complete(.success(.object([ - "action": .string("dismissed"), - "count": .number(Double(urls.count)), - ]))) - } - self.retainCoordinator(coordinator, token: token) - controller.dataSource = coordinator - controller.delegate = coordinator - presenter.present(controller, animated: true) - } catch let error as BridgeError { - complete(.failure(error)) - } catch { - complete(.failure(.nativeFailure(error.localizedDescription))) - } - } - } - - public func captureCamera(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - #if os(iOS) - let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs - let mediaType = arguments.string("mediaType")?.lowercased() ?? "any" - let outputDirectory = try outputDirectoryURL(from: arguments, context: context) - let token = UUID() - - let capture: CameraCaptureResult? = try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in - do { - guard UIImagePickerController.isSourceTypeAvailable(.camera) else { - complete(.failure(.unsupportedPlatform("camera.ui.capture"))) - return - } - - let presenter = try self.requirePresenter() - let controller = UIImagePickerController() - controller.sourceType = .camera - let mediaTypes = self.cameraMediaTypes(for: mediaType) - guard mediaTypes.isEmpty == false else { - complete(.failure(.unsupportedPlatform("camera.ui.capture \(mediaType)"))) - return - } - controller.mediaTypes = mediaTypes - controller.allowsEditing = false - - let coordinator = CameraCaptureCoordinator(outputDirectory: outputDirectory) { [weak self] result in - self?.releaseCoordinator(token) - complete(result) - } - self.retainCoordinator(coordinator, token: token) - controller.delegate = coordinator - presenter.present(controller, animated: true) - } catch let error as BridgeError { - complete(.failure(error)) - } catch { - complete(.failure(.nativeFailure(error.localizedDescription))) - } - } - - guard let capture else { - return .object(["action": .string("cancelled")]) - } - return try fileArtifactMetadata( - url: capture.url, - artifactName: capture.url.lastPathComponent, - context: context, - mediaType: capture.mediaType, - typeIdentifier: capture.typeIdentifier, - extra: ["action": .string("captured")] - ) - #else - _ = arguments - _ = context - throw BridgeError.unsupportedPlatform("camera.ui.capture") - #endif - } - - public func scanData(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - #if canImport(VisionKit) - let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs - let token = UUID() - - return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in - do { - guard DataScannerViewController.isSupported, DataScannerViewController.isAvailable else { - complete(.failure(.unsupportedPlatform("camera.ui.scanData"))) - return - } - - let presenter = try self.requirePresenter() - let controller = DataScannerViewController( - recognizedDataTypes: self.scannerRecognizedDataTypes(from: arguments), - qualityLevel: self.scannerQualityLevel(from: arguments), - recognizesMultipleItems: arguments.bool("recognizesMultipleItems") ?? false, - isHighFrameRateTrackingEnabled: true, - isPinchToZoomEnabled: true, - isGuidanceEnabled: true, - isHighlightingEnabled: true - ) - let navigation = UINavigationController(rootViewController: controller) - let coordinator = DataScannerCoordinator( - controller: controller, - navigationController: navigation, - returnsOnFirstResult: arguments.bool("returnsOnFirstResult") ?? true - ) { [weak self] result in - self?.releaseCoordinator(token) - complete(result) - } - self.retainCoordinator(coordinator, token: token) - controller.delegate = coordinator - controller.navigationItem.leftBarButtonItem = UIBarButtonItem( - barButtonSystemItem: .cancel, - target: coordinator, - action: #selector(DataScannerCoordinator.cancel) - ) - - presenter.present(navigation, animated: true) { - do { - try controller.startScanning() - } catch let error as DataScannerViewController.ScanningUnavailable { - navigation.dismiss(animated: true) - self.releaseCoordinator(token) - complete(.failure(.unsupportedPlatform("camera.ui.scanData \(error)"))) - } catch { - navigation.dismiss(animated: true) - self.releaseCoordinator(token) - complete(.failure(.nativeFailure(error.localizedDescription))) - } - } - } catch let error as BridgeError { - complete(.failure(error)) - } catch { - complete(.failure(.nativeFailure(error.localizedDescription))) - } - } - #else - _ = arguments - _ = context - throw BridgeError.unsupportedPlatform("camera.ui.scanData") - #endif - } - - public func composeMail(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - #if canImport(MessageUI) && os(iOS) - let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs - let token = UUID() - - return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in - do { - guard MFMailComposeViewController.canSendMail() else { - complete(.failure(.unsupportedPlatform("mail.ui.compose"))) - return - } - - let presenter = try self.requirePresenter() - let controller = MFMailComposeViewController() - controller.setToRecipients(self.stringArray(arguments, key: "to")) - controller.setCcRecipients(self.stringArray(arguments, key: "cc")) - controller.setBccRecipients(self.stringArray(arguments, key: "bcc")) - controller.setSubject(arguments.string("subject") ?? "") - controller.setMessageBody(arguments.string("body") ?? "", isHTML: arguments.bool("isHTML") ?? false) - try self.addMailAttachments(from: arguments, to: controller, context: context) - - let coordinator = MailComposeCoordinator { [weak self] result in - self?.releaseCoordinator(token) - complete(.success(.object(["action": .string(self?.mailActionString(result) ?? "unknown")]))) - } - self.retainCoordinator(coordinator, token: token) - controller.mailComposeDelegate = coordinator - presenter.present(controller, animated: true) - } catch let error as BridgeError { - complete(.failure(error)) - } catch { - complete(.failure(.nativeFailure(error.localizedDescription))) - } - } - #else - _ = arguments - _ = context - throw BridgeError.unsupportedPlatform("mail.ui.compose") - #endif - } - - public func composeMessage(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - #if canImport(MessageUI) && os(iOS) - let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs - let token = UUID() - - return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in - do { - guard MFMessageComposeViewController.canSendText() else { - complete(.failure(.unsupportedPlatform("messages.ui.compose"))) - return - } - - let presenter = try self.requirePresenter() - let controller = MFMessageComposeViewController() - controller.recipients = self.stringArray(arguments, key: "recipients") - controller.body = arguments.string("body") - if MFMessageComposeViewController.canSendSubject() { - controller.subject = arguments.string("subject") - } - try self.addMessageAttachments(from: arguments, to: controller, context: context) - - let coordinator = MessageComposeCoordinator { [weak self] result in - self?.releaseCoordinator(token) - complete(.success(.object(["action": .string(self?.messageActionString(result) ?? "unknown")]))) - } - self.retainCoordinator(coordinator, token: token) - controller.messageComposeDelegate = coordinator - presenter.present(controller, animated: true) - } catch let error as BridgeError { - complete(.failure(error)) - } catch { - complete(.failure(.nativeFailure(error.localizedDescription))) - } - } - #else - _ = arguments - _ = context - throw BridgeError.unsupportedPlatform("messages.ui.compose") - #endif - } - - public func presentPrint(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs - let urls = try sandboxURLs(arguments: arguments, context: context) - let token = UUID() - - return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in - do { - let presenter = try self.requirePresenter() - let controller = UIPrintInteractionController.shared - let printInfo = UIPrintInfo(dictionary: nil) - printInfo.jobName = arguments.string("jobName") ?? urls.first?.lastPathComponent ?? "CodeMode Print Job" - printInfo.outputType = self.printOutputType(from: arguments) - controller.printInfo = printInfo - controller.showsNumberOfCopies = arguments.bool("showsNumberOfCopies") ?? true - if urls.count == 1 { - controller.printingItem = urls[0] - controller.printingItems = nil - } else { - controller.printingItem = nil - controller.printingItems = urls - } - - let coordinator = PrintCoordinator(parent: presenter) - self.retainCoordinator(coordinator, token: token) - controller.delegate = coordinator - - guard controller.present(animated: true, completionHandler: { [weak self] controller, completed, error in - controller.delegate = nil - self?.releaseCoordinator(token) - if let error { - complete(.failure(.nativeFailure(error.localizedDescription))) - return - } - complete(.success(.object([ - "action": .string(completed ? "completed" : "cancelled"), - "completed": .bool(completed), - ]))) - }) else { - controller.delegate = nil - self.releaseCoordinator(token) - complete(.failure(.unsupportedPlatform("print.ui.present"))) - return - } - } catch let error as BridgeError { - complete(.failure(error)) - } catch { - complete(.failure(.nativeFailure(error.localizedDescription))) - } - } - } - - public func presentWeb(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs - let url = URL(string: arguments.string("url") ?? "")! - let token = UUID() - - #if os(iOS) - return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in - do { - let presenter = try self.requirePresenter() - let configuration = SFSafariViewController.Configuration() - configuration.entersReaderIfAvailable = arguments.bool("entersReaderIfAvailable") ?? false - let controller = SFSafariViewController(url: url, configuration: configuration) - let coordinator = SafariCoordinator { [weak self] in - self?.releaseCoordinator(token) - complete(.success(.object(["action": .string("dismissed")]))) - } - self.retainCoordinator(coordinator, token: token) - controller.delegate = coordinator - presenter.present(controller, animated: true) - } catch let error as BridgeError { - complete(.failure(error)) - } catch { - complete(.failure(.nativeFailure(error.localizedDescription))) - } - } - #else - return try runUIOperation(timeoutMs: timeoutMs) { complete in - UIApplication.shared.open(url, options: [:]) { success in - complete(.success(.object([ - "action": .string(success ? "opened" : "failed"), - "opened": .bool(success), - ]))) - } - } - #endif - } - - public func authenticateWeb(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs - let url = URL(string: arguments.string("url") ?? "")! - let callbackURLScheme = arguments.string("callbackURLScheme") - let token = UUID() - - return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in - do { - let presenter = try self.requirePresenter() - guard let anchor = presenter.view.window ?? UIApplication.shared.connectedScenes - .compactMap({ $0 as? UIWindowScene }) - .flatMap(\.windows) - .first(where: { $0.isKeyWindow }) - else { - complete(.failure(.uiPresenterUnavailable)) - return - } - - let coordinator = WebAuthenticationCoordinator(anchor: anchor) - let session = ASWebAuthenticationSession( - url: url, - callbackURLScheme: callbackURLScheme - ) { [weak self] callbackURL, error in - self?.releaseCoordinator(token) - if let callbackURL { - complete(.success(.object([ - "action": .string("callback"), - "callbackURL": .string(callbackURL.absoluteString), - ]))) - return - } - - if let error = error as? ASWebAuthenticationSessionError, - error.code == .canceledLogin - { - complete(.success(.object(["action": .string("cancelled")]))) - return - } - - if let error { - complete(.failure(.nativeFailure(error.localizedDescription))) - } else { - complete(.success(.object(["action": .string("cancelled")]))) - } - } - session.presentationContextProvider = coordinator - session.prefersEphemeralWebBrowserSession = arguments.bool("prefersEphemeralSession") ?? false - coordinator.session = session - self.retainCoordinator(coordinator, token: token) - - guard session.start() else { - self.releaseCoordinator(token) - complete(.failure(.nativeFailure("auth.ui.webAuthenticate could not start authentication session"))) - return - } - } catch let error as BridgeError { - complete(.failure(error)) - } catch { - complete(.failure(.nativeFailure(error.localizedDescription))) - } - } - } - - public func presentAlert(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs - let token = UUID() - - return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in - do { - let presenter = try self.requirePresenter() - let preferredStyle = arguments.string("preferredStyle")?.lowercased() - let controller = UIAlertController( - title: arguments.string("title"), - message: arguments.string("message"), - preferredStyle: preferredStyle == "actionsheet" ? .actionSheet : .alert - ) - - for (index, button) in (arguments.array("buttons") ?? []).enumerated() { - guard let object = button.objectValue else { - complete(.failure(.invalidArguments("ui.alert.present buttons must contain objects"))) - return - } - let title = object.string("title") ?? "" - let id = object.string("id") ?? title - let styleName = object.string("style")?.lowercased() ?? "default" - let actionStyle = self.alertActionStyle(styleName) - controller.addAction( - UIAlertAction(title: title, style: actionStyle) { [weak self] _ in - self?.releaseCoordinator(token) - complete(.success(.object([ - "action": .string("selected"), - "buttonID": .string(id), - "buttonTitle": .string(title), - "buttonIndex": .number(Double(index)), - "style": .string(styleName), - ]))) - } - ) - } - - controller.popoverPresentationController?.sourceView = presenter.view - controller.popoverPresentationController?.sourceRect = CGRect( - x: presenter.view.bounds.midX, - y: presenter.view.bounds.midY, - width: 1, - height: 1 - ) - - self.retainCoordinator(controller, token: token) - presenter.present(controller, animated: true) - } catch let error as BridgeError { - complete(.failure(error)) - } catch { - complete(.failure(.nativeFailure(error.localizedDescription))) - } - } - } - - public func presentPrompt(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs - let token = UUID() - - return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in - do { - let presenter = try self.requirePresenter() - let controller = UIAlertController( - title: arguments.string("title"), - message: arguments.string("message"), - preferredStyle: .alert - ) - let fields = arguments.array("fields") ?? [] - - for field in fields { - let object = field.objectValue ?? [:] - controller.addTextField { textField in - textField.placeholder = object.string("placeholder") - textField.text = object.string("text") ?? object.string("defaultValue") - textField.isSecureTextEntry = object.bool("secure") ?? false - textField.keyboardType = self.keyboardType(object.string("keyboardType")) - } - } - - for (index, button) in (arguments.array("buttons") ?? []).enumerated() { - guard let object = button.objectValue else { - complete(.failure(.invalidArguments("ui.prompt.present buttons must contain objects"))) - return - } - let title = object.string("title") ?? "" - let id = object.string("id") ?? title - let styleName = object.string("style")?.lowercased() ?? "default" - let actionStyle = self.alertActionStyle(styleName) - controller.addAction( - UIAlertAction(title: title, style: actionStyle) { [weak self, weak controller] _ in - var values: [String: JSONValue] = [:] - for (fieldIndex, field) in fields.enumerated() { - let fieldID = field.objectValue?.string("id") ?? "\(fieldIndex)" - values[fieldID] = .string(controller?.textFields?[fieldIndex].text ?? "") - } - self?.releaseCoordinator(token) - complete(.success(.object([ - "action": .string("selected"), - "buttonID": .string(id), - "buttonTitle": .string(title), - "buttonIndex": .number(Double(index)), - "style": .string(styleName), - "values": .object(values), - ]))) - } - ) - } - - self.retainCoordinator(controller, token: token) - presenter.present(controller, animated: true) - } catch let error as BridgeError { - complete(.failure(error)) - } catch { - complete(.failure(.nativeFailure(error.localizedDescription))) - } - } - } - - public func openSettings(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs - guard let url = URL(string: UIApplication.openSettingsURLString) else { - throw BridgeError.unsupportedPlatform("settings.ui.open") - } - - return try runUIOperation(timeoutMs: timeoutMs) { complete in - UIApplication.shared.open(url, options: [:]) { success in - complete(.success(.object([ - "action": .string(success ? "opened" : "failed"), - "opened": .bool(success), - ]))) - } - } - } - - private func runUIOperation( + func runUIOperation( timeoutMs: Int, onTimeout: (@Sendable () -> Void)? = nil, start: @escaping @MainActor @Sendable (@escaping @Sendable (Result) -> Void) -> Void @@ -1099,14 +60,14 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl return try result.get() } - @MainActor private func requirePresenter() throws -> UIViewController { + @MainActor func requirePresenter() throws -> UIViewController { guard let presenter = presentingViewControllerProvider() else { throw BridgeError.uiPresenterUnavailable } return topMostPresenter(from: presenter) } - @MainActor private func topMostPresenter(from root: UIViewController) -> UIViewController { + @MainActor func topMostPresenter(from root: UIViewController) -> UIViewController { var current = root while let presented = current.presentedViewController { current = presented @@ -1114,19 +75,19 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl return current } - private func retainCoordinator(_ coordinator: AnyObject, token: UUID) { + func retainCoordinator(_ coordinator: AnyObject, token: UUID) { coordinatorLock.lock() retainedCoordinators[token] = coordinator coordinatorLock.unlock() } - private func releaseCoordinator(_ token: UUID) { + func releaseCoordinator(_ token: UUID) { coordinatorLock.lock() retainedCoordinators[token] = nil coordinatorLock.unlock() } - private func actionString(_ action: EKEventEditViewAction) -> String { + func actionString(_ action: EKEventEditViewAction) -> String { switch action { case .canceled: return "cancelled" @@ -1139,7 +100,7 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl } } - @MainActor private func alertActionStyle(_ styleName: String) -> UIAlertAction.Style { + @MainActor func alertActionStyle(_ styleName: String) -> UIAlertAction.Style { switch styleName { case "cancel": return .cancel @@ -1150,7 +111,7 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl } } - @MainActor private func keyboardType(_ name: String?) -> UIKeyboardType { + @MainActor func keyboardType(_ name: String?) -> UIKeyboardType { switch name?.lowercased() { case "email": return .emailAddress @@ -1165,7 +126,7 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl } } - @MainActor private func printOutputType(from arguments: [String: JSONValue]) -> UIPrintInfo.OutputType { + @MainActor func printOutputType(from arguments: [String: JSONValue]) -> UIPrintInfo.OutputType { switch arguments.string("outputType")?.lowercased() { case "photo": return .photo @@ -1176,7 +137,25 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl } } - private func photoAuthorizationStatusString(_ status: PHAuthorizationStatus) -> String { + @MainActor func popoverSourceRect(from arguments: [String: JSONValue], presenter: UIViewController) -> CGRect { + if let sourceRect = arguments.object("sourceRect"), + let x = sourceRect.double("x"), + let y = sourceRect.double("y"), + let width = sourceRect.double("width"), + let height = sourceRect.double("height") + { + return CGRect(x: x, y: y, width: width, height: height) + } + + return CGRect( + x: presenter.view.bounds.midX, + y: presenter.view.bounds.midY, + width: 1, + height: 1 + ) + } + + func photoAuthorizationStatusString(_ status: PHAuthorizationStatus) -> String { switch status { case .authorized: return "authorized" @@ -1194,7 +173,7 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl } #if canImport(VisionKit) - @MainActor private func scannerRecognizedDataTypes(from arguments: [String: JSONValue]) -> Set { + @MainActor func scannerRecognizedDataTypes(from arguments: [String: JSONValue]) -> Set { let languages = (arguments.array("languages") ?? []).compactMap(\.stringValue) let requested = (arguments.array("recognizedDataTypes") ?? []).compactMap { $0.stringValue?.lowercased() } let types = requested.isEmpty ? [arguments.string("mode")?.lowercased() ?? "any"] : requested @@ -1212,7 +191,7 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl return recognized } - @MainActor private func scannerQualityLevel(from arguments: [String: JSONValue]) -> DataScannerViewController.QualityLevel { + @MainActor func scannerQualityLevel(from arguments: [String: JSONValue]) -> DataScannerViewController.QualityLevel { switch arguments.string("qualityLevel")?.lowercased() { case "fast": return .fast @@ -1224,7 +203,7 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl } #endif - private func exportPickerResult( + func exportPickerResult( _ result: PhotoPickerSelection, index: Int, mediaType: String, @@ -1289,7 +268,7 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl return .object(object) } - private func preferredTypeIdentifier(for itemProvider: NSItemProvider, mediaType: String) -> String? { + func preferredTypeIdentifier(for itemProvider: NSItemProvider, mediaType: String) -> String? { let identifiers = itemProvider.registeredTypeIdentifiers let preferredTypes: [UTType] switch mediaType { @@ -1310,12 +289,12 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl return identifiers.first } - private func outputFileName(for typeIdentifier: String) -> String { + func outputFileName(for typeIdentifier: String) -> String { let ext = UTType(typeIdentifier)?.preferredFilenameExtension ?? "bin" return "picked-\(UUID().uuidString).\(ext)" } - private func mediaTypeString(for typeIdentifier: String) -> String { + func mediaTypeString(for typeIdentifier: String) -> String { guard let type = UTType(typeIdentifier) else { return "unknown" } @@ -1328,7 +307,7 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl return "unknown" } - private func mapCalendar(_ calendar: EKCalendar) -> JSONValue { + func mapCalendar(_ calendar: EKCalendar) -> JSONValue { .object([ "identifier": .string(calendar.calendarIdentifier), "title": .string(calendar.title), @@ -1337,7 +316,7 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl ]) } - private func calendarTypeString(_ type: EKCalendarType) -> String { + func calendarTypeString(_ type: EKCalendarType) -> String { switch type { case .local: return "local" @@ -1354,7 +333,7 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl } } - private func mapContact(_ contact: CNContact) -> JSONValue { + func mapContact(_ contact: CNContact) -> JSONValue { .object([ "identifier": .string(contact.identifier), "givenName": .string(availableString(CNContactGivenNameKey, contact: contact) { $0.givenName }), @@ -1365,7 +344,7 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl ]) } - @MainActor private func fetchContact(identifier: String, displayedPropertyKeys: [String]?) throws -> CNContact { + @MainActor func fetchContact(identifier: String, displayedPropertyKeys: [String]?) throws -> CNContact { let store = CNContactStore() var keys: [CNKeyDescriptor] = [ CNContactViewController.descriptorForRequiredKeys(), @@ -1379,14 +358,14 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl return try store.unifiedContact(withIdentifier: identifier, keysToFetch: keys) } - private func outputDirectoryURL(from arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> URL { + func outputDirectoryURL(from arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> URL { let outputDirectory = arguments.string("outputDirectory") ?? "tmp:" let outputURL = try context.pathPolicy.resolve(path: outputDirectory) try FileManager.default.createDirectory(at: outputURL, withIntermediateDirectories: true) return outputURL } - private func documentContentTypes(from arguments: [String: JSONValue]) throws -> [UTType] { + func documentContentTypes(from arguments: [String: JSONValue]) throws -> [UTType] { guard let identifiers = arguments.array("contentTypes")?.compactMap(\.stringValue), identifiers.isEmpty == false else { return [.item] } @@ -1399,7 +378,7 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl } } - private func copyExternalFile( + func copyExternalFile( from sourceURL: URL, suggestedName: String, outputDirectory: URL, @@ -1422,7 +401,7 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl ) } - private func fileArtifactMetadata( + func fileArtifactMetadata( url: URL, artifactName: String, context: BridgeInvocationContext, @@ -1458,14 +437,14 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl return .object(object) } - private func typeIdentifier(for url: URL) -> String? { + func typeIdentifier(for url: URL) -> String? { if let resourceType = try? url.resourceValues(forKeys: [.contentTypeKey]).contentType { return resourceType.identifier } return UTType(filenameExtension: url.pathExtension)?.identifier } - private func uniqueOutputURL(in directory: URL, suggestedName: String) -> URL { + func uniqueOutputURL(in directory: URL, suggestedName: String) -> URL { let cleanName = suggestedName.isEmpty ? "artifact-\(UUID().uuidString)" : suggestedName let initial = directory.appendingPathComponent(cleanName) guard FileManager.default.fileExists(atPath: initial.path) else { @@ -1478,7 +457,7 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl return directory.appendingPathComponent(name) } - private func sandboxURLs(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> [URL] { + func sandboxURLs(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> [URL] { var paths: [String] = [] if let path = arguments.string("path") { paths.append(path) @@ -1487,11 +466,11 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl return try paths.map { try context.pathPolicy.resolve(path: $0) } } - private func sandboxURL(path: String, context: BridgeInvocationContext) throws -> URL { + func sandboxURL(path: String, context: BridgeInvocationContext) throws -> URL { try context.pathPolicy.resolve(path: path) } - @MainActor private func shareItems(from arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> [Any] { + @MainActor func shareItems(from arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> [Any] { var items: [Any] = [] if let text = arguments.string("text") { items.append(text) @@ -1510,7 +489,7 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl return items } - private func stringArray(_ arguments: [String: JSONValue], key: String) -> [String]? { + func stringArray(_ arguments: [String: JSONValue], key: String) -> [String]? { guard let values = arguments.array(key) else { return nil } @@ -1518,7 +497,7 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl } #if os(iOS) - @MainActor private func cameraMediaTypes(for mediaType: String) -> [String] { + @MainActor func cameraMediaTypes(for mediaType: String) -> [String] { let available = UIImagePickerController.availableMediaTypes(for: .camera) ?? [UTType.image.identifier] switch mediaType { case "image", "photo": @@ -1529,10 +508,47 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl return available } } + + @MainActor func cameraDevice(from arguments: [String: JSONValue]) -> UIImagePickerController.CameraDevice { + switch arguments.string("cameraDevice")?.lowercased() { + case "front": + return .front + default: + return .rear + } + } + + @MainActor func cameraFlashMode(from arguments: [String: JSONValue]) -> UIImagePickerController.CameraFlashMode { + switch arguments.string("flashMode")?.lowercased() { + case "on": + return .on + case "off": + return .off + default: + return .auto + } + } + + @MainActor func cameraVideoQuality(from arguments: [String: JSONValue]) -> UIImagePickerController.QualityType { + switch arguments.string("videoQuality")?.lowercased() { + case "medium": + return .typeMedium + case "low": + return .typeLow + case "640x480": + return .type640x480 + case "iframe1280x720": + return .typeIFrame1280x720 + case "iframe960x540": + return .typeIFrame960x540 + default: + return .typeHigh + } + } #endif #if canImport(MessageUI) && os(iOS) - @MainActor private func addMailAttachments( + @MainActor func addMailAttachments( from arguments: [String: JSONValue], to controller: MFMailComposeViewController, context: BridgeInvocationContext @@ -1549,7 +565,7 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl } } - @MainActor private func addMessageAttachments( + @MainActor func addMessageAttachments( from arguments: [String: JSONValue], to controller: MFMessageComposeViewController, context: BridgeInvocationContext @@ -1566,7 +582,7 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl } } - private func mailActionString(_ result: MFMailComposeResult) -> String { + func mailActionString(_ result: MFMailComposeResult) -> String { switch result { case .cancelled: return "cancelled" @@ -1581,7 +597,7 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl } } - private func messageActionString(_ result: MessageComposeResult) -> String { + func messageActionString(_ result: MessageComposeResult) -> String { switch result { case .cancelled: return "cancelled" @@ -1595,18 +611,18 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl } #endif - private func availableString(_ key: String, contact: CNContact, read: (CNContact) -> String) -> String { + func availableString(_ key: String, contact: CNContact, read: (CNContact) -> String) -> String { contact.isKeyAvailable(key) ? read(contact) : "" } - private func availablePhoneNumbers(_ contact: CNContact) -> [JSONValue] { + func availablePhoneNumbers(_ contact: CNContact) -> [JSONValue] { guard contact.isKeyAvailable(CNContactPhoneNumbersKey) else { return [] } return contact.phoneNumbers.map { .string($0.value.stringValue) } } - private func availableEmailAddresses(_ contact: CNContact) -> [JSONValue] { + func availableEmailAddresses(_ contact: CNContact) -> [JSONValue] { guard contact.isKeyAvailable(CNContactEmailAddressesKey) else { return [] } diff --git a/Sources/CodeModeEvaluation/EvalModels.swift b/Sources/CodeModeEvaluation/EvalModels.swift index 6c7de3c..b8ae289 100644 --- a/Sources/CodeModeEvaluation/EvalModels.swift +++ b/Sources/CodeModeEvaluation/EvalModels.swift @@ -10,11 +10,33 @@ public struct CodeModeEvalToolCall: Codable, Sendable, Equatable { public var tool: CodeModeEvalToolName public var code: String public var allowedCapabilities: [CapabilityID] + public var allowedCapabilityKeys: [CodeModeCapabilityKey] - public init(tool: CodeModeEvalToolName, code: String, allowedCapabilities: [CapabilityID] = []) { + private enum CodingKeys: String, CodingKey { + case tool + case code + case allowedCapabilities + case allowedCapabilityKeys + } + + public init( + tool: CodeModeEvalToolName, + code: String, + allowedCapabilities: [CapabilityID] = [], + allowedCapabilityKeys: [CodeModeCapabilityKey] = [] + ) { self.tool = tool self.code = code self.allowedCapabilities = allowedCapabilities + self.allowedCapabilityKeys = allowedCapabilityKeys + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.tool = try container.decode(CodeModeEvalToolName.self, forKey: .tool) + self.code = try container.decode(String.self, forKey: .code) + self.allowedCapabilities = try container.decodeIfPresent([CapabilityID].self, forKey: .allowedCapabilities) ?? [] + self.allowedCapabilityKeys = try container.decodeIfPresent([CodeModeCapabilityKey].self, forKey: .allowedCapabilityKeys) ?? [] } } diff --git a/Sources/CodeModeEvaluation/EvalScenarios.swift b/Sources/CodeModeEvaluation/EvalScenarios.swift index 396e419..091e965 100644 --- a/Sources/CodeModeEvaluation/EvalScenarios.swift +++ b/Sources/CodeModeEvaluation/EvalScenarios.swift @@ -28,6 +28,29 @@ public enum CodeModeEvalScenarios { catalogIOSOnlySystemUIDiscovery, contactsPermissionDenied, weatherArgumentValidation, + keychainRoundTrip, + notificationsPermissionRequest, + locationPermissionStatus, + networkInvalidURL, + calendarLifecycleCatalogDiscovery, + networkBase64TimeoutCatalogDiscovery, + notificationsDeliveredContentCatalogDiscovery, + systemUIParameterCatalogDiscovery, + cloudKitBigTicketCatalogDiscovery, + notificationsRemoteCatalogDiscovery, + speechBigTicketCatalogDiscovery, + mapsBigTicketCatalogDiscovery, + foundationModelsAppIntentsActivityCatalogDiscovery, + walletMusicStoreKitSafetyCatalogDiscovery, + cloudKitInvalidDatabaseValidation, + mapsInvalidTransportValidation, + storeKitEmptyProductIDsValidation, + notificationsMalformedCategoriesValidation, + activityInvalidDismissalPolicyValidation, + musicInvalidPlaybackActionValidation, + calendarWritePermissionDenied, + homeWriteValidation, + mediaMetadataValidation, badFileSystemHelperSuggestion, ] @@ -483,7 +506,7 @@ public enum CodeModeEvalScenarios { public static let reminderCatalogDiscovery = CodeModeEvalScenario( id: "catalog.reminder-create", title: "Reminder helper discovery", - task: "Search the catalog for the JavaScript helper and capability used to create reminders. Return the capability, first JS name, and required arguments.", + task: "Search the catalog for the JavaScript helper and capability used to create reminders. Return the capability, first JS name, optional arguments, and argument hints.", searchCode: """ async () => { const wanted = ["reminders", "create"]; @@ -501,7 +524,8 @@ public enum CodeModeEvalScenarios { .map(ref => ({ capability: ref.capability, jsName: ref.jsNames[0], - requiredArguments: ref.requiredArguments + optionalArguments: ref.optionalArguments, + argumentHints: ref.argumentHints })); } """, @@ -694,6 +718,8 @@ public enum CodeModeEvalScenarios { return [name, ref ? { capability: ref.capability, jsNames: ref.jsNames, + summary: ref.summary, + tags: ref.tags, requiredArguments: ref.requiredArguments, optionalArguments: ref.optionalArguments, argumentHints: ref.argumentHints, @@ -743,6 +769,8 @@ public enum CodeModeEvalScenarios { return [name, ref ? { capability: ref.capability, jsNames: ref.jsNames, + summary: ref.summary, + tags: ref.tags, requiredArguments: ref.requiredArguments, optionalArguments: ref.optionalArguments, argumentHints: ref.argumentHints, @@ -793,6 +821,8 @@ public enum CodeModeEvalScenarios { return [name, ref ? { capability: ref.capability, jsNames: ref.jsNames, + summary: ref.summary, + tags: ref.tags, requiredArguments: ref.requiredArguments, optionalArguments: ref.optionalArguments, argumentHints: ref.argumentHints, @@ -836,6 +866,8 @@ public enum CodeModeEvalScenarios { return [name, ref ? { capability: ref.capability, jsNames: ref.jsNames, + summary: ref.summary, + tags: ref.tags, requiredArguments: ref.requiredArguments, optionalArguments: ref.optionalArguments, argumentHints: ref.argumentHints, @@ -911,6 +943,833 @@ public enum CodeModeEvalScenarios { ) ) + public static let keychainRoundTrip = CodeModeEvalScenario( + id: "keychain.round-trip", + title: "Keychain round trip", + task: "First search for the keychain get, set, and delete helpers. Then write the exact value \"eval-secret\" to a temporary keychain key, read it back, delete it, read the key again, and return exactly { value, missing } where value is the string you read before deletion and missing is the post-delete read result, which should be null.", + searchCode: """ + async () => { + return { + get: api.byJSName["apple.keychain.get"], + set: api.byJSName["apple.keychain.set"], + delete: api.byJSName["apple.keychain.delete"] + }; + } + """, + executeCode: """ + const key = "codemode-eval-keychain-" + String(Date.now()); + await apple.keychain.set(key, "eval-secret"); + const read = await apple.keychain.get(key); + await apple.keychain.delete(key); + const missing = await apple.keychain.get(key); + return { + value: read ? read.value : null, + missing + }; + """, + allowedCapabilities: [.keychainWrite, .keychainRead, .keychainDelete], + expectation: CodeModeEvalExpectation( + toolOrder: [.searchJavaScriptAPI, .executeJavaScript], + exactAllowedCapabilities: [.keychainWrite, .keychainRead, .keychainDelete], + requiredSearchResultFragments: [ + "keychain.read", + "keychain.write", + "keychain.delete", + "apple.keychain.get", + "apple.keychain.set", + "apple.keychain.delete", + ], + requiredExecuteCodeFragments: [ + "apple.keychain.set", + "apple.keychain.get", + "apple.keychain.delete", + ], + expectedOutput: .object([ + "missing": .null, + "value": .string("eval-secret"), + ]) + ) + ) + + public static let notificationsPermissionRequest = CodeModeEvalScenario( + id: "notifications.permission-request", + title: "Notifications permission request", + task: "First search for apple.notifications.requestPermission, then request notification permission and return the status payload.", + searchCode: """ + async () => { + return api.byJSName["apple.notifications.requestPermission"]; + } + """, + executeCode: """ + return await apple.notifications.requestPermission(); + """, + allowedCapabilities: [.notificationsPermissionRequest], + permissions: CodeModeEvalPermissions( + statuses: [.notifications: .notDetermined], + requestStatuses: [.notifications: .granted] + ), + expectation: CodeModeEvalExpectation( + toolOrder: [.searchJavaScriptAPI, .executeJavaScript], + exactAllowedCapabilities: [.notificationsPermissionRequest], + requiredSearchResultFragments: [ + "notifications.permission.request", + "apple.notifications.requestPermission", + ], + requiredExecuteCodeFragments: ["apple.notifications.requestPermission"], + expectedOutput: .object([ + "granted": .bool(true), + "status": .string("granted"), + ]) + ) + ) + + public static let locationPermissionStatus = CodeModeEvalScenario( + id: "location.permission-status", + title: "Location permission status", + task: "First search for apple.location.getPermissionStatus, then read the current location permission status without requesting location coordinates and return exactly { status }.", + searchCode: """ + async () => { + return api.byJSName["apple.location.getPermissionStatus"]; + } + """, + executeCode: """ + const status = await apple.location.getPermissionStatus(); + return { status }; + """, + allowedCapabilities: [.locationRead], + permissions: CodeModeEvalPermissions(statuses: [.locationWhenInUse: .restricted]), + expectation: CodeModeEvalExpectation( + toolOrder: [.searchJavaScriptAPI, .executeJavaScript], + exactAllowedCapabilities: [.locationRead], + forbiddenCapabilities: [.locationPermissionRequest], + requiredSearchResultFragments: [ + "location.read", + "apple.location.getPermissionStatus", + ], + requiredExecuteCodeFragments: ["apple.location.getPermissionStatus"], + expectedOutput: .object(["status": .string("restricted")]) + ) + ) + + public static let networkInvalidURL = CodeModeEvalScenario( + id: "network.invalid-url", + title: "Network invalid URL", + task: "First search for fetch. Then call fetch with the invalid URL string \"http://%zz\" and let executeJavaScript surface the structured invalid-arguments error.", + searchCode: """ + async () => { + return api.byJSName["fetch"]; + } + """, + executeCode: """ + return await fetch("http://%zz"); + """, + allowedCapabilities: [.networkFetch], + expectation: CodeModeEvalExpectation( + toolOrder: [.searchJavaScriptAPI, .executeJavaScript], + exactAllowedCapabilities: [.networkFetch], + requiredSearchResultFragments: ["network.fetch", "fetch"], + requiredErrorSuggestionFragments: ["url", "Example:"], + requiredExecuteCodeFragments: ["fetch", "http://%zz"], + expectedErrorCode: "INVALID_ARGUMENTS" + ) + ) + + public static let calendarLifecycleCatalogDiscovery = CodeModeEvalScenario( + id: "calendar.lifecycle-catalog", + title: "EventKit lifecycle catalog discovery", + task: "Search for calendar and reminders lifecycle helpers. Return each capability, JavaScript name, optional arguments, argument hints, and result summary, including create/update/delete helpers and calendar filtering arguments.", + searchCode: """ + async () => { + const names = [ + "apple.calendar.listEvents", + "apple.calendar.createEvent", + "apple.calendar.updateEvent", + "apple.calendar.deleteEvent", + "apple.reminders.listReminders", + "apple.reminders.createReminder", + "apple.reminders.updateReminder", + "apple.reminders.completeReminder", + "apple.reminders.deleteReminder" + ]; + return Object.fromEntries(names.map(name => { + const ref = api.byJSName[name]; + return [name, ref ? { + capability: ref.capability, + jsNames: ref.jsNames, + summary: ref.summary, + tags: ref.tags, + requiredArguments: ref.requiredArguments, + optionalArguments: ref.optionalArguments, + argumentHints: ref.argumentHints, + resultSummary: ref.resultSummary + } : null]; + })); + } + """, + expectation: CodeModeEvalExpectation( + toolOrder: [.searchJavaScriptAPI], + requiredSearchResultFragments: [ + "apple.calendar.deleteEvent", + "apple.calendar.updateEvent", + "apple.reminders.completeReminder", + "apple.reminders.deleteReminder", + "apple.reminders.updateReminder", + "calendar.delete", + "calendar.write", + "calendarIdentifier", + "calendarIdentifiers", + "includeCompleted", + "isAllDay", + "isCompleted", + "reminders.delete", + "span", + ] + ) + ) + + public static let networkBase64TimeoutCatalogDiscovery = CodeModeEvalScenario( + id: "network.base64-timeout-catalog", + title: "Network base64 and timeout catalog discovery", + task: "Search for fetch and return the network.fetch JavaScript name, arguments, hints, and result summary. The result must include timeoutMs, bodyBase64, responseEncoding, and base64 response support.", + searchCode: """ + async () => { + const ref = api.byJSName["fetch"]; + return { + capability: ref.capability, + jsNames: ref.jsNames, + requiredArguments: ref.requiredArguments, + optionalArguments: ref.optionalArguments, + argumentHints: ref.argumentHints, + resultSummary: ref.resultSummary + }; + } + """, + expectation: CodeModeEvalExpectation( + toolOrder: [.searchJavaScriptAPI], + requiredSearchResultFragments: [ + "network.fetch", + "fetch", + "options.bodyBase64", + "options.responseEncoding", + "options.timeoutMs", + "base64", + "HTTP(S)", + ] + ) + ) + + public static let notificationsDeliveredContentCatalogDiscovery = CodeModeEvalScenario( + id: "notifications.delivered-content-catalog", + title: "Notifications delivered/content catalog discovery", + task: "Search for notification scheduling, pending, and delivered helpers. Return each capability, JavaScript name, arguments, hints, and result summary, including richer schedule content fields and delivered-notification management.", + searchCode: """ + async () => { + const names = [ + "apple.notifications.schedule", + "apple.notifications.listPending", + "apple.notifications.cancelPending", + "apple.notifications.listDelivered", + "apple.notifications.removeDelivered" + ]; + return Object.fromEntries(names.map(name => { + const ref = api.byJSName[name]; + return [name, ref ? { + capability: ref.capability, + jsNames: ref.jsNames, + summary: ref.summary, + tags: ref.tags, + requiredArguments: ref.requiredArguments, + optionalArguments: ref.optionalArguments, + argumentHints: ref.argumentHints, + resultSummary: ref.resultSummary + } : null]; + })); + } + """, + expectation: CodeModeEvalExpectation( + toolOrder: [.searchJavaScriptAPI], + requiredSearchResultFragments: [ + "apple.notifications.listDelivered", + "apple.notifications.removeDelivered", + "badge", + "categoryIdentifier", + "notifications.delivered.delete", + "notifications.delivered.read", + "sound", + "threadIdentifier", + "userInfo", + ] + ) + ) + + public static let systemUIParameterCatalogDiscovery = CodeModeEvalScenario( + id: "system-ui.parameter-catalog", + title: "UIKit parameter catalog discovery", + task: "Search the iOS system UI catalog for calendar editor, camera capture, live data scanner, and alert helpers. Return arguments, hints, and result summaries that expose the new timeout/sourceRect/camera/scanner parameters.", + catalogPlatform: .iOS, + searchCode: """ + async () => { + const names = [ + "apple.calendar.presentNewEvent", + "apple.camera.capture", + "apple.camera.scanData", + "apple.ui.presentAlert" + ]; + return Object.fromEntries(names.map(name => { + const ref = api.byJSName[name]; + return [name, ref ? { + capability: ref.capability, + jsNames: ref.jsNames, + summary: ref.summary, + tags: ref.tags, + requiredArguments: ref.requiredArguments, + optionalArguments: ref.optionalArguments, + argumentHints: ref.argumentHints, + resultSummary: ref.resultSummary + } : null]; + })); + } + """, + expectation: CodeModeEvalExpectation( + toolOrder: [.searchJavaScriptAPI], + requiredSearchResultFragments: [ + "allowsEditing", + "apple.calendar.presentNewEvent", + "apple.camera.capture", + "apple.camera.scanData", + "apple.ui.presentAlert", + "cameraDevice", + "flashMode", + "isGuidanceEnabled", + "isHighFrameRateTrackingEnabled", + "isHighlightingEnabled", + "isPinchToZoomEnabled", + "maximumDurationSeconds", + "sourceRect", + "timeoutMs", + "videoQuality", + ] + ) + ) + + public static let cloudKitBigTicketCatalogDiscovery = CodeModeEvalScenario( + id: "cloudkit.big-ticket-catalog", + title: "CloudKit serverless catalog discovery", + task: "Search for CloudKit account, query, write, delete, subscription, and subscription-inbox helpers. Return capability names, JavaScript names, arguments, hints, and result summaries for serverless synced state.", + searchCode: """ + async () => { + const names = [ + "apple.cloudkit.getAccountStatus", + "apple.cloudkit.queryRecords", + "apple.cloudkit.saveRecord", + "apple.cloudkit.deleteRecord", + "apple.cloudkit.subscribe", + "apple.cloudkit.listEvents" + ]; + return Object.fromEntries(names.map(name => { + const ref = api.byJSName[name]; + return [name, ref ? { + capability: ref.capability, + jsNames: ref.jsNames, + requiredArguments: ref.requiredArguments, + optionalArguments: ref.optionalArguments, + argumentHints: ref.argumentHints, + resultSummary: ref.resultSummary + } : null]; + })); + } + """, + expectation: CodeModeEvalExpectation( + toolOrder: [.searchJavaScriptAPI], + requiredSearchResultFragments: [ + "apple.cloudkit.queryRecords", + "apple.cloudkit.saveRecord", + "apple.cloudkit.subscribe", + "cloudkit.records.query", + "cloudkit.record.save", + "cloudkit.subscription.save", + "cloudkit.subscriptionEvents.read", + "containerIdentifier", + "database", + "private", + "public", + "shared", + "recordType", + "inbox", + ] + ) + ) + + public static let notificationsRemoteCatalogDiscovery = CodeModeEvalScenario( + id: "notifications.remote-catalog", + title: "APNs remote notification catalog discovery", + task: "Search for client-side APNs registration, token, settings, categories/actions, and response inbox helpers. Return arguments, hints, and result summaries; do not include APNs provider-send APIs.", + searchCode: """ + async () => { + const names = [ + "apple.notifications.registerRemote", + "apple.notifications.getRemoteToken", + "apple.notifications.getSettings", + "apple.notifications.setCategories", + "apple.notifications.listResponses" + ]; + return Object.fromEntries(names.map(name => { + const ref = api.byJSName[name]; + return [name, ref ? { + capability: ref.capability, + jsNames: ref.jsNames, + summary: ref.summary, + tags: ref.tags, + requiredArguments: ref.requiredArguments, + optionalArguments: ref.optionalArguments, + argumentHints: ref.argumentHints, + resultSummary: ref.resultSummary + } : null]; + })); + } + """, + expectation: CodeModeEvalExpectation( + toolOrder: [.searchJavaScriptAPI], + requiredSearchResultFragments: [ + "APNs", + "apple.notifications.registerRemote", + "apple.notifications.getRemoteToken", + "apple.notifications.getSettings", + "apple.notifications.setCategories", + "apple.notifications.listResponses", + "notifications.remote.register", + "notifications.remote.token.read", + "notifications.settings.read", + "notifications.categories.set", + "notifications.responses.read", + "categories", + "actionIdentifier", + "inbox", + ] + ) + ) + + public static let speechBigTicketCatalogDiscovery = CodeModeEvalScenario( + id: "speech.big-ticket-catalog", + title: "Speech transcription catalog discovery", + task: "Search for Speech permission/status, file transcription, and microphone transcription helpers. Return arguments, permissions, hints, and result summaries including timeout and locale options.", + searchCode: """ + async () => { + const names = [ + "apple.speech.requestPermission", + "apple.speech.getStatus", + "apple.speech.transcribeFile", + "apple.speech.transcribeMicrophone" + ]; + return Object.fromEntries(names.map(name => { + const ref = api.byJSName[name]; + return [name, ref ? { + capability: ref.capability, + jsNames: ref.jsNames, + requiredArguments: ref.requiredArguments, + optionalArguments: ref.optionalArguments, + argumentHints: ref.argumentHints, + resultSummary: ref.resultSummary + } : null]; + })); + } + """, + expectation: CodeModeEvalExpectation( + toolOrder: [.searchJavaScriptAPI], + requiredSearchResultFragments: [ + "apple.speech.requestPermission", + "apple.speech.transcribeFile", + "apple.speech.transcribeMicrophone", + "speech.file.transcribe", + "speech.microphone.transcribe", + "locale", + "microphone", + "requiresOnDeviceRecognition", + "timeoutMs", + "transcript", + ] + ) + ) + + public static let mapsBigTicketCatalogDiscovery = CodeModeEvalScenario( + id: "maps.big-ticket-catalog", + title: "MapKit catalog discovery", + task: "Search for MapKit geocode, reverse-geocode, local search, route estimate, and open-Maps helpers. Return arguments, hints, and result summaries.", + searchCode: """ + async () => { + const names = [ + "apple.maps.geocode", + "apple.maps.reverseGeocode", + "apple.maps.search", + "apple.maps.routeEstimate", + "apple.maps.open" + ]; + return Object.fromEntries(names.map(name => { + const ref = api.byJSName[name]; + return [name, ref ? { + capability: ref.capability, + jsNames: ref.jsNames, + requiredArguments: ref.requiredArguments, + optionalArguments: ref.optionalArguments, + argumentHints: ref.argumentHints, + resultSummary: ref.resultSummary + } : null]; + })); + } + """, + expectation: CodeModeEvalExpectation( + toolOrder: [.searchJavaScriptAPI], + requiredSearchResultFragments: [ + "apple.maps.geocode", + "apple.maps.reverseGeocode", + "apple.maps.search", + "apple.maps.routeEstimate", + "apple.maps.open", + "maps.geocode", + "maps.search", + "address", + "latitude", + "longitude", + "origin", + "destination", + "transportType", + ] + ) + ) + + public static let foundationModelsAppIntentsActivityCatalogDiscovery = CodeModeEvalScenario( + id: "foundation-appintents-activity.catalog", + title: "Foundation Models, App Intents, and Activity catalog discovery", + task: "Search for Foundation Models generation/extraction, host App Intents adapters, and iOS Live Activity adapter helpers. Return capability names, arguments, hints, and result summaries.", + catalogPlatform: .iOS, + searchCode: """ + async () => { + const names = [ + "apple.foundationModels.getStatus", + "apple.foundationModels.generate", + "apple.foundationModels.extract", + "apple.appIntents.list", + "apple.appIntents.run", + "apple.appIntents.listHandoffs", + "apple.activity.start", + "apple.activity.update", + "apple.activity.getPushToken" + ]; + return Object.fromEntries(names.map(name => { + const ref = api.byJSName[name]; + return [name, ref ? { + capability: ref.capability, + jsNames: ref.jsNames, + summary: ref.summary, + tags: ref.tags, + requiredArguments: ref.requiredArguments, + optionalArguments: ref.optionalArguments, + argumentHints: ref.argumentHints, + resultSummary: ref.resultSummary + } : null]; + })); + } + """, + expectation: CodeModeEvalExpectation( + toolOrder: [.searchJavaScriptAPI], + requiredSearchResultFragments: [ + "apple.foundationModels.generate", + "apple.foundationModels.extract", + "foundationModels.generate", + "schemaIdentifier", + "host-defined", + "apple.appIntents.run", + "appintents.run", + "host-registered", + "apple.appIntents.listHandoffs", + "apple.activity.start", + "activity.start", + "activityType", + "pushToken", + ] + ) + ) + + public static let walletMusicStoreKitSafetyCatalogDiscovery = CodeModeEvalScenario( + id: "wallet-music-storekit.safety-catalog", + title: "Wallet, MusicKit, and StoreKit safety catalog discovery", + task: "Search the iOS catalog for Wallet/Apple Pay, MusicKit, and StoreKit helpers. Return capability names, arguments, hints, and result summaries, especially user-mediated and explicit-confirmation constraints.", + catalogPlatform: .iOS, + searchCode: """ + async () => { + const names = [ + "apple.wallet.getStatus", + "apple.wallet.addPass", + "apple.wallet.presentPayment", + "apple.music.getSubscriptionStatus", + "apple.music.search", + "apple.music.play", + "apple.storekit.listProducts", + "apple.storekit.purchase", + "apple.storekit.listTransactions" + ]; + return Object.fromEntries(names.map(name => { + const ref = api.byJSName[name]; + return [name, ref ? { + capability: ref.capability, + jsNames: ref.jsNames, + summary: ref.summary, + tags: ref.tags, + requiredArguments: ref.requiredArguments, + optionalArguments: ref.optionalArguments, + argumentHints: ref.argumentHints, + resultSummary: ref.resultSummary + } : null]; + })); + } + """, + expectation: CodeModeEvalExpectation( + toolOrder: [.searchJavaScriptAPI], + requiredSearchResultFragments: [ + "apple.wallet.addPass", + "apple.wallet.presentPayment", + "passkit.applePay.present", + "host merchant configuration", + "explicit user-visible confirmation", + "apple.music.search", + "apple.music.play", + "music.catalog.search", + "music.playback.control", + "subscription", + "apple.storekit.purchase", + "storekit.purchase", + "confirmed", + "apple.storekit.listTransactions", + "inbox", + ] + ) + ) + + public static let cloudKitInvalidDatabaseValidation = CodeModeEvalScenario( + id: "cloudkit.invalid-database-validation", + title: "CloudKit database validation", + task: "First search for apple.cloudkit.queryRecords. Then call it with database exactly \"archive\" and recordType \"Task\". Do not catch the error in JavaScript; let executeJavaScript surface structured INVALID_ARGUMENTS before any CloudKit client is required.", + searchCode: """ + async () => { + return api.byJSName["apple.cloudkit.queryRecords"]; + } + """, + executeCode: """ + return await apple.cloudkit.queryRecords({ database: "archive", recordType: "Task" }); + """, + allowedCapabilities: [.cloudKitRecordsQuery], + expectation: CodeModeEvalExpectation( + toolOrder: [.searchJavaScriptAPI, .executeJavaScript], + exactAllowedCapabilities: [.cloudKitRecordsQuery], + requiredSearchResultFragments: ["apple.cloudkit.queryRecords", "database", "private", "shared", "public"], + requiredExecuteCodeFragments: ["apple.cloudkit.queryRecords", "archive"], + expectedErrorCode: "INVALID_ARGUMENTS" + ) + ) + + public static let mapsInvalidTransportValidation = CodeModeEvalScenario( + id: "maps.invalid-transport-validation", + title: "MapKit transport validation", + task: "First search for apple.maps.routeEstimate. Then call it with valid origin/destination coordinates but transportType exactly \"hoverboard\". Do not catch the error in JavaScript; let executeJavaScript surface structured INVALID_ARGUMENTS before any Maps client is required.", + searchCode: """ + async () => { + return api.byJSName["apple.maps.routeEstimate"]; + } + """, + executeCode: """ + return await apple.maps.routeEstimate({ + origin: { latitude: 37.33, longitude: -122.03 }, + destination: { latitude: 37.77, longitude: -122.42 }, + transportType: "hoverboard" + }); + """, + allowedCapabilities: [.mapsRouteEstimate], + expectation: CodeModeEvalExpectation( + toolOrder: [.searchJavaScriptAPI, .executeJavaScript], + exactAllowedCapabilities: [.mapsRouteEstimate], + requiredSearchResultFragments: ["apple.maps.routeEstimate", "transportType", "automobile"], + requiredExecuteCodeFragments: ["apple.maps.routeEstimate", "hoverboard"], + expectedErrorCode: "INVALID_ARGUMENTS" + ) + ) + + public static let storeKitEmptyProductIDsValidation = CodeModeEvalScenario( + id: "storekit.empty-productids-validation", + title: "StoreKit productIDs validation", + task: "First search for apple.storekit.listProducts. Then call it with productIDs as an empty array. Do not catch the error in JavaScript; let executeJavaScript surface structured INVALID_ARGUMENTS before any StoreKit client is required.", + searchCode: """ + async () => { + return api.byJSName["apple.storekit.listProducts"]; + } + """, + executeCode: """ + return await apple.storekit.listProducts({ productIDs: [] }); + """, + allowedCapabilities: [.storeKitProductsRead], + expectation: CodeModeEvalExpectation( + toolOrder: [.searchJavaScriptAPI, .executeJavaScript], + exactAllowedCapabilities: [.storeKitProductsRead], + requiredSearchResultFragments: ["apple.storekit.listProducts", "productIDs"], + requiredExecuteCodeFragments: ["apple.storekit.listProducts", "productIDs"], + expectedErrorCode: "INVALID_ARGUMENTS" + ) + ) + + public static let notificationsMalformedCategoriesValidation = CodeModeEvalScenario( + id: "notifications.malformed-categories-validation", + title: "APNs category shape validation", + task: "First search for apple.notifications.setCategories. Then call it with a category missing identifier. Do not catch the error in JavaScript; let executeJavaScript surface structured INVALID_ARGUMENTS before any remote notification client is required.", + searchCode: """ + async () => { + return api.byJSName["apple.notifications.setCategories"]; + } + """, + executeCode: """ + return await apple.notifications.setCategories({ + categories: [{ actions: [{ identifier: "done", title: "Done" }] }] + }); + """, + allowedCapabilities: [.notificationsCategoriesSet], + expectation: CodeModeEvalExpectation( + toolOrder: [.searchJavaScriptAPI, .executeJavaScript], + exactAllowedCapabilities: [.notificationsCategoriesSet], + requiredSearchResultFragments: ["apple.notifications.setCategories", "categories", "actions"], + requiredExecuteCodeFragments: ["apple.notifications.setCategories", "categories"], + expectedErrorCode: "INVALID_ARGUMENTS" + ) + ) + + public static let activityInvalidDismissalPolicyValidation = CodeModeEvalScenario( + id: "activity.invalid-dismissal-policy-validation", + title: "ActivityKit dismissal policy validation", + task: "First search for apple.activity.end on iOS. Then call it with dismissalPolicy exactly \"later\". Do not catch the error in JavaScript; let executeJavaScript surface structured INVALID_ARGUMENTS before any ActivityKit client is required.", + catalogPlatform: .iOS, + searchCode: """ + async () => { + return api.byJSName["apple.activity.end"]; + } + """, + executeCode: """ + return await apple.activity.end({ identifier: "activity-1", dismissalPolicy: "later" }); + """, + allowedCapabilities: [.activityEnd], + expectation: CodeModeEvalExpectation( + toolOrder: [.searchJavaScriptAPI, .executeJavaScript], + exactAllowedCapabilities: [.activityEnd], + requiredSearchResultFragments: ["apple.activity.end", "dismissalPolicy", "immediate"], + requiredExecuteCodeFragments: ["apple.activity.end", "later"], + expectedErrorCode: "INVALID_ARGUMENTS" + ) + ) + + public static let musicInvalidPlaybackActionValidation = CodeModeEvalScenario( + id: "music.invalid-playback-action-validation", + title: "Music playback action validation", + task: "First search for apple.music.play. Then call it with action exactly \"shuffleEverything\". Do not catch the error in JavaScript; let executeJavaScript surface structured INVALID_ARGUMENTS before any Music permission or client is required.", + searchCode: """ + async () => { + return api.byJSName["apple.music.play"]; + } + """, + executeCode: """ + return await apple.music.play({ action: "shuffleEverything" }); + """, + allowedCapabilities: [.musicPlaybackControl], + expectation: CodeModeEvalExpectation( + toolOrder: [.searchJavaScriptAPI, .executeJavaScript], + exactAllowedCapabilities: [.musicPlaybackControl], + requiredSearchResultFragments: ["apple.music.play", "action", "playCatalog"], + requiredExecuteCodeFragments: ["apple.music.play", "shuffleEverything"], + expectedErrorCode: "INVALID_ARGUMENTS" + ) + ) + + public static let calendarWritePermissionDenied = CodeModeEvalScenario( + id: "calendar.write-permission-denied", + title: "Calendar write permission denied", + task: "First search for apple.calendar.createEvent. Then call executeJavaScript with exactly the calendar.write allowed capability and try to create a valid event titled \"Eval Standup\" from 2026-02-22T16:00:00Z to 2026-02-22T16:15:00Z while calendar write-only privacy permission is denied. Do not omit the capability and do not catch the error in JavaScript; let executeJavaScript surface the structured PERMISSION_DENIED error.", + searchCode: """ + async () => { + return api.byJSName["apple.calendar.createEvent"]; + } + """, + executeCode: """ + return await apple.calendar.createEvent({ + title: "Eval Standup", + start: "2026-02-22T16:00:00Z", + end: "2026-02-22T16:15:00Z" + }); + """, + allowedCapabilities: [.calendarWrite], + permissions: CodeModeEvalPermissions(statuses: [.calendarWriteOnly: .denied]), + expectation: CodeModeEvalExpectation( + toolOrder: [.searchJavaScriptAPI, .executeJavaScript], + exactAllowedCapabilities: [.calendarWrite], + requiredSearchResultFragments: [ + "calendar.write", + "apple.calendar.createEvent", + ], + requiredExecuteCodeFragments: ["apple.calendar.createEvent", "Eval Standup"], + expectedErrorCode: "PERMISSION_DENIED" + ) + ) + + public static let homeWriteValidation = CodeModeEvalScenario( + id: "home.write-validation", + title: "Home write validation", + task: "First search for apple.home.writeCharacteristic. Then call it with only accessoryIdentifier set exactly to \"accessory-1\" and let executeJavaScript surface the structured missing-arguments error before any HomeKit permission flow.", + searchCode: """ + async () => { + return api.byJSName["apple.home.writeCharacteristic"]; + } + """, + executeCode: """ + return await apple.home.writeCharacteristic({ accessoryIdentifier: "accessory-1" }); + """, + allowedCapabilities: [.homeWrite], + permissions: CodeModeEvalPermissions(statuses: [.homeKit: .granted]), + expectation: CodeModeEvalExpectation( + toolOrder: [.searchJavaScriptAPI, .executeJavaScript], + exactAllowedCapabilities: [.homeWrite], + requiredSearchResultFragments: [ + "home.write", + "apple.home.writeCharacteristic", + "characteristicType", + "value", + ], + requiredErrorSuggestionFragments: ["characteristicType:string", "value", "Example:"], + requiredExecuteCodeFragments: ["apple.home.writeCharacteristic", "accessory-1"], + expectedErrorCode: "INVALID_ARGUMENTS" + ) + ) + + public static let mediaMetadataValidation = CodeModeEvalScenario( + id: "media.metadata-validation", + title: "Media metadata validation", + task: "First search for apple.media.metadata. Then call it without a path and let executeJavaScript surface the structured missing-arguments error.", + searchCode: """ + async () => { + return api.byJSName["apple.media.metadata"]; + } + """, + executeCode: """ + return await apple.media.metadata({}); + """, + allowedCapabilities: [.mediaMetadataRead], + expectation: CodeModeEvalExpectation( + toolOrder: [.searchJavaScriptAPI, .executeJavaScript], + exactAllowedCapabilities: [.mediaMetadataRead], + requiredSearchResultFragments: [ + "media.metadata.read", + "apple.media.metadata", + "path", + ], + requiredErrorSuggestionFragments: ["path", "Example:"], + requiredExecuteCodeFragments: ["apple.media.metadata"], + expectedErrorCode: "INVALID_ARGUMENTS" + ) + ) + public static let badFileSystemHelperSuggestion = CodeModeEvalScenario( id: "fs.bad-helper-suggestion", title: "Bad helper names get suggestions", diff --git a/Sources/CodeModeEvaluation/ToolCallGrading.swift b/Sources/CodeModeEvaluation/ToolCallGrading.swift new file mode 100644 index 0000000..a9e0d75 --- /dev/null +++ b/Sources/CodeModeEvaluation/ToolCallGrading.swift @@ -0,0 +1,21 @@ +import CodeMode + +public enum CodeModeEvalToolCallGrader { + public static func orderedToolCalls( + _ toolCalls: [CodeModeEvalToolCall], + expectedOrder: [CodeModeEvalToolName] + ) -> [CodeModeEvalToolCall] { + var start = toolCalls.startIndex + var matches: [CodeModeEvalToolCall] = [] + + for expectedTool in expectedOrder { + guard let matchIndex = toolCalls[start...].firstIndex(where: { $0.tool == expectedTool }) else { + break + } + matches.append(toolCalls[matchIndex]) + start = toolCalls.index(after: matchIndex) + } + + return matches + } +} diff --git a/Tests/CodeModeEvalTests/CodeModeEvalRunnerTests.swift b/Tests/CodeModeEvalTests/CodeModeEvalRunnerTests.swift index 1918786..340f01b 100644 --- a/Tests/CodeModeEvalTests/CodeModeEvalRunnerTests.swift +++ b/Tests/CodeModeEvalTests/CodeModeEvalRunnerTests.swift @@ -53,6 +53,37 @@ import Testing #expect(failures.contains(where: { $0.contains("Forbidden capabilities") })) } +@Test func orderedToolCallGradingDoesNotCollapseRepeatedExecuteCalls() { + let scenario = CodeModeEvalScenarios.filesystemRepairAfterInvalidArguments + let calls = [ + CodeModeEvalToolCall(tool: .searchJavaScriptAPI, code: scenario.searchCode ?? ""), + CodeModeEvalToolCall( + tool: .executeJavaScript, + code: """ + const result = await apple.fs.read({ path: "tmp:repair.txt", encoding: "utf8" }); + return result.text; + """, + allowedCapabilities: [.fsRead] + ), + ] + + let gradedCalls = CodeModeEvalToolCallGrader.orderedToolCalls( + calls, + expectedOrder: scenario.expectation.toolOrder + ) + + let failures = CodeModeEvalRunner().validateTranscript( + scenario: scenario, + toolCalls: gradedCalls, + searchResult: .string("fs.read apple.fs.read path"), + executionOutput: .string("repair target"), + error: nil + ) + + #expect(gradedCalls.count == 2) + #expect(failures.contains(where: { $0.contains("Tool order") })) +} + @Test func evalRunnerDoesNotDuplicateStreamedLogs() async { let runner = CodeModeEvalRunner() @@ -68,6 +99,41 @@ import Testing #expect(pathPolicyLogCount == 1) } +@Test func expandedNonFilesystemScenariosLockValidationSignals() { + let runner = CodeModeEvalRunner() + let scenarios = [ + CodeModeEvalScenarios.keychainRoundTrip, + CodeModeEvalScenarios.notificationsPermissionRequest, + CodeModeEvalScenarios.locationPermissionStatus, + CodeModeEvalScenarios.networkInvalidURL, + CodeModeEvalScenarios.calendarWritePermissionDenied, + CodeModeEvalScenarios.homeWriteValidation, + CodeModeEvalScenarios.mediaMetadataValidation, + ] + + for scenario in scenarios { + let calls = scenario.expectation.toolOrder.map { tool in + CodeModeEvalToolCall(tool: tool, code: "", allowedCapabilities: []) + } + let failures = runner.validateTranscript( + scenario: scenario, + toolCalls: calls, + searchResult: .null, + executionOutput: nil, + error: nil + ) + + #expect(failures.contains(where: { $0.contains("Search result") })) + #expect(failures.contains(where: { $0.contains("Allowed capabilities") })) + if scenario.expectation.expectedOutput != nil { + #expect(failures.contains(where: { $0.contains("Execution output") })) + } + if scenario.expectation.expectedErrorCode != nil { + #expect(failures.contains(where: { $0.contains("Error code") })) + } + } +} + private func failureSummary(_ results: [CodeModeEvalResult]) -> String { results .filter { $0.passed == false } diff --git a/Tests/CodeModeTests/BigTicketAppleBridgeTests.swift b/Tests/CodeModeTests/BigTicketAppleBridgeTests.swift new file mode 100644 index 0000000..8caad54 --- /dev/null +++ b/Tests/CodeModeTests/BigTicketAppleBridgeTests.swift @@ -0,0 +1,498 @@ +import Foundation +import Testing +@testable import CodeMode + +@Test func defaultCloudKitClientReportsUnsupportedPlatform() async throws { + let (tools, sandbox) = try makeTools() + defer { cleanup(sandbox) } + + let observed = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: "return await apple.cloudkit.getAccountStatus();", + allowedCapabilities: [.cloudKitAccountStatus] + ) + ) + + #expect(observed.error?.code == "UNSUPPORTED_PLATFORM") +} + +@Test func systemCloudKitAndMapsClientsAreOptInConfigurationValues() { + let configuration = CodeModeConfiguration( + cloudKitClient: SystemCloudKitClient(), + mapsClient: SystemMapsClient() + ) + + #expect(configuration.cloudKitClient is SystemCloudKitClient) + #expect(configuration.mapsClient is SystemMapsClient) +} + +@Test func eventInboxRoutesExistingInboxStyleReads() async throws { + let (tools, sandbox) = try makeTools(eventInbox: FakeEventInbox()) + defer { cleanup(sandbox) } + + let observed = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: """ + const cloud = await apple.cloudkit.listEvents({ limit: 2 }); + const notifications = await apple.notifications.listResponses({ limit: 3 }); + const handoffs = await apple.appIntents.listHandoffs({ limit: 4 }); + const transactions = await apple.storekit.listTransactions({ limit: 5 }); + return { + cloud: cloud.source, + notifications: notifications.source, + handoffs: handoffs.source, + transactions: transactions.source, + transactionLimit: transactions.limit + }; + """, + allowedCapabilities: [ + .cloudKitSubscriptionEventsRead, + .notificationsResponsesRead, + .appIntentsHandoffsRead, + .storeKitTransactionsRead, + ] + ) + ) + + let output = try #require(observed.result?.output?.objectValue) + #expect(output["cloud"]?.stringValue == "cloudkit.subscription") + #expect(output["notifications"]?.stringValue == "notifications.response") + #expect(output["handoffs"]?.stringValue == "appintents.handoff") + #expect(output["transactions"]?.stringValue == "storekit.transaction") + #expect(output["transactionLimit"]?.intValue == 5) +} + +@Test func unavailableEventInboxFallsBackToClientUnsupportedPlatform() async throws { + let (tools, sandbox) = try makeTools() + defer { cleanup(sandbox) } + + let observed = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: "return await apple.cloudkit.listEvents({ limit: 1 });", + allowedCapabilities: [.cloudKitSubscriptionEventsRead] + ) + ) + + #expect(observed.error?.code == "UNSUPPORTED_PLATFORM") +} + +@Test func cloudKitAndMapsAdaptersForwardValidatedArguments() async throws { + let (tools, sandbox) = try makeTools( + cloudKitClient: FakeCloudKitClient(), + mapsClient: FakeMapsClient() + ) + defer { cleanup(sandbox) } + + let observed = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: """ + const records = await apple.cloudkit.queryRecords({ database: "private", recordType: "Task", limit: 2 }); + const places = await apple.maps.search({ query: "coffee", limit: 3 }); + const route = await apple.maps.routeEstimate({ + origin: { latitude: 37.33, longitude: -122.03 }, + destination: { latitude: 37.77, longitude: -122.42 }, + transportType: "walking" + }); + return { recordType: records.recordType, database: records.database, query: places.query, limit: places.limit, transportType: route.transportType }; + """, + allowedCapabilities: [.cloudKitRecordsQuery, .mapsSearch, .mapsRouteEstimate] + ) + ) + + let output = try #require(observed.result?.output?.objectValue) + #expect(output["recordType"]?.stringValue == "Task") + #expect(output["database"]?.stringValue == "private") + #expect(output["query"]?.stringValue == "coffee") + #expect(output["limit"]?.intValue == 3) + #expect(output["transportType"]?.stringValue == "walking") +} + +@Test func cloudKitValidationHappensBeforeClientCalls() async throws { + let (tools, sandbox) = try makeTools(cloudKitClient: FailingCloudKitClient()) + defer { cleanup(sandbox) } + + let invalidDatabase = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: "return await apple.cloudkit.queryRecords({ database: 'archive', recordType: 'Task' });", + allowedCapabilities: [.cloudKitRecordsQuery] + ) + ) + #expect(invalidDatabase.error?.code == "INVALID_ARGUMENTS") + + let invalidFields = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: "return await apple.cloudkit.saveRecord({ recordType: 'Task', fields: {} });", + allowedCapabilities: [.cloudKitRecordSave] + ) + ) + #expect(invalidFields.error?.code == "INVALID_ARGUMENTS") + + let invalidPredicate = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: "return await apple.cloudkit.queryRecords({ recordType: 'Task', predicate: 'TRUEPREDICATE' });", + allowedCapabilities: [.cloudKitRecordsQuery] + ) + ) + #expect(invalidPredicate.error?.code == "INVALID_ARGUMENTS") +} + +@Test func bigTicketValidationRejectsMalformedArgumentsBeforePermissionsOrClients() async throws { + let (tools, sandbox) = try makeTools( + permissionBroker: FixedPermissionBroker(statuses: [.music: .denied]), + hostPlatform: .iOS + ) + defer { cleanup(sandbox) } + + let badCategories = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: "return await apple.notifications.setCategories({ categories: [{ actions: [{ identifier: 'done', title: 'Done' }] }] });", + allowedCapabilities: [.notificationsCategoriesSet] + ) + ) + #expect(badCategories.error?.code == "INVALID_ARGUMENTS") + + let badTransport = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: """ + return await apple.maps.routeEstimate({ + origin: { latitude: 37.33, longitude: -122.03 }, + destination: { latitude: 37.77, longitude: -122.42 }, + transportType: "hoverboard" + }); + """, + allowedCapabilities: [.mapsRouteEstimate] + ) + ) + #expect(badTransport.error?.code == "INVALID_ARGUMENTS") + + let emptyProducts = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: "return await apple.storekit.listProducts({ productIDs: [] });", + allowedCapabilities: [.storeKitProductsRead] + ) + ) + #expect(emptyProducts.error?.code == "INVALID_ARGUMENTS") + + let badDismissal = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: "return await apple.activity.end({ identifier: 'activity-1', dismissalPolicy: 'later' });", + allowedCapabilities: [.activityEnd] + ) + ) + #expect(badDismissal.error?.code == "INVALID_ARGUMENTS") + + let badMusic = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: "return await apple.music.play({ action: 'shuffleEverything' });", + allowedCapabilities: [.musicPlaybackControl] + ) + ) + #expect(badMusic.error?.code == "INVALID_ARGUMENTS") +} + +@Test func speechTranscriptionRequiresPermissionAndResolvesSandboxPath() async throws { + let (tools, sandbox) = try makeTools( + permissionBroker: FixedPermissionBroker(statuses: [.speechRecognition: .granted]), + speechClient: FakeSpeechClient() + ) + defer { cleanup(sandbox) } + + let observed = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: "return await apple.speech.transcribeFile({ path: 'tmp:audio.m4a', locale: 'en-US' });", + allowedCapabilities: [.speechFileTranscribe] + ) + ) + + let output = try #require(observed.result?.output?.objectValue) + #expect(output["transcript"]?.stringValue == "hello") + #expect(output["resolvedPath"]?.stringValue == sandbox.tmp.appendingPathComponent("audio.m4a").path) +} + +@Test func speechPermissionDeniedStaysStructured() async throws { + let (tools, sandbox) = try makeTools( + permissionBroker: FixedPermissionBroker(statuses: [.speechRecognition: .denied]), + speechClient: FakeSpeechClient() + ) + defer { cleanup(sandbox) } + + let observed = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: "return await apple.speech.transcribeFile({ path: 'tmp:audio.m4a' });", + allowedCapabilities: [.speechFileTranscribe] + ) + ) + + #expect(observed.error?.code == "PERMISSION_DENIED") + #expect(observed.error?.message.contains("speech.recognition") == true) +} + +@Test func musicLibraryRequiresMusicPermission() async throws { + let (tools, sandbox) = try makeTools( + permissionBroker: FixedPermissionBroker(statuses: [.music: .denied]), + musicClient: FakeMusicClient() + ) + defer { cleanup(sandbox) } + + let observed = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: "return await apple.music.readLibrary({ type: 'playlists' });", + allowedCapabilities: [.musicLibraryRead] + ) + ) + + #expect(observed.error?.code == "PERMISSION_DENIED") + #expect(observed.error?.message.contains("music") == true) +} + +@Test func storeKitPurchaseRequiresExplicitConfirmation() async throws { + let (tools, sandbox) = try makeTools(storeKitClient: FakeStoreKitClient()) + defer { cleanup(sandbox) } + + let denied = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: "return await apple.storekit.purchase({ productID: 'pro.monthly', confirmed: false });", + allowedCapabilities: [.storeKitPurchase] + ) + ) + #expect(denied.error?.code == "INVALID_ARGUMENTS") + #expect(denied.error?.message.contains("confirmed: true") == true) + + let purchased = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: "return await apple.storekit.purchase({ productID: 'pro.monthly', confirmed: true });", + allowedCapabilities: [.storeKitPurchase] + ) + ) + let output = try #require(purchased.result?.output?.objectValue) + #expect(output["productID"]?.stringValue == "pro.monthly") + #expect(output["status"]?.stringValue == "success") +} + +@Test func walletAddPassIsIOSScopedAndResolvesSandboxPath() async throws { + let (tools, sandbox) = try makeTools( + passKitClient: FakePassKitClient(), + hostPlatform: .iOS + ) + defer { cleanup(sandbox) } + + let observed = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: "return await apple.wallet.addPass({ path: 'tmp:ticket.pkpass' });", + allowedCapabilities: [.passKitPassAdd] + ) + ) + + let output = try #require(observed.result?.output?.objectValue) + #expect(output["resolvedPath"]?.stringValue == sandbox.tmp.appendingPathComponent("ticket.pkpass").path) + #expect(output["added"]?.boolValue == true) +} + +private struct FakeCloudKitClient: CloudKitClient { + func accountStatus(arguments: [String: JSONValue]) throws -> JSONValue { + .object(["status": .string("available"), "accountAvailable": .bool(true)]) + } + + func queryRecords(arguments: [String: JSONValue]) throws -> JSONValue { + .object([ + "recordType": .string(arguments.string("recordType") ?? ""), + "database": .string(arguments.string("database") ?? "private"), + "limit": .number(Double(arguments.int("limit") ?? 0)), + ]) + } + + func saveRecord(arguments: [String: JSONValue]) throws -> JSONValue { + .object(["recordName": .string(arguments.string("recordName") ?? "new-record"), "saved": .bool(true)]) + } + + func deleteRecord(arguments: [String: JSONValue]) throws -> JSONValue { + .object(["recordName": .string(arguments.string("recordName") ?? ""), "deleted": .bool(true)]) + } + + func saveSubscription(arguments: [String: JSONValue]) throws -> JSONValue { + .object(["subscriptionID": .string(arguments.string("subscriptionID") ?? ""), "saved": .bool(true)]) + } + + func readSubscriptionEvents(arguments: [String: JSONValue]) throws -> JSONValue { + .array([]) + } +} + +private struct FailingCloudKitClient: CloudKitClient { + func accountStatus(arguments: [String: JSONValue]) throws -> JSONValue { + throw BridgeError.nativeFailure("CloudKit client should not be called") + } + + func queryRecords(arguments: [String: JSONValue]) throws -> JSONValue { + throw BridgeError.nativeFailure("CloudKit client should not be called") + } + + func saveRecord(arguments: [String: JSONValue]) throws -> JSONValue { + throw BridgeError.nativeFailure("CloudKit client should not be called") + } + + func deleteRecord(arguments: [String: JSONValue]) throws -> JSONValue { + throw BridgeError.nativeFailure("CloudKit client should not be called") + } + + func saveSubscription(arguments: [String: JSONValue]) throws -> JSONValue { + throw BridgeError.nativeFailure("CloudKit client should not be called") + } + + func readSubscriptionEvents(arguments: [String: JSONValue]) throws -> JSONValue { + throw BridgeError.nativeFailure("CloudKit client should not be called") + } +} + +private struct FakeEventInbox: CodeModeEventInbox { + func readEvents(source: String, arguments: [String: JSONValue]) throws -> JSONValue { + .object([ + "source": .string(source), + "limit": .number(Double(arguments.int("limit") ?? 0)), + ]) + } +} + +private struct FakeMapsClient: MapsClient { + func geocode(arguments: [String: JSONValue]) throws -> JSONValue { + .array([.object(["address": .string(arguments.string("address") ?? "")])]) + } + + func reverseGeocode(arguments: [String: JSONValue]) throws -> JSONValue { + .array([.object(["latitude": arguments["latitude"] ?? .null, "longitude": arguments["longitude"] ?? .null])]) + } + + func search(arguments: [String: JSONValue]) throws -> JSONValue { + .object([ + "query": .string(arguments.string("query") ?? ""), + "limit": .number(Double(arguments.int("limit") ?? 0)), + ]) + } + + func routeEstimate(arguments: [String: JSONValue]) throws -> JSONValue { + .object([ + "distanceMeters": .number(1_000), + "expectedTravelTimeSeconds": .number(600), + "transportType": .string(arguments.string("transportType") ?? "automobile"), + ]) + } + + func open(arguments: [String: JSONValue]) throws -> JSONValue { + .object(["opened": .bool(true)]) + } +} + +private struct FakeSpeechClient: SpeechClient { + func transcribeFile(arguments: [String: JSONValue]) throws -> JSONValue { + .object([ + "transcript": .string("hello"), + "resolvedPath": .string(arguments.string("resolvedPath") ?? ""), + ]) + } + + func transcribeMicrophone(arguments: [String: JSONValue]) throws -> JSONValue { + .object(["transcript": .string("live")]) + } +} + +private struct FakeMusicClient: MusicClient { + func requestAuthorization(arguments: [String: JSONValue]) throws -> JSONValue { + .object(["status": .string("granted")]) + } + + func subscriptionStatus(arguments: [String: JSONValue]) throws -> JSONValue { + .object(["canPlayCatalogContent": .bool(true)]) + } + + func searchCatalog(arguments: [String: JSONValue]) throws -> JSONValue { + .object(["term": .string(arguments.string("term") ?? "")]) + } + + func catalogDetails(arguments: [String: JSONValue]) throws -> JSONValue { + .object(["identifier": .string(arguments.string("identifier") ?? "")]) + } + + func readLibrary(arguments: [String: JSONValue]) throws -> JSONValue { + .array([]) + } + + func writePlaylist(arguments: [String: JSONValue]) throws -> JSONValue { + .object(["name": .string(arguments.string("name") ?? ""), "written": .bool(true)]) + } + + func controlPlayback(arguments: [String: JSONValue]) throws -> JSONValue { + .object(["action": .string(arguments.string("action") ?? ""), "status": .string("ok")]) + } +} + +private struct FakePassKitClient: PassKitClient { + func walletStatus(arguments: [String: JSONValue]) throws -> JSONValue { + .object(["available": .bool(true)]) + } + + func listPasses(arguments: [String: JSONValue]) throws -> JSONValue { + .array([]) + } + + func addPass(arguments: [String: JSONValue]) throws -> JSONValue { + .object([ + "added": .bool(true), + "resolvedPath": .string(arguments.string("resolvedPath") ?? ""), + ]) + } + + func presentPass(arguments: [String: JSONValue]) throws -> JSONValue { + .object(["presented": .bool(true), "identifier": .string(arguments.string("identifier") ?? "")]) + } + + func applePayStatus(arguments: [String: JSONValue]) throws -> JSONValue { + .object(["canMakePayments": .bool(true)]) + } + + func presentApplePay(arguments: [String: JSONValue]) throws -> JSONValue { + .object(["authorized": .bool(true)]) + } +} + +private struct FakeStoreKitClient: StoreKitClient { + func products(arguments: [String: JSONValue]) throws -> JSONValue { + .array(arguments.array("productIDs") ?? []) + } + + func currentEntitlements(arguments: [String: JSONValue]) throws -> JSONValue { + .array([]) + } + + func purchase(arguments: [String: JSONValue]) throws -> JSONValue { + .object([ + "productID": .string(arguments.string("productID") ?? ""), + "status": .string("success"), + ]) + } + + func restore(arguments: [String: JSONValue]) throws -> JSONValue { + .object(["restored": .bool(true)]) + } + + func transactionUpdates(arguments: [String: JSONValue]) throws -> JSONValue { + .array([]) + } +} diff --git a/Tests/CodeModeTests/CapabilityRegistryTests.swift b/Tests/CodeModeTests/CapabilityRegistryTests.swift index a056c03..0910fc2 100644 --- a/Tests/CodeModeTests/CapabilityRegistryTests.swift +++ b/Tests/CodeModeTests/CapabilityRegistryTests.swift @@ -1,7 +1,15 @@ import Foundation +import JavaScriptCore import Testing @testable import CodeMode + +private func jsNames(for capability: CapabilityID) -> [String] { + DefaultCapabilityLoader.loadAllRegistrations() + .first { $0.descriptor.id == capability }? + .jsNames ?? [] +} + @Test func defaultCapabilityLoaderCoversAllCapabilityIDs() { let registrations = DefaultCapabilityLoader.loadAllRegistrations() let loaded = Set(registrations.map { $0.descriptor.id }) @@ -10,6 +18,53 @@ import Testing #expect(loaded == expected) } +@Test func defaultCapabilityRegistrationsOwnJavaScriptNames() { + let registrations = DefaultCapabilityLoader.loadAllRegistrations() + let missingNames = registrations + .filter { $0.jsNames.isEmpty } + .map(\.descriptor.id.rawValue) + + #expect(missingNames.isEmpty) +} + +@Test func hardcodedBootstrapHelpersHaveRegistrationMetadata() throws { + let context = try #require(JSContext()) + let invokeBlock: @convention(block) (String, String) -> String = { _, _ in + #"{"ok":true,"value":null}"# + } + context.setObject(invokeBlock, forKeyedSubscript: "__bridgeInvokeSync" as NSString) + #expect(context.evaluateScript(RuntimeJavaScript.bootstrap) != nil) + + let namesJSON = try #require( + context.evaluateScript( + """ + (function(){ + const roots = ["fetch", "apple", "ios", "fs"]; + const names = []; + function visit(path, value) { + if (typeof value === "function") { + names.push(path); + return; + } + if (!value || typeof value !== "object") return; + Object.keys(value).forEach(function(key) { + visit(path ? path + "." + key : key, value[key]); + }); + } + roots.forEach(function(root) { visit(root, globalThis[root]); }); + return JSON.stringify(names.sort()); + })() + """ + )?.toString() + ) + let hardcodedNames = try JSONDecoder().decode([String].self, from: Data(namesJSON.utf8)) + let registeredNames = Set(DefaultCapabilityLoader.loadAllRegistrations().flatMap(\.jsNames)) + let missingMetadata = hardcodedNames.filter { registeredNames.contains($0) == false } + + #expect(hardcodedNames.isEmpty == false) + #expect(missingMetadata.isEmpty) +} + @Test func platformSupportFilterMatchesCurrentPlatform() { let registrations = DefaultCapabilityLoader.loadAllRegistrations() let filtered = CapabilityPlatformSupport.filter(registrations, for: .current) @@ -64,44 +119,170 @@ import Testing let calendar = try #require(descriptors[.calendarUIPresentNewEvent]) #expect(calendar.requiredPermissions == [.calendarWriteOnly]) - #expect(JavaScriptBindingCatalog.names(for: .calendarUIPresentNewEvent) == ["apple.calendar.presentNewEvent"]) + #expect(jsNames(for: .calendarUIPresentNewEvent) == ["apple.calendar.presentNewEvent"]) let contacts = try #require(descriptors[.contactsUIPick]) #expect(contacts.requiredPermissions.isEmpty) - #expect(JavaScriptBindingCatalog.names(for: .contactsUIPick) == ["apple.contacts.pick"]) + #expect(jsNames(for: .contactsUIPick) == ["apple.contacts.pick"]) let photos = try #require(descriptors[.photosUIPick]) #expect(photos.requiredPermissions.isEmpty) - #expect(JavaScriptBindingCatalog.names(for: .photosUIPick) == ["apple.photos.pick"]) + #expect(jsNames(for: .photosUIPick) == ["apple.photos.pick"]) #expect(descriptors[.documentsUIPick]?.requiredPermissions.isEmpty == true) - #expect(JavaScriptBindingCatalog.names(for: .documentsUIPick) == ["apple.documents.pick"]) - #expect(JavaScriptBindingCatalog.names(for: .documentsUIExport) == ["apple.documents.export", "apple.documents.save"]) - #expect(JavaScriptBindingCatalog.names(for: .documentsUIOpenIn) == ["apple.documents.openIn"]) - #expect(JavaScriptBindingCatalog.names(for: .documentsUIScan) == ["apple.documents.scan"]) - #expect(JavaScriptBindingCatalog.names(for: .shareUIPresent) == ["apple.share.present"]) - #expect(JavaScriptBindingCatalog.names(for: .quickLookUIPreview) == ["apple.quicklook.preview"]) - #expect(JavaScriptBindingCatalog.names(for: .cameraUICapture) == ["apple.camera.capture"]) - #expect(JavaScriptBindingCatalog.names(for: .cameraUIScanData) == ["apple.camera.scanData"]) - #expect(JavaScriptBindingCatalog.names(for: .mailUICompose) == ["apple.mail.compose"]) - #expect(JavaScriptBindingCatalog.names(for: .messagesUICompose) == ["apple.messages.compose"]) - #expect(JavaScriptBindingCatalog.names(for: .printUIPresent) == ["apple.print.present"]) - #expect(JavaScriptBindingCatalog.names(for: .webUIPresent) == ["apple.web.present"]) - #expect(JavaScriptBindingCatalog.names(for: .authUIWebAuthenticate) == ["apple.auth.webAuthenticate"]) - #expect(JavaScriptBindingCatalog.names(for: .uiAlertPresent) == ["apple.ui.presentAlert"]) - #expect(JavaScriptBindingCatalog.names(for: .uiPromptPresent) == ["apple.ui.presentPrompt"]) - #expect(JavaScriptBindingCatalog.names(for: .photosUIPresentLimitedLibraryPicker) == ["apple.photos.presentLimitedLibraryPicker"]) - #expect(JavaScriptBindingCatalog.names(for: .settingsUIOpen) == ["apple.settings.open"]) + #expect(jsNames(for: .documentsUIPick) == ["apple.documents.pick"]) + #expect(jsNames(for: .documentsUIExport) == ["apple.documents.export", "apple.documents.save"]) + #expect(jsNames(for: .documentsUIOpenIn) == ["apple.documents.openIn"]) + #expect(jsNames(for: .documentsUIScan) == ["apple.documents.scan"]) + #expect(jsNames(for: .shareUIPresent) == ["apple.share.present"]) + #expect(jsNames(for: .quickLookUIPreview) == ["apple.quicklook.preview"]) + #expect(jsNames(for: .cameraUICapture) == ["apple.camera.capture"]) + #expect(jsNames(for: .cameraUIScanData) == ["apple.camera.scanData"]) + #expect(jsNames(for: .mailUICompose) == ["apple.mail.compose"]) + #expect(jsNames(for: .messagesUICompose) == ["apple.messages.compose"]) + #expect(jsNames(for: .printUIPresent) == ["apple.print.present"]) + #expect(jsNames(for: .webUIPresent) == ["apple.web.present"]) + #expect(jsNames(for: .authUIWebAuthenticate) == ["apple.auth.webAuthenticate"]) + #expect(jsNames(for: .uiAlertPresent) == ["apple.ui.presentAlert"]) + #expect(jsNames(for: .uiPromptPresent) == ["apple.ui.presentPrompt"]) + #expect(jsNames(for: .photosUIPresentLimitedLibraryPicker) == ["apple.photos.presentLimitedLibraryPicker"]) + #expect(jsNames(for: .settingsUIOpen) == ["apple.settings.open"]) #expect(descriptors[.calendarUIPickCalendar]?.requiredPermissions == [.calendarWriteOnly]) #expect(descriptors[.calendarUIPresentEvent]?.requiredPermissions == [.calendar]) - #expect(JavaScriptBindingCatalog.names(for: .calendarUIPickCalendar) == ["apple.calendar.pickCalendar"]) - #expect(JavaScriptBindingCatalog.names(for: .calendarUIPresentEvent) == ["apple.calendar.presentEvent"]) + #expect(jsNames(for: .calendarUIPickCalendar) == ["apple.calendar.pickCalendar"]) + #expect(jsNames(for: .calendarUIPresentEvent) == ["apple.calendar.presentEvent"]) #expect(descriptors[.contactsUIPresentContact]?.requiredPermissions == [.contacts]) #expect(descriptors[.contactsUIPresentNewContact]?.requiredPermissions == [.contacts]) - #expect(JavaScriptBindingCatalog.names(for: .contactsUIPresentContact) == ["apple.contacts.presentContact"]) - #expect(JavaScriptBindingCatalog.names(for: .contactsUIPresentNewContact) == ["apple.contacts.presentNewContact"]) + #expect(jsNames(for: .contactsUIPresentContact) == ["apple.contacts.presentContact"]) + #expect(jsNames(for: .contactsUIPresentNewContact) == ["apple.contacts.presentNewContact"]) +} + +@Test func expandedAPIDescriptorsExposeExpectedJavaScriptNames() throws { + let descriptors = Dictionary( + uniqueKeysWithValues: DefaultCapabilityLoader.loadAllRegistrations().map { ($0.descriptor.id, $0.descriptor) } + ) + + let calendarWrite = try #require(descriptors[.calendarWrite]) + #expect(calendarWrite.requiredPermissions.isEmpty) + #expect(calendarWrite.optionalArguments.contains("calendarIdentifier")) + #expect(jsNames(for: .calendarWrite) == ["apple.calendar.createEvent", "apple.calendar.updateEvent"]) + + let calendarDelete = try #require(descriptors[.calendarDelete]) + #expect(calendarDelete.requiredPermissions == [.calendar]) + #expect(calendarDelete.requiredArguments == ["identifier"]) + #expect(jsNames(for: .calendarDelete) == ["apple.calendar.deleteEvent"]) + + let remindersWrite = try #require(descriptors[.remindersWrite]) + #expect(remindersWrite.optionalArguments.contains("isCompleted")) + #expect(jsNames(for: .remindersWrite) == [ + "apple.reminders.createReminder", + "apple.reminders.updateReminder", + "apple.reminders.completeReminder", + ]) + + let remindersDelete = try #require(descriptors[.remindersDelete]) + #expect(remindersDelete.requiredArguments == ["identifier"]) + #expect(jsNames(for: .remindersDelete) == ["apple.reminders.deleteReminder"]) + + let networkFetch = try #require(descriptors[.networkFetch]) + #expect(networkFetch.optionalArguments.contains("options.timeoutMs")) + #expect(networkFetch.optionalArguments.contains("options.bodyBase64")) + #expect(networkFetch.optionalArguments.contains("options.responseEncoding")) + + let notificationsSchedule = try #require(descriptors[.notificationsSchedule]) + #expect(notificationsSchedule.optionalArguments.contains("userInfo")) + #expect(notificationsSchedule.optionalArguments.contains("threadIdentifier")) + #expect(jsNames(for: .notificationsDeliveredRead) == ["apple.notifications.listDelivered"]) + #expect(jsNames(for: .notificationsDeliveredDelete) == ["apple.notifications.removeDelivered"]) + + let cameraCapture = try #require(descriptors[.cameraUICapture]) + #expect(cameraCapture.optionalArguments.contains("cameraDevice")) + #expect(cameraCapture.optionalArguments.contains("maximumDurationSeconds")) + + let scanData = try #require(descriptors[.cameraUIScanData]) + #expect(scanData.optionalArguments.contains("isGuidanceEnabled")) + + let alert = try #require(descriptors[.uiAlertPresent]) + #expect(alert.optionalArguments.contains("sourceRect")) +} + +@Test func bigTicketAPIDescriptorsExposeExpectedJavaScriptNames() throws { + let descriptors = Dictionary( + uniqueKeysWithValues: DefaultCapabilityLoader.loadAllRegistrations().map { ($0.descriptor.id, $0.descriptor) } + ) + + let cloudKit = try #require(descriptors[.cloudKitRecordsQuery]) + #expect(cloudKit.requiredArguments == ["recordType"]) + #expect(cloudKit.optionalArguments.contains("database")) + #expect(jsNames(for: .cloudKitRecordsQuery) == ["apple.cloudkit.queryRecords"]) + #expect(jsNames(for: .cloudKitSubscriptionEventsRead) == ["apple.cloudkit.listEvents"]) + + let remote = try #require(descriptors[.notificationsRemoteRegister]) + #expect(remote.summary.contains("APNs")) + #expect(jsNames(for: .notificationsRemoteTokenRead) == ["apple.notifications.getRemoteToken"]) + #expect(jsNames(for: .notificationsResponsesRead) == ["apple.notifications.listResponses"]) + + let speechFile = try #require(descriptors[.speechFileTranscribe]) + #expect(speechFile.requiredPermissions == [.speechRecognition]) + #expect(speechFile.optionalArguments.contains("locale")) + #expect(jsNames(for: .speechMicrophoneTranscribe) == ["apple.speech.transcribeMicrophone"]) + + let appIntentRun = try #require(descriptors[.appIntentsRun]) + #expect(appIntentRun.requiredArguments == ["identifier"]) + #expect(jsNames(for: .appIntentsHandoffsRead) == ["apple.appIntents.listHandoffs"]) + + let foundationGenerate = try #require(descriptors[.foundationModelsGenerate]) + #expect(foundationGenerate.requiredArguments == ["prompt"]) + #expect(jsNames(for: .foundationModelsExtract) == ["apple.foundationModels.extract"]) + + let activityStart = try #require(descriptors[.activityStart]) + #expect(activityStart.requiredArguments == ["activityType", "attributes"]) + #expect(jsNames(for: .activityPushTokenRead) == ["apple.activity.getPushToken"]) + + let mapsSearch = try #require(descriptors[.mapsSearch]) + #expect(mapsSearch.requiredArguments == ["query"]) + #expect(jsNames(for: .mapsRouteEstimate) == ["apple.maps.routeEstimate"]) + + let musicLibrary = try #require(descriptors[.musicLibraryRead]) + #expect(musicLibrary.requiredPermissions == [.music]) + #expect(jsNames(for: .musicPlaybackControl) == ["apple.music.play"]) + + let walletPayment = try #require(descriptors[.passKitApplePayPresent]) + #expect(walletPayment.argumentHints["confirmed"]?.contains("explicit user-visible confirmation") == true) + #expect(jsNames(for: .passKitPassAdd) == ["apple.wallet.addPass"]) + + let storePurchase = try #require(descriptors[.storeKitPurchase]) + #expect(storePurchase.requiredArguments == ["productID", "confirmed"]) + #expect(storePurchase.argumentHints["confirmed"]?.contains("explicit user-visible confirmation") == true) + #expect(jsNames(for: .storeKitTransactionsRead) == ["apple.storekit.listTransactions"]) +} + +@Test func bigTicketCapabilitiesHaveExpectedPlatformScope() { + let crossApple: Set = [ + .cloudKitRecordsQuery, + .notificationsRemoteRegister, + .speechFileTranscribe, + .appIntentsRun, + .foundationModelsGenerate, + .mapsSearch, + .musicCatalogSearch, + .storeKitProductsRead, + ] + let iOSOnly: Set = [ + .activityStart, + .activityPushTokenRead, + .passKitPassAdd, + .passKitApplePayPresent, + ] + + #expect(crossApple.isSubset(of: CapabilityPlatformSupport.supportedCapabilities(for: .iOS))) + #expect(crossApple.isSubset(of: CapabilityPlatformSupport.supportedCapabilities(for: .macOS))) + #expect(crossApple.isSubset(of: CapabilityPlatformSupport.supportedCapabilities(for: .visionOS))) + #expect(iOSOnly.isSubset(of: CapabilityPlatformSupport.supportedCapabilities(for: .iOS))) + #expect(CapabilityPlatformSupport.supportedCapabilities(for: .macOS).isDisjoint(with: iOSOnly)) + #expect(CapabilityPlatformSupport.supportedCapabilities(for: .visionOS).isDisjoint(with: iOSOnly)) } @Test func filesystemListDescriptorDocumentsEntryObjects() throws { @@ -152,6 +333,36 @@ import Testing #expect(statuses == [.notDetermined, .granted]) } +@Test func registryAcceptsCalendarWriteOnlyStatusForCalendarWriteOnlyPermission() throws { + let descriptor = CapabilityDescriptor( + id: .calendarWrite, + title: "Calendar Write", + summary: "Test capability", + tags: ["test"], + example: "noop", + requiredPermissions: [.calendarWriteOnly] + ) + + let registry = CapabilityRegistry( + registrations: [ + CapabilityRegistration(descriptor: descriptor) { _, _ in + .string("ok") + } + ] + ) + + let broker = FixedPermissionBroker(statuses: [.calendarWriteOnly: .writeOnly]) + let (context, sandbox) = try makeInvocationContext( + permissionBroker: broker, + allowedCapabilities: [.calendarWrite] + ) + defer { cleanup(sandbox) } + + let value = try registry.invoke("calendar.write", arguments: [:], context: context) + #expect(value.stringValue == "ok") + #expect(context.allPermissionEvents().map(\.status) == [.writeOnly]) +} + @Test func registryValidationBlocksMissingRequiredArgsBeforePermissionChecks() throws { let descriptor = CapabilityDescriptor( id: .contactsSearch, @@ -231,6 +442,60 @@ import Testing } } +@Test func registryValidationRejectsDescriptorConstrainedValuesBeforePermissions() throws { + let descriptor = CapabilityDescriptor( + id: .musicPlaybackControl, + title: "Music Playback", + summary: "Test capability", + tags: ["test"], + example: "noop", + requiredPermissions: [.music], + requiredArguments: ["action"] + ) + + let registry = CapabilityRegistry( + registrations: [ + CapabilityRegistration(descriptor: descriptor) { _, _ in + .string("ok") + } + ] + ) + + let broker = FixedPermissionBroker(statuses: [.music: .denied]) + let (context, sandbox) = try makeInvocationContext( + permissionBroker: broker, + allowedCapabilities: [.musicPlaybackControl] + ) + defer { cleanup(sandbox) } + + do { + _ = try registry.invoke( + "music.playback.control", + arguments: ["action": .string("shuffleEverything")], + context: context + ) + Issue.record("Expected descriptor constraint validation to throw") + } catch { + #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") + } + + #expect(context.allPermissionEvents().isEmpty) +} + +@Test func descriptorConstraintsAreExposedThroughCatalogReferences() throws { + let registry = CapabilityRegistry(registrations: DefaultCapabilityLoader.loadAllRegistrations()) + let catalog = BridgeCatalog(registry: registry) + + let music = try #require(catalog.reference(for: .musicPlaybackControl)) + #expect(music.argumentConstraints.allowedStringValues["action"]?.contains("playCatalog") == true) + + let maps = try #require(catalog.reference(for: .mapsRouteEstimate)) + #expect(maps.argumentConstraints.allowedStringValues["transportType"] == ["automobile", "walking", "transit", "any"]) + + let network = try #require(catalog.reference(for: .networkFetch)) + #expect(network.argumentConstraints.allowedStringValues["options.responseEncoding"] == ["text", "base64"]) +} + @Test func registryValidationRejectsUnknownArguments() throws { let descriptor = CapabilityDescriptor( id: .fsRead, diff --git a/Tests/CodeModeTests/CodeModeProviderTests.swift b/Tests/CodeModeTests/CodeModeProviderTests.swift new file mode 100644 index 0000000..67387c9 --- /dev/null +++ b/Tests/CodeModeTests/CodeModeProviderTests.swift @@ -0,0 +1,634 @@ +import Foundation +import Testing +@testable import CodeMode + +private struct ManualProvider: CodeModeProvider { + let codeModePath = "myapp.api" + + func codeModeRegistrations() -> [CodeModeRegistration] { + [ + CodeModeRegistration( + capabilityKey: "myapp.api.doTheThing", + jsPath: "myapp.api.doTheThing", + title: "doTheThing", + summary: "Do the thing.", + tags: ["myapp", "api"], + example: #"await myapp.api.doTheThing({ id: "123" })"#, + requiredArguments: ["id"], + argumentTypes: ["id": .string], + argumentHints: ["id": "Thing identifier"], + resultSummary: "Echoed identifier" + ) { arguments, _ in + .object([ + "id": .string(try CodeModeArgumentDecoder.requireString("id", in: arguments)), + "done": .bool(true), + ]) + }, + CodeModeRegistration( + capabilityKey: "myapp.api.failInvalid", + jsPath: "myapp.api.failInvalid", + title: "failInvalid", + summary: "Fail with custom invalid arguments.", + example: "await myapp.api.failInvalid({})" + ) { _, _ in + throw CodeModeFunctionError.invalidArguments("Custom invalid argument") + }, + ] + } +} + +@Test func customProviderAppearsInSearchCatalog() async throws { + let (tools, sandbox) = try makeTools(codeModeProviders: [ManualProvider()]) + defer { cleanup(sandbox) } + + let response = try await tools.searchJavaScriptAPI( + JavaScriptAPISearchRequest( + code: """ + async () => { + const ref = api.byJSName["myapp.api.doTheThing"]; + return { + capability: ref.capability, + jsNames: ref.jsNames, + summary: ref.summary, + requiredArguments: ref.requiredArguments, + argumentHints: ref.argumentHints + }; + } + """ + ) + ) + + let result = try #require(response.result?.objectValue) + #expect(result.string("capability") == "myapp.api.doTheThing") + #expect(result.array("jsNames") == [.string("myapp.api.doTheThing")]) + #expect(result.string("summary") == "Do the thing.") + #expect(result.array("requiredArguments") == [.string("id")]) + #expect(result.object("argumentHints")?.string("id") == "Thing identifier") +} + +@Test func builtInCatalogUsesRegistrationJavaScriptNames() throws { + let descriptor = CapabilityDescriptor( + id: .weatherRead, + title: "Weather", + summary: "Read weather.", + tags: ["weather"], + example: "await custom.weather.current({ latitude: 0, longitude: 0 })", + requiredArguments: ["latitude", "longitude"], + resultSummary: "Weather payload" + ) + let registry = CapabilityRegistry( + registrations: [ + CapabilityRegistration( + jsNames: ["custom.weather.current"], + descriptor: descriptor + ) { _, _ in + .null + }, + ] + ) + + let catalog = BridgeCatalog(registry: registry) + let reference = try #require(catalog.reference(for: .weatherRead)) + #expect(reference.jsNames == ["custom.weather.current"]) + #expect(reference.summary == "Read weather.") +} + +@Test func builtInRuntimeInstallsRegistrationBackedFallbackBindings() async throws { + let descriptor = CapabilityDescriptor( + id: .weatherRead, + title: "Weather", + summary: "Read weather.", + tags: ["weather"], + example: "await custom.weather.current({ latitude: 0, longitude: 0 })", + requiredArguments: ["latitude", "longitude"], + resultSummary: "Weather payload" + ) + let registry = CapabilityRegistry( + registrations: [ + CapabilityRegistration( + jsNames: ["custom.weather.current"], + descriptor: descriptor + ) { arguments, _ in + .object([ + "latitude": arguments["latitude"] ?? .null, + "ok": .bool(true), + ]) + }, + ] + ) + let catalog = BridgeCatalog(registry: registry) + let runtime = BridgeRuntime(registry: registry, catalog: catalog, config: .init()) + + let call = runtime.makeExecutionCall( + JavaScriptExecutionRequest( + code: "return await custom.weather.current({ latitude: 12, longitude: 34 });", + allowedCapabilities: [.weatherRead] + ) + ) + let observed = await observe(call) + let output = try #require(observed.result?.output?.objectValue) + #expect(output["latitude"] == .number(12)) + #expect(output["ok"] == .bool(true)) +} + +@Test func metadataOnlyBuiltInHelpersInstallFromRegistrationMetadata() async throws { + let metadataOnlyPrefixes = [ + "apple.vision.", + "apple.notifications.", + "ios.alarm.", + "apple.health.", + "apple.home.", + "apple.media.", + "apple.cloudkit.", + "apple.speech.", + "apple.appIntents.", + "apple.foundationModels.", + "apple.activity.", + "apple.maps.", + "apple.music.", + "apple.wallet.", + "apple.storekit.", + "apple.fs.", + ] + func isMetadataOnly(_ jsName: String) -> Bool { + metadataOnlyPrefixes.contains { jsName.hasPrefix($0) } + } + + let registrations = DefaultCapabilityLoader.loadAllRegistrations() + .compactMap { registration -> CapabilityRegistration? in + let jsNames = registration.jsNames.filter(isMetadataOnly) + guard jsNames.isEmpty == false else { + return nil + } + return CapabilityRegistration( + jsNames: jsNames, + descriptor: registration.descriptor + ) { _, _ in + .object(["capability": .string(registration.descriptor.id.rawValue)]) + } + } + let jsNames = registrations.flatMap(\.jsNames).sorted() + + #expect(jsNames.isEmpty == false) + for jsName in jsNames { + #expect(RuntimeJavaScript.bootstrap.contains(jsName) == false) + } + + let registry = CapabilityRegistry(registrations: registrations) + let catalog = BridgeCatalog(registry: registry) + let runtime = BridgeRuntime(registry: registry, catalog: catalog, config: .init(hostPlatform: .iOS)) + let checks = jsNames + .map { name in "\(jsonString(name)): typeof \(name) === 'function'" } + .joined(separator: ",\n") + + let call = runtime.makeExecutionCall( + JavaScriptExecutionRequest( + code: """ + return { + \(checks) + }; + """, + allowedCapabilities: [] + ) + ) + let observed = await observe(call) + let output = try #require(observed.result?.output?.objectValue) + + for jsName in jsNames { + #expect(output[jsName] == .bool(true)) + } + + let macRuntime = BridgeRuntime( + registry: registry, + catalog: catalog, + config: .init(hostPlatform: .macOS), + unsupportedBuiltInJavaScriptNames: CapabilityPlatformSupport.unsupportedJavaScriptNames( + from: registrations, + for: .macOS + ) + ) + let macCall = macRuntime.makeExecutionCall( + JavaScriptExecutionRequest( + code: """ + return { + alarm: (typeof ios === 'undefined' || typeof ios.alarm === 'undefined') ? 'undefined' : typeof ios.alarm.schedule, + cloudkit: typeof apple.cloudkit.queryRecords + }; + """, + allowedCapabilities: [] + ) + ) + let macObserved = await observe(macCall) + let macOutput = try #require(macObserved.result?.output?.objectValue) + #expect(macOutput["alarm"] == .string("undefined")) + #expect(macOutput["cloudkit"] == .string("function")) +} + +@Test func everyBuiltInJavaScriptNameInvokesRegistrationOwnedCapabilityAndPrunesUnsupportedNames() async throws { + let invocations = SynchronizedBox<[String]>([]) + let allRegistrations = DefaultCapabilityLoader.loadAllRegistrations() + let fakeRegistrations = allRegistrations.map { registration in + CapabilityRegistration( + jsNames: registration.jsNames, + descriptor: registration.descriptor + ) { _, _ in + invocations.mutate { $0.append(registration.descriptor.id.rawValue) } + return fakeRuntimeValue(for: registration.descriptor.id) + } + } + let checks = fakeRegistrations + .flatMap { registration in + registration.jsNames.map { jsName in + BuiltInJavaScriptBindingCheck( + jsName: jsName, + descriptor: registration.descriptor + ) + } + } + .sorted { $0.jsName < $1.jsName } + + #expect(checks.isEmpty == false) + + let registry = CapabilityRegistry(registrations: fakeRegistrations) + let catalog = BridgeCatalog(registry: registry) + let permissionBroker = FixedPermissionBroker( + statuses: Dictionary(uniqueKeysWithValues: PermissionKind.allCases.map { ($0, PermissionStatus.granted) }) + ) + let runtime = BridgeRuntime( + registry: registry, + catalog: catalog, + config: .init(permissionBroker: permissionBroker, hostPlatform: .iOS) + ) + let statements = checks.map { check in + """ + try { + await \(javaScriptInvocation(for: check.jsName, descriptor: check.descriptor)); + } catch (error) { + failures.push({ + jsName: \(jsonString(check.jsName)), + code: error && error.code ? String(error.code) : null, + message: error && error.message ? String(error.message) : String(error) + }); + } + """ + }.joined(separator: "\n") + + let call = runtime.makeExecutionCall( + JavaScriptExecutionRequest( + code: """ + const failures = []; + \(statements) + return { failures }; + """, + allowedCapabilities: Array(CapabilityID.allCases), + timeoutMs: 20_000 + ) + ) + let observed = await observe(call) + let output = try #require(observed.result?.output?.objectValue) + #expect(output.array("failures") == []) + #expect(invocations.get() == checks.map { $0.descriptor.id.rawValue }) + + let macRegistrations = CapabilityPlatformSupport.filter(fakeRegistrations, for: .macOS) + let unsupportedMacNames = CapabilityPlatformSupport.unsupportedJavaScriptNames(from: fakeRegistrations, for: .macOS) + #expect(unsupportedMacNames.isEmpty == false) + + let macRegistry = CapabilityRegistry(registrations: macRegistrations) + let macRuntime = BridgeRuntime( + registry: macRegistry, + catalog: BridgeCatalog(registry: macRegistry), + config: .init(permissionBroker: permissionBroker, hostPlatform: .macOS), + unsupportedBuiltInJavaScriptNames: unsupportedMacNames + ) + let unsupportedChecks = unsupportedMacNames + .sorted() + .map { name in "\(jsonString(name)): __typeOfPath(\(jsonString(name)))" } + .joined(separator: ",\n") + let macCall = macRuntime.makeExecutionCall( + JavaScriptExecutionRequest( + code: """ + function __typeOfPath(path) { + const parts = String(path).split('.').filter(function(part){ return part.length > 0; }); + let value = globalThis; + for (let i = 0; i < parts.length; i++) { + if (!value || typeof value[parts[i]] === 'undefined') return 'undefined'; + value = value[parts[i]]; + } + return typeof value; + } + return { + \(unsupportedChecks) + }; + """, + allowedCapabilities: [] + ) + ) + let macObserved = await observe(macCall) + let macOutput = try #require(macObserved.result?.output?.objectValue) + for name in unsupportedMacNames { + #expect(macOutput[name] == .string("undefined")) + } +} + +@Test func platformPruningCanUseRegistrationJavaScriptNames() throws { + let descriptor = CapabilityDescriptor( + id: .calendarUIPresentNewEvent, + title: "Present New Event", + summary: "Present a new event editor.", + tags: ["calendar", "ui"], + example: "await custom.calendar.presentNewEvent({ title: \"Plan\" })" + ) + let registration = CapabilityRegistration( + jsNames: ["custom.calendar.presentNewEvent"], + descriptor: descriptor + ) { _, _ in + .null + } + + #expect( + CapabilityPlatformSupport.unsupportedJavaScriptNames( + from: [registration], + for: .macOS + ) == ["custom.calendar.presentNewEvent"] + ) + #expect( + CapabilityPlatformSupport.unsupportedJavaScriptNames( + from: [registration], + for: .iOS + ).isEmpty + ) +} + +private func jsonString(_ value: String) -> String { + guard let data = try? JSONEncoder.codeModeBridge.encode(value), + let string = String(data: data, encoding: .utf8) + else { + return "\"\"" + } + return string +} + +private struct BuiltInJavaScriptBindingCheck { + var jsName: String + var descriptor: CapabilityDescriptor +} + +private func javaScriptInvocation(for jsName: String, descriptor: CapabilityDescriptor) -> String { + switch jsName { + case "fetch": + return #"fetch("https://example.com", {})"# + case "fs.promises.readFile": + return #"fs.promises.readFile("tmp:input.txt", "utf8")"# + case "fs.promises.writeFile": + return #"fs.promises.writeFile("tmp:output.txt", "data", "utf8")"# + case "fs.promises.readdir": + return #"fs.promises.readdir("tmp:")"# + case "fs.promises.stat": + return #"fs.promises.stat("tmp:input.txt")"# + case "fs.promises.access": + return #"fs.promises.access("tmp:input.txt")"# + case "fs.promises.mkdir": + return #"fs.promises.mkdir("tmp:folder", { recursive: true })"# + case "fs.promises.rm": + return #"fs.promises.rm("tmp:input.txt", { recursive: true })"# + case "fs.promises.rename": + return #"fs.promises.rename("tmp:from.txt", "tmp:to.txt")"# + case "fs.promises.copyFile": + return #"fs.promises.copyFile("tmp:from.txt", "tmp:to.txt")"# + case "apple.keychain.get": + return #"apple.keychain.get("sample-key")"# + case "apple.keychain.set": + return #"apple.keychain.set("sample-key", "sample-value")"# + case "apple.keychain.delete": + return #"apple.keychain.delete("sample-key")"# + case "apple.location.getPermissionStatus", + "apple.location.requestPermission", + "apple.location.getCurrentPosition": + return "\(jsName)()" + default: + return "\(jsName)(\(jsonLiteral(sampleArguments(for: descriptor))))" + } +} + +private func sampleArguments(for descriptor: CapabilityDescriptor) -> JSONValue { + let fields = descriptor.requiredArguments.reduce(into: [String: JSONValue]()) { result, name in + result[name] = sampleArgumentValue(name: name, type: descriptor.argumentTypes[name]) + } + return .object(fields) +} + +private func sampleArgumentValue(name: String, type: CapabilityArgumentType?) -> JSONValue { + switch name { + case "url": + return .string("https://example.com") + case "path": + return .string("tmp:input.txt") + case "from": + return .string("tmp:from.txt") + case "to": + return .string("tmp:to.txt") + case "key": + return .string("sample-key") + case "latitude": + return .number(37.7749) + case "longitude": + return .number(-122.4194) + case "start": + return .string("2026-01-01T00:00:00Z") + case "end": + return .string("2026-01-01T01:00:00Z") + case "title": + return .string("Sample") + case "identifier", "localIdentifier", "recordName", "subscriptionID", "productID", "paymentRequestID", + "accessoryIdentifier", "characteristicType", "type", "name": + return .string("sample-id") + case "recordType": + return .string("Task") + case "fields": + if type == .array { + return .array([ + .object([ + "id": .string("name"), + "label": .string("Name"), + ]), + ]) + } + return .object(["title": .string("Sample")]) + case "categories": + return .array([ + .object([ + "identifier": .string("task"), + "actions": .array([ + .object([ + "identifier": .string("done"), + "title": .string("Done"), + ]), + ]), + ]), + ]) + case "buttons": + return .array([ + .object([ + "id": .string("ok"), + "title": .string("OK"), + ]), + ]) + case "activityType": + return .string("delivery") + case "attributes", "contentState", "parameters": + return .object([:]) + case "origin": + return .object(["latitude": .number(37.7749), "longitude": .number(-122.4194)]) + case "destination": + return .object(["latitude": .number(37.7849), "longitude": .number(-122.4094)]) + case "address": + return .string("1 Market St, San Francisco, CA") + case "query", "term", "input", "prompt": + return .string("sample") + case "productIDs": + return .array([.string("product.sample")]) + case "confirmed": + return .bool(true) + case "action": + return .string("play") + default: + break + } + + switch type { + case .string: + return .string("sample") + case .number: + return .number(1) + case .bool: + return .bool(true) + case .object: + return .object([:]) + case .array: + return .array([]) + case .any, .none: + return .string("sample") + } +} + +private func fakeRuntimeValue(for capability: CapabilityID) -> JSONValue { + switch capability { + case .networkFetch: + return .object([ + "ok": .bool(true), + "status": .number(200), + "statusText": .string("OK"), + "headers": .object([:]), + "bodyText": .string("{}"), + "bodyBase64": .string("e30="), + ]) + case .fsRead: + return .object([ + "path": .string("tmp:input.txt"), + "text": .string("sample"), + "base64": .string("c2FtcGxl"), + ]) + case .fsList: + return .array([]) + case .fsStat: + return .object([ + "path": .string("tmp:input.txt"), + "isDirectory": .bool(false), + "size": .number(0), + ]) + default: + return .object(["ok": .bool(true)]) + } +} + +private func jsonLiteral(_ value: JSONValue) -> String { + guard let data = try? JSONEncoder.codeModeBridge.encode(value), + let string = String(data: data, encoding: .utf8) + else { + return "{}" + } + return string +} + +@Test func customProviderRequiresAllowedCapabilityKey() async throws { + let (tools, sandbox) = try makeTools(codeModeProviders: [ManualProvider()]) + defer { cleanup(sandbox) } + + let deniedCall = try await tools.executeJavaScript( + JavaScriptExecutionRequest( + code: #"return await myapp.api.doTheThing({ id: "123" });"#, + allowedCapabilities: [] + ) + ) + let denied = await observe(deniedCall) + let deniedError = try #require(denied.error) + #expect(deniedError.code == "CAPABILITY_DENIED") + #expect(deniedError.capabilityKey == "myapp.api.doTheThing") + + let allowedCall = try await tools.executeJavaScript( + JavaScriptExecutionRequest( + code: #"return await myapp.api.doTheThing({ id: "123" });"#, + allowedCapabilities: [], + allowedCapabilityKeys: ["myapp.api.doTheThing"] + ) + ) + let allowed = await observe(allowedCall) + let result = try #require(allowed.result?.output?.objectValue) + #expect(result.string("id") == "123") + #expect(result.bool("done") == true) +} + +@Test func builtInCapabilitiesCanBeAllowedByCapabilityKey() async throws { + let (tools, sandbox) = try makeTools() + defer { cleanup(sandbox) } + + let call = try await tools.executeJavaScript( + JavaScriptExecutionRequest( + code: #"return await apple.keychain.get("missing-provider-key-test");"#, + allowedCapabilities: [], + allowedCapabilityKeys: [CapabilityID.keychainRead.codeModeKey] + ) + ) + let observed = await observe(call) + #expect(observed.result?.output == .null) +} + +@Test func customProviderFunctionErrorsRemainStructured() async throws { + let (tools, sandbox) = try makeTools(codeModeProviders: [ManualProvider()]) + defer { cleanup(sandbox) } + + let call = try await tools.executeJavaScript( + JavaScriptExecutionRequest( + code: "return await myapp.api.failInvalid({});", + allowedCapabilities: [], + allowedCapabilityKeys: ["myapp.api.failInvalid"] + ) + ) + let observed = await observe(call) + let error = try #require(observed.error) + #expect(error.code == "INVALID_ARGUMENTS") + #expect(error.message == "Custom invalid argument") + #expect(error.capabilityKey == "myapp.api.failInvalid") +} + +@Test func builtInAndCustomProvidersCoexist() async throws { + let (tools, sandbox) = try makeTools(codeModeProviders: [ManualProvider()]) + defer { cleanup(sandbox) } + + let response = try await tools.searchJavaScriptAPI( + JavaScriptAPISearchRequest( + code: """ + async () => { + return { + custom: api.byJSName["myapp.api.doTheThing"].capability, + builtIn: api.byJSName["apple.weather.getCurrentWeather"].capability + }; + } + """ + ) + ) + + let result = try #require(response.result?.objectValue) + #expect(result.string("custom") == "myapp.api.doTheThing") + #expect(result.string("builtIn") == CapabilityID.weatherRead.rawValue) +} diff --git a/Tests/CodeModeTests/EventInboxTests.swift b/Tests/CodeModeTests/EventInboxTests.swift new file mode 100644 index 0000000..6a3eb86 --- /dev/null +++ b/Tests/CodeModeTests/EventInboxTests.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing +@testable import CodeMode + +@Test func boundedEventInboxAppendsReadsAndEvictsPerSource() throws { + let inbox = BoundedCodeModeEventInbox(maxEventsPerSource: 2) + let oldDate = Date(timeIntervalSince1970: 1_000) + let newDate = Date(timeIntervalSince1970: 2_000) + + try inbox.appendEvent(CodeModeInboxEvent(id: "cloud-1", source: "cloudkit.subscription", timestamp: oldDate, payload: .object(["recordName": .string("old")]))) + try inbox.appendEvent(CodeModeInboxEvent(id: "cloud-2", source: "cloudkit.subscription", timestamp: newDate, payload: .object(["recordName": .string("new")]))) + try inbox.appendEvent(CodeModeInboxEvent(id: "notification-1", source: "notifications.response", payload: .object(["actionIdentifier": .string("open")]))) + try inbox.appendEvent(CodeModeInboxEvent( + id: "cloud-3", + source: "cloudkit.subscription", + payload: .object(["recordName": .string("latest")]), + metadata: ["subscriptionID": .string("tasks")] + )) + + let cloudEvents = try requireArray(inbox.readEvents(source: "cloudkit.subscription", arguments: ["limit": .number(10)])) + #expect(cloudEvents.count == 2) + + let newest = try #require(cloudEvents.first?.objectValue) + #expect(newest["id"]?.stringValue == "cloud-3") + #expect(newest["source"]?.stringValue == "cloudkit.subscription") + #expect(newest["payload"]?.objectValue?["recordName"]?.stringValue == "latest") + #expect(newest["metadata"]?.objectValue?["subscriptionID"]?.stringValue == "tasks") + + let oldestRemaining = try #require(cloudEvents.last?.objectValue) + #expect(oldestRemaining["id"]?.stringValue == "cloud-2") + + let notificationEvents = try requireArray(inbox.readEvents(source: "notifications.response", arguments: [:])) + #expect(notificationEvents.count == 1) + #expect(notificationEvents.first?.objectValue?["id"]?.stringValue == "notification-1") +} + +@Test func boundedEventInboxHonorsReadLimitAndRejectsInvalidInput() throws { + let inbox = BoundedCodeModeEventInbox(maxEventsPerSource: 5) + try inbox.appendEvent(CodeModeInboxEvent(id: "1", source: "storekit.transaction", payload: .object([:]))) + try inbox.appendEvent(CodeModeInboxEvent(id: "2", source: "storekit.transaction", payload: .object([:]))) + + let limited = try requireArray(inbox.readEvents(source: "storekit.transaction", arguments: ["limit": .number(1)])) + #expect(limited.compactMap { $0.objectValue?["id"]?.stringValue } == ["2"]) + + do { + _ = try inbox.readEvents(source: "storekit.transaction", arguments: ["limit": .number(0)]) + Issue.record("Expected invalid inbox limit to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") + } + + do { + try inbox.appendEvent(CodeModeInboxEvent(source: " ", payload: .null)) + Issue.record("Expected empty source append to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") + } +} + +@Test func unavailableEventInboxAppendAndReadReportUnsupportedPlatform() throws { + let inbox = UnavailableCodeModeEventInbox() + + do { + try inbox.appendEvent(CodeModeInboxEvent(source: "cloudkit.subscription", payload: .object([:]))) + Issue.record("Expected append on unavailable inbox to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "UNSUPPORTED_PLATFORM") + } + + do { + _ = try inbox.readEvents(source: "cloudkit.subscription", arguments: [:]) + Issue.record("Expected read on unavailable inbox to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "UNSUPPORTED_PLATFORM") + } +} diff --git a/Tests/CodeModeTests/EventKitBridgeTests.swift b/Tests/CodeModeTests/EventKitBridgeTests.swift index 79de329..6fb95b9 100644 --- a/Tests/CodeModeTests/EventKitBridgeTests.swift +++ b/Tests/CodeModeTests/EventKitBridgeTests.swift @@ -24,6 +24,24 @@ import Testing } catch { #expect(requireBridgeErrorCode(error) == "PERMISSION_DENIED") } + + do { + _ = try bridge.writeEvent(arguments: [ + "operation": .string("update"), + "identifier": .string("event-1"), + "title": .string("Updated"), + ], context: context) + Issue.record("Expected permission denial for calendar.write update") + } catch { + #expect(requireBridgeErrorCode(error) == "PERMISSION_DENIED") + } + + do { + _ = try bridge.deleteEvent(arguments: ["identifier": .string("event-1")], context: context) + Issue.record("Expected permission denial for calendar.delete") + } catch { + #expect(requireBridgeErrorCode(error) == "PERMISSION_DENIED") + } } @Test func eventKitReminderOperationsRequirePermission() throws { @@ -45,6 +63,13 @@ import Testing } catch { #expect(requireBridgeErrorCode(error) == "PERMISSION_DENIED") } + + do { + _ = try bridge.deleteReminder(arguments: ["identifier": .string("reminder-1")], context: context) + Issue.record("Expected permission denial for reminders.delete") + } catch { + #expect(requireBridgeErrorCode(error) == "PERMISSION_DENIED") + } } @Test func executeUsesCalendarBridgeWithPermissionDenial() async throws { diff --git a/Tests/CodeModeTests/FileSystemBridgeTests.swift b/Tests/CodeModeTests/FileSystemBridgeTests.swift index 6dd2b94..2cae75c 100644 --- a/Tests/CodeModeTests/FileSystemBridgeTests.swift +++ b/Tests/CodeModeTests/FileSystemBridgeTests.swift @@ -168,6 +168,30 @@ private final class RecordingCodeModeFileSystem: CodeModeFileSystem, @unchecked } } +@Test func fileSystemRejectsSymlinkEscapeThroughAllowedRoot() async throws { + let (tools, sandbox) = try makeTools() + defer { cleanup(sandbox) } + + let outside = sandbox.root.appendingPathComponent("outside", isDirectory: true) + try FileManager.default.createDirectory(at: outside, withIntermediateDirectories: true) + try Data("outside".utf8).write(to: outside.appendingPathComponent("secret.txt")) + try FileManager.default.createSymbolicLink( + at: sandbox.tmp.appendingPathComponent("escape"), + withDestinationURL: outside + ) + + let observed = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: "return await apple.fs.read({ path: 'tmp:escape/secret.txt' });", + allowedCapabilities: [.fsRead] + ) + ) + + #expect(observed.result == nil) + #expect(observed.error?.code == "PATH_POLICY_VIOLATION") +} + @Test func executeUsesConfiguredFileSystemOperations() async throws { let fileSystem = RecordingCodeModeFileSystem() let (tools, sandbox) = try makeTools(fileSystem: fileSystem) diff --git a/Tests/CodeModeTests/HealthBridgeTests.swift b/Tests/CodeModeTests/HealthBridgeTests.swift index 1690a71..d34a723 100644 --- a/Tests/CodeModeTests/HealthBridgeTests.swift +++ b/Tests/CodeModeTests/HealthBridgeTests.swift @@ -2,6 +2,24 @@ import Foundation import Testing @testable import CodeMode +#if canImport(HealthKit) +import HealthKit +#endif + +@Test func systemPermissionBrokerDoesNotReportGlobalHealthKitGrant() { + let status = SystemPermissionBroker().status(for: .healthKit) + + #if canImport(HealthKit) + if HKHealthStore.isHealthDataAvailable() { + #expect(status == .notDetermined) + } else { + #expect(status == .unavailable) + } + #else + #expect(status == .unavailable) + #endif +} + @Test func healthOperationsRequirePermission() throws { let bridge = HealthBridge() let broker = FixedPermissionBroker(statuses: [.healthKit: .denied]) diff --git a/Tests/CodeModeTests/HostConfigurationValidatorTests.swift b/Tests/CodeModeTests/HostConfigurationValidatorTests.swift index d2609b0..671aa27 100644 --- a/Tests/CodeModeTests/HostConfigurationValidatorTests.swift +++ b/Tests/CodeModeTests/HostConfigurationValidatorTests.swift @@ -52,14 +52,14 @@ import Testing #expect(issues.contains(where: { $0.key == "WeatherKit capability" && $0.severity == .warning })) } -@Test func validatorRequiresWriteOnlyCalendarKeyForCalendarWrite() { +@Test func validatorRequiresCalendarKeysForCalendarWriteLifecycle() { let issues = HostConfigurationValidator.validate( requiredCapabilities: [.calendarWrite], infoPlist: [:] ) #expect(issues.contains(where: { $0.key == "NSCalendarsWriteOnlyAccessUsageDescription" && $0.severity == .error })) - #expect(issues.contains(where: { $0.key == "NSCalendarsFullAccessUsageDescription" }) == false) + #expect(issues.contains(where: { $0.key == "NSCalendarsFullAccessUsageDescription" && $0.severity == .error })) } @Test func validatorRequiresWriteOnlyCalendarKeyForCalendarUIPresentationOnly() { @@ -127,7 +127,7 @@ import Testing @Test func validatorAddsNotificationsAndHomeWarnings() { let issues = HostConfigurationValidator.validate( - requiredCapabilities: [.notificationsSchedule, .homeRead], + requiredCapabilities: [.notificationsSchedule, .notificationsDeliveredRead, .notificationsDeliveredDelete, .homeRead], infoPlist: [ "NSHomeKitUsageDescription": "Need HomeKit to read home accessories", ] @@ -157,3 +157,47 @@ import Testing #expect(issues.contains(where: { $0.key == "NSHealthUpdateUsageDescription" && $0.severity == .error })) #expect(issues.contains(where: { $0.key == "HealthKit capability" && $0.severity == .warning })) } + +@Test func validatorRequiresSpeechAndMusicPrivacyKeys() { + let keys = HostConfigurationValidator.requiredInfoPlistKeys( + for: [.speechFileTranscribe, .speechMicrophoneTranscribe, .musicLibraryRead, .musicPlaybackControl] + ) + + #expect(keys.contains("NSSpeechRecognitionUsageDescription")) + #expect(keys.contains("NSMicrophoneUsageDescription")) + #expect(keys.contains("NSAppleMusicUsageDescription")) +} + +@Test func validatorAddsBigTicketConfigurationWarnings() { + let issues = HostConfigurationValidator.validate( + requiredCapabilities: [ + .cloudKitRecordsQuery, + .cloudKitSubscriptionSave, + .notificationsRemoteRegister, + .notificationsCategoriesSet, + .speechMicrophoneTranscribe, + .appIntentsRun, + .foundationModelsGenerate, + .activityStart, + .musicCatalogSearch, + .passKitPassAdd, + .passKitApplePayPresent, + .storeKitPurchase, + ], + infoPlist: [ + "NSSpeechRecognitionUsageDescription": "Need speech recognition", + "NSMicrophoneUsageDescription": "Need microphone", + ] + ) + + #expect(issues.contains(where: { $0.key == "CloudKit capability" && $0.severity == .warning })) + #expect(issues.contains(where: { $0.key == "APNs client configuration" && $0.severity == .warning })) + #expect(issues.contains(where: { $0.key == "Speech capability" && $0.severity == .warning })) + #expect(issues.contains(where: { $0.key == "App Intents adapters" && $0.severity == .warning })) + #expect(issues.contains(where: { $0.key == "Foundation Models availability" && $0.severity == .warning })) + #expect(issues.contains(where: { $0.key == "ActivityKit adapters" && $0.severity == .warning })) + #expect(issues.contains(where: { $0.key == "MusicKit capability" && $0.severity == .warning })) + #expect(issues.contains(where: { $0.key == "PassKit Wallet capability" && $0.severity == .warning })) + #expect(issues.contains(where: { $0.key == "Apple Pay merchant configuration" && $0.severity == .warning })) + #expect(issues.contains(where: { $0.key == "StoreKit configuration" && $0.severity == .warning })) +} diff --git a/Tests/CodeModeTests/HostRuntimeTests.swift b/Tests/CodeModeTests/HostRuntimeTests.swift index 79f425f..158798a 100644 --- a/Tests/CodeModeTests/HostRuntimeTests.swift +++ b/Tests/CodeModeTests/HostRuntimeTests.swift @@ -187,9 +187,10 @@ import Testing let result = try #require(response.result?.objectValue) #expect(result.string("capability") == CapabilityID.calendarWrite.rawValue) - #expect(result.array("requiredArguments")?.contains(.string("title")) == true) - #expect(result.array("requiredArguments")?.contains(.string("start")) == true) - #expect(result.array("requiredArguments")?.contains(.string("end")) == true) + #expect(result.array("optionalArguments")?.contains(.string("title")) == true) + #expect(result.array("optionalArguments")?.contains(.string("start")) == true) + #expect(result.array("optionalArguments")?.contains(.string("end")) == true) + #expect(result.array("jsNames")?.contains(.string("apple.calendar.updateEvent")) == true) } @Test func searchReturnsProjectedResults() async throws { @@ -262,9 +263,11 @@ import Testing #expect(observed.result == nil) #expect(observed.error?.code == "CAPABILITY_DENIED") + #expect(observed.error?.suggestions.contains("Add \"fs.read\" to allowedCapabilities and retry.") == true) #expect(observed.events.contains(where: { if case .toolError(let error) = $0 { - return error.code == "CAPABILITY_DENIED" + return error.code == "CAPABILITY_DENIED" && + error.suggestions.contains("Add \"fs.read\" to allowedCapabilities and retry.") } return false })) @@ -408,6 +411,32 @@ import Testing #expect(observed.events.last == .finished) } +@Test func rawBridgeAndInstallHelpersAreHiddenFromUserJavaScript() async throws { + let (tools, sandbox) = try makeTools() + defer { cleanup(sandbox) } + + let observed = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: """ + return { + bridge: typeof globalThis.__bridgeInvokeSync, + install: typeof globalThis.__codemodeInstallBinding, + installIfMissing: typeof globalThis.__codemodeInstallBindingIfMissing, + fsReadType: typeof apple.fs.read + }; + """, + allowedCapabilities: [] + ) + ) + + let payload = try requireJSONObject(from: try #require(observed.result)) + #expect(payload["bridge"] as? String == "undefined") + #expect(payload["install"] as? String == "undefined") + #expect(payload["installIfMissing"] as? String == "undefined") + #expect(payload["fsReadType"] as? String == "function") +} + @Test func executeSupportsLoopDrivenBridgeCalls() async throws { let (tools, sandbox) = try makeTools() defer { cleanup(sandbox) } @@ -482,6 +511,26 @@ import Testing #expect(observed.events.last == .finished) } +@Test func executeReturnsBareTopLevelAwaitValue() async throws { + let (tools, sandbox) = try makeTools() + defer { cleanup(sandbox) } + + let observed = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: "await Promise.resolve({ ok: true, count: 42 });", + allowedCapabilities: [] + ) + ) + + let result = try #require(observed.result) + let payload = try requireJSONObject(from: result) + #expect(payload["ok"] as? Bool == true) + #expect(payload["count"] as? Int == 42) + #expect(result.diagnostics.contains(where: { $0.code == "NO_RETURN_VALUE" }) == false) + #expect(observed.events.last == .finished) +} + @Test func executeReturnsNilOutputWhenScriptOmitsReturnValue() async throws { let (tools, sandbox) = try makeTools() defer { cleanup(sandbox) } @@ -556,6 +605,32 @@ import Testing #expect(observed.error?.code == "EXECUTION_TIMEOUT") } +@Test func executeRecoversWithFreshContextAfterTimeout() async throws { + let (tools, sandbox) = try makeTools() + defer { cleanup(sandbox) } + + let timedOut = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: "await new Promise(() => {});", + allowedCapabilities: [], + timeoutMs: 30 + ) + ) + #expect(timedOut.error?.code == "EXECUTION_TIMEOUT") + + let recovered = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: "return { ok: true };", + allowedCapabilities: [] + ) + ) + + let payload = try requireJSONObject(from: try #require(recovered.result)) + #expect(payload["ok"] as? Bool == true) +} + @Test func executeRecordsPermissionEventsOnFailures() async throws { let broker = FixedPermissionBroker(statuses: [.locationWhenInUse: .denied]) let (tools, sandbox) = try makeTools(permissionBroker: broker) @@ -571,6 +646,8 @@ import Testing #expect(observed.result == nil) #expect(observed.error?.code == "PERMISSION_DENIED") + #expect(observed.error?.suggestions.contains(where: { $0.contains("apple.location.requestPermission()") }) == true) + #expect(observed.error?.suggestions.contains(where: { $0.contains("Do not repair this by adding more allowedCapabilities") }) == true) #expect(observed.error?.permissionEvents.contains(where: { $0.permission == .locationWhenInUse }) == true) } diff --git a/Tests/CodeModeTests/NetworkBridgeTests.swift b/Tests/CodeModeTests/NetworkBridgeTests.swift index d1b73c8..5e444ae 100644 --- a/Tests/CodeModeTests/NetworkBridgeTests.swift +++ b/Tests/CodeModeTests/NetworkBridgeTests.swift @@ -41,6 +41,8 @@ private final class StubHTTPURLProtocol: URLProtocol { "url": request.url?.absoluteString ?? "", "method": request.httpMethod ?? "GET", "body": requestBody, + "timeoutMs": Int((request.timeoutInterval * 1_000).rounded()), + "xUnit": request.value(forHTTPHeaderField: "X-Unit") ?? "", ] let bodyData = (try? JSONSerialization.data(withJSONObject: payload, options: [])) ?? Data("{}".utf8) @@ -90,6 +92,64 @@ private final class StubHTTPURLProtocol: URLProtocol { #expect(decoded?["body"] as? String == "payload") } +@Test func networkFetchSupportsBase64BodyHeadersAndTimeout() throws { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [StubHTTPURLProtocol.self] + let session = URLSession(configuration: configuration) + + let bridge = NetworkBridge(session: session) + let (context, sandbox) = try makeInvocationContext() + defer { + cleanup(sandbox) + session.invalidateAndCancel() + } + + let result = try bridge.fetch(arguments: [ + "url": .string("https://unit.test/endpoint"), + "options": .object([ + "method": .string("PUT"), + "headers": .object(["X-Unit": .string("yes")]), + "bodyBase64": .string(Data("payload".utf8).base64EncodedString()), + "timeoutMs": .number(1_234), + ]), + ], context: context) + + let object = try requireObject(result) + let bodyText = object.string("bodyText") ?? "" + let decoded = try JSONSerialization.jsonObject(with: Data(bodyText.utf8)) as? [String: Any] + #expect(decoded?["method"] as? String == "PUT") + #expect(decoded?["body"] as? String == "payload") + #expect(decoded?["timeoutMs"] as? Int == 1_234) + #expect(decoded?["xUnit"] as? String == "yes") +} + +@Test func networkFetchCanReturnBase64Response() throws { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [StubHTTPURLProtocol.self] + let session = URLSession(configuration: configuration) + + let bridge = NetworkBridge(session: session) + let (context, sandbox) = try makeInvocationContext() + defer { + cleanup(sandbox) + session.invalidateAndCancel() + } + + let result = try bridge.fetch(arguments: [ + "url": .string("https://unit.test/endpoint"), + "options": .object([ + "responseEncoding": .string("base64"), + ]), + ], context: context) + + let object = try requireObject(result) + let encoded = try #require(object.string("bodyBase64")) + let data = try #require(Data(base64Encoded: encoded)) + let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any] + #expect(decoded?["method"] as? String == "GET") + #expect(object.string("bodyText") == "") +} + @Test func networkFetchRejectsInvalidURL() throws { let bridge = NetworkBridge() let (context, sandbox) = try makeInvocationContext() @@ -100,7 +160,33 @@ private final class StubHTTPURLProtocol: URLProtocol { Issue.record("Expected invalid URL to throw") } catch { let code = requireBridgeErrorCode(error) - #expect(code == "INVALID_ARGUMENTS" || code == "NATIVE_FAILURE") + #expect(code == "INVALID_ARGUMENTS") + } + + do { + _ = try bridge.fetch(arguments: ["url": .string("file:///tmp/a.txt")], context: context) + Issue.record("Expected non-HTTP URL to throw") + } catch { + #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") + } +} + +@Test func networkFetchRejectsConflictingRequestBodies() throws { + let bridge = NetworkBridge() + let (context, sandbox) = try makeInvocationContext() + defer { cleanup(sandbox) } + + do { + _ = try bridge.fetch(arguments: [ + "url": .string("https://unit.test/endpoint"), + "options": .object([ + "body": .string("payload"), + "bodyBase64": .string(Data("payload".utf8).base64EncodedString()), + ]), + ], context: context) + Issue.record("Expected body/bodyBase64 conflict to throw") + } catch { + #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") } } @@ -119,5 +205,25 @@ private final class StubHTTPURLProtocol: URLProtocol { ) ) - #expect(observed.error?.code == "INVALID_ARGUMENTS" || observed.error?.code == "NATIVE_FAILURE") + #expect(observed.error?.code == "INVALID_ARGUMENTS") +} + +@Test func executeURLPolyfillStringifiesURLObjects() async throws { + let (tools, sandbox) = try makeTools() + defer { cleanup(sandbox) } + + let observed = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: """ + const url = new URL("https://example.com/path?q=1"); + return { stringValue: String(url), methodValue: url.toString() }; + """, + allowedCapabilities: [] + ) + ) + + let result = try #require(observed.result?.output?.objectValue) + #expect(result.string("stringValue") == "https://example.com/path?q=1") + #expect(result.string("methodValue") == "https://example.com/path?q=1") } diff --git a/Tests/CodeModeTests/NotificationsBridgeTests.swift b/Tests/CodeModeTests/NotificationsBridgeTests.swift index 3df88fb..7eafa65 100644 --- a/Tests/CodeModeTests/NotificationsBridgeTests.swift +++ b/Tests/CodeModeTests/NotificationsBridgeTests.swift @@ -28,6 +28,20 @@ import Testing } catch { #expect(requireBridgeErrorCode(error) == "PERMISSION_DENIED") } + + do { + _ = try bridge.readDelivered(arguments: [:], context: context) + Issue.record("Expected notifications.delivered.read to require permission") + } catch { + #expect(requireBridgeErrorCode(error) == "PERMISSION_DENIED") + } + + do { + _ = try bridge.deleteDelivered(arguments: [:], context: context) + Issue.record("Expected notifications.delivered.delete to require permission") + } catch { + #expect(requireBridgeErrorCode(error) == "PERMISSION_DENIED") + } } @Test func notificationsRequestPermissionUsesBrokerRequestStatus() throws { @@ -45,6 +59,19 @@ import Testing #expect(object.bool("granted") == true) } +@Test func notificationUserInfoShapeSerializesAsJSONObject() throws { + let value = JSONValue(any: [ + AnyHashable("string"): "value", + AnyHashable("number"): 3, + AnyHashable("nested"): ["flag": true] as [String: Any], + ] as [AnyHashable: Any]) + + let object = try requireObject(value) + #expect(object.string("string") == "value") + #expect(object.int("number") == 3) + #expect(object.object("nested")?.bool("flag") == true) +} + @Test func executeUsesNotificationsBridgeWithPermissionDenial() async throws { let broker = FixedPermissionBroker(statuses: [.notifications: .denied]) let (tools, sandbox) = try makeTools(permissionBroker: broker) diff --git a/Tests/CodeModeTests/SearchQualityTests.swift b/Tests/CodeModeTests/SearchQualityTests.swift index b0e508b..d2db895 100644 --- a/Tests/CodeModeTests/SearchQualityTests.swift +++ b/Tests/CodeModeTests/SearchQualityTests.swift @@ -81,14 +81,23 @@ import Testing "executeJavaScript", ]) #expect(CodeModeAgentToolDescriptions.searchJavaScriptAPI.description.contains("api.references")) + #expect(CodeModeAgentToolDescriptions.searchJavaScriptAPI.description.contains("capabilityKey")) + #expect(CodeModeAgentToolDescriptions.searchJavaScriptAPI.description.contains("builtInCapability")) #expect(CodeModeAgentToolDescriptions.searchJavaScriptAPI.description.contains("byJSName")) #expect(CodeModeAgentToolDescriptions.searchJavaScriptAPI.description.contains("current host platform")) #expect(CodeModeAgentToolDescriptions.searchJavaScriptAPI.description.contains("apple.fs.read")) + #expect(CodeModeAgentToolDescriptions.searchJavaScriptAPI.description.contains(".slice(0, 10)")) #expect(CodeModeAgentToolDescriptions.searchJavaScriptAPI.description.contains("result shapes")) #expect(CodeModeAgentToolDescriptions.searchJavaScriptAPI.description.contains("resultSummary: ref.resultSummary")) + #expect(CodeModeAgentToolDescriptions.executeJavaScriptParameterSchema.objectValue?.object("properties")?.object("allowedCapabilityKeys") != nil) #expect(CodeModeAgentToolDescriptions.executeJavaScript.description.contains("allowedCapabilities")) + #expect(CodeModeAgentToolDescriptions.executeJavaScript.description.contains("allowedCapabilityKeys")) #expect(CodeModeAgentToolDescriptions.executeJavaScript.description.contains("apple.*")) #expect(CodeModeAgentToolDescriptions.executeJavaScript.description.contains("ios.alarm.*")) #expect(CodeModeAgentToolDescriptions.executeJavaScript.description.contains("top-level return")) + #expect(CodeModeAgentToolDescriptions.executeJavaScript.description.contains("bare final top-level await")) + #expect(CodeModeAgentToolDescriptions.executeJavaScript.description.contains("10000ms")) + #expect(CodeModeAgentToolDescriptions.executeJavaScript.description.contains("setTimeout")) + #expect(CodeModeAgentToolDescriptions.executeJavaScript.description.contains("CAPABILITY_DENIED")) #expect(CodeModeAgentToolDescriptions.executeJavaScript.description.contains("async IIFE")) } diff --git a/Tests/CodeModeTests/SystemAppleServiceClientMappingTests.swift b/Tests/CodeModeTests/SystemAppleServiceClientMappingTests.swift new file mode 100644 index 0000000..ef16d87 --- /dev/null +++ b/Tests/CodeModeTests/SystemAppleServiceClientMappingTests.swift @@ -0,0 +1,145 @@ +import Foundation +import Testing +@testable import CodeMode + +#if canImport(CloudKit) +@preconcurrency import CloudKit +#endif +#if canImport(MapKit) +@preconcurrency import MapKit +#endif + +@Test func cloudKitMappingValidatesDatabasePredicateAndFields() throws { + #expect(try SystemCloudKitMapping.databaseName(arguments: [:]) == "private") + #expect(try SystemCloudKitMapping.databaseName(arguments: ["database": .string("shared")]) == "shared") + + do { + _ = try SystemCloudKitMapping.databaseName(arguments: ["database": .string("archive")]) + Issue.record("Expected invalid database to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") + } + + let predicate = try SystemCloudKitMapping.safePredicate( + arguments: ["predicate": .object(["field": .string("status"), "equals": .string("open")])], + capability: "cloudkit.records.query" + ) + #expect(predicate?.field == "status") + #expect(predicate?.equals == .string("open")) + + do { + _ = try SystemCloudKitMapping.safePredicate( + arguments: ["predicate": .string("TRUEPREDICATE")], + capability: "cloudkit.records.query" + ) + Issue.record("Expected raw predicate string to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") + } + + let fields = try SystemCloudKitMapping.recordFields( + arguments: [ + "fields": .object([ + "title": .string("Review"), + "rank": .number(1), + "done": .bool(false), + "tags": .array([.string("work"), .number(2)]), + ]), + ], + capability: "cloudkit.record.save" + ) + #expect(fields.keys.contains("title")) + + do { + _ = try SystemCloudKitMapping.recordFields( + arguments: ["fields": .object(["bad": .object(["nested": .bool(true)])])], + capability: "cloudkit.record.save" + ) + Issue.record("Expected nested record field object to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") + } +} + +#if canImport(CloudKit) +@Test func cloudKitMappingSerializesRecordOutput() throws { + let record = CKRecord(recordType: "Task", recordID: CKRecord.ID(recordName: "task-1")) + record["title"] = try SystemCloudKitMapping.recordValue(.string("Review"), field: "title") + record["priority"] = try SystemCloudKitMapping.recordValue(.number(3), field: "priority") + record["done"] = try SystemCloudKitMapping.recordValue(.bool(true), field: "done") + record["tags"] = try SystemCloudKitMapping.recordValue(.array([.string("ios"), .string("agent")]), field: "tags") + + let object = try requireObject(SystemCloudKitMapping.recordJSON(record)) + #expect(object["recordName"]?.stringValue == "task-1") + #expect(object["recordType"]?.stringValue == "Task") + + let fields = try #require(object["fields"]?.objectValue) + #expect(fields["title"]?.stringValue == "Review") + #expect(fields["priority"]?.intValue == 3) + #expect(fields["done"]?.boolValue == true) + #expect(fields["tags"]?.arrayValue?.compactMap(\.stringValue) == ["ios", "agent"]) +} +#endif + +@Test func mapsMappingValidatesCoordinatesTransportAndQueryURLs() throws { + let coordinate = try SystemMapsMapping.coordinate( + arguments: ["latitude": .number(37.3318), "longitude": .number(-122.0312)], + name: "origin", + capability: "maps.route.estimate" + ) + #expect(coordinate.latitude == 37.3318) + #expect(coordinate.longitude == -122.0312) + + do { + _ = try SystemMapsMapping.coordinate(arguments: ["latitude": .number(37.3318)], name: "origin", capability: "maps.route.estimate") + Issue.record("Expected missing longitude to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") + } + + #expect(try SystemMapsMapping.transportTypeName(arguments: [:], defaultValue: "automobile") == "automobile") + #expect(try SystemMapsMapping.transportTypeName(arguments: ["transportType": .string("transit")]) == "transit") + + do { + _ = try SystemMapsMapping.transportTypeName(arguments: ["transportType": .string("hoverboard")]) + Issue.record("Expected invalid transportType to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") + } + + let url = try SystemMapsMapping.appleMapsQueryURL(query: "Apple Park") + #expect(url.absoluteString.contains("maps.apple.com")) + #expect(url.absoluteString.contains("Apple%20Park")) +} + +#if canImport(MapKit) +@Test func mapsMappingBuildsMapKitRegionAndLaunchOptions() throws { + let radiusRegion = try #require(SystemMapsMapping.mapKitRegion([ + "latitude": .number(37.3318), + "longitude": .number(-122.0312), + "radiusMeters": .number(500), + ])) + #expect(radiusRegion.center.latitude == 37.3318) + #expect(radiusRegion.center.longitude == -122.0312) + + let spanRegion = try #require(SystemMapsMapping.mapKitRegion([ + "center": .object(["latitude": .number(37.3318), "longitude": .number(-122.0312)]), + "latitudeDelta": .number(0.2), + "longitudeDelta": .number(0.3), + ])) + #expect(spanRegion.span.latitudeDelta == 0.2) + #expect(spanRegion.span.longitudeDelta == 0.3) + + let rawLaunchOptions = try SystemMapsMapping.mapsLaunchOptions(["transportType": .string("walking")]) + let launchOptions = try #require(rawLaunchOptions) + #expect(launchOptions[MKLaunchOptionsDirectionsModeKey] as? String == MKLaunchOptionsDirectionsModeWalking) + + let item = SystemMapsMapping.mapItem(coordinate: [ + "latitude": .number(37.3318), + "longitude": .number(-122.0312), + "name": .string("Apple Park"), + ]) + #expect(item.name == "Apple Park") + #expect(item.placemark.coordinate.latitude == 37.3318) +} +#endif diff --git a/Tests/CodeModeTests/SystemUIBridgeTests.swift b/Tests/CodeModeTests/SystemUIBridgeTests.swift index 505f017..0a66080 100644 --- a/Tests/CodeModeTests/SystemUIBridgeTests.swift +++ b/Tests/CodeModeTests/SystemUIBridgeTests.swift @@ -243,6 +243,41 @@ import Testing #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") } + do { + _ = try bridge.captureCamera(arguments: ["cameraDevice": .string("side")], context: context) + Issue.record("Expected invalid cameraDevice to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") + } + + do { + _ = try bridge.captureCamera(arguments: ["flashMode": .string("blink")], context: context) + Issue.record("Expected invalid flashMode to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") + } + + do { + _ = try bridge.captureCamera(arguments: ["videoQuality": .string("4k")], context: context) + Issue.record("Expected invalid videoQuality to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") + } + + do { + _ = try bridge.captureCamera(arguments: ["maximumDurationSeconds": .number(0)], context: context) + Issue.record("Expected invalid maximumDurationSeconds to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") + } + + do { + _ = try bridge.scanData(arguments: ["isGuidanceEnabled": .string("yes")], context: context) + Issue.record("Expected invalid scanner toggle to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") + } + do { _ = try bridge.presentWeb(arguments: ["url": .string("file:///tmp/a.html")], context: context) Issue.record("Expected non-HTTP web URL to fail") @@ -264,6 +299,19 @@ import Testing #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") } + do { + _ = try bridge.presentAlert( + arguments: [ + "buttons": .array([.object(["title": .string("OK")])]), + "sourceRect": .object(["x": .number(0), "y": .number(0), "width": .number(10)]), + ], + context: context + ) + Issue.record("Expected invalid alert sourceRect to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") + } + do { _ = try bridge.presentAlert( arguments: [ @@ -293,6 +341,64 @@ import Testing } } +@Test func systemUIBridgeForwardsExpandedUIKitArguments() throws { + let bridge = SystemUIBridge() + let calendarArgs: [String: JSONValue] = [ + "title": .string("Standup"), + "start": .string("2026-02-22T16:00:00Z"), + "end": .string("2026-02-22T16:15:00Z"), + "timeoutMs": .number(2_500), + ] + let cameraArgs: [String: JSONValue] = [ + "mediaType": .string("video"), + "allowsEditing": .bool(true), + "cameraDevice": .string("front"), + "flashMode": .string("off"), + "videoQuality": .string("medium"), + "maximumDurationSeconds": .number(12), + "timeoutMs": .number(5_000), + ] + let scannerArgs: [String: JSONValue] = [ + "mode": .string("text"), + "isGuidanceEnabled": .bool(false), + "isHighlightingEnabled": .bool(false), + "isPinchToZoomEnabled": .bool(false), + "isHighFrameRateTrackingEnabled": .bool(false), + "timeoutMs": .number(5_000), + ] + let alertArgs: [String: JSONValue] = [ + "preferredStyle": .string("actionSheet"), + "sourceRect": .object([ + "x": .number(10), + "y": .number(20), + "width": .number(30), + "height": .number(40), + ]), + "buttons": .array([.object(["id": .string("ok"), "title": .string("OK")])]), + ] + let presenter = FakeSystemUIPresenter( + calendarResult: .object(["action": .string("saved")]), + extraResults: [ + .cameraUICapture: .object(["artifactID": .string("camera-1")]), + .cameraUIScanData: .object(["action": .string("recognized")]), + .uiAlertPresent: .object(["buttonID": .string("ok")]), + ], + expectedArguments: [ + .calendarUIPresentNewEvent: calendarArgs, + .cameraUICapture: cameraArgs, + .cameraUIScanData: scannerArgs, + .uiAlertPresent: alertArgs, + ] + ) + let (context, sandbox) = try makeInvocationContext(systemUIPresenter: presenter) + defer { cleanup(sandbox) } + + #expect(try bridge.presentNewCalendarEvent(arguments: calendarArgs, context: context).objectValue?.string("action") == "saved") + #expect(try bridge.captureCamera(arguments: cameraArgs, context: context).objectValue?.string("artifactID") == "camera-1") + #expect(try bridge.scanData(arguments: scannerArgs, context: context).objectValue?.string("action") == "recognized") + #expect(try bridge.presentAlert(arguments: alertArgs, context: context).objectValue?.string("buttonID") == "ok") +} + @Test func systemUIBridgeHonorsCancellationBeforePresentation() throws { let sandbox = try makeTestSandbox() defer { cleanup(sandbox) } diff --git a/Tests/CodeModeTests/SystemUITestSupport.swift b/Tests/CodeModeTests/SystemUITestSupport.swift index 0eaedd6..f001305 100644 --- a/Tests/CodeModeTests/SystemUITestSupport.swift +++ b/Tests/CodeModeTests/SystemUITestSupport.swift @@ -6,6 +6,7 @@ struct FakeSystemUIPresenter: SystemUIPresenter { var photosResult: JSONValue var contactsResult: JSONValue var extraResults: [CapabilityID: JSONValue] + var expectedArguments: [CapabilityID: [String: JSONValue]] var error: BridgeError? init( @@ -13,31 +14,33 @@ struct FakeSystemUIPresenter: SystemUIPresenter { photosResult: JSONValue = .array([]), contactsResult: JSONValue = .array([]), extraResults: [CapabilityID: JSONValue] = [:], + expectedArguments: [CapabilityID: [String: JSONValue]] = [:], error: BridgeError? = nil ) { self.calendarResult = calendarResult self.photosResult = photosResult self.contactsResult = contactsResult self.extraResults = extraResults + self.expectedArguments = expectedArguments self.error = error } func presentNewCalendarEvent(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - _ = arguments + try assertArguments(arguments, for: .calendarUIPresentNewEvent) _ = context if let error { throw error } return calendarResult } func pickPhotos(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - _ = arguments + try assertArguments(arguments, for: .photosUIPick) _ = context if let error { throw error } return photosResult } func pickContacts(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - _ = arguments + try assertArguments(arguments, for: .contactsUIPick) _ = context if let error { throw error } return contactsResult @@ -128,9 +131,19 @@ struct FakeSystemUIPresenter: SystemUIPresenter { } private func result(for capability: CapabilityID, arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - _ = arguments + try assertArguments(arguments, for: capability) _ = context if let error { throw error } return extraResults[capability] ?? .object(["action": .string("cancelled")]) } + + private func assertArguments(_ arguments: [String: JSONValue], for capability: CapabilityID) throws { + guard let expected = expectedArguments[capability] else { + return + } + + guard arguments == expected else { + throw BridgeError.nativeFailure("Expected \(capability.rawValue) arguments \(expected), received \(arguments)") + } + } } diff --git a/Tests/CodeModeTests/TestSupport.swift b/Tests/CodeModeTests/TestSupport.swift index 9cf2c4d..3e2a23a 100644 --- a/Tests/CodeModeTests/TestSupport.swift +++ b/Tests/CodeModeTests/TestSupport.swift @@ -36,7 +36,20 @@ func cleanup(_ sandbox: TestSandbox) { func makeTools( permissionBroker: any PermissionBroker = NoopPermissionBroker(), fileSystem: any CodeModeFileSystem = LocalCodeModeFileSystem(), - systemUIPresenter: any SystemUIPresenter = UnavailableSystemUIPresenter() + systemUIPresenter: any SystemUIPresenter = UnavailableSystemUIPresenter(), + eventInbox: any CodeModeEventInbox = UnavailableCodeModeEventInbox(), + cloudKitClient: any CloudKitClient = UnavailableCloudKitClient(), + remoteNotificationsClient: any RemoteNotificationsClient = UnavailableRemoteNotificationsClient(), + speechClient: any SpeechClient = UnavailableSpeechClient(), + appIntentsClient: any AppIntentsClient = UnavailableAppIntentsClient(), + foundationModelsClient: any FoundationModelsClient = UnavailableFoundationModelsClient(), + activityClient: any ActivityClient = UnavailableActivityClient(), + mapsClient: any MapsClient = UnavailableMapsClient(), + musicClient: any MusicClient = UnavailableMusicClient(), + passKitClient: any PassKitClient = UnavailablePassKitClient(), + storeKitClient: any StoreKitClient = UnavailableStoreKitClient(), + codeModeProviders: [any CodeModeProvider] = [], + hostPlatform: HostPlatform = .current ) throws -> (CodeModeAgentTools, TestSandbox) { let sandbox = try makeTestSandbox() @@ -50,7 +63,20 @@ func makeTools( artifactStore: InMemoryArtifactStore(), permissionBroker: permissionBroker, auditLogger: SyncAuditLogger(), - systemUIPresenter: systemUIPresenter + systemUIPresenter: systemUIPresenter, + eventInbox: eventInbox, + cloudKitClient: cloudKitClient, + remoteNotificationsClient: remoteNotificationsClient, + speechClient: speechClient, + appIntentsClient: appIntentsClient, + foundationModelsClient: foundationModelsClient, + activityClient: activityClient, + mapsClient: mapsClient, + musicClient: musicClient, + passKitClient: passKitClient, + storeKitClient: storeKitClient, + codeModeProviders: codeModeProviders, + hostPlatform: hostPlatform ) let tools = CodeModeAgentTools(config: configuration) diff --git a/Tools/CodeModeAuthoring/Package.resolved b/Tools/CodeModeAuthoring/Package.resolved new file mode 100644 index 0000000..b4dc611 --- /dev/null +++ b/Tools/CodeModeAuthoring/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "b74d116a7a0a89a95f1429d15cd7d38a7d0658cca5bffcf3e4d4a90e195a6be1", + "pins" : [ + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", + "version" : "601.0.1" + } + } + ], + "version" : 3 +} diff --git a/Tools/CodeModeAuthoring/Package.swift b/Tools/CodeModeAuthoring/Package.swift new file mode 100644 index 0000000..f51a879 --- /dev/null +++ b/Tools/CodeModeAuthoring/Package.swift @@ -0,0 +1,49 @@ +// swift-tools-version: 6.1 +import PackageDescription +import CompilerPluginSupport + +let package = Package( + name: "CodeModeAuthoring", + platforms: [ + .iOS(.v18), + .macOS(.v15), + .visionOS(.v2), + ], + products: [ + .library( + name: "CodeModeAuthoring", + targets: ["CodeModeAuthoring"] + ), + ], + dependencies: [ + .package(name: "codemode-ios", path: "../.."), + .package(url: "https://github.com/swiftlang/swift-syntax.git", exact: "601.0.1"), + ], + targets: [ + .target( + name: "CodeModeAuthoring", + dependencies: [ + .product(name: "CodeMode", package: "codemode-ios"), + "CodeModeMacros", + ] + ), + .macro( + name: "CodeModeMacros", + dependencies: [ + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"), + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftDiagnostics", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + ] + ), + .testTarget( + name: "CodeModeAuthoringTests", + dependencies: [ + "CodeModeAuthoring", + "CodeModeMacros", + .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), + ] + ), + ] +) diff --git a/Tools/CodeModeAuthoring/README.md b/Tools/CodeModeAuthoring/README.md new file mode 100644 index 0000000..21a3b50 --- /dev/null +++ b/Tools/CodeModeAuthoring/README.md @@ -0,0 +1,69 @@ +# CodeModeAuthoring + +`CodeModeAuthoring` contains the optional `@CodeMode` macro package. It lives outside the core `CodeMode` package so runtime clients and eval tooling do not inherit a `swift-syntax` dependency. + +Hosts that want macro-authored providers can import `CodeModeAuthoring`: + +```swift +import CodeMode +import CodeModeAuthoring + +@CodeMode(path: "myapp.tasks.complete", description: "Mark a task complete.") +struct TaskComplete: Sendable { + let store: TaskStore + + struct Arguments { + @CodeModeParam("Task identifier") + var id: String + + @CodeModeParam("Optional completion note") + var note: String? + } + + @CodeModeResult("Completion payload") + struct Result { + var id: String + var note: String? + var completed: Bool + } + + func call(arguments: Arguments) async throws -> Result { + try await store.complete(id: arguments.id, note: arguments.note) + return Result(id: arguments.id, note: arguments.note, completed: true) + } +} +``` + +Register provider instances through `CodeModeConfiguration(codeModeProviders:)`: + +```swift +let tools = CodeModeAgentTools( + config: CodeModeConfiguration( + codeModeProviders: [ + TaskComplete(store: taskStore), + ] + ) +) +``` + +The generated JavaScript path and capability key are the same string: + +```javascript +await myapp.tasks.complete({ id: "task-123", note: "Done in app" }) +``` + +Custom providers are gated by `allowedCapabilityKeys`, not `allowedCapabilities`: + +```swift +let call = try await tools.executeJavaScript( + JavaScriptExecutionRequest( + code: #"return await myapp.tasks.complete({ id: "task-123" });"#, + allowedCapabilities: [], + allowedCapabilityKeys: ["myapp.tasks.complete"] + ) +) +``` + +Macro v1 maps one type to one JavaScript function. `Arguments` must be a nested struct, and no-arg tools use an empty `Arguments` struct. `call(arguments:)` must be `throws` or `async throws`, and it can return `Void` or a nested `Result` struct. + +Supported `Arguments` and `Result` property shapes are JSON primitives, `JSONValue`, arrays/dictionaries of JSON-shaped values, and optional forms. Throw `CodeModeFunctionError` for structured failures that should surface as CodeMode bridge errors. diff --git a/Tools/CodeModeAuthoring/Sources/CodeModeAuthoring/CodeModeMacros.swift b/Tools/CodeModeAuthoring/Sources/CodeModeAuthoring/CodeModeMacros.swift new file mode 100644 index 0000000..93cf314 --- /dev/null +++ b/Tools/CodeModeAuthoring/Sources/CodeModeAuthoring/CodeModeMacros.swift @@ -0,0 +1,10 @@ +@_exported import CodeMode + +@attached(extension, conformances: CodeModeProvider, names: named(codeModePath), named(codeModeRegistrations)) +public macro CodeMode(path: String, description: String) = #externalMacro(module: "CodeModeMacros", type: "CodeModeMacro") + +@attached(peer) +public macro CodeModeParam(_ description: String) = #externalMacro(module: "CodeModeMacros", type: "CodeModeParamMacro") + +@attached(peer) +public macro CodeModeResult(_ description: String) = #externalMacro(module: "CodeModeMacros", type: "CodeModeResultMacro") diff --git a/Tools/CodeModeAuthoring/Sources/CodeModeMacros/CodeModeMacro.swift b/Tools/CodeModeAuthoring/Sources/CodeModeMacros/CodeModeMacro.swift new file mode 100644 index 0000000..fe7b18b --- /dev/null +++ b/Tools/CodeModeAuthoring/Sources/CodeModeMacros/CodeModeMacro.swift @@ -0,0 +1,607 @@ +import Foundation +import SwiftCompilerPlugin +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +@main +struct CodeModePlugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + CodeModeMacro.self, + CodeModeParamMacro.self, + CodeModeResultMacro.self, + ] +} + +public struct CodeModeParamMacro: PeerMacro { + public static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + [] + } +} + +public struct CodeModeResultMacro: PeerMacro { + public static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + [] + } +} + +public struct CodeModeMacro: ExtensionMacro { + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [ExtensionDeclSyntax] { + guard isSupportedProviderDeclaration(declaration) else { + context.diagnose(CodeModeDiagnostic("@CodeMode can only be attached to a struct, final class, or actor", node: Syntax(declaration))) + return [] + } + + guard isNonGenericDeclaration(declaration) else { + context.diagnose(CodeModeDiagnostic("@CodeMode does not support generic types", node: Syntax(declaration))) + return [] + } + + let macroStrings = stringArguments(in: node.description) + guard macroStrings.count >= 2 else { + context.diagnose(CodeModeDiagnostic("@CodeMode requires path and description string arguments", node: Syntax(node))) + return [] + } + + let path = macroStrings[0] + let summary = macroStrings[1] + guard isValidPath(path) else { + context.diagnose(CodeModeDiagnostic("@CodeMode requires a valid dotted JavaScript path", node: Syntax(node))) + return [] + } + + guard let argumentsStruct = nestedStruct(named: "Arguments", in: declaration) else { + context.diagnose(CodeModeDiagnostic("@CodeMode requires a nested Arguments struct", node: Syntax(declaration))) + return [] + } + + guard let call = callMethod(in: declaration, context: context) else { + return [] + } + + let argumentFields = fields(in: argumentsStruct, attributeName: "CodeModeParam", context: context) + guard argumentFields.invalid == false else { + return [] + } + + let resultStruct = nestedStruct(named: "Result", in: declaration) + let resultInfo = resultStruct.map { structDecl in + let parsed = fields(in: structDecl, attributeName: nil, context: context) + return ParsedResult( + summary: attributeString(named: "CodeModeResult", attributes: structDecl.attributes).first ?? "JSON value", + fields: parsed.fields, + invalid: parsed.invalid + ) + } + guard resultInfo?.invalid != true else { + return [] + } + + guard validate(call: call, hasResultStruct: resultStruct != nil, context: context) else { + return [] + } + + let returnsVoid = call.returnType == "Void" || call.returnType == "()" + if returnsVoid, resultStruct != nil { + context.diagnose(CodeModeDiagnostic("@CodeMode Result is only valid when call returns Result", node: Syntax(resultStruct!))) + return [] + } + + let access = providerAccessModifier(for: declaration) + let extensionSource = """ + extension \(type.trimmedDescription): CodeModeProvider { + \(access)var codeModePath: String { \(literal(path)) } + + \(access)func codeModeRegistrations() -> [CodeModeRegistration] { + [ + CodeModeRegistration( + capabilityKey: CodeModeCapabilityKey(rawValue: \(literal(path))), + jsPath: \(literal(path)), + title: \(literal(title(for: path))), + summary: \(literal(summary)), + tags: [\(literal(parentPath(for: path)))], + example: "await \(path)({})", + requiredArguments: [\(argumentFields.fields.filter { !$0.optional }.map { literal($0.name) }.joined(separator: ", "))], + optionalArguments: [\(argumentFields.fields.filter(\.optional).map { literal($0.name) }.joined(separator: ", "))], + argumentTypes: \(argumentTypesSource(for: argumentFields.fields)), + argumentHints: \(argumentHintsSource(for: argumentFields.fields)), + resultSummary: \(literal(returnsVoid ? "null" : resultInfo?.summary ?? "JSON value")), + handler: { arguments, _ in + try CodeModeAsyncBridge.run { + \(indent(argumentsSource(for: argumentFields.fields), spaces: 24)) + \(indent(resultSource(for: call, returnsVoid: returnsVoid, resultFields: resultInfo?.fields ?? []), spaces: 24)) + } + } + ) + ] + } + } + """ + + return [try ExtensionDeclSyntax(SyntaxNodeString(stringLiteral: extensionSource))] + } + + private static func callMethod(in declaration: some DeclGroupSyntax, context: some MacroExpansionContext) -> CallMethod? { + let functions = declaration.memberBlock.members.compactMap { $0.decl.as(FunctionDeclSyntax.self) } + .filter { $0.name.text == "call" } + + guard functions.count == 1, let function = functions.first else { + context.diagnose(CodeModeDiagnostic("@CodeMode requires exactly one call(arguments:) method", node: Syntax(declaration))) + return nil + } + + guard function.genericParameterClause == nil else { + context.diagnose(CodeModeDiagnostic("@CodeMode does not support generic methods", node: Syntax(function.name))) + return nil + } + + guard function.signature.effectSpecifiers?.throwsClause != nil else { + context.diagnose(CodeModeDiagnostic("@CodeMode call(arguments:) must be throws or async throws", node: Syntax(function.name))) + return nil + } + + let parameters = Array(function.signature.parameterClause.parameters) + guard parameters.count == 1, let parameter = parameters.first else { + context.diagnose(CodeModeDiagnostic("@CodeMode call method must accept exactly one arguments parameter", node: Syntax(function.name))) + return nil + } + + guard parameter.defaultValue == nil, parameter.ellipsis == nil else { + context.diagnose(CodeModeDiagnostic("@CodeMode call(arguments:) does not support default or variadic parameters", node: Syntax(parameter))) + return nil + } + + guard parameter.firstName.text == "arguments", + parameter.secondName == nil, + parameter.type.trimmedDescription == "Arguments" + else { + context.diagnose(CodeModeDiagnostic("@CodeMode call method must be call(arguments: Arguments)", node: Syntax(parameter))) + return nil + } + + return CallMethod( + isAsync: function.signature.effectSpecifiers?.asyncSpecifier != nil, + returnType: function.signature.returnClause?.type.trimmedDescription ?? "Void", + node: Syntax(function.name) + ) + } + + private static func validate( + call: CallMethod, + hasResultStruct: Bool, + context: some MacroExpansionContext + ) -> Bool { + if call.returnType == "Void" || call.returnType == "()" { + return true + } + + guard call.returnType == "Result" else { + context.diagnose(CodeModeDiagnostic("@CodeMode call return type must be Void or nested Result", node: call.node)) + return false + } + + guard hasResultStruct else { + context.diagnose(CodeModeDiagnostic("@CodeMode call returning Result requires a nested Result struct", node: call.node)) + return false + } + + return true + } + + private static func fields( + in structDecl: StructDeclSyntax, + attributeName: String?, + context: some MacroExpansionContext + ) -> ParsedFields { + var result: [ToolField] = [] + var invalid = false + var seenNames: Set = [] + + for member in structDecl.memberBlock.members { + guard let variable = member.decl.as(VariableDeclSyntax.self) else { + continue + } + + guard variable.bindings.count == 1, let binding = variable.bindings.first else { + context.diagnose(CodeModeDiagnostic("@CodeMode only supports one stored property per declaration", node: Syntax(variable))) + invalid = true + continue + } + + guard binding.accessorBlock == nil else { + context.diagnose(CodeModeDiagnostic("@CodeMode does not support computed properties", node: Syntax(binding))) + invalid = true + continue + } + + guard binding.initializer == nil else { + context.diagnose(CodeModeDiagnostic("@CodeMode does not support default property values", node: Syntax(binding))) + invalid = true + continue + } + + guard let identifier = binding.pattern.as(IdentifierPatternSyntax.self) else { + context.diagnose(CodeModeDiagnostic("@CodeMode properties must use simple identifiers", node: Syntax(binding.pattern))) + invalid = true + continue + } + + let name = identifier.identifier.text + guard isValidPathSegment(name) else { + context.diagnose(CodeModeDiagnostic("@CodeMode property names must be valid JavaScript object keys", node: Syntax(identifier))) + invalid = true + continue + } + + guard seenNames.insert(name).inserted else { + context.diagnose(CodeModeDiagnostic("@CodeMode properties cannot use duplicate names", node: Syntax(identifier))) + invalid = true + continue + } + + guard let type = binding.typeAnnotation?.type.trimmedDescription else { + context.diagnose(CodeModeDiagnostic("@CodeMode properties must have explicit types", node: Syntax(binding))) + invalid = true + continue + } + + let typeInfo = TypeInfo(typeSyntax: type) + guard typeInfo.isSupported else { + context.diagnose(CodeModeDiagnostic("@CodeMode does not support property type '\(type)'", node: Syntax(binding))) + invalid = true + continue + } + + result.append( + ToolField( + name: name, + swiftType: typeInfo.swiftType, + argumentType: typeInfo.argumentType, + optional: typeInfo.optional, + description: attributeName.flatMap { attributeString(named: $0, attributes: variable.attributes).first } + ) + ) + } + + return ParsedFields(fields: result, invalid: invalid) + } + + private static func nestedStruct(named name: String, in declaration: some DeclGroupSyntax) -> StructDeclSyntax? { + declaration.memberBlock.members.compactMap { member in + member.decl.as(StructDeclSyntax.self) + } + .first { $0.name.text == name } + } + + private static func argumentsSource(for fields: [ToolField]) -> String { + guard fields.isEmpty == false else { + return "let decodedArguments = Arguments()" + } + + let assignments = fields.map { field in + let method = field.optional ? "optional" : "require" + return "\(field.name): try CodeModeArgumentDecoder.\(method)(\(literal(field.name)), as: \(field.swiftType).self, in: arguments)" + }.joined(separator: ",\n") + + return """ + let decodedArguments = Arguments( + \(indent(assignments, spaces: 4)) + ) + """ + } + + private static func resultSource(for call: CallMethod, returnsVoid: Bool, resultFields: [ToolField]) -> String { + let callPrefix = call.isAsync ? "try await " : "try " + let invocation = "\(callPrefix)self.call(arguments: decodedArguments)" + + guard returnsVoid == false else { + return """ + \(invocation) + return .null + """ + } + + guard resultFields.isEmpty == false else { + return """ + _ = \(invocation) + return .object([:]) + """ + } + + let objectEntries = resultFields.map { field in + "\(literal(field.name)): CodeModeValueEncoder.encode(result.\(field.name))" + }.joined(separator: ",\n") + + return """ + let result = \(invocation) + return .object([ + \(indent(objectEntries, spaces: 4)) + ]) + """ + } + + private static func argumentTypesSource(for fields: [ToolField]) -> String { + guard fields.isEmpty == false else { + return "[:]" + } + return "[" + fields.map { field in + "\(literal(field.name)): CapabilityArgumentType.\(field.argumentType)" + }.joined(separator: ", ") + "]" + } + + private static func argumentHintsSource(for fields: [ToolField]) -> String { + let entries = fields.compactMap { field -> String? in + guard let description = field.description else { + return nil + } + return "\(literal(field.name)): \(literal(description))" + } + guard entries.isEmpty == false else { + return "[:]" + } + return "[" + entries.joined(separator: ", ") + "]" + } + + private static func isSupportedProviderDeclaration(_ declaration: some DeclGroupSyntax) -> Bool { + if declaration.as(StructDeclSyntax.self) != nil { return true } + if declaration.as(ActorDeclSyntax.self) != nil { return true } + if let classDecl = declaration.as(ClassDeclSyntax.self) { + return classDecl.modifiers.contains { $0.name.text == "final" } + } + return false + } + + private static func isNonGenericDeclaration(_ declaration: some DeclGroupSyntax) -> Bool { + if let structDecl = declaration.as(StructDeclSyntax.self) { + return structDecl.genericParameterClause == nil + } + if let actorDecl = declaration.as(ActorDeclSyntax.self) { + return actorDecl.genericParameterClause == nil + } + if let classDecl = declaration.as(ClassDeclSyntax.self) { + return classDecl.genericParameterClause == nil + } + return true + } + + private static func providerAccessModifier(for declaration: some DeclGroupSyntax) -> String { + let modifiers: DeclModifierListSyntax? + if let structDecl = declaration.as(StructDeclSyntax.self) { + modifiers = structDecl.modifiers + } else if let actorDecl = declaration.as(ActorDeclSyntax.self) { + modifiers = actorDecl.modifiers + } else if let classDecl = declaration.as(ClassDeclSyntax.self) { + modifiers = classDecl.modifiers + } else { + modifiers = nil + } + guard let modifiers else { + return "" + } + if modifiers.contains(where: { $0.name.text == "public" || $0.name.text == "open" }) { + return "public " + } + return "" + } + + private static func attributeString(named name: String, attributes: AttributeListSyntax) -> [String] { + attributes.compactMap { element -> [String]? in + guard case let .attribute(attribute) = element, + attribute.attributeName.trimmedDescription == name + else { + return nil + } + return stringArguments(in: attribute.description) + } + .flatMap { $0 } + } + + private static func stringArguments(in source: String) -> [String] { + var values: [String] = [] + var current = "" + var inString = false + var escaped = false + for character in source { + if inString { + if escaped { + switch character { + case "n": current.append("\n") + case "t": current.append("\t") + case "\"": current.append("\"") + case "\\": current.append("\\") + default: current.append(character) + } + escaped = false + } else if character == "\\" { + escaped = true + } else if character == "\"" { + values.append(current) + current = "" + inString = false + } else { + current.append(character) + } + } else if character == "\"" { + inString = true + } + } + return values + } + + private static func isValidPath(_ path: String) -> Bool { + let segments = path.split(separator: ".").map(String.init) + return segments.isEmpty == false && segments.allSatisfy(isValidPathSegment) + } + + private static func isValidPathSegment(_ segment: String) -> Bool { + guard let first = segment.first, + first == "_" || first == "$" || first.isASCIILetter + else { + return false + } + return segment.allSatisfy { $0 == "_" || $0 == "$" || $0.isASCIILetter || $0.isASCIIDigit } + } + + private static func title(for path: String) -> String { + path.split(separator: ".").last.map(String.init) ?? path + } + + private static func parentPath(for path: String) -> String { + let segments = path.split(separator: ".").map(String.init) + guard segments.count > 1 else { + return path + } + return segments.dropLast().joined(separator: ".") + } + + private static func literal(_ value: String) -> String { + let escaped = value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "\\n") + return "\"\(escaped)\"" + } + + private static func indent(_ value: String, spaces: Int) -> String { + let prefix = String(repeating: " ", count: spaces) + return value + .split(separator: "\n", omittingEmptySubsequences: false) + .map { $0.isEmpty ? "" : prefix + $0 } + .joined(separator: "\n") + } +} + +private struct ParsedFields { + var fields: [ToolField] + var invalid: Bool +} + +private struct ParsedResult { + var summary: String + var fields: [ToolField] + var invalid: Bool +} + +private struct CallMethod { + var isAsync: Bool + var returnType: String + var node: Syntax +} + +private struct ToolField { + var name: String + var swiftType: String + var argumentType: String + var optional: Bool + var description: String? +} + +private struct TypeInfo { + var swiftType: String + var argumentType: String + var optional: Bool + var isSupported: Bool + + init(typeSyntax rawType: String) { + let trimmed = rawType.replacingOccurrences(of: " ", with: "") + let optional: Bool + let unwrapped: String + if trimmed.hasSuffix("?") { + optional = true + unwrapped = String(trimmed.dropLast()) + } else if trimmed.hasPrefix("Optional<"), trimmed.hasSuffix(">") { + optional = true + unwrapped = String(trimmed.dropFirst("Optional<".count).dropLast()) + } else { + optional = false + unwrapped = trimmed + } + + self.optional = optional + self.swiftType = unwrapped + + switch unwrapped { + case "String": + self.argumentType = "string" + self.isSupported = true + case "Bool": + self.argumentType = "bool" + self.isSupported = true + case "Int", "Double", "Float": + self.argumentType = "number" + self.isSupported = true + case "JSONValue": + self.argumentType = "any" + self.isSupported = true + default: + if unwrapped.hasPrefix("["), unwrapped.hasSuffix("]") { + self.argumentType = unwrapped.contains(":") ? "object" : "array" + self.isSupported = Self.isSupportedCollection(unwrapped) + } else { + self.argumentType = "any" + self.isSupported = false + } + } + } + + private static func isSupportedCollection(_ type: String) -> Bool { + let scalarTypes: Set = ["String", "Bool", "Int", "Double", "Float", "JSONValue"] + let inner = String(type.dropFirst().dropLast()) + if inner.hasPrefix("String:") { + let value = String(inner.dropFirst("String:".count)) + return scalarTypes.contains(value) + } + return scalarTypes.contains(inner) + } +} + +private struct CodeModeDiagnostic: DiagnosticMessage { + var message: String + var diagnosticID: MessageID + var severity: DiagnosticSeverity + var node: Syntax + + init(_ message: String, node: Syntax) { + self.message = message + self.diagnosticID = MessageID(domain: "CodeModeMacros", id: message) + self.severity = .error + self.node = node + } +} + +private extension MacroExpansionContext { + func diagnose(_ message: CodeModeDiagnostic) { + diagnose(Diagnostic(node: message.node, message: message)) + } +} + +private extension Character { + var isASCIILetter: Bool { + guard unicodeScalars.count == 1, let scalar = unicodeScalars.first else { + return false + } + return (65...90).contains(Int(scalar.value)) || (97...122).contains(Int(scalar.value)) + } + + var isASCIIDigit: Bool { + guard unicodeScalars.count == 1, let scalar = unicodeScalars.first else { + return false + } + return (48...57).contains(Int(scalar.value)) + } +} diff --git a/Tools/CodeModeAuthoring/Tests/CodeModeAuthoringTests/CodeModeMacroExpansionTests.swift b/Tools/CodeModeAuthoring/Tests/CodeModeAuthoringTests/CodeModeMacroExpansionTests.swift new file mode 100644 index 0000000..b696ce6 --- /dev/null +++ b/Tools/CodeModeAuthoring/Tests/CodeModeAuthoringTests/CodeModeMacroExpansionTests.swift @@ -0,0 +1,511 @@ +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import Testing +import CodeModeAuthoring +import CodeModeMacros + +private func codeModeTestMacros() -> [String: Macro.Type] { + [ + "CodeMode": CodeModeMacro.self, + "CodeModeParam": CodeModeParamMacro.self, + "CodeModeResult": CodeModeResultMacro.self, + ] +} + +@CodeMode(path: "myapp.tasks.complete", description: "Mark a task complete.") +private struct ExampleTaskComplete: Sendable { + struct Arguments { + @CodeModeParam("Task identifier") + var id: String + + @CodeModeParam("Optional completion note") + var note: String? + } + + @CodeModeResult("Completion payload") + struct Result { + var id: String + var note: String? + var completed: Bool + } + + func call(arguments: Arguments) async throws -> Result { + Result(id: arguments.id, note: arguments.note, completed: true) + } +} + +@CodeMode(path: "myapp.tasks.ping", description: "Ping the task system.") +private struct ExampleVoidTool: Sendable { + struct Arguments {} + + func call(arguments: Arguments) throws {} +} + +private struct FailingTaskTool: CodeModeProvider { + var codeModePath: String { "myapp.tasks.fail" } + + func codeModeRegistrations() -> [CodeModeRegistration] { + [ + CodeModeRegistration( + capabilityKey: "myapp.tasks.fail", + jsPath: "myapp.tasks.fail", + title: "fail", + summary: "Fail with custom invalid arguments.", + example: "await myapp.tasks.fail({})" + ) { _, _ in + throw CodeModeFunctionError.invalidArguments("Custom invalid argument") + }, + ] + } +} + +@Test func codeModeMacroToolRegistersDocsAndExecutes() async throws { + let provider = ExampleTaskComplete() + #expect(provider.codeModePath == "myapp.tasks.complete") + + let registrations = provider.codeModeRegistrations() + let registration = try #require(registrations.first) + #expect(registration.capabilityKey == "myapp.tasks.complete") + #expect(registration.jsPath == "myapp.tasks.complete") + #expect(registration.title == "complete") + #expect(registration.summary == "Mark a task complete.") + #expect(registration.tags == ["myapp.tasks"]) + #expect(registration.requiredArguments == ["id"]) + #expect(registration.optionalArguments == ["note"]) + #expect(registration.argumentTypes["id"] == .string) + #expect(registration.argumentTypes["note"] == .string) + #expect(registration.argumentHints["id"] == "Task identifier") + #expect(registration.argumentHints["note"] == "Optional completion note") + #expect(registration.resultSummary == "Completion payload") + + let tools = CodeModeAgentTools(config: CodeModeConfiguration(codeModeProviders: [provider])) + let search = try await tools.searchJavaScriptAPI( + JavaScriptAPISearchRequest( + code: """ + async () => { + const ref = api.byJSName["myapp.tasks.complete"]; + return { + capabilityKey: ref.capabilityKey, + summary: ref.summary, + requiredArguments: ref.requiredArguments, + optionalArguments: ref.optionalArguments, + argumentHints: ref.argumentHints, + resultSummary: ref.resultSummary + }; + } + """ + ) + ) + let reference = try #require(search.result?.objectValue) + #expect(reference.string("capabilityKey") == "myapp.tasks.complete") + #expect(reference.string("summary") == "Mark a task complete.") + #expect(reference.array("requiredArguments") == [.string("id")]) + #expect(reference.array("optionalArguments") == [.string("note")]) + #expect(reference.object("argumentHints")?.string("id") == "Task identifier") + #expect(reference.string("resultSummary") == "Completion payload") + + let call = try await tools.executeJavaScript( + JavaScriptExecutionRequest( + code: #"return await myapp.tasks.complete({ id: "task-123", note: "Done in app" });"#, + allowedCapabilities: [], + allowedCapabilityKeys: ["myapp.tasks.complete"] + ) + ) + let observed = await observe(call) + let output = try #require(observed.result?.output?.objectValue) + #expect(output.string("id") == "task-123") + #expect(output.string("note") == "Done in app") + #expect(output.bool("completed") == true) +} + +@Test func codeModeMacroToolSupportsVoidResult() async throws { + let tools = CodeModeAgentTools(config: CodeModeConfiguration(codeModeProviders: [ExampleVoidTool()])) + let call = try await tools.executeJavaScript( + JavaScriptExecutionRequest( + code: "return await myapp.tasks.ping({});", + allowedCapabilities: [], + allowedCapabilityKeys: ["myapp.tasks.ping"] + ) + ) + let observed = await observe(call) + #expect(observed.result?.output == .null) +} + +@Test func codeModeMacroToolRequiresAllowedCapabilityKey() async throws { + let tools = CodeModeAgentTools(config: CodeModeConfiguration(codeModeProviders: [ExampleTaskComplete()])) + + let deniedCall = try await tools.executeJavaScript( + JavaScriptExecutionRequest( + code: #"return await myapp.tasks.complete({ id: "task-123" });"#, + allowedCapabilities: [] + ) + ) + let denied = await observe(deniedCall) + let deniedError = try #require(denied.error) + #expect(deniedError.code == "CAPABILITY_DENIED") + #expect(deniedError.capabilityKey == "myapp.tasks.complete") +} + +@Test func customProviderFunctionErrorsRemainStructured() async throws { + let tools = CodeModeAgentTools(config: CodeModeConfiguration(codeModeProviders: [FailingTaskTool()])) + let call = try await tools.executeJavaScript( + JavaScriptExecutionRequest( + code: "return await myapp.tasks.fail({});", + allowedCapabilities: [], + allowedCapabilityKeys: ["myapp.tasks.fail"] + ) + ) + let observed = await observe(call) + let error = try #require(observed.error) + #expect(error.code == "INVALID_ARGUMENTS") + #expect(error.message == "Custom invalid argument") + #expect(error.capabilityKey == "myapp.tasks.fail") +} + +private struct ObservedExecution { + let result: JavaScriptExecutionResult? + let error: CodeModeToolError? +} + +private func observe(_ call: JavaScriptExecutionCall) async -> ObservedExecution { + do { + return ObservedExecution(result: try await call.result, error: nil) + } catch let error as CodeModeToolError { + return ObservedExecution(result: nil, error: error) + } catch { + return ObservedExecution( + result: nil, + error: CodeModeToolError(code: "UNEXPECTED", message: error.localizedDescription) + ) + } +} + +@Test func codeModeMacroExpansionGeneratesToolProviderConformance() { + assertMacroExpansion( + """ + @CodeMode(path: "macro.api.greet", description: "Greet a person.") + struct Demo: Sendable { + struct Arguments { + @CodeModeParam("Person name") + var name: String + + @CodeModeParam("Whether to add emphasis") + var excited: Bool? + } + + @CodeModeResult("Greeting payload") + struct Result { + var greeting: String + var excited: Bool? + } + + func call(arguments: Arguments) async throws -> Result { + Result(greeting: "Hello", excited: arguments.excited) + } + } + """, + expandedSource: + """ + struct Demo: Sendable { + struct Arguments { + var name: String + + var excited: Bool? + } + + struct Result { + var greeting: String + var excited: Bool? + } + + func call(arguments: Arguments) async throws -> Result { + Result(greeting: "Hello", excited: arguments.excited) + } + } + + extension Demo: CodeModeProvider { + var codeModePath: String { "macro.api.greet" } + + func codeModeRegistrations() -> [CodeModeRegistration] { + [ + CodeModeRegistration( + capabilityKey: CodeModeCapabilityKey(rawValue: "macro.api.greet"), + jsPath: "macro.api.greet", + title: "greet", + summary: "Greet a person.", + tags: ["macro.api"], + example: "await macro.api.greet({})", + requiredArguments: ["name"], + optionalArguments: ["excited"], + argumentTypes: ["name": CapabilityArgumentType.string, "excited": CapabilityArgumentType.bool], + argumentHints: ["name": "Person name", "excited": "Whether to add emphasis"], + resultSummary: "Greeting payload", + handler: { arguments, _ in + try CodeModeAsyncBridge.run { + let decodedArguments = Arguments( + name: try CodeModeArgumentDecoder.require("name", as: String.self, in: arguments), + excited: try CodeModeArgumentDecoder.optional("excited", as: Bool.self, in: arguments) + ) + let result = try await self.call(arguments: decodedArguments) + return .object([ + "greeting": CodeModeValueEncoder.encode(result.greeting), + "excited": CodeModeValueEncoder.encode(result.excited) + ]) + } + } + ) + ] + } + } + """, + macros: codeModeTestMacros() + ) +} + +@Test func codeModeMacroExpansionSupportsEmptyArgumentsAndVoid() { + assertMacroExpansion( + """ + @CodeMode(path: "macro.api.ping", description: "Ping.") + struct Ping: Sendable { + struct Arguments {} + + func call(arguments: Arguments) throws {} + } + """, + expandedSource: + """ + struct Ping: Sendable { + struct Arguments {} + + func call(arguments: Arguments) throws {} + } + + extension Ping: CodeModeProvider { + var codeModePath: String { "macro.api.ping" } + + func codeModeRegistrations() -> [CodeModeRegistration] { + [ + CodeModeRegistration( + capabilityKey: CodeModeCapabilityKey(rawValue: "macro.api.ping"), + jsPath: "macro.api.ping", + title: "ping", + summary: "Ping.", + tags: ["macro.api"], + example: "await macro.api.ping({})", + requiredArguments: [], + optionalArguments: [], + argumentTypes: [:], + argumentHints: [:], + resultSummary: "null", + handler: { arguments, _ in + try CodeModeAsyncBridge.run { + let decodedArguments = Arguments() + try self.call(arguments: decodedArguments) + return .null + } + } + ) + ] + } + } + """, + macros: codeModeTestMacros() + ) +} + +@Test func codeModeMacroExpansionDiagnosesInvalidPath() { + assertMacroExpansion( + """ + @CodeMode(path: "bad-path", description: "Bad.") + struct Demo: Sendable { + struct Arguments {} + func call(arguments: Arguments) throws {} + } + """, + expandedSource: + """ + struct Demo: Sendable { + struct Arguments {} + func call(arguments: Arguments) throws {} + } + """, + diagnostics: [ + DiagnosticSpec(message: "@CodeMode requires a valid dotted JavaScript path", line: 1, column: 1), + ], + macros: codeModeTestMacros() + ) +} + +@Test func codeModeMacroExpansionDiagnosesMissingArguments() { + assertMacroExpansion( + """ + @CodeMode(path: "macro.api.bad", description: "Bad.") + struct Demo: Sendable { + func call(arguments: Arguments) throws {} + } + """, + expandedSource: + """ + struct Demo: Sendable { + func call(arguments: Arguments) throws {} + } + """, + diagnostics: [ + DiagnosticSpec(message: "@CodeMode requires a nested Arguments struct", line: 2, column: 8), + ], + macros: codeModeTestMacros() + ) +} + +@Test func codeModeMacroExpansionDiagnosesUnsupportedPropertyType() { + assertMacroExpansion( + """ + @CodeMode(path: "macro.api.bad", description: "Bad.") + struct Demo: Sendable { + struct Arguments { + @CodeModeParam("Bad") + var date: Date + } + + func call(arguments: Arguments) throws {} + } + """, + expandedSource: + """ + struct Demo: Sendable { + struct Arguments { + var date: Date + } + + func call(arguments: Arguments) throws {} + } + """, + diagnostics: [ + DiagnosticSpec(message: "@CodeMode does not support property type 'Date'", line: 5, column: 13), + ], + macros: codeModeTestMacros() + ) +} + +@Test func codeModeMacroExpansionDiagnosesComputedProperty() { + assertMacroExpansion( + """ + @CodeMode(path: "macro.api.bad", description: "Bad.") + struct Demo: Sendable { + struct Arguments { + var id: String { "x" } + } + + func call(arguments: Arguments) throws {} + } + """, + expandedSource: + """ + struct Demo: Sendable { + struct Arguments { + var id: String { "x" } + } + + func call(arguments: Arguments) throws {} + } + """, + diagnostics: [ + DiagnosticSpec(message: "@CodeMode does not support computed properties", line: 4, column: 13), + ], + macros: codeModeTestMacros() + ) +} + +@Test func codeModeMacroExpansionDiagnosesNonThrowingCall() { + assertMacroExpansion( + """ + @CodeMode(path: "macro.api.bad", description: "Bad.") + struct Demo: Sendable { + struct Arguments {} + func call(arguments: Arguments) {} + } + """, + expandedSource: + """ + struct Demo: Sendable { + struct Arguments {} + func call(arguments: Arguments) {} + } + """, + diagnostics: [ + DiagnosticSpec(message: "@CodeMode call(arguments:) must be throws or async throws", line: 4, column: 10), + ], + macros: codeModeTestMacros() + ) +} + +@Test func codeModeMacroExpansionDiagnosesGenericType() { + assertMacroExpansion( + """ + @CodeMode(path: "macro.api.bad", description: "Bad.") + struct Demo: Sendable { + struct Arguments {} + func call(arguments: Arguments) throws {} + } + """, + expandedSource: + """ + struct Demo: Sendable { + struct Arguments {} + func call(arguments: Arguments) throws {} + } + """, + diagnostics: [ + DiagnosticSpec(message: "@CodeMode does not support generic types", line: 2, column: 8), + ], + macros: codeModeTestMacros() + ) +} + +@Test func codeModeMacroExpansionDiagnosesOverloadedCall() { + assertMacroExpansion( + """ + @CodeMode(path: "macro.api.bad", description: "Bad.") + struct Demo: Sendable { + struct Arguments {} + func call(arguments: Arguments) throws {} + func call(arguments: Arguments, extra: String) throws {} + } + """, + expandedSource: + """ + struct Demo: Sendable { + struct Arguments {} + func call(arguments: Arguments) throws {} + func call(arguments: Arguments, extra: String) throws {} + } + """, + diagnostics: [ + DiagnosticSpec(message: "@CodeMode requires exactly one call(arguments:) method", line: 2, column: 8), + ], + macros: codeModeTestMacros() + ) +} + +@Test func codeModeMacroExpansionDiagnosesDefaultCallArgument() { + assertMacroExpansion( + """ + @CodeMode(path: "macro.api.bad", description: "Bad.") + struct Demo: Sendable { + struct Arguments {} + func call(arguments: Arguments = Arguments()) throws {} + } + """, + expandedSource: + """ + struct Demo: Sendable { + struct Arguments {} + func call(arguments: Arguments = Arguments()) throws {} + } + """, + diagnostics: [ + DiagnosticSpec(message: "@CodeMode call(arguments:) does not support default or variadic parameters", line: 4, column: 15), + ], + macros: codeModeTestMacros() + ) +} diff --git a/Tools/CodeModeDeterministicEval/Package.resolved b/Tools/CodeModeDeterministicEval/Package.resolved deleted file mode 100644 index c942548..0000000 --- a/Tools/CodeModeDeterministicEval/Package.resolved +++ /dev/null @@ -1,15 +0,0 @@ -{ - "originHash" : "c4c5dbdb1edb61070d17bc2d74d18cc56eb97198bb68767f43e10d1574afd0c4", - "pins" : [ - { - "identity" : "swift-argument-parser", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser", - "state" : { - "revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", - "version" : "1.7.1" - } - } - ], - "version" : 3 -} diff --git a/Tools/CodeModeDeterministicEval/Package.swift b/Tools/CodeModeDeterministicEval/Package.swift deleted file mode 100644 index eb79b71..0000000 --- a/Tools/CodeModeDeterministicEval/Package.swift +++ /dev/null @@ -1,28 +0,0 @@ -// swift-tools-version: 6.1 -import PackageDescription - -let package = Package( - name: "CodeModeDeterministicEvalTool", - platforms: [ - .macOS(.v15), - ], - products: [ - .executable( - name: "codemode-deterministic-eval", - targets: ["CodeModeDeterministicEvalCLI"] - ), - ], - dependencies: [ - .package(name: "codemode-ios", path: "../.."), - .package(url: "https://github.com/apple/swift-argument-parser", from: "1.7.0"), - ], - targets: [ - .executableTarget( - name: "CodeModeDeterministicEvalCLI", - dependencies: [ - .product(name: "CodeModeEvaluation", package: "codemode-ios"), - .product(name: "ArgumentParser", package: "swift-argument-parser"), - ] - ), - ] -) diff --git a/Tools/CodeModeDeterministicEval/Sources/CodeModeDeterministicEvalCLI/main.swift b/Tools/CodeModeDeterministicEval/Sources/CodeModeDeterministicEvalCLI/main.swift deleted file mode 100644 index abcda8b..0000000 --- a/Tools/CodeModeDeterministicEval/Sources/CodeModeDeterministicEvalCLI/main.swift +++ /dev/null @@ -1,117 +0,0 @@ -import ArgumentParser -import CodeModeEvaluation -import Foundation - -@main -struct CodeModeDeterministicEvalCLI: AsyncParsableCommand { - static let configuration = CommandConfiguration( - commandName: "codemode-deterministic-eval", - abstract: "Run deterministic CodeMode evaluation scenarios.", - subcommands: [ - List.self, - Run.self, - ], - defaultSubcommand: Run.self - ) -} - -struct List: ParsableCommand { - static let configuration = CommandConfiguration(abstract: "List built-in evaluation scenarios.") - - mutating func run() throws { - for scenario in CodeModeEvalScenarios.all { - print("\(scenario.id)\t\(scenario.title)") - } - } -} - -struct Run: AsyncParsableCommand { - static let configuration = CommandConfiguration(abstract: "Run deterministic built-in evaluation scenarios.") - - @Argument(help: "Scenario IDs to run. Omit to run all scenarios.") - var scenarioIDs: [String] = [] - - @Flag(name: .long, help: "Emit machine-readable JSON.") - var json = false - - @Flag(name: .long, help: "Print the search and execute code for each scenario.") - var showCode = false - - mutating func run() async throws { - let scenarios = try selectedScenarios(from: scenarioIDs) - let results = await CodeModeEvalRunner().runAll(scenarios) - - if json { - try printJSON(results) - } else { - printTextReport(results: results, scenarios: scenarios) - } - - if results.contains(where: { $0.passed == false }) { - throw ExitCode.failure - } - } - - private func printTextReport(results: [CodeModeEvalResult], scenarios: [CodeModeEvalScenario]) { - let scenariosByID = Dictionary(uniqueKeysWithValues: scenarios.map { ($0.id, $0) }) - - for result in results { - let status = result.passed ? "PASS" : "FAIL" - print("\(status) \(result.scenarioID) - \(result.title)") - - if showCode, let scenario = scenariosByID[result.scenarioID] { - if let searchCode = scenario.searchCode { - printIndented(label: "search", code: searchCode) - } - if let executeCode = scenario.executeCode { - printIndented(label: "execute", code: executeCode) - } - for (index, step) in (scenario.executeSteps ?? []).enumerated() { - printIndented(label: "execute step \(index + 1)", code: step.code) - } - } - - for failure in result.failures { - print(" - \(failure)") - } - } - - let passed = results.filter(\.passed).count - print("") - print("Deterministic Eval Summary") - print(" Total runs: \(results.count)") - print(" Passed: \(passed)/\(results.count)") - } - - private func printIndented(label: String, code: String) { - print(" \(label):") - for line in code.split(separator: "\n", omittingEmptySubsequences: false) { - print(" \(line)") - } - } -} - -func selectedScenarios(from scenarioIDs: [String]) throws -> [CodeModeEvalScenario] { - if scenarioIDs.isEmpty { - return CodeModeEvalScenarios.all - } - - let scenariosByID = Dictionary(uniqueKeysWithValues: CodeModeEvalScenarios.all.map { ($0.id, $0) }) - let unknown = scenarioIDs.filter { scenariosByID[$0] == nil } - if unknown.isEmpty == false { - throw ValidationError("Unknown scenario ID(s): \(unknown.joined(separator: ", "))") - } - - return scenarioIDs.compactMap { scenariosByID[$0] } -} - -func printJSON(_ value: T) throws { - let data = try encodedJSON(value) - print(String(decoding: data, as: UTF8.self)) -} - -func encodedJSON(_ value: T) throws -> Data { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - return try encoder.encode(value) -} diff --git a/Tools/CodeModeEval/Baselines/core-r5-summary.json b/Tools/CodeModeEval/Baselines/core-r5-summary.json index 412e0b3..aa95985 100644 --- a/Tools/CodeModeEval/Baselines/core-r5-summary.json +++ b/Tools/CodeModeEval/Baselines/core-r5-summary.json @@ -18,12 +18,15 @@ "catalog.console-diagnostics", "catalog.alias-platform-pruning", "weather.argument-validation", + "keychain.round-trip", + "notifications.permission-request", + "location.permission-status", "fs.bad-helper-suggestion" ], "suite" : "core", "summary" : { "averageRetries" : 0, - "averageTurns" : 2.769230769230769, + "averageTurns" : 2.8125, "byScenario" : [ { "averageRetries" : 0, @@ -226,6 +229,54 @@ "failedRuns" : 0, "failureCategories" : [ + ], + "passRate" : 1, + "passedRuns" : 5, + "scenarioID" : "keychain.round-trip", + "title" : "Keychain round trip", + "totalRuns" : 5 + }, + { + "averageRetries" : 0, + "averageTurns" : 3, + "exactCapabilityPassRate" : 1, + "exactCapabilityPassedRuns" : 5, + "exactCapabilityRuns" : 5, + "failedRuns" : 0, + "failureCategories" : [ + + ], + "passRate" : 1, + "passedRuns" : 5, + "scenarioID" : "notifications.permission-request", + "title" : "Notifications permission request", + "totalRuns" : 5 + }, + { + "averageRetries" : 0, + "averageTurns" : 3, + "exactCapabilityPassRate" : 1, + "exactCapabilityPassedRuns" : 5, + "exactCapabilityRuns" : 5, + "failedRuns" : 0, + "failureCategories" : [ + + ], + "passRate" : 1, + "passedRuns" : 5, + "scenarioID" : "location.permission-status", + "title" : "Location permission status", + "totalRuns" : 5 + }, + { + "averageRetries" : 0, + "averageTurns" : 3, + "exactCapabilityPassRate" : 1, + "exactCapabilityPassedRuns" : 5, + "exactCapabilityRuns" : 5, + "failedRuns" : 0, + "failureCategories" : [ + ], "passRate" : 1, "passedRuns" : 5, @@ -235,14 +286,14 @@ } ], "exactCapabilityPassRate" : 1, - "exactCapabilityPassedRuns" : 50, - "exactCapabilityRuns" : 50, + "exactCapabilityPassedRuns" : 65, + "exactCapabilityRuns" : 65, "failedRuns" : 0, "failureCategories" : [ ], "passRate" : 1, - "passedRuns" : 65, - "totalRuns" : 65 + "passedRuns" : 80, + "totalRuns" : 80 } } \ No newline at end of file diff --git a/Tools/CodeModeEval/Baselines/failures-r5-summary.json b/Tools/CodeModeEval/Baselines/failures-r5-summary.json index afab783..1efbb7f 100644 --- a/Tools/CodeModeEval/Baselines/failures-r5-summary.json +++ b/Tools/CodeModeEval/Baselines/failures-r5-summary.json @@ -11,12 +11,16 @@ "fs.capability-denied", "execution.timeout", "catalog.rejects-non-function", - "contacts.permission-denied" + "contacts.permission-denied", + "network.invalid-url", + "calendar.write-permission-denied", + "home.write-validation", + "media.metadata-validation" ], "suite" : "failures", "summary" : { "averageRetries" : 0, - "averageTurns" : 2.6666666666666665, + "averageTurns" : 2.8, "byScenario" : [ { "averageRetries" : 0, @@ -113,17 +117,81 @@ "scenarioID" : "contacts.permission-denied", "title" : "Permission denial stays structured", "totalRuns" : 5 + }, + { + "averageRetries" : 0, + "averageTurns" : 3, + "exactCapabilityPassRate" : 1, + "exactCapabilityPassedRuns" : 5, + "exactCapabilityRuns" : 5, + "failedRuns" : 0, + "failureCategories" : [ + + ], + "passRate" : 1, + "passedRuns" : 5, + "scenarioID" : "network.invalid-url", + "title" : "Network invalid URL", + "totalRuns" : 5 + }, + { + "averageRetries" : 0, + "averageTurns" : 3, + "exactCapabilityPassRate" : 1, + "exactCapabilityPassedRuns" : 5, + "exactCapabilityRuns" : 5, + "failedRuns" : 0, + "failureCategories" : [ + + ], + "passRate" : 1, + "passedRuns" : 5, + "scenarioID" : "calendar.write-permission-denied", + "title" : "Calendar write permission denied", + "totalRuns" : 5 + }, + { + "averageRetries" : 0, + "averageTurns" : 3, + "exactCapabilityPassRate" : 1, + "exactCapabilityPassedRuns" : 5, + "exactCapabilityRuns" : 5, + "failedRuns" : 0, + "failureCategories" : [ + + ], + "passRate" : 1, + "passedRuns" : 5, + "scenarioID" : "home.write-validation", + "title" : "Home write validation", + "totalRuns" : 5 + }, + { + "averageRetries" : 0, + "averageTurns" : 3, + "exactCapabilityPassRate" : 1, + "exactCapabilityPassedRuns" : 5, + "exactCapabilityRuns" : 5, + "failedRuns" : 0, + "failureCategories" : [ + + ], + "passRate" : 1, + "passedRuns" : 5, + "scenarioID" : "media.metadata-validation", + "title" : "Media metadata validation", + "totalRuns" : 5 } ], "exactCapabilityPassRate" : 1, - "exactCapabilityPassedRuns" : 25, - "exactCapabilityRuns" : 25, + "exactCapabilityPassedRuns" : 45, + "exactCapabilityRuns" : 45, "failedRuns" : 0, "failureCategories" : [ ], "passRate" : 1, - "passedRuns" : 30, - "totalRuns" : 30 + "passedRuns" : 50, + "totalRuns" : 50 } } \ No newline at end of file diff --git a/Tools/CodeModeEval/Package.resolved b/Tools/CodeModeEval/Package.resolved index 2b41eae..8ac29a5 100644 --- a/Tools/CodeModeEval/Package.resolved +++ b/Tools/CodeModeEval/Package.resolved @@ -1,60 +1,6 @@ { - "originHash" : "f98861a074454a4577479171e05275ffa5b23c13dda39b9230869485ad9ac9c6", + "originHash" : "055a86933d9f9badc3965bdc45e92a0f17c975f946b0b6226d76705599520f02", "pins" : [ - { - "identity" : "asyncserversentevents", - "kind" : "remoteSourceControl", - "location" : "https://github.com/velos/AsyncServerSentEvents.git", - "state" : { - "branch" : "main", - "revision" : "5ea675bfad3b8445e8b9543aa51e30f5bfdc19e7" - } - }, - { - "identity" : "callablefunction", - "kind" : "remoteSourceControl", - "location" : "https://github.com/velos/CallableFunction.git", - "state" : { - "branch" : "feature/function-updates", - "revision" : "338a24c3c90631c8dc4c19e5c44d1cecc0b09b05" - } - }, - { - "identity" : "endpoints", - "kind" : "remoteSourceControl", - "location" : "https://github.com/velos/Endpoints.git", - "state" : { - "branch" : "authentication", - "revision" : "70e7a02f4895e0eacbb6cc031a3c53a929e896a8" - } - }, - { - "identity" : "gzipswift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/1024jp/GzipSwift", - "state" : { - "revision" : "731037f6cc2be2ec01562f6597c1d0aa3fe6fd05", - "version" : "6.0.1" - } - }, - { - "identity" : "mlx-swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ml-explore/mlx-swift", - "state" : { - "revision" : "072b684acaae80b6a463abab3a103732f33774bf", - "version" : "0.29.1" - } - }, - { - "identity" : "mlx-swift-examples", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ml-explore/mlx-swift-examples.git", - "state" : { - "revision" : "9bff95ca5f0b9e8c021acc4d71a2bbe4a7441631", - "version" : "2.29.1" - } - }, { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", @@ -63,60 +9,6 @@ "revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", "version" : "1.7.1" } - }, - { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections.git", - "state" : { - "revision" : "6675bc0ff86e61436e615df6fc5174e043e57924", - "version" : "1.4.1" - } - }, - { - "identity" : "swift-jinja", - "kind" : "remoteSourceControl", - "location" : "https://github.com/huggingface/swift-jinja.git", - "state" : { - "revision" : "0aeefadec459ce8e11a333769950fb86183aca43", - "version" : "2.3.5" - } - }, - { - "identity" : "swift-numerics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-numerics", - "state" : { - "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", - "version" : "1.1.1" - } - }, - { - "identity" : "swift-syntax", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-syntax.git", - "state" : { - "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", - "version" : "509.1.1" - } - }, - { - "identity" : "swift-transformers", - "kind" : "remoteSourceControl", - "location" : "https://github.com/huggingface/swift-transformers", - "state" : { - "revision" : "a2e184dddb4757bc943e77fbe99ac6786c53f0b2", - "version" : "1.0.0" - } - }, - { - "identity" : "wavelike-ios", - "kind" : "remoteSourceControl", - "location" : "https://github.com/velos/wavelike-ios.git", - "state" : { - "branch" : "main", - "revision" : "dafbb0e3dff69cd9d5346387d29774b73881800c" - } } ], "version" : 3 diff --git a/Tools/CodeModeEval/Package.swift b/Tools/CodeModeEval/Package.swift index f61573a..5cf8c28 100644 --- a/Tools/CodeModeEval/Package.swift +++ b/Tools/CodeModeEval/Package.swift @@ -17,8 +17,6 @@ let package = Package( dependencies: [ .package(name: "codemode-ios", path: "../.."), .package(url: "https://github.com/apple/swift-argument-parser", from: "1.7.0"), - .package(url: "https://github.com/velos/wavelike-ios.git", branch: "main"), - .package(url: "https://github.com/velos/CallableFunction.git", branch: "feature/function-updates"), ], targets: [ .executableTarget( @@ -27,9 +25,8 @@ let package = Package( .product(name: "CodeMode", package: "codemode-ios"), .product(name: "CodeModeEvaluation", package: "codemode-ios"), .product(name: "ArgumentParser", package: "swift-argument-parser"), - .product(name: "Wavelike", package: "wavelike-ios"), - .product(name: "CallableFunction", package: "CallableFunction"), - ] + ], + exclude: ["LLM.swift"] ), ] ) diff --git a/Tools/CodeModeEval/Sources/CodeModeEvalCLI/CodeModeEvalCLI.swift b/Tools/CodeModeEval/Sources/CodeModeEvalCLI/CodeModeEvalCLI.swift index 1f86b23..4c7ed39 100644 --- a/Tools/CodeModeEval/Sources/CodeModeEvalCLI/CodeModeEvalCLI.swift +++ b/Tools/CodeModeEval/Sources/CodeModeEvalCLI/CodeModeEvalCLI.swift @@ -71,6 +71,9 @@ struct Run: AsyncParsableCommand { if let executeCode = scenario.executeCode { printIndented(label: "execute", code: executeCode) } + for (index, step) in (scenario.executeSteps ?? []).enumerated() { + printIndented(label: "execute step \(index + 1)", code: step.code) + } } for failure in result.failures { diff --git a/Tools/CodeModeEval/Sources/CodeModeEvalCLI/LLM.swift b/Tools/CodeModeEval/Sources/CodeModeEvalCLI/LLM.swift index bf24f5f..0ca5181 100644 --- a/Tools/CodeModeEval/Sources/CodeModeEvalCLI/LLM.swift +++ b/Tools/CodeModeEval/Sources/CodeModeEvalCLI/LLM.swift @@ -14,7 +14,7 @@ struct LLM: AsyncParsableCommand { @Argument(help: "Scenario IDs to run. Omit to use --suite.") var scenarioIDs: [String] = [] - @Option(name: .long, help: "Scenario suite to run when no scenario IDs are provided: smoke, core, failures, or all.") + @Option(name: .long, help: "Scenario suite to run when no scenario IDs are provided: smoke, core, failures, catalog, or all.") var suite: LLMEvalSuite = .smoke @Option(name: .customLong("repeat"), help: "Number of times to run each selected scenario.") @@ -362,6 +362,7 @@ enum LLMEvalSuite: String, CaseIterable, Codable, ExpressibleByArgument, Sendabl case smoke case core case failures + case catalog case all var scenarioIDs: [String] { @@ -387,6 +388,9 @@ enum LLMEvalSuite: String, CaseIterable, Codable, ExpressibleByArgument, Sendabl "catalog.console-diagnostics", "catalog.alias-platform-pruning", "weather.argument-validation", + "keychain.round-trip", + "notifications.permission-request", + "location.permission-status", "fs.bad-helper-suggestion", ] case .failures: @@ -397,6 +401,33 @@ enum LLMEvalSuite: String, CaseIterable, Codable, ExpressibleByArgument, Sendabl "execution.timeout", "catalog.rejects-non-function", "contacts.permission-denied", + "network.invalid-url", + "calendar.write-permission-denied", + "home.write-validation", + "media.metadata-validation", + ] + case .catalog: + return [ + "catalog.reminder-create", + "catalog.fs-read-shape", + "catalog.console-diagnostics", + "catalog.rejects-non-function", + "catalog.alias-platform-pruning", + "catalog.system-ui-platform-pruning", + "catalog.system-ui-documents-discovery", + "catalog.system-ui-interaction-discovery", + "catalog.system-ui-photo-camera-discovery", + "catalog.system-ui-ios-only-discovery", + "calendar.lifecycle-catalog", + "network.base64-timeout-catalog", + "notifications.delivered-content-catalog", + "system-ui.parameter-catalog", + "cloudkit.big-ticket-catalog", + "notifications.remote-catalog", + "speech.big-ticket-catalog", + "maps.big-ticket-catalog", + "foundation-appintents-activity.catalog", + "wallet-music-storekit.safety-catalog", ] case .all: return [] @@ -435,6 +466,7 @@ struct CodeModeLLMToolAttempt: Codable, Sendable { var index: Int var toolName: String var allowedCapabilities: [String] + var allowedCapabilityKeys: [String] var succeeded: Bool var errorCode: String? var errorMessage: String? @@ -766,6 +798,7 @@ private final class WavelikeLLMEvalRunner: Sendable { await toolState.execute( code: parameters.code, allowedCapabilities: parameters.allowedCapabilities.values, + allowedCapabilityKeys: parameters.allowedCapabilityKeys?.values ?? [], timeoutMs: parameters.timeoutMs ) } @@ -823,7 +856,10 @@ private final class WavelikeLLMEvalRunner: Sendable { } let snapshot = await toolState.snapshot() - let gradedToolCalls = gradedToolCalls(snapshot.toolCalls, expectedOrder: scenario.expectation.toolOrder) + let gradedToolCalls = CodeModeEvalToolCallGrader.orderedToolCalls( + snapshot.toolCalls, + expectedOrder: scenario.expectation.toolOrder + ) let validationFailures = CodeModeEvalRunner().validateTranscript( scenario: scenario, toolCalls: gradedToolCalls, @@ -928,15 +964,6 @@ private final class WavelikeLLMEvalRunner: Sendable { text.contains("temporarily") } - private func gradedToolCalls( - _ toolCalls: [CodeModeEvalToolCall], - expectedOrder: [CodeModeEvalToolName] - ) -> [CodeModeEvalToolCall] { - expectedOrder.compactMap { expectedTool in - toolCalls.last(where: { $0.tool == expectedTool }) - } - } - private static let instructions = """ You are solving a CodeMode evaluation task. @@ -948,11 +975,11 @@ private final class WavelikeLLMEvalRunner: Sendable { \(CodeModeAgentToolDescriptions.executeJavaScript.name): \(CodeModeAgentToolDescriptions.executeJavaScript.description) - Use searchJavaScriptAPI before executeJavaScript whenever you need helper names, arguments, result shapes, examples, or capability IDs. Search first for privileged Apple helpers such as filesystem, contacts, weather, reminders, calendar, photos, health, home, location, keychain, notifications, and alarms, even when the helper name looks obvious. Prefer api.byJSName["known.name"] for direct helper lookup; use api.byCapability["capability.id"] for capability IDs; use ?? null when a missing lookup must appear as null in JSON. For filesystem catalog searches, filter by ref.tags.includes("filesystem") and return the fields you will need to execute correctly, especially jsNames, requiredArguments, optionalArguments, argumentHints, resultSummary, and example. Avoid repeating searchJavaScriptAPI for references already returned by a previous search; run another search only when the previous result is missing information you need. When calling executeJavaScript, pass only the minimal allowedCapabilities needed by the JavaScript you run. Platform permission failures still need the relevant capability in allowedCapabilities; capability allowlisting is separate from user privacy permission. Use sandbox paths exactly as the user gives them, including tmp:, caches:, and documents: prefixes. + Use searchJavaScriptAPI before executeJavaScript whenever you need helper names, arguments, result shapes, examples, or capability IDs. Search first for privileged Apple helpers such as filesystem, contacts, weather, reminders, calendar, photos, health, home, location, keychain, notifications, and alarms, even when the helper name looks obvious. Prefer api.byJSName["known.name"] for direct helper lookup; use api.byCapability["capability.id"] for capability IDs; use ?? null when a missing lookup must appear as null in JSON. For filesystem catalog searches, filter by ref.tags.includes("filesystem") and return the fields you will need to execute correctly, especially jsNames, requiredArguments, optionalArguments, argumentHints, resultSummary, and example. Avoid repeating searchJavaScriptAPI for references already returned by a previous search; run another search only when the previous result is missing information you need. When calling executeJavaScript, pass only the minimal allowedCapabilities needed by the JavaScript you run and put custom provider keys in allowedCapabilityKeys. Platform permission failures still need the relevant capability in allowedCapabilities; capability allowlisting is separate from user privacy permission. Use sandbox paths exactly as the user gives them, including tmp:, caches:, and documents: prefixes. The execution runtime does not support require() or import. Never write const fs = require("fs"); fs is already a global variable. Use the provided globals directly. Node-style filesystem aliases are available as global fs.promises methods with positional arguments, while apple.fs.* helpers use object arguments with named fields. - The JavaScript return value is what will be graded. executeJavaScript does not automatically return the final expression, so use an explicit top-level return statement for successful outputs. The runtime already wraps your code in an async function; do not wrap your code in `(async () => { ... })()` unless you also return or await that promise from the top level. Prefer top-level await statements followed by a top-level `return { ... }`. If the user asks for a string, number, boolean, or specific object shape, make the script return exactly that shape rather than returning a larger helper result and summarizing it later. Pay attention to each catalog reference's resultSummary and examples; for example, apple.fs.read returns an object with text/base64 fields, fs.promises.readFile(path, "utf8") returns text, and apple.fs.list/fs.promises.readdir return entry objects where filenames are in entry.name. + The JavaScript return value is what will be graded. Use an explicit top-level return statement for multi-statement successful outputs. A script that is only a bare final top-level await expression also returns that awaited value. The runtime already wraps your code in an async function; do not wrap your code in `(async () => { ... })()` unless you also return or await that promise from the top level. Prefer top-level await statements followed by a top-level `return { ... }`. If the user asks for a string, number, boolean, or specific object shape, make the script return exactly that shape rather than returning a larger helper result and summarizing it later. Pay attention to each catalog reference's resultSummary and examples; for example, apple.fs.read returns an object with text/base64 fields, fs.promises.readFile(path, "utf8") returns text, and apple.fs.list/fs.promises.readdir return entry objects where filenames are in entry.name. Do not catch CodeMode helper errors inside JavaScript just to return an error object. Let helper errors propagate to executeJavaScript so the structured tool response includes code, functionName, diagnostics, and suggestions. If a tool call fails and you can repair it from the structured error or suggestions, retry with corrected code. Give a final answer only after the last useful tool call. """ @@ -993,6 +1020,7 @@ private actor CodeModeLLMToolState { return await execute( code: parameters.code, allowedCapabilities: parameters.allowedCapabilities.values, + allowedCapabilityKeys: parameters.allowedCapabilityKeys?.values ?? [], timeoutMs: parameters.timeoutMs ) @@ -1056,7 +1084,12 @@ private actor CodeModeLLMToolState { } } - func execute(code: String, allowedCapabilities rawCapabilities: [String], timeoutMs: Int?) async -> String { + func execute( + code: String, + allowedCapabilities rawCapabilities: [String], + allowedCapabilityKeys rawCapabilityKeys: [String], + timeoutMs: Int? + ) async -> String { let unknownCapabilities = rawCapabilities.filter { CapabilityID(rawValue: $0) == nil } guard unknownCapabilities.isEmpty else { let message = "Unknown allowedCapabilities: \(unknownCapabilities.joined(separator: ", "))" @@ -1064,6 +1097,7 @@ private actor CodeModeLLMToolState { recordAttempt( toolName: CodeModeAgentToolDescriptions.executeJavaScript.name, allowedCapabilities: rawCapabilities, + allowedCapabilityKeys: rawCapabilityKeys, succeeded: false, errorCode: "UNKNOWN_CAPABILITY", errorMessage: message @@ -1072,11 +1106,13 @@ private actor CodeModeLLMToolState { } let capabilities = rawCapabilities.compactMap(CapabilityID.init(rawValue:)) + let capabilityKeys = rawCapabilityKeys.map(CodeModeCapabilityKey.init(rawValue:)) toolCalls.append( CodeModeEvalToolCall( tool: .executeJavaScript, code: code, - allowedCapabilities: capabilities + allowedCapabilities: capabilities, + allowedCapabilityKeys: capabilityKeys ) ) @@ -1085,6 +1121,7 @@ private actor CodeModeLLMToolState { JavaScriptExecutionRequest( code: code, allowedCapabilities: capabilities, + allowedCapabilityKeys: capabilityKeys, timeoutMs: timeoutMs ?? scenario.timeoutMs ) ) @@ -1098,6 +1135,7 @@ private actor CodeModeLLMToolState { recordAttempt( toolName: CodeModeAgentToolDescriptions.executeJavaScript.name, allowedCapabilities: rawCapabilities, + allowedCapabilityKeys: rawCapabilityKeys, succeeded: false, error: toolError, diagnostics: observed.diagnostics @@ -1108,6 +1146,7 @@ private actor CodeModeLLMToolState { recordAttempt( toolName: CodeModeAgentToolDescriptions.executeJavaScript.name, allowedCapabilities: rawCapabilities, + allowedCapabilityKeys: rawCapabilityKeys, succeeded: true, diagnostics: observed.diagnostics ) @@ -1126,6 +1165,7 @@ private actor CodeModeLLMToolState { recordAttempt( toolName: CodeModeAgentToolDescriptions.executeJavaScript.name, allowedCapabilities: rawCapabilities, + allowedCapabilityKeys: rawCapabilityKeys, succeeded: false, error: toolError ) @@ -1136,6 +1176,7 @@ private actor CodeModeLLMToolState { recordAttempt( toolName: CodeModeAgentToolDescriptions.executeJavaScript.name, allowedCapabilities: rawCapabilities, + allowedCapabilityKeys: rawCapabilityKeys, succeeded: false, errorCode: "UNEXPECTED_ERROR", errorMessage: message @@ -1161,6 +1202,7 @@ private actor CodeModeLLMToolState { private func recordAttempt( toolName: String, allowedCapabilities: [String] = [], + allowedCapabilityKeys: [String] = [], succeeded: Bool, error: CodeModeToolError? = nil, errorCode: String? = nil, @@ -1174,6 +1216,7 @@ private actor CodeModeLLMToolState { index: toolAttempts.count + 1, toolName: toolName, allowedCapabilities: allowedCapabilities, + allowedCapabilityKeys: allowedCapabilityKeys, succeeded: succeeded, errorCode: error?.code ?? errorCode, errorMessage: error?.message ?? errorMessage, @@ -1412,6 +1455,7 @@ private struct ExecuteJavaScriptFunction: CallableFunction { struct Parameters: Codable { var code: String var allowedCapabilities: CapabilityList + var allowedCapabilityKeys: CapabilityList? var timeoutMs: Int? } @@ -1428,13 +1472,17 @@ private struct ExecuteJavaScriptFunction: CallableFunction { parameters: .object( properties: [ "code": .string( - description: "JavaScript body to execute. Return the final value to grade.", + description: "JavaScript body to execute. Return the final value to grade; a bare top-level await expression also returns its awaited value.", enum: nil ), "allowedCapabilities": .string( description: "Comma-separated capability IDs required by the JavaScript, for example fs.write,fs.read. Use an empty string when no capabilities are needed.", enum: nil ), + "allowedCapabilityKeys": .string( + description: "Optional comma-separated custom provider capability keys required by the JavaScript. Use an empty string or omit when no custom provider capabilities are needed.", + enum: nil + ), "timeoutMs": .integer( description: "Optional execution timeout in milliseconds.", minimum: 1, diff --git a/Tools/CodeModeEval/Sources/CodeModeEvalCLI/LLMEvalModels.swift b/Tools/CodeModeEval/Sources/CodeModeEvalCLI/LLMEvalModels.swift new file mode 100644 index 0000000..98ea07c --- /dev/null +++ b/Tools/CodeModeEval/Sources/CodeModeEvalCLI/LLMEvalModels.swift @@ -0,0 +1,249 @@ +import ArgumentParser +import CodeModeEvaluation +import Foundation + +enum LLMEvalSuite: String, CaseIterable, Codable, ExpressibleByArgument, Sendable { + case smoke + case core + case failures + case catalog + case all + + var scenarioIDs: [String] { + switch self { + case .smoke: + return [ + "fs.round-trip", + "fs.read-only-minimal", + "execution.console-logs", + "catalog.reminder-create", + ] + case .core: + return [ + "fs.round-trip", + "fs.read-only-minimal", + "fs.multi-file-summary", + "fs.copy-move-stat", + "fs.nested-report-summary", + "fs.read-api-shapes", + "fs.repair-invalid-read-arguments", + "execution.console-logs", + "catalog.reminder-create", + "catalog.console-diagnostics", + "catalog.alias-platform-pruning", + "weather.argument-validation", + "keychain.round-trip", + "notifications.permission-request", + "location.permission-status", + "fs.bad-helper-suggestion", + ] + case .failures: + return [ + "fs.path-policy-escape", + "fs.delete-directory-recursive", + "fs.capability-denied", + "execution.timeout", + "catalog.rejects-non-function", + "contacts.permission-denied", + "network.invalid-url", + "calendar.write-permission-denied", + "home.write-validation", + "media.metadata-validation", + ] + case .catalog: + return [ + "catalog.reminder-create", + "catalog.fs-read-shape", + "catalog.console-diagnostics", + "catalog.rejects-non-function", + "catalog.alias-platform-pruning", + "catalog.system-ui-platform-pruning", + "catalog.system-ui-documents-discovery", + "catalog.system-ui-interaction-discovery", + "catalog.system-ui-photo-camera-discovery", + "catalog.system-ui-ios-only-discovery", + "calendar.lifecycle-catalog", + "network.base64-timeout-catalog", + "notifications.delivered-content-catalog", + "system-ui.parameter-catalog", + "cloudkit.big-ticket-catalog", + "notifications.remote-catalog", + "speech.big-ticket-catalog", + "maps.big-ticket-catalog", + "foundation-appintents-activity.catalog", + "wallet-music-storekit.safety-catalog", + ] + case .all: + return [] + } + } +} + +struct CodeModeLLMEvalReport: Codable, Sendable { + var modelID: String + var suite: String + var scenarioIDs: [String] + var repeatCount: Int + var maxTurns: Int + var summary: CodeModeLLMEvalSummary + var results: [CodeModeLLMEvalResult] + var failureSummaries: [CodeModeLLMFailureSummary]? = nil +} + +struct CodeModeLLMEvalResult: Codable, Sendable { + var modelID: String + var runIndex: Int + var scenarioID: String + var title: String + var passed: Bool + var failures: [String] + var failureCategories: [LLMEvalFailureCategory] + var turns: Int + var retryCount: Int + var exactCapabilityMatched: Bool? + var assistantMessage: String? + var toolAttempts: [CodeModeLLMToolAttempt]? = nil + var evalResult: CodeModeEvalResult +} + +struct CodeModeLLMToolAttempt: Codable, Sendable { + var index: Int + var toolName: String + var allowedCapabilities: [String] + var allowedCapabilityKeys: [String] + var succeeded: Bool + var errorCode: String? + var errorMessage: String? + var functionName: String? + var diagnostics: [String] + var suggestions: [String] + var repairedByNextAttempt: Bool? +} + +struct CodeModeLLMEvalSummary: Codable, Sendable { + var totalRuns: Int + var passedRuns: Int + var failedRuns: Int + var passRate: Double + var averageTurns: Double + var averageRetries: Double + var exactCapabilityRuns: Int + var exactCapabilityPassedRuns: Int + var exactCapabilityPassRate: Double + var byScenario: [CodeModeLLMScenarioSummary] + var failureCategories: [CodeModeLLMFailureCategoryCount] + + static func make(results: [CodeModeLLMEvalResult], scenarios: [CodeModeEvalScenario]) -> CodeModeLLMEvalSummary { + let passedRuns = results.filter(\.passed).count + let exactCapabilityResults = results.filter { $0.exactCapabilityMatched != nil } + let exactCapabilityPassedRuns = exactCapabilityResults.filter { $0.exactCapabilityMatched == true }.count + let groupedResults = Dictionary(grouping: results, by: \.scenarioID) + + return CodeModeLLMEvalSummary( + totalRuns: results.count, + passedRuns: passedRuns, + failedRuns: results.count - passedRuns, + passRate: ratio(passedRuns, results.count), + averageTurns: average(results.map(\.turns)), + averageRetries: average(results.map(\.retryCount)), + exactCapabilityRuns: exactCapabilityResults.count, + exactCapabilityPassedRuns: exactCapabilityPassedRuns, + exactCapabilityPassRate: ratio(exactCapabilityPassedRuns, exactCapabilityResults.count), + byScenario: scenarios.map { scenario in + CodeModeLLMScenarioSummary.make( + scenario: scenario, + results: groupedResults[scenario.id] ?? [] + ) + }, + failureCategories: failureCategoryCounts(results) + ) + } +} + +struct CodeModeLLMScenarioSummary: Codable, Sendable { + var scenarioID: String + var title: String + var totalRuns: Int + var passedRuns: Int + var failedRuns: Int + var passRate: Double + var averageTurns: Double + var averageRetries: Double + var exactCapabilityRuns: Int + var exactCapabilityPassedRuns: Int + var exactCapabilityPassRate: Double + var failureCategories: [CodeModeLLMFailureCategoryCount] + + static func make(scenario: CodeModeEvalScenario, results: [CodeModeLLMEvalResult]) -> CodeModeLLMScenarioSummary { + let passedRuns = results.filter(\.passed).count + let exactCapabilityResults = results.filter { $0.exactCapabilityMatched != nil } + let exactCapabilityPassedRuns = exactCapabilityResults.filter { $0.exactCapabilityMatched == true }.count + + return CodeModeLLMScenarioSummary( + scenarioID: scenario.id, + title: scenario.title, + totalRuns: results.count, + passedRuns: passedRuns, + failedRuns: results.count - passedRuns, + passRate: ratio(passedRuns, results.count), + averageTurns: average(results.map(\.turns)), + averageRetries: average(results.map(\.retryCount)), + exactCapabilityRuns: exactCapabilityResults.count, + exactCapabilityPassedRuns: exactCapabilityPassedRuns, + exactCapabilityPassRate: ratio(exactCapabilityPassedRuns, exactCapabilityResults.count), + failureCategories: failureCategoryCounts(results) + ) + } +} + +struct CodeModeLLMFailureCategoryCount: Codable, Sendable { + var category: LLMEvalFailureCategory + var count: Int +} + +struct CodeModeLLMFailureSummary: Codable, Sendable { + var scenarioID: String + var title: String + var runIndex: Int + var failures: [String] + var failureCategories: [LLMEvalFailureCategory] +} + +enum LLMEvalFailureCategory: String, CaseIterable, Codable, Sendable { + case wrongTool = "wrong_tool" + case wrongJavaScript = "wrong_js" + case overbroadCapability = "overbroad_capability" + case failedRecovery = "failed_recovery" + case noFinalAnswer = "no_final_answer" + case other +} + +private func ratio(_ numerator: Int, _ denominator: Int) -> Double { + guard denominator > 0 else { + return 0 + } + return Double(numerator) / Double(denominator) +} + +private func average(_ values: [Int]) -> Double { + guard values.isEmpty == false else { + return 0 + } + return Double(values.reduce(0, +)) / Double(values.count) +} + +private func failureCategoryCounts(_ results: [CodeModeLLMEvalResult]) -> [CodeModeLLMFailureCategoryCount] { + var counts: [LLMEvalFailureCategory: Int] = [:] + for result in results { + for category in Set(result.failureCategories) { + counts[category, default: 0] += 1 + } + } + + return LLMEvalFailureCategory.allCases.compactMap { category in + guard let count = counts[category], count > 0 else { + return nil + } + return CodeModeLLMFailureCategoryCount(category: category, count: count) + } +} diff --git a/Tools/CodeModeEval/Sources/CodeModeEvalCLI/LLMUnavailable.swift b/Tools/CodeModeEval/Sources/CodeModeEvalCLI/LLMUnavailable.swift new file mode 100644 index 0000000..f094df2 --- /dev/null +++ b/Tools/CodeModeEval/Sources/CodeModeEvalCLI/LLMUnavailable.swift @@ -0,0 +1,62 @@ +import ArgumentParser + +struct LLM: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "llm", + abstract: "Run live model evaluation scenarios." + ) + + @Argument(help: "Scenario IDs to run. Omit to use --suite.") + var scenarioIDs: [String] = [] + + @Option(name: .long, help: "Scenario suite to run when no scenario IDs are provided: smoke, core, failures, catalog, or all.") + var suite: LLMEvalSuite = .smoke + + @Option(name: .customLong("repeat"), help: "Number of times to run each selected scenario.") + var repeatCount = 1 + + @Option(name: .long, help: "Model ID.") + var model: String? + + @Option(name: .long, help: "Path to a dotenv file containing model credentials.") + var envFile = ".env" + + @Option(name: .long, help: "Maximum model/tool turns per scenario.") + var maxTurns = 6 + + @Option(name: .long, help: "Maximum retries for transient model transport errors.") + var modelRetries = 3 + + @Option(name: .long, help: "Base delay in milliseconds for transient model transport retries.") + var retryDelayMs = 2_000 + + @Option(name: .long, help: "Delay in milliseconds before each model request.") + var requestDelayMs = 0 + + @Option(name: .long, help: "Maximum output tokens for each model call.") + var maxOutputTokens: Int? + + @Option(name: .long, help: "Optional reasoning effort: none, low, medium, high, or xhigh.") + var reasoningEffort: String? + + @Flag(name: .long, help: "Emit machine-readable JSON.") + var json = false + + @Option(name: .long, help: "Write the JSON report to a file path.") + var output: String? + + @Flag(name: .long, help: "Suppress per-scenario progress output on stderr.") + var quiet = false + + @Flag(name: .long, help: "Print captured tool code in text output.") + var showCode = false + + mutating func run() async throws { + throw ValidationError( + """ + Live LLM evals are not included in the default CodeModeEval package because they require private Wavelike dependencies. + Use `codemode-eval run` for deterministic checks and `codemode-eval plan` to preview LLM suite budgets. + """ + ) + } +} diff --git a/Tools/CodeModeEval/Sources/CodeModeEvalCLI/Plan.swift b/Tools/CodeModeEval/Sources/CodeModeEvalCLI/Plan.swift index 17311dc..54dd94f 100644 --- a/Tools/CodeModeEval/Sources/CodeModeEvalCLI/Plan.swift +++ b/Tools/CodeModeEval/Sources/CodeModeEvalCLI/Plan.swift @@ -10,7 +10,7 @@ struct Plan: ParsableCommand { @Argument(help: "Scenario IDs to run. Omit to use --suite.") var scenarioIDs: [String] = [] - @Option(name: .long, help: "Scenario suite to plan when no scenario IDs are provided: smoke, core, failures, or all.") + @Option(name: .long, help: "Scenario suite to plan when no scenario IDs are provided: smoke, core, failures, catalog, or all.") var suite: LLMEvalSuite = .smoke @Option(name: .customLong("repeat"), help: "Number of times each selected scenario would run.")